killeros 2.1.27 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.1.28] - 2026-09-15
8
+
9
+ ### Changed
10
+
11
+ - Simplified goal presentation without changing goal behavior: transcript history shows one iconless event row per lifecycle change (`Goal started`, `Goal replaced`, `Goal paused`, `Goal resumed`, `Goal cleared`), progress and blocker outcomes render once through the goal tool result, the footer shows compact `/goal active · 1/20 · 10s` status, the `/goal` panel leads with status then objective then `Pause goal` / `Resume goal` / `Clear goal` actions, and duplicate TUI success notifications are suppressed while RPC confirmations remain. Active-goal labels use muted teal `#6FAEB2` through the packaged theme's extension-label role.
12
+ - Removed the legacy `✻` marker from version-1 `Worked for …` transcript rows; stored entries are unchanged and remain readable.
13
+
14
+ ### Added
15
+
16
+ - Appended the selected model ID as a gray `· <model-id>` suffix to every TUI activity working message, retained for the full request cycle and falling back to `unknown model` when no usable ID exists.
17
+
18
+ ### Fixed
19
+
20
+ - Kept resumed blocked goals and goals paused by truncated provider errors readable after reload.
21
+ - Made goal continuation explicit: each settled turn now needs one accepted `continue`, `complete`, or blocker decision. Missing decisions, repeated continuation reports, unavailable goal tools, and runtime failures pause without scheduling another turn; compaction recovery resumes the same logical turn.
22
+ - Preserved accepted goal decisions across automatic compaction: a `continue` or blocker audit recorded before compaction now authorizes exactly one next turn instead of being discarded and re-decided. Duplicate compaction and settlement callbacks cannot start a second turn.
23
+ - Scoped goal pause reasons to the current paused state: resume and manual pause no longer surface an earlier automatic reason, terminal states keep no pause reason, and contradictory persisted states fail closed instead of starting automatic work.
24
+
7
25
  ## [2.1.27] - 2026-09-12
8
26
 
9
27
  ### Added
package/README.md CHANGED
@@ -5,7 +5,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
5
5
  ## What you get
6
6
 
7
7
  - A custom TUI: startup masthead with versions, model, working directory, and Git branch; a dark theme with coral accents; a multiline editor with slash-command completion; a footer that tracks model, context, and goal state; settled task receipts with duration and token usage.
8
- - `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. New goals pause after 20 turns; `/goal resume` grants another 20.
8
+ - `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. Each turn must record `continue` with evidence and one next action, `complete`, or the existing blocker decision; otherwise the goal pauses. New goals pause after 20 turns; `/goal resume` grants another 20.
9
9
  - `/codex-fast`: toggles the `priority` service tier on Codex requests or reports its status.
10
10
  - `/handoff`: starts a fresh linked session carrying visible continuation context.
11
11
  - Automatic context compaction when remaining tokens drop below 15% of the window (configurable).
@@ -32,7 +32,7 @@ Or from GitHub:
32
32
  pi install git:github.com/KyrosHendrix/pi-KillerOS
33
33
  ```
34
34
 
35
- Pin a release by appending its tag, for example `@v2.1.27`. Add `-l` to install only for the current project. Restart Pi after installing.
35
+ Pin a release by appending its tag, for example `@v2.1.28`. Add `-l` to install only for the current project. Restart Pi after installing.
36
36
 
37
37
  ## Commands
38
38
 
@@ -85,7 +85,7 @@ A direct quoted file target binds silent file proof:
85
85
  /goal Fix `killeros/footer.ts`, verified by npm test
86
86
  ```
87
87
 
88
- KillerOS captures the file baseline at goal start and only completes when the file is created or changed. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Restored goals keep their persisted limit.
88
+ KillerOS captures the file baseline at goal start and only completes when the file is created or changed. A normal response never continues a goal by itself: the agent must record `continue`, `complete`, or a blocker decision through `killeros_goal_update`. Repeated continuation reports and unavailable goal tools pause the goal. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Restored goals keep their persisted limit.
89
89
 
90
90
  Completion sounds are off by default; change with `/notification` in TUI mode. The tab-title indicator requires a Nerd Font.
91
91
 
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { modelDisplayName } from "./display.ts";
3
4
 
4
5
  export type ActivityMessage =
5
6
  | { kind: "prompt" }
@@ -12,7 +13,7 @@ function safeToolName(toolName: string): string {
12
13
  return truncateToWidth(normalized || "tool", 32, "…");
13
14
  }
14
15
 
15
- export function formatActivityMessage(message: ActivityMessage, theme: Theme): string {
16
+ export function formatActivityMessage(message: ActivityMessage, theme: Theme, modelId?: string): string {
16
17
  let verb: string;
17
18
  let detail: string;
18
19
 
@@ -58,38 +59,46 @@ export function formatActivityMessage(message: ActivityMessage, theme: Theme): s
58
59
  }
59
60
  }
60
61
 
61
- return `${theme.fg("accent", verb)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · ${detail})`)}`;
62
+ const suffix = modelDisplayName({ id: modelId }) || "unknown model";
63
+ return `${theme.fg("accent", verb)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · ${detail})`)}${theme.fg("dim", ` · ${suffix}`)}`;
62
64
  }
63
65
 
64
66
  export function registerRequestActivity(pi: ExtensionAPI): void {
65
67
  let active = false;
68
+ let activeModel: string | undefined;
69
+
70
+ const render = (message: ActivityMessage, ctx: ExtensionContext): void => {
71
+ ctx.ui.setWorkingMessage(formatActivityMessage(message, ctx.ui.theme, activeModel));
72
+ };
66
73
 
67
74
  const clear = (ctx?: ExtensionContext): void => {
75
+ active = false;
76
+ activeModel = undefined;
68
77
  if (ctx?.mode === "tui") {
69
78
  ctx.ui.setWorkingMessage();
70
79
  }
71
- active = false;
72
80
  };
73
81
 
74
82
  pi.on("agent_start", (_event, ctx) => {
75
83
  if (ctx.mode !== "tui") return;
84
+ if (!active) activeModel = ctx.model?.id;
76
85
  active = true;
77
- ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "prompt" }, ctx.ui.theme));
86
+ render({ kind: "prompt" }, ctx);
78
87
  });
79
88
 
80
89
  pi.on("tool_execution_start", (event, ctx) => {
81
90
  if (ctx.mode !== "tui" || !active) return;
82
- ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "tool", toolName: event.toolName }, ctx.ui.theme));
91
+ render({ kind: "tool", toolName: event.toolName }, ctx);
83
92
  });
84
93
 
85
94
  pi.on("tool_execution_end", (event, ctx) => {
86
95
  if (ctx.mode !== "tui" || !active) return;
87
- ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "tool-result", failed: event.isError }, ctx.ui.theme));
96
+ render({ kind: "tool-result", failed: event.isError }, ctx);
88
97
  });
89
98
 
90
99
  pi.on("message_update", (event, ctx) => {
91
100
  if (ctx.mode !== "tui" || !active || event.assistantMessageEvent.type !== "text_start") return;
92
- ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "responding" }, ctx.ui.theme));
101
+ render({ kind: "responding" }, ctx);
93
102
  });
94
103
 
95
104
  pi.on("agent_settled", (_event, ctx) => {
@@ -383,11 +383,13 @@ function renderFooter(rows: string[], width: number, theme: Theme): string[] {
383
383
  function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
384
384
  if (!state) return "";
385
385
  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()))})`);
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}`)}`;
388
390
  }
389
- if (state.status === "paused") return theme.fg("warning", "/goal is paused");
390
- 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");
391
393
  return "";
392
394
  }
393
395
 
@@ -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) {