killeros 2.1.27 → 2.1.29

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,10 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { watch } from "node:fs";
3
+ import path from "node:path";
3
4
  import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
4
5
  import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
5
6
  import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
6
7
  import { formatCwd, formatTime, modelDisplayName, padRight } from "./display.ts";
7
- import { PASSIVE_GIT_CONFIG_ARGS, passiveGitCommand, passiveGitEnv, passiveStatusSafetyArgs, samePassiveFilters } from "./passive-git-status.ts";
8
+ import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, type PassiveGitRunner } from "./passive-git-status.ts";
8
9
  import { goalElapsedMilliseconds } from "./goal-state.ts";
9
10
  import type { GoalRuntime, GoalState } from "./runtime.ts";
10
11
  import { safeTerminalText } from "./safe-terminal-text.ts";
@@ -36,26 +37,16 @@ type GitStatusExecutor = (
36
37
  callback: (error: Error | null, stdout: string) => void,
37
38
  ) => unknown;
38
39
 
39
- /** Resolves changed-file counts with a bounded asynchronous Git status process in a trusted project. */
40
- export function resolveGitFileChanges(
40
+ /** Resolves changed-file counts without asking Git to inspect worktree content. */
41
+ export async function resolveGitFileChanges(
41
42
  cwd: string,
42
43
  execute: GitStatusExecutor = execFile,
43
44
  trusted = true,
44
45
  ): Promise<GitFileChanges | undefined> {
45
- if (!trusted) return Promise.resolve(undefined);
46
- return new Promise((resolve) => {
47
- let gitCommand: string;
48
- try {
49
- const found = passiveGitCommand(cwd);
50
- if (!found) {
51
- resolve(undefined);
52
- return;
53
- }
54
- gitCommand = found;
55
- } catch {
56
- resolve(undefined);
57
- return;
58
- }
46
+ if (!trusted) return undefined;
47
+ try {
48
+ const gitCommand = passiveGitCommand(cwd);
49
+ if (!gitCommand) return undefined;
59
50
  const options = {
60
51
  encoding: "utf8" as const,
61
52
  env: passiveGitEnv(),
@@ -63,64 +54,42 @@ export function resolveGitFileChanges(
63
54
  timeout: GIT_STATUS_TIMEOUT_MS,
64
55
  windowsHide: true as const,
65
56
  };
66
- const verifyFiltersUnchanged = (before: string, done: (unchanged: boolean) => void): void => {
67
- try {
68
- execute(gitCommand, ["-C", cwd, ...PASSIVE_GIT_CONFIG_ARGS], options, (error, after) => {
69
- done(!error && samePassiveFilters(before, after));
70
- });
71
- } catch {
72
- done(false);
73
- }
74
- };
75
- const runStatus = (config: string, args: string[]): void => {
76
- try {
77
- execute(gitCommand, args, options, (error, stdout) => {
78
- if (error) {
79
- resolve(undefined);
80
- return;
81
- }
82
- verifyFiltersUnchanged(config, (unchanged) => {
83
- if (!unchanged) {
84
- resolve(undefined);
85
- return;
86
- }
87
-
88
- const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
89
- const entries = stdout.split("\0");
90
- for (let index = 0; index < entries.length; index += 1) {
91
- const entry = entries[index];
92
- if (!entry) continue;
93
- const status = entry.slice(0, 2);
94
- if (status.includes("D")) changes.deleted += 1;
95
- else if (status === "??" || status.includes("A")) changes.added += 1;
96
- else changes.modified += 1;
97
- if (status.includes("R") || status.includes("C")) index += 1;
98
- }
99
- resolve(changes);
100
- });
101
- });
102
- } catch {
103
- resolve(undefined);
104
- }
105
- };
106
-
107
- try {
108
- execute(gitCommand, ["-C", cwd, ...PASSIVE_GIT_CONFIG_ARGS], options, (error, config) => {
109
- if (error) {
110
- resolve(undefined);
111
- return;
112
- }
113
- const safetyArgs = passiveStatusSafetyArgs(config);
114
- if (!safetyArgs) {
115
- resolve(undefined);
116
- return;
117
- }
118
- runStatus(config, ["-C", cwd, ...safetyArgs, "status", "--porcelain=v1", "-z", "--untracked-files=all"]);
57
+ const runAt = (directory: string): PassiveGitRunner => (args) => new Promise((resolve, reject) => {
58
+ execute(gitCommand, ["-C", directory, ...args], options, (error, stdout) => {
59
+ if (error) reject(error);
60
+ else resolve(Buffer.from(stdout));
119
61
  });
120
- } catch {
121
- resolve(undefined);
62
+ });
63
+ const initialRunner = runAt(cwd);
64
+ const root = new TextDecoder("utf-8", { fatal: true }).decode(
65
+ await initialRunner(["rev-parse", "--path-format=absolute", "--show-toplevel"]),
66
+ ).trim();
67
+ if (!path.isAbsolute(root)) return undefined;
68
+ const snapshot = await inspectWorktreeWithoutFilters(root, runAt(root));
69
+ const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
70
+ const deletedObjectIds: string[] = [];
71
+ const addedObjectIds: string[] = [];
72
+ for (const file of snapshot.files.values()) {
73
+ if (!file.mode) {
74
+ changes.deleted += 1;
75
+ if (!file.indexMode && file.headObjectId) deletedObjectIds.push(file.headObjectId);
76
+ } else if (!file.headMode) {
77
+ changes.added += 1;
78
+ if (file.indexObjectId) addedObjectIds.push(file.indexObjectId);
79
+ } else changes.modified += 1;
122
80
  }
123
- });
81
+ for (const objectId of deletedObjectIds) {
82
+ const match = addedObjectIds.indexOf(objectId);
83
+ if (match < 0) continue;
84
+ addedObjectIds.splice(match, 1);
85
+ changes.deleted -= 1;
86
+ changes.added -= 1;
87
+ changes.modified += 1;
88
+ }
89
+ return changes;
90
+ } catch {
91
+ return undefined;
92
+ }
124
93
  }
125
94
 
126
95
  async function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
@@ -383,11 +352,13 @@ function renderFooter(rows: string[], width: number, theme: Theme): string[] {
383
352
  function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
384
353
  if (!state) return "";
385
354
  if (state.status === "active") {
386
- const turns = state.maxTurns === undefined ? "" : ` ${state.turns}/${state.maxTurns}`;
387
- return theme.fg("warning", `/goal is active${turns} (${formatTime(goalElapsedMilliseconds(state, Date.now()))})`);
355
+ const detail = state.maxTurns === undefined
356
+ ? `${state.turns} turn${state.turns === 1 ? "" : "s"} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))}`
357
+ : `${state.turns}/${state.maxTurns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))}`;
358
+ return `${theme.fg("customMessageLabel", "/goal active")}${theme.fg("dim", ` · ${detail}`)}`;
388
359
  }
389
- if (state.status === "paused") return theme.fg("warning", "/goal is paused");
390
- if (state.status === "blocked") return theme.fg("error", "/goal is blocked");
360
+ if (state.status === "paused") return theme.fg("warning", "/goal paused");
361
+ if (state.status === "blocked") return theme.fg("error", "/goal blocked");
391
362
  return "";
392
363
  }
393
364
 
@@ -7,19 +7,24 @@ import { formatTime, formatTokens } from "./display.ts";
7
7
  import { reportError } from "./errors.ts";
8
8
  import { parseGoalCommand } from "./goal-command.ts";
9
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_MAX_TURNS, goalElapsedMilliseconds, GOAL_VERSION, inferGoalVerification, parseGoalState, recordGoalBlockerAudit, transitionGoalState, verifyGoalDeliverable } from "./goal-state.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
11
  import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
12
12
  import { safeTerminalText } from "./safe-terminal-text.ts";
13
13
 
14
14
  const GoalUpdateParams = Type.Object({
15
- status: StringEnum(["complete", "blocked"] as const, {
16
- description: "Mark the active goal complete or blocked",
15
+ status: StringEnum(["complete", "continue", "blocked"] as const, {
16
+ description: "Record exactly one active-goal decision: complete, continue, or blocked",
17
17
  }),
18
18
  evidence: Type.String({
19
19
  minLength: 1,
20
- maxLength: 2_000,
21
- description: "Concise evidence that the objective is complete, or the repeated blocker and attempted workarounds",
20
+ maxLength: GOAL_EVIDENCE_LIMIT,
21
+ description: "Concrete current-turn evidence for completion, progress, or the repeated blocker",
22
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
+ })),
23
28
  blockerKey: Type.Optional(Type.String({
24
29
  minLength: 1,
25
30
  maxLength: 120,
@@ -29,21 +34,18 @@ const GoalUpdateParams = Type.Object({
29
34
  });
30
35
 
31
36
  interface GoalUpdateDetails {
32
- status: "complete" | "blocked" | "blocker-audit";
37
+ status: "complete" | "continue" | "blocked" | "blocker-audit";
33
38
  evidence: string;
39
+ nextAction?: string;
34
40
  verification?: "file" | "model-reported";
35
41
  blockerKey?: string;
36
42
  streak?: number;
37
43
  }
38
44
 
39
- function goalStatusLabel(status: GoalStatus): string {
40
- return `${status.charAt(0).toUpperCase()}${status.slice(1)}`;
41
- }
42
-
43
45
  function goalPanelActions(status: GoalStatus): Array<{ label: string; control: "pause" | "resume" | "clear" }> {
44
- if (status === "active") return [{ label: "Pause automatic continuation", control: "pause" }, { label: "Clear goal", control: "clear" }];
46
+ if (status === "active") return [{ label: "Pause goal", control: "pause" }, { label: "Clear goal", control: "clear" }];
45
47
  if (status === "paused" || status === "blocked") {
46
- return [{ label: "Resume automatic continuation", control: "resume" }, { label: "Clear goal", control: "clear" }];
48
+ return [{ label: "Resume goal", control: "resume" }, { label: "Clear goal", control: "clear" }];
47
49
  }
48
50
  return [{ label: "Clear goal", control: "clear" }];
49
51
  }
@@ -54,11 +56,33 @@ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
54
56
  ? `${state.turns} turn${state.turns === 1 ? "" : "s"}`
55
57
  : `${state.turns}/${state.maxTurns} turns`;
56
58
  const lines = [
57
- `Goal ${goalStatusLabel(state.status).toLowerCase()} · ${turns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))} · ${formatTokens(usedTokens)} tokens`,
58
- ...(state.verification === undefined ? [] : [`Deliverable: ${state.verification.path}`]),
59
+ `Goal ${state.status} · ${turns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))} · ${formatTokens(usedTokens)} tokens`,
59
60
  state.objective,
61
+ ...(state.verification === undefined ? [] : [`Deliverable · ${state.verification.path}`]),
60
62
  ];
61
- if (state.result) lines.push(state.result);
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
+ }
62
86
  return safeTerminalText(lines.join("\n"));
63
87
  }
64
88
 
@@ -68,24 +92,63 @@ export function registerGoalInterface(
68
92
  ): void {
69
93
  pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, options, theme) => {
70
94
  const data = entry.data;
71
- if (!data || data.version !== GOAL_VERSION || data.event === "turn" || data.event === "checkpoint") return undefined;
95
+ if (!data || data.version !== GOAL_VERSION) return undefined;
72
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;
73
99
  const state = parseGoalState(data.state);
74
100
  if (!state) return undefined;
75
- const icon = state.status === "active" ? "✻" : state.status === "paused" ? "Ⅱ" : state.status === "blocked" ? "!" : "✓";
76
- const color: ThemeColor = state.status === "active" ? "accent" : state.status === "paused" ? "warning" : state.status === "blocked" ? "error" : "success";
77
- const status = theme.fg(color, `${icon} Goal ${state.status}`);
101
+ const reason = state.stopReason ?? state.result;
78
102
  const objective = safeTerminalText(state.objective);
79
- if (!options.expanded) return new BoundedText(`${status}${theme.fg("dim", ` · ${objective}`)}`, 3);
80
- const lines = [status, theme.fg("dim", objective)];
81
- if (state.result) lines.push(theme.fg("muted", safeTerminalText(state.result)));
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)));
82
145
  return new BoundedText(lines.join("\n"));
83
146
  });
84
147
 
85
148
  pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
86
149
  name: GOAL_UPDATE_TOOL,
87
150
  label: "Goal update",
88
- description: "Mark the active KillerOS long-running goal complete after verification, or record the same blocker key on three consecutive goal turns before blocking it.",
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.",
89
152
  parameters: GoalUpdateParams,
90
153
  executionMode: "sequential",
91
154
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -94,42 +157,110 @@ export function registerGoalInterface(
94
157
  if (!isSavedSession(ctx)) throw new Error("KillerOS goals require a saved session");
95
158
  const state = runtime.state;
96
159
  if (!state || state.status !== "active") throw new Error("There is no active KillerOS goal to update");
97
- const evidence = params.evidence.trim();
98
- if (!evidence) throw new Error("Goal evidence must not be empty");
160
+ if (!runtime.goalTurnInFlight
161
+ || runtime.goalTurn?.turn !== state.turns
162
+ || runtime.goalTurn.revision !== state.revision) {
163
+ throw new Error("A goal decision can only be recorded during an active KillerOS goal turn");
164
+ }
165
+ if (state.turnDecision !== undefined) {
166
+ throw new Error("Only one goal decision may be accepted per logical goal turn");
167
+ }
168
+ if (params.status !== "complete" && params.status !== "continue" && params.status !== "blocked") {
169
+ throw new Error("Goal update status is invalid");
170
+ }
171
+ if (typeof params.evidence !== "string") throw new Error("Goal evidence must be text");
172
+ const evidence = normalizeGoalText(params.evidence, GOAL_EVIDENCE_LIMIT, "Goal evidence");
173
+
99
174
  if (params.status === "complete") {
100
175
  if (state.verification) await verifyGoalDeliverable(state.verification);
101
176
  signal?.throwIfAborted();
102
- if (runtime.state !== state) throw new Error("Goal changed while completion was being verified");
103
- const verification = state.verification ? "file" : "model-reported";
104
- transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
105
- const safeEvidence = safeTerminalText(evidence);
177
+ if (runtime.state !== state
178
+ || runtime.goalTurn?.turn !== state.turns
179
+ || runtime.goalTurn.revision !== state.revision
180
+ || state.turnDecision !== undefined
181
+ || !runtime.goalTurnInFlight) throw new Error("Goal changed while completion was being verified");
182
+ const verification: "file" | "model-reported" = state.verification ? "file" : "model-reported";
183
+ const decision = { kind: "complete" as const, turn: state.turns, evidence, verification };
184
+ try {
185
+ transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true, decision });
186
+ } catch (error) {
187
+ pauseGoalAfterFailure(pi, runtime, ctx, `goal completion could not be saved: ${error instanceof Error ? error.message : String(error)}`);
188
+ throw error;
189
+ }
106
190
  const text = state.verification
107
- ? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${safeEvidence}`
108
- : `Goal marked complete (model-reported): ${safeEvidence}`;
191
+ ? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${evidence}`
192
+ : `Goal marked complete (model-reported): ${evidence}`;
109
193
  return {
110
194
  content: [{ type: "text", text }],
111
195
  details: { status: "complete", evidence, verification },
112
196
  };
113
197
  }
114
- if (!runtime.goalTurnInFlight) throw new Error("A blocker audit can only be recorded during an active KillerOS goal turn");
198
+
199
+ if (params.status === "continue") {
200
+ if (typeof params.nextAction !== "string") throw new Error("A continue decision requires a nextAction");
201
+ const nextAction = normalizeGoalText(params.nextAction, GOAL_EVIDENCE_LIMIT, "Goal nextAction");
202
+ const previous = state.lastContinueReport;
203
+ if (previous?.turn === state.turns - 1
204
+ && previous.evidence === evidence
205
+ && previous.nextAction === nextAction) {
206
+ pauseGoalAfterFailure(pi, runtime, ctx, "repeated continue report", "Run /goal resume only after choosing a different next action.", false);
207
+ ctx.ui.notify(
208
+ `Goal paused: repeated continue report on turns ${previous.turn} and ${state.turns}\nRun /goal resume only after choosing a different next action.`,
209
+ "error",
210
+ );
211
+ throw new Error("repeated continue report");
212
+ }
213
+ const decision = { kind: "continue" as const, turn: state.turns, evidence, nextAction };
214
+ try {
215
+ const next = recordGoalDecision(state, decision, Date.now());
216
+ persistGoalState(pi, runtime, "continue", next);
217
+ runtime.goalTurn = { turn: next.turns, revision: next.revision };
218
+ } catch (error) {
219
+ pauseGoalAfterFailure(pi, runtime, ctx, `goal decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
220
+ throw error;
221
+ }
222
+ return {
223
+ content: [{ type: "text", text: `Model-reported progress recorded; the goal remains active: ${evidence}` }],
224
+ details: { status: "continue", evidence, nextAction },
225
+ };
226
+ }
227
+
115
228
  const blockerKey = params.blockerKey;
116
229
  if (!blockerKey || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(blockerKey)) {
117
230
  throw new Error("A blocked goal update requires a stable lowercase blockerKey");
118
231
  }
119
232
  const previous = state.blockerAudit;
120
- const sameTurn = previous?.key === blockerKey && previous.lastTurn === state.turns;
233
+ if (previous?.lastTurn === state.turns) {
234
+ throw new Error("Only one goal decision may be accepted per logical goal turn");
235
+ }
121
236
  const consecutive = previous?.key === blockerKey && previous.lastTurn === state.turns - 1;
122
- const streak = sameTurn ? previous.streak : consecutive ? previous.streak + 1 : 1;
237
+ const streak = consecutive ? previous.streak + 1 : 1;
123
238
  const blockerAudit = { key: blockerKey, streak, lastTurn: state.turns, evidence };
124
239
  if (streak < 3) {
125
- const next = recordGoalBlockerAudit(state, blockerAudit, Date.now());
126
- persistGoalState(pi, runtime, "blocker-audit", next);
240
+ const decision = { kind: "blocker-audit" as const, turn: state.turns, blockerKey, streak, evidence };
241
+ try {
242
+ const next = {
243
+ ...recordGoalDecision(state, decision, Date.now()),
244
+ blockerAudit,
245
+ };
246
+ persistGoalState(pi, runtime, "blocker-audit", next);
247
+ runtime.goalTurn = { turn: next.turns, revision: next.revision };
248
+ } catch (error) {
249
+ pauseGoalAfterFailure(pi, runtime, ctx, `blocker decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
250
+ throw error;
251
+ }
127
252
  return {
128
253
  content: [{ type: "text", text: `Blocker audit ${streak}/3 recorded; the goal remains active: ${evidence}` }],
129
254
  details: { status: "blocker-audit", evidence, blockerKey, streak },
130
255
  };
131
256
  }
132
- transitionGoal(pi, runtime, "blocked", "blocked", evidence, { blockerAudit });
257
+ const decision = { kind: "blocked" as const, turn: state.turns, blockerKey, streak: 3 as const, evidence };
258
+ try {
259
+ transitionGoal(pi, runtime, "blocked", "blocked", evidence, { blockerAudit, decision });
260
+ } catch (error) {
261
+ pauseGoalAfterFailure(pi, runtime, ctx, `blocked decision could not be saved: ${error instanceof Error ? error.message : String(error)}`);
262
+ throw error;
263
+ }
133
264
  return {
134
265
  content: [{ type: "text", text: `Goal marked blocked: ${evidence}` }],
135
266
  details: { status: "blocked", evidence, blockerKey, streak },
@@ -142,15 +273,29 @@ export function registerGoalInterface(
142
273
  if (context?.isError) {
143
274
  const first = result.content[0];
144
275
  const message = first?.type === "text" ? safeTerminalText(first.text) : "Goal update failed";
145
- return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 3);
276
+ return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 1);
146
277
  }
147
278
  const details = result.details;
148
- if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
149
- const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
150
- const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${safeTerminalText(details.evidence)}`)}`;
151
- return new BoundedText(text, options.expanded ? undefined : 3);
279
+ if (!details || typeof details.evidence !== "string") return new BoundedText(theme.fg("dim", "Goal updated"), options.expanded ? undefined : 1);
280
+ const label = details.status === "complete"
281
+ ? "Goal completed"
282
+ : details.status === "blocked"
283
+ ? "Goal blocked"
284
+ : details.status === "continue"
285
+ ? "Progress recorded"
286
+ : `Blocker audit ${details.streak}/3`;
287
+ const color: ThemeColor = details.status === "complete"
288
+ ? "success"
289
+ : details.status === "blocked"
290
+ ? "error"
291
+ : details.status === "continue"
292
+ ? "customMessageLabel"
293
+ : "warning";
294
+ const text = `${theme.fg(color, label)}${theme.fg("dim", " · ")}${safeTerminalText(details.evidence)}`;
295
+ return new BoundedText(text, options.expanded ? undefined : 1);
152
296
  },
153
297
  });
298
+ syncGoalUpdateTool(pi, runtime);
154
299
  const handleGoalCommand = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
155
300
  const command = parseGoalCommand(args);
156
301
  if (ctx.mode === "print" || ctx.mode === "json") {
@@ -216,7 +361,7 @@ export function registerGoalInterface(
216
361
  return;
217
362
  }
218
363
  if (saved) {
219
- ctx.ui.notify("Goal cleared", "info");
364
+ if (ctx.mode !== "tui") ctx.ui.notify("Goal cleared", "info");
220
365
  } else {
221
366
  ctx.ui.notify("Goal paused: the requested clear could not be saved\nAutomatic continuation is stopped. Retry /goal clear to remove the goal.", "error");
222
367
  }
@@ -277,7 +422,7 @@ export function registerGoalInterface(
277
422
  return;
278
423
  }
279
424
  if (saved) {
280
- ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
425
+ if (ctx.mode !== "tui") ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
281
426
  } else {
282
427
  ctx.ui.notify(`Goal paused: ${failureReason}\nAutomatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.`, "error");
283
428
  }
@@ -308,7 +453,7 @@ export function registerGoalInterface(
308
453
  const base = transitionGoalState(runtime.state, "active", undefined, { resetBlockedAudit: true }, Date.now());
309
454
  persistGoalState(pi, runtime, "resume", { ...base, maxTurns: renewed });
310
455
  runtime.continuationScheduled = false;
311
- if (scheduleGoalContinuation(pi, runtime, ctx)) ctx.ui.notify("Goal resumed", "info");
456
+ if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
312
457
  } catch (error) {
313
458
  reportError(ctx, "Goal could not be resumed", error);
314
459
  }
@@ -317,7 +462,7 @@ export function registerGoalInterface(
317
462
  try {
318
463
  transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
319
464
  runtime.continuationScheduled = false;
320
- if (scheduleGoalContinuation(pi, runtime, ctx)) ctx.ui.notify("Goal resumed", "info");
465
+ if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
321
466
  } catch (error) {
322
467
  reportError(ctx, "Goal could not be resumed", error);
323
468
  }
@@ -375,8 +520,10 @@ export function registerGoalInterface(
375
520
  maxTurns: DEFAULT_GOAL_MAX_TURNS,
376
521
  });
377
522
  persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
378
- if (scheduleGoalContinuation(pi, runtime, ctx)) {
379
- ctx.ui.notify("Goal active. KillerOS will continue until completion, a repeated blocker, or pause.", "info");
523
+ // The replacement supersedes any recovery still parked after waitForIdle().
524
+ if (unfinished) runtime.automaticCompaction = undefined;
525
+ if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") {
526
+ ctx.ui.notify(`${unfinished ? "Goal replaced" : "Goal started"}. Each turn must record continue, complete, or a blocker decision before another turn starts.`, "info");
380
527
  }
381
528
  } catch (error) {
382
529
  if (!unfinished) {