Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Changelog

## 0.3.0

### Breaking

- `update_goal` now accepts only `complete` or `blocked`; blocking requires a non-empty `reason`, while completion rejects one.

### Added

- Completed goals can be replaced through `create_goal`; the previous goal is archived in per-thread JSONL history.
- Oversized objectives are marker-budget truncated and their full text is saved in a per-thread spill file.
- Blocked goal state, interruption blocking, next-user-message auto-resume, and continuation suppression.
- Streamed mid-turn usage accounting and inert `tokenBudget` persistence for wire compatibility.
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,25 @@ Goals are stored under Pi's active session directory, keyed by session id. If Pi

## Agent Tools

- `create_goal({ objective, token_budget? })` creates a new active goal. This follows Codex's model-facing schema.
- `update_goal({ status: "complete" })` only marks the current goal complete. Pause, resume, budget-limited, and clear transitions are user/system controlled.
- `create_goal({ objective })` creates a new active goal. Objectives are limited to 4,000 characters; oversized objectives are truncated with the full text saved beside the goal store.
- `update_goal({ status: "complete" })` marks the current goal complete.
- `update_goal({ status: "blocked", reason })` records a repeated blocking condition and its reason.
- `get_goal({})` returns the current goal summary.

Statuses are `active`, `paused`, `budgetLimited`, and `complete`. When a goal reaches its token budget, the extension marks it `budgetLimited` and queues a prompt asking the agent to summarize remaining work instead of silently continuing.
Statuses are `active`, `paused`, `blocked`, and `complete`. Pause and resume remain user/system controlled.

## TUI Behavior

When a goal exists, pi keeps the normal footer information and renders the Codex-style goal indicator on the bottom-right footer line: `Pursuing goal (...)`, `Goal paused (/goal resume)`, `Goal unmet (...)`, or `Goal achieved (...)`. The older below-editor goal widget is cleared.
When a goal exists, pi keeps the normal footer information and renders the Codex-style goal indicator on the bottom-right footer line: `Pursuing goal (...)`, `Goal paused (/goal resume)`, `Goal blocked`, or `Goal achieved (...)`. The older below-editor goal widget is cleared.

On session start, after `/goal <objective>`, after `/goal resume`, and after every agent turn that leaves the goal `active`, the extension queues Codex's goal continuation prompt as hidden model-visible context. The objective is XML-escaped and wrapped as untrusted user data so it does not become higher-priority instructions.

## Blocked goals

Use `update_goal` with `status: "blocked"` only after the same blocking condition has recurred for at least three consecutive goal turns. A resumed goal starts a fresh audit; do not block a goal merely because work is hard, slow, or uncertain.

If an active turn ends with `ctx.signal.aborted`, pi-goal records `user interrupted the turn` and suppresses continuation. The next real user prompt resumes that blocked goal before accounting starts; goal-continuation messages do not resume it. The published extension API exposes no abort source, so this `ctx.signal` heuristic cannot distinguish user-initiated aborts from system aborts and may label a non-user abort as `user interrupted the turn`. Follow-up: upstream an `aborted` flag and abort-source field in the published extension API.

## Development

```bash
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pi-goal",
"version": "0.2.1",
"version": "0.3.0",
"description": "Persistent goal tracking for pi-coding-agent with Codex-style goal tools, TUI footer, and continuation prompts.",
"type": "module",
"license": "MIT",
Expand Down
103 changes: 103 additions & 0 deletions src/goal/command-registration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";

import { parseGoalCommand } from "./command.js";
import { formatGoalForTool, goalStatusLabel } from "./format.js";
import { clearGoal, createGoal, readGoal, updateGoal } from "./store.js";
import type { Goal, GoalAccountingMode, GoalStoreRef } from "./types.js";
import { updateGoalUi } from "./ui.js";

const GOAL_USAGE = "Usage: /goal <objective>";
const GOAL_EMPTY_HINT = "No goal is currently set.";
const REPLACE_GOAL_CHOICE = "Replace current goal";
const CANCEL_REPLACE_GOAL_CHOICE = "Cancel";

export type GoalCommandRegistrationDeps = {
goalStoreRef(ctx: ExtensionContext): GoalStoreRef;
beginAgentGoalAccounting(goal: Goal): void;
stopAgentGoalAccounting(goalId: string): void;
clearAgentGoalAccounting(): void;
accountCurrentAgentTurn(ctx: ExtensionContext, mode: GoalAccountingMode): Promise<Goal | null>;
queueGoalContinuation(ctx: ExtensionContext, goal: Goal): void;
};

export function registerGoalCommand(pi: ExtensionAPI, deps: GoalCommandRegistrationDeps): void {
pi.registerCommand("goal", {
description: "Set, inspect, pause, resume, or clear the persistent goal",
handler: async (rawArgs, ctx) => {
const command = parseGoalCommand(rawArgs);
try {
switch (command.kind) {
case "show": {
const goal = await readGoal(deps.goalStoreRef(ctx));
updateGoalUi(ctx, goal);
ctx.ui.notify(
goal === null ? `${GOAL_USAGE}\n${GOAL_EMPTY_HINT}` : formatGoalForTool(goal),
goal ? "info" : "warning",
);
return;
}
case "setObjective": {
await setGoalObjective(ctx, command.objective, deps);
return;
}
case "setStatus": {
if (command.status === "paused") {
await deps.accountCurrentAgentTurn(ctx, "active");
}
const goal = await updateGoal(deps.goalStoreRef(ctx), { status: command.status }, "user");
if (goal.status === "active") {
deps.beginAgentGoalAccounting(goal);
} else {
deps.stopAgentGoalAccounting(goal.id);
}
updateGoalUi(ctx, goal);
ctx.ui.notify(`Goal ${goalStatusLabel(goal.status)}\n${formatGoalForTool(goal)}`, "info");
deps.queueGoalContinuation(ctx, goal);
return;
}
case "clear": {
await deps.accountCurrentAgentTurn(ctx, "active");
const cleared = await clearGoal(deps.goalStoreRef(ctx));
deps.clearAgentGoalAccounting();
updateGoalUi(ctx, null);
ctx.ui.notify(
cleared ? "Goal cleared" : "No goal to clear\nThis thread does not currently have a goal.",
cleared ? "info" : "warning",
);
return;
}
}
} catch (error) {
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
}
},
});
}

async function setGoalObjective(
ctx: ExtensionContext,
objective: string,
deps: GoalCommandRegistrationDeps,
): Promise<void> {
const ref = deps.goalStoreRef(ctx);
const current = await readGoal(ref);
if (current !== null && !(await confirmReplaceGoal(ctx, objective))) return;

if (current?.status === "active") {
await deps.accountCurrentAgentTurn(ctx, "active");
}
const goal = current === null ? await createGoal(ref, objective) : await updateGoal(ref, { objective }, "user");
if (goal.status === "active") deps.beginAgentGoalAccounting(goal);
updateGoalUi(ctx, goal);
ctx.ui.notify(`Goal ${goalStatusLabel(goal.status)}\n${formatGoalForTool(goal)}`, "info");
deps.queueGoalContinuation(ctx, goal);
}

async function confirmReplaceGoal(ctx: ExtensionContext, objective: string): Promise<boolean> {
if (!ctx.hasUI) return true;
const choice = await ctx.ui.select(`Replace goal?\nNew objective: ${objective}`, [
REPLACE_GOAL_CHOICE,
CANCEL_REPLACE_GOAL_CHOICE,
]);
return choice === REPLACE_GOAL_CHOICE;
}
10 changes: 8 additions & 2 deletions src/goal/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export function goalStatusLabel(status: GoalStatus): string {
return "active";
case "paused":
return "paused";
case "blocked":
return "blocked";
case "complete":
return "complete";
}
Expand All @@ -45,6 +47,7 @@ export function formatGoalForTool(goal: Goal | null): string {
`Time used: ${formatGoalElapsedSeconds(goal.timeUsedSeconds)}`,
`Tokens used: ${formatTokensCompact(goal.tokensUsed)}`,
];
if (goal.blockedReason) lines.push(`Blocked reason: ${goal.blockedReason}`);
if (goal.completedAt) lines.push(`Completed at: ${new Date(goal.completedAt * 1000).toISOString()}`);
return lines.join("\n");
}
Expand All @@ -53,8 +56,9 @@ export function goalToolResponse(goal: Goal | null): GoalToolResponse {
return { goal: goal === null ? null : goalToolSnapshot(goal) };
}

export function formatGoalToolResponse(goal: Goal | null): string {
return JSON.stringify(goalToolResponse(goal), null, 2);
export function formatGoalToolResponse(goal: Goal | null, notice?: string): string {
const response = JSON.stringify(goalToolResponse(goal), null, 2);
return notice === undefined ? response : `${response}\n${notice}`;
}

function goalToolSnapshot(goal: Goal): GoalToolSnapshot {
Expand All @@ -66,6 +70,8 @@ function goalToolSnapshot(goal: Goal): GoalToolSnapshot {
timeUsedSeconds: goal.timeUsedSeconds,
createdAt: goal.createdAt,
updatedAt: goal.updatedAt,
...(goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason }),
...(goal.blockedAt === undefined ? {} : { blockedAt: goal.blockedAt }),
};
}

Expand Down
Loading
Loading