killeros 3.0.6 → 3.0.8

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.
@@ -1,585 +1,666 @@
1
- import { StringEnum } from "@earendil-works/pi-ai";
2
- import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
3
- import { Text } from "@earendil-works/pi-tui";
4
- import { Type } from "typebox";
5
- import { BoundedText } from "./bounded-text.ts";
6
- import { formatTime, formatTokens } from "./display.ts";
7
- import { reportError } from "./errors.ts";
8
- import { parseGoalCommand } from "./goal-command.ts";
9
- import { GOAL_ENTRY_TYPE, GOAL_UPDATE_TOOL, isGoalModeSupported, isSavedSession, pauseGoalAfterFailure, persistGoalState, scheduleGoalContinuation, stopGoalRun, sumGoalTokens, syncGoalUpdateTool, transitionGoal, type GoalEntryData } from "./goal-runtime.ts";
10
- import { checkpointPausedGoalState, createNewGoalState, DEFAULT_GOAL_MAX_TURNS, GOAL_EVIDENCE_LIMIT, GOAL_MAX_TURNS, goalElapsedMilliseconds, GOAL_VERSION, inferGoalVerification, normalizeGoalText, parseGoalState, recordGoalDecision, transitionGoalState, verifyGoalDeliverable } from "./goal-state.ts";
11
- import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
12
- import { safeTerminalText } from "./safe-terminal-text.ts";
13
-
14
- const GoalUpdateParams = Type.Object({
15
- status: StringEnum(["complete", "continue", "blocked"] as const, {
16
- description: "Record an active-goal decision, or complete a blocked goal during an eligible later ordinary request",
17
- }),
18
- evidence: Type.String({
19
- minLength: 1,
20
- maxLength: GOAL_EVIDENCE_LIMIT,
21
- description: "Concrete current-turn evidence for completion, progress, or the repeated blocker",
22
- }),
23
- nextAction: Type.Optional(Type.String({
24
- minLength: 1,
25
- maxLength: GOAL_EVIDENCE_LIMIT,
26
- description: "One concrete action toward the unchanged objective when status is continue",
27
- })),
28
- blockerKey: Type.Optional(Type.String({
29
- minLength: 1,
30
- maxLength: 120,
31
- pattern: "^[a-z0-9][a-z0-9._-]{0,119}$",
32
- description: "Stable lowercase key identifying the repeated blocker",
33
- })),
34
- });
35
-
36
- interface GoalUpdateDetails {
37
- status: "complete" | "continue" | "blocked" | "blocker-audit";
38
- evidence: string;
39
- nextAction?: string;
40
- verification?: "file" | "model-reported";
41
- blockerKey?: string;
42
- streak?: number;
43
- }
44
-
45
- function goalPanelActions(status: GoalStatus): Array<{ label: string; control: "pause" | "resume" | "clear" }> {
46
- if (status === "active") return [{ label: "Pause goal", control: "pause" }, { label: "Clear goal", control: "clear" }];
47
- if (status === "paused" || status === "blocked") {
48
- return [{ label: "Resume goal", control: "resume" }, { label: "Clear goal", control: "clear" }];
49
- }
50
- return [{ label: "Clear goal", control: "clear" }];
51
- }
52
-
53
- function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
54
- const usedTokens = Math.max(0, sumGoalTokens(ctx) - state.baselineTokens);
55
- const turns = state.maxTurns === undefined
56
- ? `${state.turns} turn${state.turns === 1 ? "" : "s"}`
57
- : `${state.turns}/${state.maxTurns} turns`;
58
- const lines = [
59
- `Goal ${state.status} · ${turns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))} · ${formatTokens(usedTokens)} tokens`,
60
- state.objective,
61
- ...(state.verification === undefined ? [] : [`Deliverable · ${state.verification.path}`]),
62
- ];
63
- const decision = state.turnDecision ?? state.lastDecision;
64
- if (decision?.kind === "continue") {
65
- lines.push(`Progress · model-reported on turn ${decision.turn}`);
66
- lines.push(`Next · ${decision.nextAction}`);
67
- } else if (decision?.kind === "blocker-audit") {
68
- lines.push(`Blocker audit · ${decision.streak}/3 on turn ${decision.turn}`);
69
- lines.push(`Evidence · ${decision.evidence}`);
70
- } else if (decision?.kind === "blocked") {
71
- lines.push(`Evidence · ${decision.evidence}`);
72
- } else if (decision?.kind === "complete") {
73
- lines.push(`Completion · ${decision.verification === "file" ? `verified file ${state.verification?.path ?? "deliverable"}` : "model-reported"}`);
74
- lines.push(`Evidence · ${decision.evidence}`);
75
- }
76
- if (state.status === "paused") {
77
- lines.push(state.turns === 0 ? "Paused at · before turn 1" : `Paused at · turn ${state.turns}`);
78
- const reason = state.stopReason ?? state.result;
79
- if (reason) lines.push(`Reason · ${reason}`);
80
- if (state.stopReason === "repeated continue report" && state.lastContinueReport) {
81
- lines.push(`Repeated turns · ${state.lastContinueReport.turn} and ${state.turns}`);
82
- }
83
- } else if (state.result && decision?.kind !== "complete" && decision?.kind !== "blocked") {
84
- lines.push(`Result · ${state.result}`);
85
- }
86
- return safeTerminalText(lines.join("\n"));
87
- }
88
-
89
- export function registerGoalInterface(
90
- pi: ExtensionAPI,
91
- runtime: GoalRuntime,
92
- ): void {
93
- pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, options, theme) => {
94
- const data = entry.data;
95
- if (!data || data.version !== GOAL_VERSION) return undefined;
96
- if (data.event === "clear" || data.state === null) return new Text(theme.fg("dim", "Goal cleared"), 0, 0);
97
- const event = data.event;
98
- if (event !== "set" && event !== "replace" && event !== "pause" && event !== "resume" && event !== "limit" && event !== "error") return undefined;
99
- const state = parseGoalState(data.state);
100
- if (!state) return undefined;
101
- const reason = state.stopReason ?? state.result;
102
- const objective = safeTerminalText(state.objective);
103
- let label: string;
104
- let detail: string;
105
- let color: ThemeColor;
106
- switch (event) {
107
- case "set":
108
- label = "Goal started";
109
- detail = objective;
110
- color = "customMessageLabel";
111
- break;
112
- case "replace":
113
- label = "Goal replaced";
114
- detail = objective;
115
- color = "customMessageLabel";
116
- break;
117
- case "resume":
118
- label = "Goal resumed";
119
- detail = objective;
120
- color = "customMessageLabel";
121
- break;
122
- case "pause":
123
- label = "Goal paused";
124
- detail = reason ?? objective;
125
- color = "warning";
126
- break;
127
- case "limit":
128
- label = "Goal paused";
129
- detail = reason ?? "";
130
- color = "warning";
131
- break;
132
- case "error":
133
- label = "Goal paused";
134
- detail = reason ?? "";
135
- color = "error";
136
- break;
137
- }
138
- const safeDetail = safeTerminalText(detail);
139
- const status = theme.fg(color, label);
140
- if (!options.expanded) {
141
- return new BoundedText(safeDetail ? `${status}${theme.fg("dim", " · ")}${safeDetail}` : status, 1);
142
- }
143
- const lines = [status, objective];
144
- if (reason && reason !== objective) lines.push(theme.fg("muted", safeTerminalText(reason)));
145
- return new BoundedText(lines.join("\n"));
146
- });
147
-
148
- pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
149
- name: GOAL_UPDATE_TOOL,
150
- label: "Goal update",
151
- description: "Record exactly one active-goal decision: complete after verification, continue with evidence and one next action, or audit the same blocker before blocking it. During an eligible later ordinary request for a blocked goal, only verified completion of the whole unchanged objective is permitted; it does not resume work or grant turns.",
152
- parameters: GoalUpdateParams,
153
- exposure: "model-only",
154
- executionMode: "sequential",
155
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
156
- signal?.throwIfAborted();
157
- if (!isGoalModeSupported(ctx)) throw new Error("KillerOS goals require TUI or RPC mode");
158
- if (!isSavedSession(ctx)) throw new Error("KillerOS goals require a saved session");
159
- const state = runtime.state;
160
- if (!state || state.status !== "active" && state.status !== "blocked") throw new Error("There is no active KillerOS goal to update");
161
- const generation = runtime.lifecycleGeneration;
162
- const permission = runtime.blockedCompletion;
163
- const ownsDecision = (): boolean => runtime.state === state
164
- && runtime.lifecycleGeneration === generation
165
- && (state.status === "blocked"
166
- ? permission?.kind === "eligible"
167
- && runtime.blockedCompletion === permission
168
- && permission.state === state
169
- && permission.generation === generation
170
- : runtime.goalTurnInFlight
171
- && runtime.goalTurn?.turn === state.turns
172
- && runtime.goalTurn.revision === state.revision
173
- && state.turnDecision === undefined);
174
- if (state.status === "blocked") {
175
- if (!ownsDecision()) throw new Error("A blocked goal can only be completed during an eligible later ordinary request");
176
- if (params.status !== "complete") throw new Error("A blocked goal accepts only complete; automatic continuation remains stopped");
177
- if (state.turns === 0) throw new Error("A zero-turn blocked goal cannot record completion; no accounted goal turn exists");
178
- } else {
179
- if (state.turnDecision !== undefined) throw new Error("Only one goal decision may be accepted per logical goal turn");
180
- if (!ownsDecision()) throw new Error("A goal decision can only be recorded during an active KillerOS goal turn");
181
- }
182
- if (params.status !== "complete" && params.status !== "continue" && params.status !== "blocked") {
183
- throw new Error("Goal update status is invalid");
184
- }
185
- if (typeof params.evidence !== "string") throw new Error("Goal evidence must be text");
186
- const evidence = normalizeGoalText(params.evidence, GOAL_EVIDENCE_LIMIT, "Goal evidence");
187
-
188
- if (params.status === "complete") {
189
- if (state.verification) await verifyGoalDeliverable(state.verification);
190
- signal?.throwIfAborted();
191
- if (!ownsDecision()) throw new Error("Goal changed while completion was being verified");
192
- const verification: "file" | "model-reported" = state.verification ? "file" : "model-reported";
193
- const decision = { kind: "complete" as const, turn: state.turns, evidence, verification };
194
- try {
195
- transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true, decision });
196
- } catch (error) {
197
- pauseGoalAfterFailure(pi, runtime, ctx, `goal completion could not be saved: ${error instanceof Error ? error.message : String(error)}`);
198
- throw error;
199
- }
200
- if (state.status === "blocked") runtime.blockedCompletion = { kind: "accepted", generation };
201
- const text = state.verification
202
- ? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${evidence}`
203
- : `Goal marked complete (model-reported): ${evidence}`;
204
- return {
205
- content: [{ type: "text", text }],
206
- details: { status: "complete", evidence, verification },
207
- };
208
- }
209
-
210
- if (state.status !== "active") throw new Error("A blocked goal accepts only complete");
211
- if (params.status === "continue") {
212
- if (typeof params.nextAction !== "string") throw new Error("A continue decision requires a nextAction");
213
- const nextAction = normalizeGoalText(params.nextAction, GOAL_EVIDENCE_LIMIT, "Goal nextAction");
214
- const previous = state.lastContinueReport;
215
- if (previous?.turn === state.turns - 1
216
- && previous.evidence === evidence
217
- && previous.nextAction === nextAction) {
218
- pauseGoalAfterFailure(pi, runtime, ctx, "repeated continue report", "Run /goal resume only after choosing a different next action.", false);
219
- ctx.ui.notify(
220
- `Goal paused: repeated continue report on turns ${previous.turn} and ${state.turns}\nRun /goal resume only after choosing a different next action.`,
221
- "error",
222
- );
223
- throw new Error("repeated continue report");
224
- }
225
- const decision = { kind: "continue" as const, turn: state.turns, evidence, nextAction };
226
- try {
227
- const next = recordGoalDecision(state, decision, Date.now());
228
- persistGoalState(pi, runtime, "continue", next);
229
- runtime.goalTurn = { turn: next.turns, revision: next.revision };
230
- } catch (error) {
231
- pauseGoalAfterFailure(pi, runtime, ctx, `goal decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
232
- throw error;
233
- }
234
- return {
235
- content: [{ type: "text", text: `Model-reported progress recorded; the goal remains active: ${evidence}` }],
236
- details: { status: "continue", evidence, nextAction },
237
- };
238
- }
239
-
240
- const blockerKey = params.blockerKey;
241
- if (!blockerKey || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(blockerKey)) {
242
- throw new Error("A blocked goal update requires a stable lowercase blockerKey");
243
- }
244
- const previous = state.blockerAudit;
245
- if (previous?.lastTurn === state.turns) {
246
- throw new Error("Only one goal decision may be accepted per logical goal turn");
247
- }
248
- const consecutive = previous?.key === blockerKey && previous.lastTurn === state.turns - 1;
249
- const streak = consecutive ? previous.streak + 1 : 1;
250
- const blockerAudit = { key: blockerKey, streak, lastTurn: state.turns, evidence };
251
- if (streak < 3) {
252
- const decision = { kind: "blocker-audit" as const, turn: state.turns, blockerKey, streak, evidence };
253
- try {
254
- const next = {
255
- ...recordGoalDecision(state, decision, Date.now()),
256
- blockerAudit,
257
- };
258
- persistGoalState(pi, runtime, "blocker-audit", next);
259
- runtime.goalTurn = { turn: next.turns, revision: next.revision };
260
- } catch (error) {
261
- pauseGoalAfterFailure(pi, runtime, ctx, `blocker decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
262
- throw error;
263
- }
264
- return {
265
- content: [{ type: "text", text: `Blocker audit ${streak}/3 recorded; the goal remains active: ${evidence}` }],
266
- details: { status: "blocker-audit", evidence, blockerKey, streak },
267
- };
268
- }
269
- const decision = { kind: "blocked" as const, turn: state.turns, blockerKey, streak: 3 as const, evidence };
270
- try {
271
- transitionGoal(pi, runtime, "blocked", "blocked", evidence, { blockerAudit, decision });
272
- } catch (error) {
273
- pauseGoalAfterFailure(pi, runtime, ctx, `blocked decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
274
- throw error;
275
- }
276
- return {
277
- content: [{ type: "text", text: `Goal marked blocked: ${evidence}` }],
278
- details: { status: "blocked", evidence, blockerKey, streak },
279
- };
280
- },
281
- renderCall(args, theme) {
282
- return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", safeTerminalText(args.status))}`, 0, 0);
283
- },
284
- renderResult(result, options, theme, context) {
285
- if (context?.isError) {
286
- const first = result.content[0];
287
- const message = first?.type === "text" ? safeTerminalText(first.text) : "Goal update failed";
288
- return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 1);
289
- }
290
- const details = result.details;
291
- if (!details || typeof details.evidence !== "string") return new BoundedText(theme.fg("dim", "Goal updated"), options.expanded ? undefined : 1);
292
- const label = details.status === "complete"
293
- ? "Goal completed"
294
- : details.status === "blocked"
295
- ? "Goal blocked"
296
- : details.status === "continue"
297
- ? "Progress recorded"
298
- : `Blocker audit ${details.streak}/3`;
299
- const color: ThemeColor = details.status === "complete"
300
- ? "success"
301
- : details.status === "blocked"
302
- ? "error"
303
- : details.status === "continue"
304
- ? "customMessageLabel"
305
- : "warning";
306
- const text = `${theme.fg(color, label)}${theme.fg("dim", " · ")}${safeTerminalText(details.evidence)}`;
307
- return new BoundedText(text, options.expanded ? undefined : 1);
308
- },
309
- });
310
- syncGoalUpdateTool(pi, runtime);
311
- const handleGoalCommand = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
312
- const command = parseGoalCommand(args);
313
- if (ctx.mode === "print" || ctx.mode === "json") {
314
- ctx.ui.notify("/goal requires TUI or RPC mode", "error");
315
- return;
316
- }
317
- if (command.kind === "invalid") {
318
- ctx.ui.notify(command.message, "error");
319
- return;
320
- }
321
- if (!isSavedSession(ctx)) {
322
- ctx.ui.notify("/goal requires a saved session", "error");
323
- return;
324
- }
325
- const generation = runtime.lifecycleGeneration;
326
- const initiatingState = runtime.state;
327
- const ownsGoal = (): boolean => runtime.lifecycleGeneration === generation && runtime.state === initiatingState;
328
-
329
- if (command.kind === "status") {
330
- if (!runtime.state) {
331
- ctx.ui.notify("No goal is set. Use /goal <objective> to start a long-running task.", "info");
332
- return;
333
- }
334
- if (ctx.mode !== "tui") {
335
- ctx.ui.notify(goalStatusSummary(runtime.state, ctx), "info");
336
- return;
337
- }
338
- const actions = goalPanelActions(runtime.state.status);
339
- const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
340
- if (!ownsGoal()) return;
341
- const action = actions.find((candidate) => candidate.label === selected);
342
- if (!action) return;
343
- if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", safeTerminalText(runtime.state.objective))) return;
344
- if (!ownsGoal()) return;
345
- await handleGoalCommand(action.control, ctx);
346
- return;
347
- }
348
-
349
- if (command.kind === "clear") {
350
- if (!runtime.state) {
351
- ctx.ui.notify("No goal is set", "info");
352
- return;
353
- }
354
- const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
355
- let saved = false;
356
- try {
357
- persistGoalState(pi, runtime, "clear", undefined);
358
- saved = true;
359
- } catch (error) {
360
- if (runtime.state?.status === "active") {
361
- pauseGoalAfterFailure(
362
- pi,
363
- runtime,
364
- ctx,
365
- `the requested clear could not be saved: ${error instanceof Error ? error.message : String(error)}`,
366
- "Automatic continuation is stopped. Retry /goal clear to remove the goal.",
367
- false,
368
- );
369
- } else {
370
- reportError(ctx, "Goal could not be cleared", error);
371
- return;
372
- }
373
- }
374
- try {
375
- await stopGoalRun(runtime, ctx, shouldStopGoalRun);
376
- } catch (error) {
377
- reportError(ctx, saved ? "Goal cleared, but the active goal turn could not be confirmed stopped" : "Goal paused, but the active goal turn could not be confirmed stopped", error);
378
- return;
379
- }
380
- if (saved) {
381
- if (ctx.mode !== "tui") ctx.ui.notify("Goal cleared", "info");
382
- } else {
383
- ctx.ui.notify("Goal paused: the requested clear could not be saved\nAutomatic continuation is stopped. Retry /goal clear to remove the goal.", "error");
384
- }
385
- return;
386
- }
387
-
388
- if (command.kind === "pause") {
389
- if (!runtime.state) {
390
- ctx.ui.notify("No goal is set", "info");
391
- return;
392
- }
393
- if (runtime.state.status === "paused") {
394
- if (!runtime.persistenceRetryNeeded
395
- && runtime.state.resumeAfterManualCompaction !== true
396
- && runtime.automaticCompaction === undefined) {
397
- ctx.ui.notify("Goal is already paused", "info");
398
- return;
399
- }
400
- const checkpoint = checkpointPausedGoalState(runtime.state, Date.now());
401
- try {
402
- persistGoalState(pi, runtime, "pause", checkpoint);
403
- ctx.ui.notify("Goal pause saved. Goal remains paused. Automatic compaction recovery is off.", "info");
404
- } catch (error) {
405
- runtime.state = checkpoint;
406
- syncGoalUpdateTool(pi, runtime);
407
- runtime.persistenceRetryNeeded = true;
408
- runtime.continuationScheduled = false;
409
- runtime.requestRender?.();
410
- reportError(ctx, "Goal pause still could not be saved", error);
411
- }
412
- return;
413
- }
414
- if (runtime.state.status !== "active") {
415
- ctx.ui.notify(`Goal is ${runtime.state.status}; only an active goal can be paused`, "warning");
416
- return;
417
- }
418
- const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
419
- let saved = false;
420
- let failureReason: string | undefined;
421
- try {
422
- transitionGoal(pi, runtime, "pause", "paused");
423
- saved = true;
424
- } catch (error) {
425
- failureReason = safeTerminalText(`the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
426
- pauseGoalAfterFailure(
427
- pi,
428
- runtime,
429
- ctx,
430
- failureReason,
431
- "Automatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.",
432
- false,
433
- );
434
- }
435
- try {
436
- await stopGoalRun(runtime, ctx, shouldStopGoalRun);
437
- } catch (error) {
438
- reportError(ctx, "Goal paused, but the active goal turn could not be confirmed stopped", error);
439
- return;
440
- }
441
- if (saved) {
442
- if (ctx.mode !== "tui") ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
443
- } else {
444
- ctx.ui.notify(`Goal paused: ${failureReason}\nAutomatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.`, "error");
445
- }
446
- return;
447
- }
448
-
449
- if (command.kind === "resume") {
450
- if (!runtime.state) {
451
- ctx.ui.notify("No goal is set", "info");
452
- return;
453
- }
454
- if (runtime.state.status === "complete") {
455
- ctx.ui.notify("The goal is complete. Set a new objective.", "info");
456
- return;
457
- }
458
- if (runtime.state.status === "active") {
459
- ctx.ui.notify("Goal is already active", "info");
460
- return;
461
- }
462
- const currentMax = runtime.state.maxTurns;
463
- if (currentMax !== undefined && runtime.state.turns >= currentMax) {
464
- if (runtime.state.turns >= GOAL_MAX_TURNS) {
465
- ctx.ui.notify(`Goal reached the lifetime limit (${runtime.state.turns}/${GOAL_MAX_TURNS}). Set a new objective.`, "warning");
466
- return;
467
- }
468
- const renewed = Math.min(Math.max(currentMax, runtime.state.turns) + DEFAULT_GOAL_MAX_TURNS, GOAL_MAX_TURNS);
469
- try {
470
- const base = transitionGoalState(runtime.state, "active", undefined, { resetBlockedAudit: true }, Date.now());
471
- persistGoalState(pi, runtime, "resume", { ...base, maxTurns: renewed });
472
- runtime.continuationScheduled = false;
473
- if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
474
- } catch (error) {
475
- reportError(ctx, "Goal could not be resumed", error);
476
- }
477
- return;
478
- }
479
- try {
480
- transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
481
- runtime.continuationScheduled = false;
482
- if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
483
- } catch (error) {
484
- reportError(ctx, "Goal could not be resumed", error);
485
- }
486
- return;
487
- }
488
-
489
- switch (command.kind) {
490
- case "objective":
491
- break;
492
- default: {
493
- const unhandled: never = command;
494
- return unhandled;
495
- }
496
- }
497
- const objective = command.objective;
498
-
499
- const unfinished = runtime.state && runtime.state.status !== "complete";
500
- if (unfinished) {
501
- if (!ctx.hasUI) {
502
- ctx.ui.notify("Clear the current goal before replacing it outside TUI mode", "error");
503
- return;
504
- }
505
- const replace = await ctx.ui.confirm("Replace active goal", "Replace the current unfinished goal and discard its continuation state?");
506
- if (!replace || !ownsGoal()) return;
507
- }
508
-
509
- const hold = Symbol();
510
- runtime.continuationHeld = hold;
511
- let waitError: unknown;
512
- try {
513
- await ctx.waitForIdle();
514
- } catch (error) {
515
- waitError = error;
516
- } finally {
517
- if (runtime.continuationHeld === hold) runtime.continuationHeld = undefined;
518
- }
519
- if (!ownsGoal()) {
520
- // Settlement may have authorized a turn while this command held continuation.
521
- if (runtime.lifecycleGeneration === generation) scheduleGoalContinuation(pi, runtime, ctx);
522
- return;
523
- }
524
- if (waitError) {
525
- reportError(ctx, "Goal could not wait for the active turn", waitError);
526
- scheduleGoalContinuation(pi, runtime, ctx);
527
- return;
528
- }
529
- let verification: Awaited<ReturnType<typeof inferGoalVerification>>;
530
- try {
531
- verification = await inferGoalVerification(objective, ctx.cwd);
532
- } catch (error) {
533
- if (!ownsGoal()) return;
534
- if (!unfinished) {
535
- reportError(ctx, "Goal could not be started", error);
536
- } else {
537
- reportError(ctx, "Goal could not be replaced", error);
538
- scheduleGoalContinuation(pi, runtime, ctx);
539
- }
540
- return;
541
- }
542
- if (!ownsGoal()) return;
543
- try {
544
- const state = createNewGoalState(objective, sumGoalTokens(ctx), verification, Date.now(), {
545
- maxTurns: DEFAULT_GOAL_MAX_TURNS,
546
- });
547
- persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
548
- // The replacement supersedes any recovery still parked after waitForIdle().
549
- if (unfinished) runtime.automaticCompaction = undefined;
550
- if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") {
551
- ctx.ui.notify(`${unfinished ? "Goal replaced" : "Goal started"}. Each turn must record continue, complete, or a blocker decision before another turn starts.`, "info");
552
- }
553
- } catch (error) {
554
- if (!unfinished) {
555
- reportError(ctx, "Goal could not be started", error);
556
- } else if (runtime.state?.status === "active") {
557
- pauseGoalAfterFailure(
558
- pi,
559
- runtime,
560
- ctx,
561
- `Goal could not be replaced: ${error instanceof Error ? error.message : String(error)}`,
562
- "Automatic continuation is stopped. Retry replacement after session storage recovers.",
563
- );
564
- } else {
565
- reportError(ctx, "Goal could not be replaced", error);
566
- }
567
- }
568
- };
569
-
570
- pi.registerCommand("goal", {
571
- description: "Set a non-command objective or view the current goal",
572
- getArgumentCompletions: (prefix) => {
573
- const normalized = prefix.trimStart().toLowerCase();
574
- const actions = [
575
- { value: "clear", description: "Remove the current goal" },
576
- { value: "pause", description: "Stop automatic continuation" },
577
- { value: "resume", description: "Resume automatic continuation" },
578
- ];
579
- return actions
580
- .filter((action) => action.value.startsWith(normalized))
581
- .map((action) => ({ ...action, label: action.value.trimEnd() }));
582
- },
583
- handler: handleGoalCommand,
584
- });
585
- }
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+ import { BoundedText } from "./bounded-text.ts";
6
+ import { formatTime, formatTokens } from "./display.ts";
7
+ import { reportError } from "./errors.ts";
8
+ import { parseGoalCommand, parseGoalObjectiveText } from "./goal-command.ts";
9
+ import { GOAL_ENTRY_TYPE, GOAL_UPDATE_TOOL, isGoalModeSupported, isSavedSession, pauseGoalAfterFailure, persistGoalState, scheduleGoalContinuation, stopGoalRun, sumGoalTokens, syncGoalUpdateTool, transitionGoal, type GoalEntryData } from "./goal-runtime.ts";
10
+ import { checkpointPausedGoalState, createNewGoalState, DEFAULT_GOAL_MAX_TURNS, editGoalState, GOAL_EVIDENCE_LIMIT, GOAL_MAX_TURNS, goalElapsedMilliseconds, GOAL_VERSION, inferGoalVerification, normalizeGoalText, parseGoalState, recordGoalDecision, transitionGoalState, verifyGoalDeliverable } from "./goal-state.ts";
11
+ import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
12
+ import { safeTerminalText } from "./safe-terminal-text.ts";
13
+
14
+ const GoalUpdateParams = Type.Object({
15
+ status: StringEnum(["complete", "continue", "blocked"] as const, {
16
+ description: "Record an active-goal decision, or complete a blocked goal during an eligible later ordinary request",
17
+ }),
18
+ evidence: Type.String({
19
+ minLength: 1,
20
+ maxLength: GOAL_EVIDENCE_LIMIT,
21
+ description: "Concrete current-turn evidence for completion, progress, or the repeated blocker",
22
+ }),
23
+ nextAction: Type.Optional(Type.String({
24
+ minLength: 1,
25
+ maxLength: GOAL_EVIDENCE_LIMIT,
26
+ description: "One concrete action toward the unchanged objective when status is continue",
27
+ })),
28
+ blockerKey: Type.Optional(Type.String({
29
+ minLength: 1,
30
+ maxLength: 120,
31
+ pattern: "^[a-z0-9][a-z0-9._-]{0,119}$",
32
+ description: "Stable lowercase key identifying the repeated blocker",
33
+ })),
34
+ });
35
+
36
+ interface GoalUpdateDetails {
37
+ status: "complete" | "continue" | "blocked" | "blocker-audit";
38
+ evidence: string;
39
+ nextAction?: string;
40
+ verification?: "file" | "model-reported";
41
+ blockerKey?: string;
42
+ streak?: number;
43
+ }
44
+
45
+ function goalPanelActions(status: GoalStatus): Array<{ label: string; control: "pause" | "resume" | "clear" }> {
46
+ if (status === "active") return [{ label: "Pause goal", control: "pause" }, { label: "Clear goal", control: "clear" }];
47
+ if (status === "paused" || status === "blocked") {
48
+ return [{ label: "Resume goal", control: "resume" }, { label: "Clear goal", control: "clear" }];
49
+ }
50
+ return [{ label: "Clear goal", control: "clear" }];
51
+ }
52
+
53
+ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
54
+ const usedTokens = Math.max(0, sumGoalTokens(ctx) - state.baselineTokens);
55
+ const turns = state.maxTurns === undefined
56
+ ? `${state.turns} turn${state.turns === 1 ? "" : "s"}`
57
+ : `${state.turns}/${state.maxTurns} turns`;
58
+ const lines = [
59
+ `Goal ${state.status} · ${turns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))} · ${formatTokens(usedTokens)} tokens`,
60
+ state.objective,
61
+ ...(state.verification === undefined ? [] : [`Deliverable · ${state.verification.path}`]),
62
+ ];
63
+ const decision = state.turnDecision ?? state.lastDecision;
64
+ if (decision?.kind === "continue") {
65
+ lines.push(`Progress · model-reported on turn ${decision.turn}`);
66
+ lines.push(`Next · ${decision.nextAction}`);
67
+ } else if (decision?.kind === "blocker-audit") {
68
+ lines.push(`Blocker audit · ${decision.streak}/3 on turn ${decision.turn}`);
69
+ lines.push(`Evidence · ${decision.evidence}`);
70
+ } else if (decision?.kind === "blocked") {
71
+ lines.push(`Evidence · ${decision.evidence}`);
72
+ } else if (decision?.kind === "complete") {
73
+ lines.push(`Completion · ${decision.verification === "file" ? `verified file ${state.verification?.path ?? "deliverable"}` : "model-reported"}`);
74
+ lines.push(`Evidence · ${decision.evidence}`);
75
+ }
76
+ if (state.status === "paused") {
77
+ lines.push(state.turns === 0 ? "Paused at · before turn 1" : `Paused at · turn ${state.turns}`);
78
+ const reason = state.stopReason ?? state.result;
79
+ if (reason) lines.push(`Reason · ${reason}`);
80
+ if (state.stopReason === "repeated continue report" && state.lastContinueReport) {
81
+ lines.push(`Repeated turns · ${state.lastContinueReport.turn} and ${state.turns}`);
82
+ }
83
+ } else if (state.result && decision?.kind !== "complete" && decision?.kind !== "blocked") {
84
+ lines.push(`Result · ${state.result}`);
85
+ }
86
+ return safeTerminalText(lines.join("\n"));
87
+ }
88
+
89
+ export function registerGoalInterface(
90
+ pi: ExtensionAPI,
91
+ runtime: GoalRuntime,
92
+ ): void {
93
+ pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, options, theme) => {
94
+ const data = entry.data;
95
+ if (!data || data.version !== GOAL_VERSION) return undefined;
96
+ if (data.event === "clear" || data.state === null) return new Text(theme.fg("dim", "Goal cleared"), 0, 0);
97
+ const event = data.event;
98
+ if (event !== "set" && event !== "replace" && event !== "edit" && event !== "pause" && event !== "resume" && event !== "limit" && event !== "error") return undefined;
99
+ const state = parseGoalState(data.state);
100
+ if (!state) return undefined;
101
+ const reason = state.stopReason ?? state.result;
102
+ const objective = safeTerminalText(state.objective);
103
+ let label: string;
104
+ let detail: string;
105
+ let color: ThemeColor;
106
+ switch (event) {
107
+ case "set":
108
+ label = "Goal started";
109
+ detail = objective;
110
+ color = "customMessageLabel";
111
+ break;
112
+ case "replace":
113
+ label = "Goal replaced";
114
+ detail = objective;
115
+ color = "customMessageLabel";
116
+ break;
117
+ case "edit":
118
+ label = "Goal objective edited";
119
+ detail = objective;
120
+ color = "customMessageLabel";
121
+ break;
122
+ case "resume":
123
+ label = "Goal resumed";
124
+ detail = objective;
125
+ color = "customMessageLabel";
126
+ break;
127
+ case "pause":
128
+ label = "Goal paused";
129
+ detail = reason ?? objective;
130
+ color = "warning";
131
+ break;
132
+ case "limit":
133
+ label = "Goal paused";
134
+ detail = reason ?? "";
135
+ color = "warning";
136
+ break;
137
+ case "error":
138
+ label = "Goal paused";
139
+ detail = reason ?? "";
140
+ color = "error";
141
+ break;
142
+ }
143
+ const safeDetail = safeTerminalText(detail);
144
+ const status = theme.fg(color, label);
145
+ if (!options.expanded) {
146
+ return new BoundedText(safeDetail ? `${status}${theme.fg("dim", " · ")}${safeDetail}` : status, 1);
147
+ }
148
+ const lines = [status, objective];
149
+ if (reason && reason !== objective) lines.push(theme.fg("muted", safeTerminalText(reason)));
150
+ return new BoundedText(lines.join("\n"));
151
+ });
152
+
153
+ pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
154
+ name: GOAL_UPDATE_TOOL,
155
+ label: "Goal update",
156
+ description: "Record exactly one active-goal decision: complete after verification, continue with evidence and one next action, or audit the same blocker before blocking it. During an eligible later ordinary request for a blocked goal, only verified completion of the whole unchanged objective is permitted; it does not resume work or grant turns.",
157
+ parameters: GoalUpdateParams,
158
+ exposure: "model-only",
159
+ executionMode: "sequential",
160
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
161
+ signal?.throwIfAborted();
162
+ if (!isGoalModeSupported(ctx)) throw new Error("KillerOS goals require TUI or RPC mode");
163
+ if (!isSavedSession(ctx)) throw new Error("KillerOS goals require a saved session");
164
+ const state = runtime.state;
165
+ if (!state || state.status !== "active" && state.status !== "blocked") throw new Error("There is no active KillerOS goal to update");
166
+ const generation = runtime.lifecycleGeneration;
167
+ const permission = runtime.blockedCompletion;
168
+ const ownsDecision = (): boolean => runtime.state === state
169
+ && runtime.lifecycleGeneration === generation
170
+ && (state.status === "blocked"
171
+ ? permission?.kind === "eligible"
172
+ && runtime.blockedCompletion === permission
173
+ && permission.state === state
174
+ && permission.generation === generation
175
+ : runtime.goalTurnInFlight
176
+ && runtime.goalTurn?.turn === state.turns
177
+ && runtime.goalTurn.revision === state.revision
178
+ && state.turnDecision === undefined);
179
+ if (state.status === "blocked") {
180
+ if (!ownsDecision()) throw new Error("A blocked goal can only be completed during an eligible later ordinary request");
181
+ if (params.status !== "complete") throw new Error("A blocked goal accepts only complete; automatic continuation remains stopped");
182
+ if (state.turns === 0) throw new Error("A zero-turn blocked goal cannot record completion; no accounted goal turn exists");
183
+ } else {
184
+ if (state.turnDecision !== undefined) throw new Error("Only one goal decision may be accepted per logical goal turn");
185
+ if (!ownsDecision()) throw new Error("A goal decision can only be recorded during an active KillerOS goal turn");
186
+ }
187
+ if (params.status !== "complete" && params.status !== "continue" && params.status !== "blocked") {
188
+ throw new Error("Goal update status is invalid");
189
+ }
190
+ if (typeof params.evidence !== "string") throw new Error("Goal evidence must be text");
191
+ const evidence = normalizeGoalText(params.evidence, GOAL_EVIDENCE_LIMIT, "Goal evidence");
192
+
193
+ if (params.status === "complete") {
194
+ if (state.verification) await verifyGoalDeliverable(state.verification);
195
+ signal?.throwIfAborted();
196
+ if (!ownsDecision()) throw new Error("Goal changed while completion was being verified");
197
+ const verification: "file" | "model-reported" = state.verification ? "file" : "model-reported";
198
+ const decision = { kind: "complete" as const, turn: state.turns, evidence, verification };
199
+ try {
200
+ transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true, decision });
201
+ } catch (error) {
202
+ pauseGoalAfterFailure(pi, runtime, ctx, `goal completion could not be saved: ${error instanceof Error ? error.message : String(error)}`);
203
+ throw error;
204
+ }
205
+ if (state.status === "blocked") runtime.blockedCompletion = { kind: "accepted", generation };
206
+ const text = state.verification
207
+ ? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${evidence}`
208
+ : `Goal marked complete (model-reported): ${evidence}`;
209
+ return {
210
+ content: [{ type: "text", text }],
211
+ details: { status: "complete", evidence, verification },
212
+ };
213
+ }
214
+
215
+ if (state.status !== "active") throw new Error("A blocked goal accepts only complete");
216
+ if (params.status === "continue") {
217
+ if (typeof params.nextAction !== "string") throw new Error("A continue decision requires a nextAction");
218
+ const nextAction = normalizeGoalText(params.nextAction, GOAL_EVIDENCE_LIMIT, "Goal nextAction");
219
+ const previous = state.lastContinueReport;
220
+ if (previous?.turn === state.turns - 1
221
+ && previous.evidence === evidence
222
+ && previous.nextAction === nextAction) {
223
+ pauseGoalAfterFailure(pi, runtime, ctx, "repeated continue report", "Run /goal resume only after choosing a different next action.", false);
224
+ ctx.ui.notify(
225
+ `Goal paused: repeated continue report on turns ${previous.turn} and ${state.turns}\nRun /goal resume only after choosing a different next action.`,
226
+ "error",
227
+ );
228
+ throw new Error("repeated continue report");
229
+ }
230
+ const decision = { kind: "continue" as const, turn: state.turns, evidence, nextAction };
231
+ try {
232
+ const next = recordGoalDecision(state, decision, Date.now());
233
+ persistGoalState(pi, runtime, "continue", next);
234
+ runtime.goalTurn = { turn: next.turns, revision: next.revision };
235
+ } catch (error) {
236
+ pauseGoalAfterFailure(pi, runtime, ctx, `goal decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
237
+ throw error;
238
+ }
239
+ return {
240
+ content: [{ type: "text", text: `Model-reported progress recorded; the goal remains active: ${evidence}` }],
241
+ details: { status: "continue", evidence, nextAction },
242
+ };
243
+ }
244
+
245
+ const blockerKey = params.blockerKey;
246
+ if (!blockerKey || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(blockerKey)) {
247
+ throw new Error("A blocked goal update requires a stable lowercase blockerKey");
248
+ }
249
+ const previous = state.blockerAudit;
250
+ if (previous?.lastTurn === state.turns) {
251
+ throw new Error("Only one goal decision may be accepted per logical goal turn");
252
+ }
253
+ const consecutive = previous?.key === blockerKey && previous.lastTurn === state.turns - 1;
254
+ const streak = consecutive ? previous.streak + 1 : 1;
255
+ const blockerAudit = { key: blockerKey, streak, lastTurn: state.turns, evidence };
256
+ if (streak < 3) {
257
+ const decision = { kind: "blocker-audit" as const, turn: state.turns, blockerKey, streak, evidence };
258
+ try {
259
+ const next = {
260
+ ...recordGoalDecision(state, decision, Date.now()),
261
+ blockerAudit,
262
+ };
263
+ persistGoalState(pi, runtime, "blocker-audit", next);
264
+ runtime.goalTurn = { turn: next.turns, revision: next.revision };
265
+ } catch (error) {
266
+ pauseGoalAfterFailure(pi, runtime, ctx, `blocker decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
267
+ throw error;
268
+ }
269
+ return {
270
+ content: [{ type: "text", text: `Blocker audit ${streak}/3 recorded; the goal remains active: ${evidence}` }],
271
+ details: { status: "blocker-audit", evidence, blockerKey, streak },
272
+ };
273
+ }
274
+ const decision = { kind: "blocked" as const, turn: state.turns, blockerKey, streak: 3 as const, evidence };
275
+ try {
276
+ transitionGoal(pi, runtime, "blocked", "blocked", evidence, { blockerAudit, decision });
277
+ } catch (error) {
278
+ pauseGoalAfterFailure(pi, runtime, ctx, `blocked decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
279
+ throw error;
280
+ }
281
+ return {
282
+ content: [{ type: "text", text: `Goal marked blocked: ${evidence}` }],
283
+ details: { status: "blocked", evidence, blockerKey, streak },
284
+ };
285
+ },
286
+ renderCall(args, theme) {
287
+ return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", safeTerminalText(args.status))}`, 0, 0);
288
+ },
289
+ renderResult(result, options, theme, context) {
290
+ if (context?.isError) {
291
+ const first = result.content[0];
292
+ const message = first?.type === "text" ? safeTerminalText(first.text) : "Goal update failed";
293
+ return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 1);
294
+ }
295
+ const details = result.details;
296
+ if (!details || typeof details.evidence !== "string") return new BoundedText(theme.fg("dim", "Goal updated"), options.expanded ? undefined : 1);
297
+ const label = details.status === "complete"
298
+ ? "Goal completed"
299
+ : details.status === "blocked"
300
+ ? "Goal blocked"
301
+ : details.status === "continue"
302
+ ? "Progress recorded"
303
+ : `Blocker audit ${details.streak}/3`;
304
+ const color: ThemeColor = details.status === "complete"
305
+ ? "success"
306
+ : details.status === "blocked"
307
+ ? "error"
308
+ : details.status === "continue"
309
+ ? "customMessageLabel"
310
+ : "warning";
311
+ const text = `${theme.fg(color, label)}${theme.fg("dim", " · ")}${safeTerminalText(details.evidence)}`;
312
+ return new BoundedText(text, options.expanded ? undefined : 1);
313
+ },
314
+ });
315
+ syncGoalUpdateTool(pi, runtime);
316
+ const handleGoalCommand = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
317
+ let command = parseGoalCommand(args);
318
+ if (ctx.mode === "print" || ctx.mode === "json") {
319
+ ctx.ui.notify("/goal requires TUI or RPC mode", "error");
320
+ return;
321
+ }
322
+ if (command.kind === "invalid") {
323
+ ctx.ui.notify(command.message, "error");
324
+ return;
325
+ }
326
+ if (!isSavedSession(ctx)) {
327
+ ctx.ui.notify("/goal requires a saved session", "error");
328
+ return;
329
+ }
330
+ const generation = runtime.lifecycleGeneration;
331
+ const initiatingState = runtime.state;
332
+ const ownsGoal = (): boolean => runtime.lifecycleGeneration === generation && runtime.state === initiatingState;
333
+
334
+ if (command.kind === "edit" && initiatingState?.status !== "active" && initiatingState?.status !== "paused") {
335
+ const fallback = parseGoalObjectiveText(args.trim());
336
+ if (fallback.kind === "invalid") {
337
+ ctx.ui.notify(fallback.message, "error");
338
+ return;
339
+ }
340
+ command = fallback;
341
+ }
342
+
343
+ if (command.kind === "status") {
344
+ if (!runtime.state) {
345
+ ctx.ui.notify("No goal is set. Use /goal <objective> to start a long-running task.", "info");
346
+ return;
347
+ }
348
+ if (ctx.mode !== "tui") {
349
+ ctx.ui.notify(goalStatusSummary(runtime.state, ctx), "info");
350
+ return;
351
+ }
352
+ const actions = goalPanelActions(runtime.state.status);
353
+ const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
354
+ if (!ownsGoal()) return;
355
+ const action = actions.find((candidate) => candidate.label === selected);
356
+ if (!action) return;
357
+ if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", safeTerminalText(runtime.state.objective))) return;
358
+ if (!ownsGoal()) return;
359
+ await handleGoalCommand(action.control, ctx);
360
+ return;
361
+ }
362
+
363
+ if (command.kind === "clear") {
364
+ if (!runtime.state) {
365
+ ctx.ui.notify("No goal is set", "info");
366
+ return;
367
+ }
368
+ const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
369
+ let saved = false;
370
+ try {
371
+ persistGoalState(pi, runtime, "clear", undefined);
372
+ saved = true;
373
+ } catch (error) {
374
+ if (runtime.state?.status === "active") {
375
+ pauseGoalAfterFailure(
376
+ pi,
377
+ runtime,
378
+ ctx,
379
+ `the requested clear could not be saved: ${error instanceof Error ? error.message : String(error)}`,
380
+ "Automatic continuation is stopped. Retry /goal clear to remove the goal.",
381
+ false,
382
+ );
383
+ } else {
384
+ reportError(ctx, "Goal could not be cleared", error);
385
+ return;
386
+ }
387
+ }
388
+ try {
389
+ await stopGoalRun(runtime, ctx, shouldStopGoalRun);
390
+ } catch (error) {
391
+ reportError(ctx, saved ? "Goal cleared, but the active goal turn could not be confirmed stopped" : "Goal paused, but the active goal turn could not be confirmed stopped", error);
392
+ return;
393
+ }
394
+ if (saved) {
395
+ if (ctx.mode !== "tui") ctx.ui.notify("Goal cleared", "info");
396
+ } else {
397
+ ctx.ui.notify("Goal paused: the requested clear could not be saved\nAutomatic continuation is stopped. Retry /goal clear to remove the goal.", "error");
398
+ }
399
+ return;
400
+ }
401
+
402
+ if (command.kind === "pause") {
403
+ if (!runtime.state) {
404
+ ctx.ui.notify("No goal is set", "info");
405
+ return;
406
+ }
407
+ if (runtime.state.status === "paused") {
408
+ if (!runtime.persistenceRetryNeeded
409
+ && runtime.state.resumeAfterManualCompaction !== true
410
+ && runtime.automaticCompaction === undefined) {
411
+ ctx.ui.notify("Goal is already paused", "info");
412
+ return;
413
+ }
414
+ const checkpoint = checkpointPausedGoalState(runtime.state, Date.now());
415
+ try {
416
+ persistGoalState(pi, runtime, "pause", checkpoint);
417
+ ctx.ui.notify("Goal pause saved. Goal remains paused. Automatic compaction recovery is off.", "info");
418
+ } catch (error) {
419
+ runtime.state = checkpoint;
420
+ syncGoalUpdateTool(pi, runtime);
421
+ runtime.persistenceRetryNeeded = true;
422
+ runtime.continuationScheduled = false;
423
+ runtime.requestRender?.();
424
+ reportError(ctx, "Goal pause still could not be saved", error);
425
+ }
426
+ return;
427
+ }
428
+ if (runtime.state.status !== "active") {
429
+ ctx.ui.notify(`Goal is ${runtime.state.status}; only an active goal can be paused`, "warning");
430
+ return;
431
+ }
432
+ const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
433
+ let saved = false;
434
+ let failureReason: string | undefined;
435
+ try {
436
+ transitionGoal(pi, runtime, "pause", "paused");
437
+ saved = true;
438
+ } catch (error) {
439
+ failureReason = safeTerminalText(`the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
440
+ pauseGoalAfterFailure(
441
+ pi,
442
+ runtime,
443
+ ctx,
444
+ failureReason,
445
+ "Automatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.",
446
+ false,
447
+ );
448
+ }
449
+ try {
450
+ await stopGoalRun(runtime, ctx, shouldStopGoalRun);
451
+ } catch (error) {
452
+ reportError(ctx, "Goal paused, but the active goal turn could not be confirmed stopped", error);
453
+ return;
454
+ }
455
+ if (saved) {
456
+ if (ctx.mode !== "tui") ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
457
+ } else {
458
+ ctx.ui.notify(`Goal paused: ${failureReason}\nAutomatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.`, "error");
459
+ }
460
+ return;
461
+ }
462
+
463
+ if (command.kind === "resume") {
464
+ if (!runtime.state) {
465
+ ctx.ui.notify("No goal is set", "info");
466
+ return;
467
+ }
468
+ if (runtime.state.status === "complete") {
469
+ ctx.ui.notify("The goal is complete. Set a new objective.", "info");
470
+ return;
471
+ }
472
+ if (runtime.state.status === "active") {
473
+ ctx.ui.notify("Goal is already active", "info");
474
+ return;
475
+ }
476
+ const currentMax = runtime.state.maxTurns;
477
+ if (currentMax !== undefined && runtime.state.turns >= currentMax) {
478
+ if (runtime.state.turns >= GOAL_MAX_TURNS) {
479
+ ctx.ui.notify(`Goal reached the lifetime limit (${runtime.state.turns}/${GOAL_MAX_TURNS}). Set a new objective.`, "warning");
480
+ return;
481
+ }
482
+ const renewed = Math.min(Math.max(currentMax, runtime.state.turns) + DEFAULT_GOAL_MAX_TURNS, GOAL_MAX_TURNS);
483
+ try {
484
+ const base = transitionGoalState(runtime.state, "active", undefined, { resetBlockedAudit: true }, Date.now());
485
+ persistGoalState(pi, runtime, "resume", { ...base, maxTurns: renewed });
486
+ runtime.continuationScheduled = false;
487
+ if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
488
+ } catch (error) {
489
+ reportError(ctx, "Goal could not be resumed", error);
490
+ }
491
+ return;
492
+ }
493
+ try {
494
+ transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
495
+ runtime.continuationScheduled = false;
496
+ if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
497
+ } catch (error) {
498
+ reportError(ctx, "Goal could not be resumed", error);
499
+ }
500
+ return;
501
+ }
502
+
503
+ if (command.kind === "edit") {
504
+ const mutation = runtime.mutation;
505
+ const ownsEdit = (): boolean => runtime.lifecycleGeneration === generation
506
+ && runtime.mutation === mutation
507
+ && runtime.state?.status === initiatingState?.status;
508
+ const objective = command.objective;
509
+ const hold = Symbol();
510
+ runtime.continuationHeld = hold;
511
+ let waitError: unknown;
512
+ try {
513
+ await ctx.waitForIdle();
514
+ } catch (error) {
515
+ waitError = error;
516
+ } finally {
517
+ if (runtime.continuationHeld === hold) runtime.continuationHeld = undefined;
518
+ }
519
+ if (!ownsEdit()) {
520
+ // Settlement may have authorized a turn while this command held continuation.
521
+ if (runtime.lifecycleGeneration === generation) scheduleGoalContinuation(pi, runtime, ctx);
522
+ return;
523
+ }
524
+ if (waitError) {
525
+ reportError(ctx, "Goal objective could not be updated", waitError);
526
+ scheduleGoalContinuation(pi, runtime, ctx);
527
+ return;
528
+ }
529
+ const state = runtime.state;
530
+ let verification: Awaited<ReturnType<typeof inferGoalVerification>>;
531
+ try {
532
+ verification = await inferGoalVerification(objective, ctx.cwd);
533
+ } catch (error) {
534
+ if (!ownsEdit() || runtime.state !== state) return;
535
+ reportError(ctx, "Goal objective could not be updated", error);
536
+ scheduleGoalContinuation(pi, runtime, ctx);
537
+ return;
538
+ }
539
+ if (!ownsEdit() || runtime.state !== state) return;
540
+ if (!state || state.status !== "active" && state.status !== "paused") return;
541
+ try {
542
+ const next = editGoalState(state, objective, verification, Date.now());
543
+ persistGoalState(pi, runtime, "edit", next);
544
+ runtime.goalTurn = undefined;
545
+ runtime.goalTurnInFlight = false;
546
+ runtime.agentEndObserved = false;
547
+ runtime.continuationScheduled = false;
548
+ if (next.status === "paused") {
549
+ if (ctx.mode !== "tui") ctx.ui.notify("Goal objective updated. The goal is paused; run /goal resume to continue.", "info");
550
+ } else if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") {
551
+ ctx.ui.notify("Goal objective updated.", "info");
552
+ }
553
+ } catch (error) {
554
+ if (runtime.state?.status === "active") {
555
+ pauseGoalAfterFailure(
556
+ pi,
557
+ runtime,
558
+ ctx,
559
+ `Goal objective could not be updated: ${error instanceof Error ? error.message : String(error)}`,
560
+ "Automatic continuation is stopped. Retry /goal edit after session storage recovers.",
561
+ );
562
+ } else {
563
+ reportError(ctx, "Goal objective could not be updated", error);
564
+ }
565
+ }
566
+ return;
567
+ }
568
+
569
+ switch (command.kind) {
570
+ case "objective":
571
+ break;
572
+ default: {
573
+ const unhandled: never = command;
574
+ return unhandled;
575
+ }
576
+ }
577
+ const objective = command.objective;
578
+
579
+ const unfinished = runtime.state && runtime.state.status !== "complete";
580
+ if (unfinished) {
581
+ if (!ctx.hasUI) {
582
+ ctx.ui.notify("Clear the current goal before replacing it outside TUI mode", "error");
583
+ return;
584
+ }
585
+ const replace = await ctx.ui.confirm("Replace active goal", "Replace the current unfinished goal and discard its continuation state?");
586
+ if (!replace || !ownsGoal()) return;
587
+ }
588
+
589
+ const hold = Symbol();
590
+ runtime.continuationHeld = hold;
591
+ let waitError: unknown;
592
+ try {
593
+ await ctx.waitForIdle();
594
+ } catch (error) {
595
+ waitError = error;
596
+ } finally {
597
+ if (runtime.continuationHeld === hold) runtime.continuationHeld = undefined;
598
+ }
599
+ if (!ownsGoal()) {
600
+ // Settlement may have authorized a turn while this command held continuation.
601
+ if (runtime.lifecycleGeneration === generation) scheduleGoalContinuation(pi, runtime, ctx);
602
+ return;
603
+ }
604
+ if (waitError) {
605
+ reportError(ctx, "Goal could not wait for the active turn", waitError);
606
+ scheduleGoalContinuation(pi, runtime, ctx);
607
+ return;
608
+ }
609
+ let verification: Awaited<ReturnType<typeof inferGoalVerification>>;
610
+ try {
611
+ verification = await inferGoalVerification(objective, ctx.cwd);
612
+ } catch (error) {
613
+ if (!ownsGoal()) return;
614
+ if (!unfinished) {
615
+ reportError(ctx, "Goal could not be started", error);
616
+ } else {
617
+ reportError(ctx, "Goal could not be replaced", error);
618
+ scheduleGoalContinuation(pi, runtime, ctx);
619
+ }
620
+ return;
621
+ }
622
+ if (!ownsGoal()) return;
623
+ try {
624
+ const state = createNewGoalState(objective, sumGoalTokens(ctx), verification, Date.now(), {
625
+ maxTurns: DEFAULT_GOAL_MAX_TURNS,
626
+ });
627
+ persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
628
+ // The replacement supersedes any recovery still parked after waitForIdle().
629
+ if (unfinished) runtime.automaticCompaction = undefined;
630
+ if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") {
631
+ ctx.ui.notify(`${unfinished ? "Goal replaced" : "Goal started"}. Each turn must record continue, complete, or a blocker decision before another turn starts.`, "info");
632
+ }
633
+ } catch (error) {
634
+ if (!unfinished) {
635
+ reportError(ctx, "Goal could not be started", error);
636
+ } else if (runtime.state?.status === "active") {
637
+ pauseGoalAfterFailure(
638
+ pi,
639
+ runtime,
640
+ ctx,
641
+ `Goal could not be replaced: ${error instanceof Error ? error.message : String(error)}`,
642
+ "Automatic continuation is stopped. Retry replacement after session storage recovers.",
643
+ );
644
+ } else {
645
+ reportError(ctx, "Goal could not be replaced", error);
646
+ }
647
+ }
648
+ };
649
+
650
+ pi.registerCommand("goal", {
651
+ description: "Set a non-command objective or view the current goal",
652
+ getArgumentCompletions: (prefix) => {
653
+ const normalized = prefix.trimStart().toLowerCase();
654
+ const actions = [
655
+ { value: "clear", description: "Remove the current goal" },
656
+ { value: "edit", description: "Update the objective without resetting the goal" },
657
+ { value: "pause", description: "Stop automatic continuation" },
658
+ { value: "resume", description: "Resume automatic continuation" },
659
+ ];
660
+ return actions
661
+ .filter((action) => action.value.startsWith(normalized))
662
+ .map((action) => ({ ...action, label: action.value.trimEnd() }));
663
+ },
664
+ handler: handleGoalCommand,
665
+ });
666
+ }