killeros 2.1.26 → 2.1.28

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.
@@ -4,6 +4,7 @@ import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor }
4
4
  import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
5
5
  import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
6
6
  import { formatCwd, formatTime, modelDisplayName, padRight } from "./display.ts";
7
+ import { PASSIVE_GIT_CONFIG_ARGS, passiveGitCommand, passiveGitEnv, passiveStatusSafetyArgs, samePassiveFilters } from "./passive-git-status.ts";
7
8
  import { goalElapsedMilliseconds } from "./goal-state.ts";
8
9
  import type { GoalRuntime, GoalState } from "./runtime.ts";
9
10
  import { safeTerminalText } from "./safe-terminal-text.ts";
@@ -35,42 +36,90 @@ type GitStatusExecutor = (
35
36
  callback: (error: Error | null, stdout: string) => void,
36
37
  ) => unknown;
37
38
 
38
- /** Resolves changed-file counts with a bounded asynchronous Git status process. */
39
+ /** Resolves changed-file counts with a bounded asynchronous Git status process in a trusted project. */
39
40
  export function resolveGitFileChanges(
40
41
  cwd: string,
41
42
  execute: GitStatusExecutor = execFile,
43
+ trusted = true,
42
44
  ): Promise<GitFileChanges | undefined> {
45
+ if (!trusted) return Promise.resolve(undefined);
43
46
  return new Promise((resolve) => {
44
- execute(
45
- "git",
46
- ["-C", cwd, "-c", "core.fsmonitor=false", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
47
- {
48
- encoding: "utf8",
49
- env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
50
- maxBuffer: 4 * 1024 * 1024,
51
- timeout: GIT_STATUS_TIMEOUT_MS,
52
- windowsHide: true,
53
- },
54
- (error, stdout) => {
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
+ }
59
+ const options = {
60
+ encoding: "utf8" as const,
61
+ env: passiveGitEnv(),
62
+ maxBuffer: 4 * 1024 * 1024,
63
+ timeout: GIT_STATUS_TIMEOUT_MS,
64
+ windowsHide: true as const,
65
+ };
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) => {
55
109
  if (error) {
56
110
  resolve(undefined);
57
111
  return;
58
112
  }
59
-
60
- const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
61
- const entries = stdout.split("\0");
62
- for (let index = 0; index < entries.length; index += 1) {
63
- const entry = entries[index];
64
- if (!entry) continue;
65
- const status = entry.slice(0, 2);
66
- if (status.includes("D")) changes.deleted += 1;
67
- else if (status === "??" || status.includes("A")) changes.added += 1;
68
- else changes.modified += 1;
69
- if (status.includes("R") || status.includes("C")) index += 1;
113
+ const safetyArgs = passiveStatusSafetyArgs(config);
114
+ if (!safetyArgs) {
115
+ resolve(undefined);
116
+ return;
70
117
  }
71
- resolve(changes);
72
- },
73
- );
118
+ runStatus(config, ["-C", cwd, ...safetyArgs, "status", "--porcelain=v1", "-z", "--untracked-files=all"]);
119
+ });
120
+ } catch {
121
+ resolve(undefined);
122
+ }
74
123
  });
75
124
  }
76
125
 
@@ -334,11 +383,13 @@ function renderFooter(rows: string[], width: number, theme: Theme): string[] {
334
383
  function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
335
384
  if (!state) return "";
336
385
  if (state.status === "active") {
337
- const turns = state.maxTurns === undefined ? "" : ` ${state.turns}/${state.maxTurns}`;
338
- return theme.fg("warning", `/goal is active${turns} (${formatTime(goalElapsedMilliseconds(state, Date.now()))})`);
386
+ const detail = state.maxTurns === undefined
387
+ ? `${state.turns} turn${state.turns === 1 ? "" : "s"} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))}`
388
+ : `${state.turns}/${state.maxTurns} · ${formatTime(goalElapsedMilliseconds(state, Date.now()))}`;
389
+ return `${theme.fg("customMessageLabel", "/goal active")}${theme.fg("dim", ` · ${detail}`)}`;
339
390
  }
340
- if (state.status === "paused") return theme.fg("warning", "/goal is paused");
341
- if (state.status === "blocked") return theme.fg("error", "/goal is blocked");
391
+ if (state.status === "paused") return theme.fg("warning", "/goal paused");
392
+ if (state.status === "blocked") return theme.fg("error", "/goal blocked");
342
393
  return "";
343
394
  }
344
395
 
@@ -389,7 +440,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
389
440
  if (JSON.stringify(changes) === JSON.stringify(gitFileChanges)) return;
390
441
  gitFileChanges = changes;
391
442
  tui.requestRender();
392
- });
443
+ }, (cwd) => resolveGitFileChanges(cwd, execFile, ctx.isProjectTrusted()));
393
444
  const unsubscribe = footerData.onBranchChange(() => {
394
445
  gitStatus.request();
395
446
  tui.requestRender();
@@ -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";
11
- import type { GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./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
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,39 +56,99 @@ 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
 
65
89
  export function registerGoalInterface(
66
90
  pi: ExtensionAPI,
67
91
  runtime: GoalRuntime,
68
- initState: InitRuntime,
69
92
  ): void {
70
93
  pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, options, theme) => {
71
94
  const data = entry.data;
72
- if (!data || data.version !== GOAL_VERSION || data.event === "turn" || data.event === "checkpoint") return undefined;
95
+ if (!data || data.version !== GOAL_VERSION) return undefined;
73
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;
74
99
  const state = parseGoalState(data.state);
75
100
  if (!state) return undefined;
76
- const icon = state.status === "active" ? "✻" : state.status === "paused" ? "Ⅱ" : state.status === "blocked" ? "!" : "✓";
77
- const color: ThemeColor = state.status === "active" ? "accent" : state.status === "paused" ? "warning" : state.status === "blocked" ? "error" : "success";
78
- const status = theme.fg(color, `${icon} Goal ${state.status}`);
101
+ const reason = state.stopReason ?? state.result;
79
102
  const objective = safeTerminalText(state.objective);
80
- if (!options.expanded) return new BoundedText(`${status}${theme.fg("dim", ` · ${objective}`)}`, 3);
81
- const lines = [status, theme.fg("dim", objective)];
82
- 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)));
83
145
  return new BoundedText(lines.join("\n"));
84
146
  });
85
147
 
86
148
  pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
87
149
  name: GOAL_UPDATE_TOOL,
88
150
  label: "Goal update",
89
- 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.",
90
152
  parameters: GoalUpdateParams,
91
153
  executionMode: "sequential",
92
154
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -95,42 +157,110 @@ export function registerGoalInterface(
95
157
  if (!isSavedSession(ctx)) throw new Error("KillerOS goals require a saved session");
96
158
  const state = runtime.state;
97
159
  if (!state || state.status !== "active") throw new Error("There is no active KillerOS goal to update");
98
- const evidence = params.evidence.trim();
99
- 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
+
100
174
  if (params.status === "complete") {
101
175
  if (state.verification) await verifyGoalDeliverable(state.verification);
102
176
  signal?.throwIfAborted();
103
- if (runtime.state !== state) throw new Error("Goal changed while completion was being verified");
104
- const verification = state.verification ? "file" : "model-reported";
105
- transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
106
- 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
+ }
107
190
  const text = state.verification
108
- ? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${safeEvidence}`
109
- : `Goal marked complete (model-reported): ${safeEvidence}`;
191
+ ? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${evidence}`
192
+ : `Goal marked complete (model-reported): ${evidence}`;
110
193
  return {
111
194
  content: [{ type: "text", text }],
112
195
  details: { status: "complete", evidence, verification },
113
196
  };
114
197
  }
115
- 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
+
116
228
  const blockerKey = params.blockerKey;
117
229
  if (!blockerKey || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(blockerKey)) {
118
230
  throw new Error("A blocked goal update requires a stable lowercase blockerKey");
119
231
  }
120
232
  const previous = state.blockerAudit;
121
- 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
+ }
122
236
  const consecutive = previous?.key === blockerKey && previous.lastTurn === state.turns - 1;
123
- const streak = sameTurn ? previous.streak : consecutive ? previous.streak + 1 : 1;
237
+ const streak = consecutive ? previous.streak + 1 : 1;
124
238
  const blockerAudit = { key: blockerKey, streak, lastTurn: state.turns, evidence };
125
239
  if (streak < 3) {
126
- const next = recordGoalBlockerAudit(state, blockerAudit, Date.now());
127
- 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
+ }
128
252
  return {
129
253
  content: [{ type: "text", text: `Blocker audit ${streak}/3 recorded; the goal remains active: ${evidence}` }],
130
254
  details: { status: "blocker-audit", evidence, blockerKey, streak },
131
255
  };
132
256
  }
133
- 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
+ }
134
264
  return {
135
265
  content: [{ type: "text", text: `Goal marked blocked: ${evidence}` }],
136
266
  details: { status: "blocked", evidence, blockerKey, streak },
@@ -143,15 +273,29 @@ export function registerGoalInterface(
143
273
  if (context?.isError) {
144
274
  const first = result.content[0];
145
275
  const message = first?.type === "text" ? safeTerminalText(first.text) : "Goal update failed";
146
- return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 3);
276
+ return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 1);
147
277
  }
148
278
  const details = result.details;
149
- if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
150
- const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
151
- const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${safeTerminalText(details.evidence)}`)}`;
152
- 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);
153
296
  },
154
297
  });
298
+ syncGoalUpdateTool(pi, runtime);
155
299
  const handleGoalCommand = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
156
300
  const command = parseGoalCommand(args);
157
301
  if (ctx.mode === "print" || ctx.mode === "json") {
@@ -217,7 +361,7 @@ export function registerGoalInterface(
217
361
  return;
218
362
  }
219
363
  if (saved) {
220
- ctx.ui.notify("Goal cleared", "info");
364
+ if (ctx.mode !== "tui") ctx.ui.notify("Goal cleared", "info");
221
365
  } else {
222
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");
223
367
  }
@@ -278,7 +422,7 @@ export function registerGoalInterface(
278
422
  return;
279
423
  }
280
424
  if (saved) {
281
- 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");
282
426
  } else {
283
427
  ctx.ui.notify(`Goal paused: ${failureReason}\nAutomatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.`, "error");
284
428
  }
@@ -286,10 +430,6 @@ export function registerGoalInterface(
286
430
  }
287
431
 
288
432
  if (command.kind === "resume") {
289
- if (initState.active) {
290
- ctx.ui.notify("Wait for /init to finish before resuming a goal", "error");
291
- return;
292
- }
293
433
  if (!runtime.state) {
294
434
  ctx.ui.notify("No goal is set", "info");
295
435
  return;
@@ -313,7 +453,7 @@ export function registerGoalInterface(
313
453
  const base = transitionGoalState(runtime.state, "active", undefined, { resetBlockedAudit: true }, Date.now());
314
454
  persistGoalState(pi, runtime, "resume", { ...base, maxTurns: renewed });
315
455
  runtime.continuationScheduled = false;
316
- if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal resumed", "info");
456
+ if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
317
457
  } catch (error) {
318
458
  reportError(ctx, "Goal could not be resumed", error);
319
459
  }
@@ -322,17 +462,13 @@ export function registerGoalInterface(
322
462
  try {
323
463
  transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
324
464
  runtime.continuationScheduled = false;
325
- if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal resumed", "info");
465
+ if (scheduleGoalContinuation(pi, runtime, ctx) && ctx.mode !== "tui") ctx.ui.notify("Goal resumed", "info");
326
466
  } catch (error) {
327
467
  reportError(ctx, "Goal could not be resumed", error);
328
468
  }
329
469
  return;
330
470
  }
331
471
 
332
- if (initState.active) {
333
- ctx.ui.notify("Wait for /init to finish before starting a goal", "error");
334
- return;
335
- }
336
472
  switch (command.kind) {
337
473
  case "objective":
338
474
  break;
@@ -364,7 +500,7 @@ export function registerGoalInterface(
364
500
  }
365
501
  if (waitError) {
366
502
  reportError(ctx, "Goal could not wait for the active turn", waitError);
367
- scheduleGoalContinuation(pi, runtime, initState, ctx);
503
+ scheduleGoalContinuation(pi, runtime, ctx);
368
504
  return;
369
505
  }
370
506
  let verification: Awaited<ReturnType<typeof inferGoalVerification>>;
@@ -375,7 +511,7 @@ export function registerGoalInterface(
375
511
  reportError(ctx, "Goal could not be started", error);
376
512
  } else {
377
513
  reportError(ctx, "Goal could not be replaced", error);
378
- scheduleGoalContinuation(pi, runtime, initState, ctx);
514
+ scheduleGoalContinuation(pi, runtime, ctx);
379
515
  }
380
516
  return;
381
517
  }
@@ -384,8 +520,10 @@ export function registerGoalInterface(
384
520
  maxTurns: DEFAULT_GOAL_MAX_TURNS,
385
521
  });
386
522
  persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
387
- if (scheduleGoalContinuation(pi, runtime, initState, ctx)) {
388
- 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");
389
527
  }
390
528
  } catch (error) {
391
529
  if (!unfinished) {