killeros 2.0.4 → 2.0.6

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,41 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.6] - 2026-08-11
8
+
9
+ ### Added
10
+
11
+ - Added a durable `✻ Worked for …` transcript line below each settled TUI response, including stopped and failed runs.
12
+ - Added optional multi-select to the `question` tool with bounded checked options, one additive custom answer, a dedicated multi-word filter editor, and compact expandable results while preserving single-select defaults.
13
+
14
+ ### Changed
15
+
16
+ - Automated GitHub releases after successful `main` CI version bumps, with package, lockfile, changelog, tag, and verified-commit checks plus a manual tag recovery path.
17
+
18
+ ### Fixed
19
+
20
+ - Entered automatic `/goal` continuations into durable goal-turn state before dispatch so turn numbers advance and blocker audits work on Pi custom-message turns.
21
+
22
+ ## [2.0.5] - 2026-08-11
23
+
24
+ ### Added
25
+
26
+ - Added shuffled session-stable suggestions to the empty three-line prompt editor.
27
+
28
+ ### Fixed
29
+
30
+ - Preserved complete UTF-8 characters in truncated `/init` evidence and chunked lifecycle-hook output, and kept filesystem-root paths visible in notification titles.
31
+ - Kept the startup-tip shuffle deck across session rebinds so every tip appears before the bank repeats.
32
+ - Rejected matchers on `agent_settled` lifecycle hooks instead of silently bypassing their tool-name guard.
33
+ - Made the goal update status schema compatible with Google models while preserving strict completion and blocking values.
34
+ - Kept multiline custom-answer drafts within tiny terminal row limits by rendering a clipped single-line preview.
35
+ - Preserved existing spaces and tabs after the cursor when applying slash-command autocomplete.
36
+ - Made `/init` abort safely when Git ignore inspection fails or returns untrusted output, preventing ignored files from entering model evidence.
37
+ - Wrapped long interactive questions to the current terminal width while keeping the question UI within the terminal height.
38
+ - Made `/goal pause` and `/goal clear` save terminal state before immediately stopping active goal work.
39
+ - Kept failed goal edits and replacements visible and fail-closed without restarting a stale objective.
40
+ - Required one durable blocker key on three consecutive goal turns before a goal can be marked blocked.
41
+
7
42
  ## [2.0.4] - 2026-08-10
8
43
 
9
44
  ### Changed
package/Killeros.ts CHANGED
@@ -14,6 +14,7 @@ import { registerQuestionTool } from "./killeros/question.ts";
14
14
  import { createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
15
15
  import { registerShellUi } from "./killeros/shell-ui.ts";
16
16
  import { registerVariants } from "./killeros/variants.ts";
17
+ import { registerWorkedFor } from "./killeros/worked-for.ts";
17
18
 
18
19
  export { CONCISE_SYSTEM_PROMPT, isConciseEnabled, isConcisedEnabled } from "./killeros/concise.ts";
19
20
  export { contextPercentRemaining, formatCost, formatContextProgress } from "./killeros/footer.ts";
@@ -43,4 +44,5 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
43
44
  registerGoalSettlement(pi, goalRuntime, initRuntime);
44
45
  registerInitSettlement(pi, initRuntime);
45
46
  registerCompletionNotifications(pi, options.completionNotifications);
47
+ registerWorkedFor(pi);
46
48
  }
package/README.md CHANGED
@@ -31,7 +31,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
31
31
  Pin an install to a release:
32
32
 
33
33
  ```bash
34
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.4
34
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.6
35
35
  ```
36
36
 
37
37
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -41,14 +41,14 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
41
41
  - 52-column Compact startup card with inline version, polished model/provider identity, adjacent `/model`, directory, conditional Git branch, and a shuffled session-stable tip
42
42
  - Cohesive dark theme with coral accents and neutral tool-call containers across pending, success, and error states
43
43
  - Animated orange 12-frame activity glyph loop at 120 ms per frame, with orange shuffled Claude-adjacent verbs changing every 2.5 seconds, a gray `(esc to interrupt · thinking)` status with bold `esc`, and a quiet hidden-thinking label
44
- - Framed multiline editor with Shift+Enter support and slash-command autocomplete; KillerOS preserves an editor factory configured by another extension
44
+ - Full-width framed multiline editor with a shuffled session-stable empty-state suggestion, Shift+Enter support, and slash-command autocomplete; KillerOS preserves an editor factory configured by another extension
45
45
  - Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
46
46
  - Pi-owned context compaction with active goals continuing from Pi's settled boundary after manual, threshold, and overflow compaction
47
47
  - Optional completion sounds after successful or failed settled requests, excluding manual aborts
48
48
  - `/variants` selector and direct reasoning-level arguments
49
- - Codex-style `/goal` with an interactive status/action panel, durable objectives, pause, resume, edit, confirmed panel clearing, automatic continuation, and explicit completion
49
+ - Codex-style `/goal` with an interactive status/action panel, durable objectives, immediate pause and clear cancellation, automatic continuation, explicit completion, and durable blocker audits
50
50
  - Automatic `/init` guideline synthesis with a frozen safe evidence map, protected existing policy, and the four packaged behavioral sections adapted from `writing-great-guidelines`
51
- - `question` tool with height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
51
+ - `question` tool with single-select and opt-in bounded multi-select, height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
52
52
  - Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
53
53
  - Goal-aware `/clear` that confirms, aborts active work, waits for settlement, and starts a new session, plus `/exit` for graceful shutdown
54
54
  - Concise system-prompt guidance and supported native concise defaults that preserve explicit provider settings
@@ -60,9 +60,9 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
60
60
  /goal Open current goal status and valid actions
61
61
  /goal <objective> Set an objective and start working
62
62
  /goal edit Edit and reactivate the current goal
63
- /goal pause Stop automatic continuation
63
+ /goal pause Stop the current goal turn and automatic continuation
64
64
  /goal resume Resume automatic continuation
65
- /goal clear Remove the current goal
65
+ /goal clear Stop current goal work and remove the goal
66
66
  /variants Open the reasoning-level selector
67
67
  /variants high Set a reasoning level directly
68
68
  /notification Configure the completion sound
@@ -70,12 +70,20 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
70
70
  /exit Quit Pi gracefully
71
71
  ```
72
72
 
73
- `/goal` requires a saved session in TUI or RPC mode. Goal state is stored in versioned session entries on the active branch and restored after reload, resume, fork, or tree navigation. Active goals inject their unchanged objective every turn and continue one settled turn at a time. The model must use KillerOS’s private goal tool to mark verified completion or a blocker repeated across at least three goal turns; final prose alone does not end the loop. Aborted turns, provider failures, and continuation failures pause safely. Replacing unfinished work requires confirmation, and `/goal edit` requires TUI mode.
73
+ `/goal` requires a saved session in TUI or RPC mode. Goal state is stored in versioned session entries on the active branch and restored after reload, resume, fork, or tree navigation. Active goals inject their unchanged objective every turn and continue one settled turn at a time. The model must use KillerOS’s private goal tool to mark verified completion. Blocking requires one stable lowercase blocker key recorded on three consecutive goal turns; a changed key, skipped turn, resume, or edit resets the streak. Final prose alone does not end the loop.
74
+
75
+ `/goal pause` and `/goal clear` save paused or cleared state before aborting current goal work, so settlement cannot restart it. Aborted turns, provider failures, and continuation failures otherwise pause safely. Failed edit and replacement writes dispatch no edited objective; an active prior objective pauses fail-closed, while inactive durable state remains unchanged. Replacing unfinished work requires confirmation, and `/goal edit` requires TUI mode.
74
76
 
75
77
  `/init` freezes a safe project-file map and exposes only dedicated read and list operations while it generates root `AGENTS.md`. Git-ignored files, known secret paths, private-key formats, other guidance, dependencies, links, non-regular files, and files outside that map are unavailable. Existing root `AGENTS.md` is separate protected policy: compatible rules are preserved, a real policy conflict leaves it unchanged with a reason, and any concurrent target change aborts installation without replacing the newer file.
76
78
 
77
79
  The generated file uses the four behavioral sections adapted from `writing-great-guidelines`; no external skill installation is required. `/init` asks no setup questions, starts no second model process, writes no other file, and reloads Pi resources only after a successful write.
78
80
 
81
+ ### Interactive questions
82
+
83
+ Single-select remains the default. An agent opts into multi-select with `mode: "multiple"` and may set `minSelections` and `maxSelections`; the custom answer counts as one selection.
84
+
85
+ In multi-select, use Space or a visible number to toggle an option, `/` to filter, and Enter to submit. The filter accepts spaces; Enter applies it and Escape returns to the choices. Checked options remain selected when the filter changes. Select **Type a custom answer** with Enter to add or edit one custom item alongside checked options.
86
+
79
87
  Supported reasoning levels are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. KillerOS limits choices to levels supported by the current model.
80
88
 
81
89
  ## Configuration
@@ -92,7 +100,7 @@ Manual `/compact` aborts the current goal turn before summarization, so KillerOS
92
100
 
93
101
  For trusted projects, KillerOS loads `AGENTS.local.md` after Pi's shared repository context. A one-line `@path` or `@~/path` file imports personal guidance from another location.
94
102
 
95
- Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`; matchers are JavaScript regular expressions over Pi tool names. Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. Failed `tool_call` hooks block the tool, while later-event failures notify the user. Aborting the parent request stops the hook process tree with bounded graceful and forced cleanup without reporting cancellation as a hook failure.
103
+ Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`. Optional matchers are JavaScript regular expressions over Pi tool names, so they are valid only for `tool_call` and `tool_result`; KillerOS rejects an `agent_settled` hook that defines a matcher. Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. Failed `tool_call` hooks block the tool, while later-event failures notify the user. Aborting the parent request stops the hook process tree with bounded graceful and forced cleanup without reporting cancellation as a hook failure.
96
104
 
97
105
  ## Behavior by mode
98
106
 
@@ -118,17 +126,17 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
118
126
 
119
127
  ## Publish
120
128
 
121
- The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog.
129
+ To create a GitHub release, update the version in `package.json` and `package-lock.json`, add the matching `CHANGELOG.md` section, and push the release commit to `main`. After the full CI workflow passes, the release workflow creates the matching tag and GitHub release from that verified commit.
130
+
131
+ Do not manually tag a normal release. If automation must recover a missing GitHub release, push the matching version tag; the same workflow validates the tag against the package and changelog before creating the release.
122
132
 
123
- For a release, publish after the validation checks pass:
133
+ The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog. GitHub release automation does not publish to npm. Publish there separately after validation:
124
134
 
125
135
  ```bash
126
136
  npm login
127
137
  npm publish
128
138
  ```
129
139
 
130
- For later releases, choose `patch`, `minor`, or `major` with `npm version`, then publish and push the version commit and tag.
131
-
132
140
  ## Security
133
141
 
134
142
  Pi extensions run with your user permissions. Review the source before installing KillerOS globally. KillerOS executes lifecycle hook commands only for projects Pi marks as trusted; review `.pi/killeros-hooks.json` before enabling project trust.
@@ -150,12 +150,11 @@ export function registerSlashAutocomplete(pi: ExtensionAPI): void {
150
150
  usage.set(tagged.killerosCommand, (usage.get(tagged.killerosCommand) ?? 0) + 1);
151
151
  const line = lines[cursorLine] ?? "";
152
152
  const beforeCursor = line.slice(0, cursorCol);
153
- let afterCursor = line.slice(cursorCol);
153
+ const afterCursor = line.slice(cursorCol);
154
154
  const match = beforeCursor.match(/(?:^|[ \t])\/([^\s/]*)$/);
155
155
  if (!match || match.index === undefined) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
156
156
  const slashIndex = match.index + (match[0].startsWith("/") ? 0 : 1);
157
157
  const newBefore = beforeCursor.slice(0, slashIndex) + item.value;
158
- if (item.value.endsWith(" ") && afterCursor.startsWith(" ")) afterCursor = afterCursor.trimStart();
159
158
  const nextLines = [...lines];
160
159
  nextLines[cursorLine] = newBefore + afterCursor;
161
160
  return { lines: nextLines, cursorLine, cursorCol: newBefore.length };
package/killeros/goals.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
3
  import { Text } from "@earendil-works/pi-tui";
3
4
  import { Type } from "typebox";
@@ -6,14 +7,14 @@ import { BoundedText } from "./bounded-text.ts";
6
7
  import { formatTime, formatTokens } from "./display.ts";
7
8
  import { reportError } from "./errors.ts";
8
9
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
9
- import type { GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
10
+ import type { GoalBlockerAudit, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
10
11
 
11
12
  const GOAL_ENTRY_TYPE = "killeros-goal";
12
13
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
13
14
  const GOAL_OBJECTIVE_LIMIT = 4_000;
14
15
  const GOAL_VERSION = 1;
15
16
 
16
- type GoalEntryEvent = "set" | "replace" | "edit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint";
17
+ type GoalEntryEvent = "set" | "replace" | "edit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
17
18
  interface GoalEntryData {
18
19
  version: 1;
19
20
  event: GoalEntryEvent;
@@ -23,6 +24,7 @@ interface GoalEntryData {
23
24
  interface GoalTransitionOptions {
24
25
  resetBlockedAudit?: boolean;
25
26
  resumeAfterManualCompaction?: true;
27
+ blockerAudit?: GoalBlockerAudit;
26
28
  }
27
29
 
28
30
  interface RestoredGoalState {
@@ -31,7 +33,7 @@ interface RestoredGoalState {
31
33
  }
32
34
 
33
35
  const GoalUpdateParams = Type.Object({
34
- status: Type.Union([Type.Literal("complete"), Type.Literal("blocked")], {
36
+ status: StringEnum(["complete", "blocked"] as const, {
35
37
  description: "Mark the active goal complete or blocked",
36
38
  }),
37
39
  evidence: Type.String({
@@ -39,11 +41,19 @@ const GoalUpdateParams = Type.Object({
39
41
  maxLength: 2_000,
40
42
  description: "Concise evidence that the objective is complete, or the repeated blocker and attempted workarounds",
41
43
  }),
44
+ blockerKey: Type.Optional(Type.String({
45
+ minLength: 1,
46
+ maxLength: 120,
47
+ pattern: "^[a-z0-9][a-z0-9._-]{0,119}$",
48
+ description: "Stable lowercase key identifying the repeated blocker",
49
+ })),
42
50
  });
43
51
 
44
52
  interface GoalUpdateDetails {
45
- status: "complete" | "blocked";
53
+ status: "complete" | "blocked" | "blocker-audit";
46
54
  evidence: string;
55
+ blockerKey?: string;
56
+ streak?: number;
47
57
  }
48
58
 
49
59
  function isGoalStatus(value: unknown): value is GoalStatus {
@@ -54,6 +64,17 @@ function finiteNonNegative(value: unknown): value is number {
54
64
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
55
65
  }
56
66
 
67
+ function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
68
+ if (!value || typeof value !== "object") return false;
69
+ const candidate = value as Partial<GoalBlockerAudit>;
70
+ if (typeof candidate.key !== "string"
71
+ || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(candidate.key)
72
+ || !Number.isInteger(candidate.streak) || (candidate.streak ?? 0) < 1 || (candidate.streak ?? 0) > 3
73
+ || !Number.isInteger(candidate.lastTurn) || (candidate.lastTurn ?? 0) < 1 || (candidate.lastTurn ?? 0) > turns) return false;
74
+ if (status === "complete") return false;
75
+ return status === "blocked" ? candidate.streak === 3 : candidate.streak! < 3;
76
+ }
77
+
57
78
  function parseGoalState(value: unknown): GoalState | undefined {
58
79
  if (!value || typeof value !== "object") return undefined;
59
80
  const candidate = value as Partial<GoalState>;
@@ -71,6 +92,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
71
92
  || !finiteNonNegative(candidate.baselineTokens)
72
93
  || candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
73
94
  || candidate.result !== undefined && typeof candidate.result !== "string"
95
+ || candidate.blockerAudit !== undefined && !isGoalBlockerAudit(candidate.blockerAudit, candidate.turns!, candidate.status)
74
96
  || candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
75
97
  || candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
76
98
  return undefined;
@@ -89,6 +111,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
89
111
  baselineTokens: candidate.baselineTokens,
90
112
  result: candidate.result,
91
113
  resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
114
+ blockerAudit: candidate.blockerAudit,
92
115
  };
93
116
  }
94
117
 
@@ -182,6 +205,7 @@ function transitionGoal(
182
205
  updatedAt: now,
183
206
  activeStartedAt: status === "active" ? now : undefined,
184
207
  blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
208
+ blockerAudit: options.resetBlockedAudit ? undefined : options.blockerAudit ?? stopped.blockerAudit,
185
209
  result,
186
210
  resumeAfterManualCompaction: options.resumeAfterManualCompaction,
187
211
  };
@@ -190,6 +214,24 @@ function transitionGoal(
190
214
  return next;
191
215
  }
192
216
 
217
+ function clearGoalExecutionFlags(runtime: GoalRuntime): void {
218
+ runtime.continuationScheduled = false;
219
+ runtime.goalTurnInFlight = false;
220
+ runtime.agentEndObserved = false;
221
+ runtime.lastStopReason = undefined;
222
+ runtime.lastError = undefined;
223
+ }
224
+
225
+ async function stopGoalRun(runtime: GoalRuntime, ctx: ExtensionCommandContext, shouldStop: boolean): Promise<void> {
226
+ if (!shouldStop) return;
227
+ try {
228
+ ctx.abort();
229
+ await ctx.waitForIdle();
230
+ } finally {
231
+ clearGoalExecutionFlags(runtime);
232
+ }
233
+ }
234
+
193
235
  function goalStatusLabel(status: GoalStatus): string {
194
236
  return `${status.charAt(0).toLocaleUpperCase()}${status.slice(1)}`;
195
237
  }
@@ -222,6 +264,7 @@ export function pauseGoalAfterFailure(
222
264
  ctx: ExtensionContext,
223
265
  reason: string,
224
266
  recoveryInstruction = "Run /goal resume after resolving the problem.",
267
+ notify = true,
225
268
  ): void {
226
269
  if (runtime.state?.status !== "active") return;
227
270
  try {
@@ -237,7 +280,7 @@ export function pauseGoalAfterFailure(
237
280
  runtime.continuationScheduled = false;
238
281
  runtime.requestRender?.();
239
282
  }
240
- ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
283
+ if (notify) ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
241
284
  }
242
285
 
243
286
  function pauseGoalForPossibleManualCompaction(
@@ -290,6 +333,34 @@ function recoverGoalAfterManualCompaction(
290
333
  return true;
291
334
  }
292
335
 
336
+ function beginGoalTurn(
337
+ pi: ExtensionAPI,
338
+ runtime: GoalRuntime,
339
+ ctx: ExtensionContext,
340
+ current: GoalState,
341
+ ): GoalState | undefined {
342
+ const now = Date.now();
343
+ const next: GoalState = {
344
+ ...current,
345
+ revision: current.revision + 1,
346
+ turns: current.turns + 1,
347
+ updatedAt: now,
348
+ activeStartedAt: current.activeStartedAt ?? now,
349
+ resumeAfterManualCompaction: undefined,
350
+ };
351
+ try {
352
+ persistGoalState(pi, runtime, "turn", next);
353
+ } catch (error) {
354
+ pauseGoalAfterFailure(pi, runtime, ctx, `turn state could not be saved: ${error instanceof Error ? error.message : String(error)}`);
355
+ return undefined;
356
+ }
357
+ runtime.goalTurnInFlight = true;
358
+ runtime.agentEndObserved = false;
359
+ runtime.lastStopReason = undefined;
360
+ runtime.lastError = undefined;
361
+ return next;
362
+ }
363
+
293
364
  function scheduleGoalContinuation(
294
365
  pi: ExtensionAPI,
295
366
  runtime: GoalRuntime,
@@ -303,17 +374,15 @@ function scheduleGoalContinuation(
303
374
  || runtime.continuationHeld
304
375
  || runtime.goalTurnInFlight
305
376
  || initState.active
377
+ || !ctx.isIdle()
306
378
  || ctx.hasPendingMessages()) return false;
307
- const current = runtime.state;
308
379
  runtime.continuationScheduled = true;
309
- runtime.goalTurnInFlight = false;
310
- runtime.agentEndObserved = false;
311
- runtime.lastStopReason = undefined;
312
- runtime.lastError = undefined;
380
+ const next = beginGoalTurn(pi, runtime, ctx, runtime.state);
381
+ if (!next) return false;
313
382
  try {
314
383
  pi.sendMessage({
315
384
  customType: GOAL_CONTINUATION_TYPE,
316
- content: goalContinuationMessage(current, ctx),
385
+ content: goalContinuationMessage(next, ctx),
317
386
  display: false,
318
387
  }, { triggerTurn: true, deliverAs: "followUp" });
319
388
  return true;
@@ -337,7 +406,7 @@ function goalInstructions(state: GoalState, heading: string): string {
337
406
  "Continue making concrete progress toward this unchanged objective. Re-check repository state and prior results instead of repeating work.",
338
407
  "Do not stop merely because one response is complete: KillerOS will start another goal turn while the goal remains active.",
339
408
  "Before declaring completion, audit every part of the objective and verify the relevant results. Then call killeros_goal_update with status complete and concise evidence.",
340
- "Call killeros_goal_update with status blocked only when the same external impasse has prevented progress for three consecutive goal turns; name the blocker and attempted workarounds.",
409
+ "Call killeros_goal_update with status blocked and the same lowercase blockerKey on each turn where one external impasse persists; attempts one and two record the audit, and attempt three marks the goal blocked.",
341
410
  "Never use the goal tool to pause, resume, edit, replace, or clear the objective. Those transitions belong to the user.",
342
411
  ].join("\n");
343
412
  }
@@ -398,7 +467,7 @@ export function registerGoal(
398
467
  pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
399
468
  name: "killeros_goal_update",
400
469
  label: "Goal update",
401
- description: "Mark the active KillerOS long-running goal complete after verification, or blocked after the same impasse persists for three consecutive goal turns.",
470
+ 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.",
402
471
  parameters: GoalUpdateParams,
403
472
  executionMode: "sequential",
404
473
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -408,13 +477,40 @@ export function registerGoal(
408
477
  if (!state || state.status !== "active") throw new Error("There is no active KillerOS goal to update");
409
478
  const evidence = params.evidence.trim();
410
479
  if (!evidence) throw new Error("Goal evidence must not be empty");
411
- if (params.status === "blocked" && state.turns - state.blockedAuditStartTurn < 3) {
412
- throw new Error("A goal cannot be marked blocked before three goal turns in the current audit; keep working and audit the same blocker again");
480
+ if (params.status === "complete") {
481
+ transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
482
+ return {
483
+ content: [{ type: "text", text: `Goal marked complete: ${evidence}` }],
484
+ details: { status: "complete", evidence },
485
+ };
486
+ }
487
+ if (!runtime.goalTurnInFlight) throw new Error("A blocker audit can only be recorded during an active KillerOS goal turn");
488
+ const blockerKey = params.blockerKey;
489
+ if (!blockerKey || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(blockerKey)) {
490
+ throw new Error("A blocked goal update requires a stable lowercase blockerKey");
413
491
  }
414
- transitionGoal(pi, runtime, params.status, params.status, evidence);
492
+ const previous = state.blockerAudit;
493
+ const sameTurn = previous?.key === blockerKey && previous.lastTurn === state.turns;
494
+ const consecutive = previous?.key === blockerKey && previous.lastTurn === state.turns - 1;
495
+ const streak = sameTurn ? previous.streak : consecutive ? previous.streak + 1 : 1;
496
+ const blockerAudit = { key: blockerKey, streak, lastTurn: state.turns };
497
+ if (streak < 3) {
498
+ const next: GoalState = {
499
+ ...state,
500
+ revision: state.revision + 1,
501
+ updatedAt: Date.now(),
502
+ blockerAudit,
503
+ };
504
+ persistGoalState(pi, runtime, "blocker-audit", next);
505
+ return {
506
+ content: [{ type: "text", text: `Blocker audit ${streak}/3 recorded; the goal remains active: ${evidence}` }],
507
+ details: { status: "blocker-audit", evidence, blockerKey, streak },
508
+ };
509
+ }
510
+ transitionGoal(pi, runtime, "blocked", "blocked", evidence, { blockerAudit });
415
511
  return {
416
- content: [{ type: "text", text: `Goal marked ${params.status}: ${evidence}` }],
417
- details: { status: params.status, evidence },
512
+ content: [{ type: "text", text: `Goal marked blocked: ${evidence}` }],
513
+ details: { status: "blocked", evidence, blockerKey, streak },
418
514
  };
419
515
  },
420
516
  renderCall(args, theme) {
@@ -423,7 +519,8 @@ export function registerGoal(
423
519
  renderResult(result, options, theme) {
424
520
  const details = result.details;
425
521
  if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
426
- const text = `${theme.fg(details.status === "complete" ? "success" : "warning", details.status === "complete" ? " Complete" : "! Blocked")}${theme.fg("dim", ` · ${details.evidence}`)}`;
522
+ const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
523
+ const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${details.evidence}`)}`;
427
524
  return new BoundedText(text, options.expanded ? undefined : 3);
428
525
  },
429
526
  });
@@ -478,25 +575,8 @@ export function registerGoal(
478
575
  const current = runtime.state;
479
576
  if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current || current.status !== "active" || initState.active) return;
480
577
  if (runtime.goalTurnInFlight) return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(current)}` };
481
- const now = Date.now();
482
- const next: GoalState = {
483
- ...current,
484
- revision: current.revision + 1,
485
- turns: current.turns + 1,
486
- updatedAt: now,
487
- activeStartedAt: current.activeStartedAt ?? now,
488
- resumeAfterManualCompaction: undefined,
489
- };
490
- try {
491
- persistGoalState(pi, runtime, "turn", next);
492
- } catch (error) {
493
- pauseGoalAfterFailure(pi, runtime, ctx, `turn state could not be saved: ${error instanceof Error ? error.message : String(error)}`);
494
- return;
495
- }
496
- runtime.goalTurnInFlight = true;
497
- runtime.agentEndObserved = false;
498
- runtime.lastStopReason = undefined;
499
- runtime.lastError = undefined;
578
+ const next = beginGoalTurn(pi, runtime, ctx, current);
579
+ if (!next) return;
500
580
  return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(next)}` };
501
581
  });
502
582
 
@@ -544,10 +624,11 @@ export function registerGoal(
544
624
  ctx.ui.notify("No goal is set", "info");
545
625
  return;
546
626
  }
627
+ const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
628
+ let saved = false;
547
629
  try {
548
630
  persistGoalState(pi, runtime, "clear", undefined);
549
- runtime.continuationScheduled = false;
550
- ctx.ui.notify("Goal cleared", "info");
631
+ saved = true;
551
632
  } catch (error) {
552
633
  if (runtime.state?.status === "active") {
553
634
  pauseGoalAfterFailure(
@@ -556,11 +637,24 @@ export function registerGoal(
556
637
  ctx,
557
638
  `the requested clear could not be saved: ${error instanceof Error ? error.message : String(error)}`,
558
639
  "Automatic continuation is stopped. Retry /goal clear to remove the goal.",
640
+ false,
559
641
  );
560
642
  } else {
561
643
  reportError(ctx, "Goal could not be cleared", error);
644
+ return;
562
645
  }
563
646
  }
647
+ try {
648
+ await stopGoalRun(runtime, ctx, shouldStopGoalRun);
649
+ } catch (error) {
650
+ 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);
651
+ return;
652
+ }
653
+ if (saved) {
654
+ ctx.ui.notify("Goal cleared", "info");
655
+ } else {
656
+ ctx.ui.notify("Goal paused: the requested clear could not be saved\nAutomatic continuation is stopped. Retry /goal clear to remove the goal.", "error");
657
+ }
564
658
  return;
565
659
  }
566
660
 
@@ -597,18 +691,34 @@ export function registerGoal(
597
691
  ctx.ui.notify(`Goal is ${runtime.state.status}; only an active goal can be paused`, "warning");
598
692
  return;
599
693
  }
694
+ const shouldStopGoalRun = runtime.goalTurnInFlight || runtime.continuationScheduled;
695
+ let saved = false;
696
+ let failureReason: string | undefined;
600
697
  try {
601
698
  transitionGoal(pi, runtime, "pause", "paused");
602
- ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
699
+ saved = true;
603
700
  } catch (error) {
701
+ failureReason = `the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`;
604
702
  pauseGoalAfterFailure(
605
703
  pi,
606
704
  runtime,
607
705
  ctx,
608
- `the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`,
706
+ failureReason,
609
707
  "Automatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.",
708
+ false,
610
709
  );
611
710
  }
711
+ try {
712
+ await stopGoalRun(runtime, ctx, shouldStopGoalRun);
713
+ } catch (error) {
714
+ reportError(ctx, "Goal paused, but the active goal turn could not be confirmed stopped", error);
715
+ return;
716
+ }
717
+ if (saved) {
718
+ ctx.ui.notify("Goal paused. Run /goal resume to continue.", "info");
719
+ } else {
720
+ ctx.ui.notify(`Goal paused: ${failureReason}\nAutomatic continuation is stopped. If session storage is still unavailable, retry /goal pause after it recovers.`, "error");
721
+ }
612
722
  return;
613
723
  }
614
724
 
@@ -687,6 +797,7 @@ export function registerGoal(
687
797
  updatedAt: now,
688
798
  activeStartedAt: now,
689
799
  blockedAuditStartTurn: current.turns,
800
+ blockerAudit: undefined,
690
801
  result: undefined,
691
802
  resumeAfterManualCompaction: undefined,
692
803
  };
@@ -695,13 +806,17 @@ export function registerGoal(
695
806
  runtime.continuationScheduled = false;
696
807
  if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal updated and active", "info");
697
808
  } catch (error) {
698
- pauseGoalAfterFailure(
699
- pi,
700
- runtime,
701
- ctx,
702
- `Goal could not be edited: ${error instanceof Error ? error.message : String(error)}`,
703
- "Automatic continuation is stopped. Retry /goal edit after session storage recovers.",
704
- );
809
+ if (runtime.state?.status === "active") {
810
+ pauseGoalAfterFailure(
811
+ pi,
812
+ runtime,
813
+ ctx,
814
+ `Goal could not be edited: ${error instanceof Error ? error.message : String(error)}`,
815
+ "Automatic continuation is stopped. Retry /goal edit after session storage recovers.",
816
+ );
817
+ } else {
818
+ reportError(ctx, "Goal could not be edited", error);
819
+ }
705
820
  }
706
821
  return;
707
822
  }
@@ -761,8 +876,19 @@ export function registerGoal(
761
876
  ctx.ui.notify("Goal active. KillerOS will continue until completion, a repeated blocker, or pause.", "info");
762
877
  }
763
878
  } catch (error) {
764
- reportError(ctx, "Goal could not be started", error);
765
- scheduleGoalContinuation(pi, runtime, initState, ctx);
879
+ if (!unfinished) {
880
+ reportError(ctx, "Goal could not be started", error);
881
+ } else if (runtime.state?.status === "active") {
882
+ pauseGoalAfterFailure(
883
+ pi,
884
+ runtime,
885
+ ctx,
886
+ `Goal could not be replaced: ${error instanceof Error ? error.message : String(error)}`,
887
+ "Automatic continuation is stopped. Retry replacement after session storage recovers.",
888
+ );
889
+ } else {
890
+ reportError(ctx, "Goal could not be replaced", error);
891
+ }
766
892
  }
767
893
  };
768
894
 
@@ -800,6 +926,8 @@ export function registerGoalSettlement(
800
926
  if (!wasGoalTurn || runtime.state?.status !== "active" || initState.active) {
801
927
  if (continuationWasScheduled && runtime.state?.status === "active" && !initState.active) {
802
928
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal continuation ended before an agent turn started");
929
+ } else if (runtime.state?.status === "active" && !initState.active) {
930
+ scheduleGoalContinuation(pi, runtime, initState, ctx);
803
931
  }
804
932
  return;
805
933
  }