@zhushanwen/pi-goal 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +13 -12
  2. package/package.json +8 -4
  3. package/src/__tests__/command-adapter.test.ts +404 -0
  4. package/src/__tests__/deserialize-state.test.ts +80 -0
  5. package/src/__tests__/event-adapter.test.ts +339 -0
  6. package/src/__tests__/goal-control-adapter.test.ts +303 -0
  7. package/src/__tests__/index.test.ts +119 -0
  8. package/src/__tests__/service.test.ts +317 -0
  9. package/src/__tests__/session.test.ts +220 -0
  10. package/src/__tests__/stale-checker.test.ts +78 -0
  11. package/src/__tests__/stubs/pi-sdk.ts +16 -0
  12. package/src/__tests__/stubs/typebox.ts +14 -0
  13. package/src/adapters/command-adapter.ts +360 -0
  14. package/src/adapters/event-adapter.ts +17 -0
  15. package/src/adapters/event-handlers/agent-end.ts +177 -0
  16. package/src/adapters/event-handlers/agent-start.ts +17 -0
  17. package/src/adapters/event-handlers/before-agent-start.ts +115 -0
  18. package/src/adapters/event-handlers/message-end.ts +37 -0
  19. package/src/adapters/event-handlers/session-start.ts +24 -0
  20. package/src/adapters/event-handlers/shared.ts +46 -0
  21. package/src/adapters/event-handlers/turn-end.ts +37 -0
  22. package/src/adapters/goal-control-adapter.ts +296 -0
  23. package/src/adapters/ports.ts +89 -0
  24. package/src/commands.ts +7 -19
  25. package/src/constants.ts +1 -3
  26. package/src/engine/__tests__/budget.test.ts +182 -0
  27. package/src/engine/__tests__/goal.test.ts +126 -0
  28. package/src/engine/budget.ts +146 -0
  29. package/src/engine/goal.ts +62 -0
  30. package/src/engine/types.ts +80 -0
  31. package/src/index.ts +110 -333
  32. package/src/persistence.ts +83 -0
  33. package/src/ports.ts +68 -0
  34. package/src/projection/__tests__/prompts.test.ts +245 -0
  35. package/src/projection/__tests__/widget.test.ts +255 -0
  36. package/src/projection/prompts.ts +250 -0
  37. package/src/projection/widget.ts +254 -0
  38. package/src/service.ts +276 -0
  39. package/src/session.ts +114 -0
  40. package/src/action-handlers.ts +0 -341
  41. package/src/agent-end-handler.ts +0 -304
  42. package/src/before-agent-start-handler.ts +0 -182
  43. package/src/budget.ts +0 -159
  44. package/src/command-handler.ts +0 -280
  45. package/src/state.ts +0 -250
  46. package/src/templates.ts +0 -232
  47. package/src/tool-handler.ts +0 -252
  48. package/src/widget.ts +0 -186
@@ -1,182 +0,0 @@
1
- /**
2
- * before_agent_start 事件处理子函数
3
- *
4
- * handleBeforeAgentStart (orchestrator, ≤20 行) 委托到 3 个子函数:
5
- * handleTerminalStateBeforeAgent, checkStaleness, checkContextUsage
6
- *
7
- * P1-5/6 修复:将 122 行的大函数拆分为 ≤20 行子函数。
8
- */
9
-
10
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
11
-
12
- import {
13
- AUTO_CLEAR_TURNS,
14
- CONTEXT_USAGE_RATIO_LIMIT,
15
- TASK_STALL_TURN_THRESHOLD,
16
- } from "./constants";
17
- import {
18
- type GoalTask,
19
- isActiveStatus,
20
- isTerminalStatus,
21
- isTerminalTaskStatus,
22
- transitionStatus,
23
- } from "./state";
24
- import {
25
- contextInjectionPrompt,
26
- stalenessReminderPrompt,
27
- } from "./templates";
28
- import {
29
- clearGoalSession,
30
- type GoalSession,
31
- persistGoalState,
32
- updateWidget,
33
- } from "./tool-handler";
34
- import { renderTerminalStatusLine } from "./widget";
35
-
36
- // ── Orchestrator ──────────────────────────────────────
37
-
38
- export async function handleBeforeAgentStart(
39
- pi: ExtensionAPI,
40
- session: GoalSession,
41
- ctx: ExtensionContext,
42
- ) {
43
- if (!session.state) return;
44
-
45
- // 终态处理
46
- if (isTerminalStatus(session.state.status)) {
47
- handleTerminalStateBeforeAgent(session, ctx);
48
- return;
49
- }
50
- if (!isActiveStatus(session.state.status)) return;
51
-
52
- session.hasPendingInjection = true;
53
-
54
- // 停滞检测
55
- const staleResult = checkStaleness(session);
56
- if (staleResult) return staleResult;
57
-
58
- // Context 使用率检查
59
- const ctxResult = checkContextUsage(pi, session, ctx);
60
- if (ctxResult) return ctxResult;
61
-
62
- // 正常 context injection
63
- return {
64
- message: {
65
- customType: "goal-context",
66
- content: contextInjectionPrompt(session.state),
67
- display: false,
68
- },
69
- };
70
- }
71
-
72
- // ── Sub-handler 1: 终态处理 ──────────────────────────
73
-
74
- function handleTerminalStateBeforeAgent(
75
- session: GoalSession, ctx: ExtensionContext,
76
- ): void {
77
- const state = session.state!;
78
- const turnsInTerminal = state.currentTurnIndex - (state.completedAtTurnIndex ?? 0);
79
-
80
- if (turnsInTerminal >= AUTO_CLEAR_TURNS) {
81
- clearGoalSession(session, ctx);
82
- return;
83
- }
84
-
85
- // 折叠 status bar
86
- const statusText = renderTerminalStatusLine(state, ctx.ui.theme);
87
- if (statusText) ctx.ui.setStatus("goal", statusText);
88
- ctx.ui.setWidget("goal", undefined);
89
- }
90
-
91
- // ── Sub-handler 2: 停滞检测 ─────────────────────────
92
-
93
- function checkStaleness(session: GoalSession) {
94
- const state = session.state!;
95
- const staleTasks: Array<{
96
- task: GoalTask;
97
- staleTurns: number;
98
- staleSubtasks: Array<{ text: string; staleTurns: number }>;
99
- }> = [];
100
- let allTerminal = true;
101
-
102
- for (const task of state.tasks) {
103
- if (!isTerminalTaskStatus(task.status)) {
104
- allTerminal = false;
105
- const staleTurns = state.currentTurnIndex - task.lastUpdatedTurn;
106
- if (staleTurns >= TASK_STALL_TURN_THRESHOLD) {
107
- const staleSubtasks: Array<{ text: string; staleTurns: number }> = [];
108
- if (task.subtasks) {
109
- for (const s of task.subtasks) {
110
- if (s.status !== "completed") {
111
- const subStale = state.currentTurnIndex - s.lastUpdatedTurn;
112
- if (subStale >= TASK_STALL_TURN_THRESHOLD) {
113
- staleSubtasks.push({ text: s.text, staleTurns: subStale });
114
- }
115
- }
116
- }
117
- }
118
- staleTasks.push({ task, staleTurns, staleSubtasks });
119
- }
120
- }
121
- }
122
-
123
- // 所有 task 已终态但 goal 仍 active
124
- if (allTerminal && state.tasks.length > 0) {
125
- return {
126
- message: {
127
- customType: "goal-staleness-reminder",
128
- content: stalenessReminderPrompt(state, [], true),
129
- display: false,
130
- },
131
- };
132
- }
133
-
134
- // 有停滞项 → 注入提醒
135
- if (staleTasks.length > 0) {
136
- // 重置被提醒项的 lastUpdatedTurn
137
- for (const item of staleTasks) {
138
- item.task.lastUpdatedTurn = state.currentTurnIndex;
139
- if (item.task.subtasks) {
140
- for (const s of item.task.subtasks) {
141
- if (s.status !== "completed") s.lastUpdatedTurn = state.currentTurnIndex;
142
- }
143
- }
144
- }
145
- return {
146
- message: {
147
- customType: "goal-staleness-reminder",
148
- content: stalenessReminderPrompt(state, staleTasks, false),
149
- display: false,
150
- },
151
- };
152
- }
153
-
154
- return null;
155
- }
156
-
157
- // ── Sub-handler 3: Context 使用率 ───────────────────
158
-
159
- function checkContextUsage(
160
- pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext,
161
- ) {
162
- const usage = ctx.getContextUsage();
163
- if (usage && usage.contextWindow > 0 && (usage.tokens ?? 0) / usage.contextWindow > CONTEXT_USAGE_RATIO_LIMIT) {
164
- const state = session.state!;
165
- state.status = transitionStatus(state.status, "paused");
166
- persistGoalState(pi, session, ctx);
167
- updateWidget(session, ctx);
168
- return {
169
- message: {
170
- customType: "goal-context-exceeded",
171
- content:
172
- "[GOAL — context space low, must wrap up now]\n" +
173
- "1. Use goal_manager's list_tasks to check remaining tasks\n" +
174
- "2. Only mark tasks you genuinely completed with evidence\n" +
175
- "3. Summarize current progress and remaining work\n" +
176
- "Do not start new tasks.",
177
- display: false,
178
- },
179
- };
180
- }
181
- return null;
182
- }
package/src/budget.ts DELETED
@@ -1,159 +0,0 @@
1
- /**
2
- * Goal 预算策略 — 阈值、计算、检查
3
- *
4
- * 集中管理 token 和时间预算的所有决策逻辑。
5
- * 调用者(handleAgentEnd、resume)通过 checkBudget() 获取决策结果,
6
- * 不再需要了解阈值细节。
7
- */
8
-
9
- import {
10
- BUDGET_PERCENT_HIGH,
11
- BUDGET_PERCENT_LOW,
12
- BUDGET_RATIO_HIGH,
13
- BUDGET_RATIO_LOW,
14
- BUDGET_RATIO_TIGHT,
15
- PERCENT_FACTOR,
16
- SECONDS_PER_MINUTE,
17
- } from "./constants";
18
- import type { GoalRuntimeState } from "./state";
19
- import { getElapsedTimeSeconds } from "./state";
20
-
21
- // ── 决策类型 ────────────────────────────────────────
22
-
23
- export type BudgetDecision =
24
- | { type: "ok" }
25
- | { type: "warning70"; dimension: "token" | "time" }
26
- | { type: "warning90"; dimension: "token" | "time" }
27
- | { type: "steer_limit"; dimension: "token" | "time" }
28
- | { type: "exceeded"; dimension: "token" | "time" };
29
-
30
- // ── 百分比计算(供 widget 使用)──────────────────────
31
-
32
- export function getTokenUsagePercent(state: GoalRuntimeState): number {
33
- if (!state.budget.tokenBudget || state.budget.tokenBudget <= 0) return 0;
34
- return (state.tokensUsed / state.budget.tokenBudget) * PERCENT_FACTOR;
35
- }
36
-
37
- export function getTimeUsagePercent(state: GoalRuntimeState): number {
38
- if (!state.budget.timeBudgetMinutes || state.budget.timeBudgetMinutes <= 0) return 0;
39
- const elapsed = getElapsedTimeSeconds(state);
40
- const budgetSeconds = state.budget.timeBudgetMinutes * SECONDS_PER_MINUTE;
41
- return (elapsed / budgetSeconds) * PERCENT_FACTOR;
42
- }
43
-
44
- // ── Widget 颜色阈值(供 widget.ts 使用)──────────────
45
-
46
- export function getBudgetColor(percent: number): "error" | "warning" | "muted" {
47
- if (percent >= BUDGET_PERCENT_HIGH) return "error";
48
- if (percent >= BUDGET_PERCENT_LOW) return "warning";
49
- return "muted";
50
- }
51
-
52
- // ── Resume 时的预算检查 ──────────────────────────────
53
-
54
- export function checkBudgetOnResume(state: GoalRuntimeState): { type: "exceeded"; dimension: "token" | "time" } | null {
55
- if (state.budget.tokenBudget && state.tokensUsed >= state.budget.tokenBudget) {
56
- return { type: "exceeded", dimension: "token" };
57
- }
58
- if (state.budget.timeBudgetMinutes) {
59
- const elapsed = getElapsedTimeSeconds(state);
60
- if (elapsed >= state.budget.timeBudgetMinutes * SECONDS_PER_MINUTE) {
61
- return { type: "exceeded", dimension: "time" };
62
- }
63
- }
64
- return null;
65
- }
66
-
67
- // ── Agent turn 结束时的预算检查 ─────────────────────
68
-
69
- export interface BudgetCheckResult {
70
- /** 终止性决策(预算耗尽时只返回一个,优先 token) */
71
- terminal: { type: "exceeded"; dimension: "token" | "time" } | null;
72
- /** 需要发送的预警(70%/90%) */
73
- warnings: BudgetDecision[];
74
- /** 是否已发送过 steering(90% token 收尾) */
75
- shouldSendSteering: boolean;
76
- }
77
-
78
- export function checkBudgetOnTurnEnd(state: GoalRuntimeState): BudgetCheckResult {
79
- const result: BudgetCheckResult = {
80
- terminal: null,
81
- warnings: [],
82
- shouldSendSteering: false,
83
- };
84
-
85
- // Token 预算检查
86
- if (state.budget.tokenBudget) {
87
- const tokenPct = state.tokensUsed / state.budget.tokenBudget;
88
-
89
- // 100% 耗尽 + 已发过 steering → 终止
90
- if (tokenPct >= 1 && state.budgetLimitSteeringSent) {
91
- result.terminal = { type: "exceeded", dimension: "token" };
92
- return result;
93
- }
94
-
95
- // 90% + 未发 steering → 发 steer
96
- if (tokenPct >= BUDGET_RATIO_HIGH && !state.budgetLimitSteeringSent) {
97
- result.shouldSendSteering = true;
98
- return result;
99
- }
100
-
101
- // 90% 预警(未发过)
102
- if (tokenPct >= BUDGET_RATIO_HIGH && !state.budgetWarning90Sent) {
103
- result.warnings.push({ type: "warning90", dimension: "token" });
104
- } else if (tokenPct >= BUDGET_RATIO_LOW && !state.budgetWarning70Sent) {
105
- result.warnings.push({ type: "warning70", dimension: "token" });
106
- }
107
- }
108
-
109
- // 时间预算检查
110
- if (state.budget.timeBudgetMinutes) {
111
- const elapsed = getElapsedTimeSeconds(state);
112
- const timePct = elapsed / (state.budget.timeBudgetMinutes * SECONDS_PER_MINUTE);
113
-
114
- if (elapsed >= state.budget.timeBudgetMinutes * SECONDS_PER_MINUTE) {
115
- result.terminal = { type: "exceeded", dimension: "time" };
116
- return result;
117
- }
118
-
119
- if (timePct >= BUDGET_RATIO_HIGH && !state.budgetWarning90Sent) {
120
- result.warnings.push({ type: "warning90", dimension: "time" });
121
- } else if (timePct >= BUDGET_RATIO_LOW && !state.budgetWarning70Sent) {
122
- result.warnings.push({ type: "warning70", dimension: "time" });
123
- }
124
- }
125
-
126
- return result;
127
- }
128
-
129
- // ── 进展评估 ────────────────────────────────────────
130
-
131
- export interface ProgressCheck {
132
- allTasksDone: boolean;
133
- noTasksCreated: boolean;
134
- maxTurnsReached: boolean;
135
- isStalled: boolean;
136
- budgetTight: boolean;
137
- completedCount: number;
138
- totalCount: number;
139
- }
140
-
141
- export function checkProgress(state: GoalRuntimeState, tasksCompletedAtStart: number): ProgressCheck {
142
- const incomplete = state.tasks.filter((t) => t.status === "pending" || t.status === "in_progress");
143
- const completedCount = state.tasks.filter((t) => t.status === "completed").length;
144
- const totalCount = state.tasks.length;
145
- const progressThisRound = completedCount - tasksCompletedAtStart;
146
-
147
- return {
148
- allTasksDone: totalCount > 0 && incomplete.length === 0 && completedCount > 0,
149
- noTasksCreated: totalCount === 0,
150
- maxTurnsReached: state.currentTurnIndex >= state.budget.maxTurns,
151
- isStalled: progressThisRound === 0,
152
- budgetTight: Boolean(
153
- state.budget.tokenBudget &&
154
- state.tokensUsed >= state.budget.tokenBudget * BUDGET_RATIO_TIGHT,
155
- ),
156
- completedCount,
157
- totalCount,
158
- };
159
- }
@@ -1,280 +0,0 @@
1
- /**
2
- * /goal 命令处理子函数
3
- *
4
- * handleGoalCommand (orchestrator, ≤20 行) 委托到 7 个子函数:
5
- * handleStatus, handlePause, handleResume, handleHistory, handleClear, handleUpdate, handleSet
6
- *
7
- * P1-5/6 修复:将 233 行的大函数拆分为 ≤20 行子函数。
8
- */
9
-
10
- import type { CustomEntry,ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
11
-
12
- import { checkBudgetOnResume } from "./budget.js";
13
- import { parseGoalArgs } from "./commands";
14
- import {
15
- OBJECTIVE_DISPLAY_LIMIT,
16
- OBJECTIVE_TRUNCATE_KEEP,
17
- SECONDS_PER_MINUTE,
18
- } from "./constants";
19
- import {
20
- type BudgetConfig,
21
- createInitialState,
22
- DEFAULT_BUDGET,
23
- getCompletedCount,
24
- getElapsedTimeSeconds,
25
- getIncompleteTasks,
26
- isActiveStatus,
27
- isTerminalStatus,
28
- transitionStatus,
29
- } from "./state";
30
- import { objectiveUpdatedPrompt } from "./templates";
31
- import {
32
- clearGoalSession,
33
- type GoalSession,
34
- persistGoalState,
35
- updateWidget,
36
- writeGoalHistoryEntry,
37
- } from "./tool-handler";
38
-
39
- // ── Orchestrator ──────────────────────────────────────
40
-
41
- export async function handleGoalCommand(
42
- pi: ExtensionAPI,
43
- session: GoalSession,
44
- args: string | undefined,
45
- ctx: ExtensionContext,
46
- ): Promise<void> {
47
- const parsed = parseGoalArgs(args ?? "");
48
- switch (parsed.action) {
49
- case "status": return handleStatus(session, ctx);
50
- case "pause": return handlePause(pi, session, ctx);
51
- case "resume": return handleResume(pi, session, ctx);
52
- case "history": return handleHistory(ctx);
53
- case "clear": return handleClear(pi, session, ctx);
54
- case "update": return handleUpdate(pi, session, parsed.objective, ctx);
55
- case "set": return handleSet(pi, session, parsed.objective ?? "", parsed.budget, ctx);
56
- }
57
- }
58
-
59
- // ── /goal status ──────────────────────────────────────
60
-
61
- function handleStatus(session: GoalSession, ctx: ExtensionContext): void {
62
- if (!session.state) {
63
- ctx.ui.notify("Goal mode not active. Use /goal <objective> to start.", "info");
64
- return;
65
- }
66
- const state = session.state;
67
- const completed = getCompletedCount(state.tasks);
68
- const total = state.tasks.length;
69
- const elapsed = getElapsedTimeSeconds(state);
70
- const lines = [
71
- `Objective: ${state.objective}`,
72
- `Status: ${state.status}`,
73
- `Turn: ${state.currentTurnIndex}/${state.budget.maxTurns}`,
74
- `Tasks: ${completed}/${total} completed`,
75
- `Stall turns: ${state.stallCount}`,
76
- `Time elapsed: ${Math.floor(elapsed / SECONDS_PER_MINUTE)}m${Math.floor(elapsed % SECONDS_PER_MINUTE)}s`,
77
- state.budget.tokenBudget ? `Token: ${state.tokensUsed}/${state.budget.tokenBudget}` : null,
78
- `Goal ID: ${state.goalId}`,
79
- ].filter(Boolean);
80
- ctx.ui.notify(lines.join("\n"), "info");
81
- }
82
-
83
- // ── /goal pause ───────────────────────────────────────
84
-
85
- function handlePause(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
86
- if (!session.state) { ctx.ui.notify("Goal mode not active.", "warning"); return; }
87
- if (isTerminalStatus(session.state.status)) {
88
- ctx.ui.notify(`Goal is in terminal state (${session.state.status}), cannot pause.`, "warning");
89
- return;
90
- }
91
- session.state.status = transitionStatus(session.state.status, "paused");
92
- persistGoalState(pi, session, ctx);
93
- updateWidget(session, ctx);
94
- ctx.ui.notify("Goal paused. Use /goal resume to continue.", "info");
95
- }
96
-
97
- // ── /goal resume ──────────────────────────────────────
98
-
99
- function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
100
- if (!session.state) { ctx.ui.notify("Goal mode not active.", "warning"); return; }
101
- const state = session.state;
102
- if (isTerminalStatus(state.status)) {
103
- ctx.ui.notify(`Goal is in terminal state (${state.status}), cannot resume.`, "warning");
104
- return;
105
- }
106
- if (state.status !== "paused" && state.status !== "blocked") {
107
- ctx.ui.notify("Goal is not paused or blocked, no need to resume.", "info");
108
- return;
109
- }
110
- state.status = "active";
111
- state.stallCount = 0;
112
- state.timeStartedAt = Date.now();
113
-
114
- // Resume 时重检预算
115
- const resumeBudgetCheck = checkBudgetOnResume(state);
116
- if (resumeBudgetCheck) {
117
- const dim = resumeBudgetCheck.dimension;
118
- state.status = transitionStatus(state.status, dim === "token" ? "budget_limited" : "time_limited");
119
- persistGoalState(pi, session, ctx);
120
- updateWidget(session, ctx);
121
- ctx.ui.notify(`${dim === "token" ? "Token" : "Time"} budget exhausted, cannot resume. Use /goal clear to reset.`, "warning");
122
- return;
123
- }
124
- persistGoalState(pi, session, ctx);
125
- updateWidget(session, ctx);
126
-
127
- const incomplete = getIncompleteTasks(state.tasks);
128
- if (incomplete.length > 0) {
129
- pi.sendUserMessage(
130
- `Goal resumed. Continuing with ${incomplete.length} remaining tasks.` +
131
- (state.lastBlockerReason ? `\n\nPrevious blocker: ${state.lastBlockerReason}. Try a different approach.` : "") +
132
- `\n\nObjective: ${state.objective}`,
133
- { deliverAs: "followUp" },
134
- );
135
- } else {
136
- ctx.ui.notify("All tasks completed.", "info");
137
- }
138
- }
139
-
140
- // ── /goal history ─────────────────────────────────────
141
-
142
- function handleHistory(ctx: ExtensionContext): void {
143
- const entries = ctx.sessionManager.getEntries();
144
- const historyEntries = entries.filter(
145
- (e) => e.type === "custom" && (e as CustomEntry).customType === "goal-history",
146
- ) as Array<CustomEntry<{
147
- goalId: string;
148
- objective: string;
149
- status: string;
150
- completedTasks: number;
151
- totalTasks: number;
152
- elapsedSeconds: number;
153
- timestamp: number;
154
- }>>;
155
-
156
- if (historyEntries.length === 0) {
157
- ctx.ui.notify("No goal history", "info");
158
- return;
159
- }
160
-
161
- const sorted = [...historyEntries].reverse();
162
- const lines: string[] = ["Goal history:\n"];
163
- for (let i = 0; i < sorted.length; i++) {
164
- const h = sorted[i]!.data;
165
- if (!h) continue;
166
- const statusIcon =
167
- h.status === "complete" ? "✓" :
168
- h.status === "cancelled" ? "✗" :
169
- h.status === "budget_limited" ? "⊗" :
170
- h.status === "time_limited" ? "⏱" : "?";
171
- const objDisplay = h.objective.length > OBJECTIVE_DISPLAY_LIMIT
172
- ? h.objective.slice(0, OBJECTIVE_TRUNCATE_KEEP) + "..."
173
- : h.objective;
174
- const mins = Math.floor(h.elapsedSeconds / SECONDS_PER_MINUTE);
175
- const secs = Math.floor(h.elapsedSeconds % SECONDS_PER_MINUTE);
176
- lines.push(`${i + 1}. ${statusIcon} ${objDisplay}`);
177
- lines.push(` ${h.completedTasks}/${h.totalTasks} tasks | ${mins}m${secs}s | ${h.status}`);
178
- }
179
- ctx.ui.notify(lines.join("\n"), "info");
180
- }
181
-
182
- // ── /goal clear ───────────────────────────────────────
183
-
184
- function handleClear(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
185
- if (!session.state) { ctx.ui.notify("Goal mode not active.", "info"); return; }
186
- session.state.status = "cancelled";
187
- session.state.completedAtTurnIndex = session.state.currentTurnIndex;
188
- writeGoalHistoryEntry(pi, session);
189
- persistGoalState(pi, session, ctx);
190
- clearGoalSession(session, ctx);
191
- ctx.ui.notify("Goal cleared.", "info");
192
- }
193
-
194
- // ── /goal update ──────────────────────────────────────
195
-
196
- function handleUpdate(
197
- pi: ExtensionAPI, session: GoalSession,
198
- newObjective: string | undefined, ctx: ExtensionContext,
199
- ): void {
200
- if (!session.state) { ctx.ui.notify("Goal mode not active.", "warning"); return; }
201
- if (!newObjective) {
202
- ctx.ui.notify("Usage: /goal update <new-objective>", "warning");
203
- return;
204
- }
205
- const state = session.state;
206
- const oldObjective = state.objective;
207
- state.objective = newObjective;
208
- state.objectiveUpdatedAt = Date.now();
209
- state.tasks = [];
210
- state.stallCount = 0;
211
- state.turnCount = 0;
212
- state.currentTurnIndex = 0;
213
- state.lastProgressTurn = 0;
214
- state.budgetLimitSteeringSent = false;
215
- state.budgetWarning70Sent = false;
216
- state.budgetWarning90Sent = false;
217
- session.tasksCompletedAtAgentStart = 0;
218
- persistGoalState(pi, session, ctx);
219
- updateWidget(session, ctx);
220
- ctx.ui.notify(`Objective updated:\nPrevious: ${oldObjective}\nNew: ${newObjective}`, "info");
221
-
222
- if (isActiveStatus(state.status)) {
223
- pi.sendUserMessage(objectiveUpdatedPrompt(state, oldObjective), { deliverAs: "steer" });
224
- }
225
- }
226
-
227
- // ── /goal set (<objective>) ───────────────────────────
228
-
229
- function handleSet(
230
- pi: ExtensionAPI, session: GoalSession,
231
- objective: string, budgetOverrides: Partial<BudgetConfig> | undefined, ctx: ExtensionContext,
232
- ): void {
233
- if (!objective) {
234
- ctx.ui.notify("Usage: /goal <objective> [--tokens N] [--timeout N]", "warning");
235
- return;
236
- }
237
- if (!objective.trim()) {
238
- ctx.ui.notify("Objective cannot be empty.", "warning");
239
- return;
240
- }
241
- if (session.state && !isTerminalStatus(session.state.status)) {
242
- ctx.ui.notify(
243
- `Cancelled previous Goal: ${session.state.objective}\n(new goal started)`,
244
- "info",
245
- );
246
- session.state.status = "cancelled";
247
- session.state.completedAtTurnIndex = session.state.currentTurnIndex;
248
- writeGoalHistoryEntry(pi, session);
249
- persistGoalState(pi, session, ctx);
250
- }
251
-
252
- if (budgetOverrides?.tokenBudget !== undefined && budgetOverrides.tokenBudget <= 0) {
253
- ctx.ui.notify("Token budget must be greater than 0.", "warning");
254
- return;
255
- }
256
- const budget: Partial<BudgetConfig> = {};
257
- if (budgetOverrides?.tokenBudget) budget.tokenBudget = budgetOverrides.tokenBudget;
258
- if (budgetOverrides?.timeBudgetMinutes) budget.timeBudgetMinutes = budgetOverrides.timeBudgetMinutes;
259
- budget.maxTurns = budgetOverrides?.maxTurns ?? DEFAULT_BUDGET.maxTurns;
260
- budget.maxStallTurns = budgetOverrides?.maxStallTurns ?? DEFAULT_BUDGET.maxStallTurns;
261
-
262
- session.state = createInitialState(objective, budget);
263
- session.tasksCompletedAtAgentStart = 0;
264
- session.hasPendingInjection = false;
265
-
266
- persistGoalState(pi, session, ctx);
267
- updateWidget(session, ctx);
268
-
269
- const budgetNotice: string[] = [];
270
- if (budget.tokenBudget) budgetNotice.push(`Token budget: ${budget.tokenBudget}`);
271
- if (budget.timeBudgetMinutes) budgetNotice.push(`Time budget: ${budget.timeBudgetMinutes} min`);
272
- const notice = [
273
- `Goal started: ${objective}`,
274
- `Max turns: ${budget.maxTurns}`,
275
- ...budgetNotice,
276
- ].join("\n");
277
- ctx.ui.notify(notice, "info");
278
-
279
- pi.sendUserMessage(objective, { deliverAs: "followUp" });
280
- }