killeros 2.0.6 → 2.0.7

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,29 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.7] - 2026-08-13
8
+
9
+ ### Changed
10
+
11
+ - Raised the supported and locked Pi baseline from 0.82.1 to the current matched 0.84.1 AI, coding-agent, and TUI packages.
12
+ - Added a compact two-deck footer beneath the prompt and removed the work-trail widget.
13
+ - Replaced the framed prompt and shuffled activity verbs with a focus-aware single-arrow editor and event-derived working copy.
14
+ - Made settled timing entries report `Done`, `Stopped`, or `Failed` while keeping existing `Worked for` history readable.
15
+
16
+ ### Fixed
17
+
18
+ - Separated response output from the prompt editor while keeping footer telemetry close beneath a quiet muted divider.
19
+ - Kept `/variants` within the active terminal height, preserved the focused reasoning level across resizes, and initially focused the current level.
20
+ - Rendered `/variants` controls from the same Pi keybinding manager that handles input, including installations with separate package module instances.
21
+ - Rendered active, paused, and blocked `/goal` status text consistently in the footer's far-right slot.
22
+ - Kept the private goal-update tool inactive outside active `/goal` runs and rendered its real execution errors instead of malformed blocker-audit fields.
23
+ - Verified exact persisted paths for clearly declared file-deliverable goals before completion, while preserving model-reported completion for general objectives.
24
+ - Accepted explicit `1`/`1` bounds for single-select questions and applied the same bounds validation before rendering and execution.
25
+ - Prevented reload and branch navigation from preserving or reconstructing stale manual-compaction recovery eligibility; only Pi's live event in the current session can resume the interrupted goal safely.
26
+ - Restricted goal deliverable verification to explicit destination phrases so source and reference paths cannot be reported as completed output.
27
+ - Kept saved goals fully inactive in print and JSON modes, including the private update tool and shutdown checkpoints.
28
+ - Rejected concurrent `/init` starts during preflight, cancelled pending preflight work on shutdown, and settled active `/init` command handlers when their session closes.
29
+
7
30
  ## [2.0.6] - 2026-08-11
8
31
 
9
32
  ### Added
package/Killeros.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerRequestActivity } from "./killeros/activity.ts";
2
3
  import { registerAliases, registerSlashAutocomplete } from "./killeros/commands.ts";
3
4
  import { registerConcisePrompt } from "./killeros/concise.ts";
4
5
  import { registerFooter } from "./killeros/footer.ts";
@@ -43,6 +44,7 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
43
44
  registerLifecycleHooks(pi);
44
45
  registerGoalSettlement(pi, goalRuntime, initRuntime);
45
46
  registerInitSettlement(pi, initRuntime);
47
+ registerRequestActivity(pi);
46
48
  registerCompletionNotifications(pi, options.completionNotifications);
47
49
  registerWorkedFor(pi);
48
50
  }
package/README.md CHANGED
@@ -5,7 +5,7 @@ A production-hardened Pi extension that combines a custom TUI, repository initia
5
5
  ## Requirements
6
6
 
7
7
  - Node.js `22.19.0` or later
8
- - Pi `0.82.1` or later
8
+ - Pi `0.84.1` or later
9
9
  - Interactive TUI mode for the custom header, editor, footer, `question` tool, and `/init`
10
10
 
11
11
  The extension is strict TypeScript. Pi provides the runtime modules.
@@ -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.6
34
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.7
35
35
  ```
36
36
 
37
37
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -40,16 +40,17 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
40
40
 
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
- - 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
- - 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
- - 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
43
+ - Animated orange 12-frame activity glyph loop at 120 ms per frame with contextual copy derived from request, tool, result, and response events, plus a quiet hidden-thinking label
44
+ - Frameless multiline editor with one focus-aware `❯`, a shuffled session-stable empty-state suggestion, overflow-only scroll indicators, Shift+Enter support, and slash-command autocomplete; KillerOS preserves an editor factory configured by another extension
45
+ - One compact TUI transcript line reporting truthful `Done`, `Stopped`, or `Failed` settlement with elapsed time while preserving older `✻ Worked for …` entries
46
+ - Compact two-deck footer with session state above workspace state; model, context, and active goals stay prioritized as reasoning, time, cost, branch, and path reduce by available width
46
47
  - Pi-owned context compaction with active goals continuing from Pi's settled boundary after manual, threshold, and overflow compaction
47
48
  - Optional completion sounds after successful or failed settled requests, excluding manual aborts
48
49
  - `/variants` selector and direct reasoning-level arguments
49
50
  - 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
51
  - 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
52
  - `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
- - Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
53
+ - Mid-prompt slash completion with current Pi `0.84.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
53
54
  - Goal-aware `/clear` that confirms, aborts active work, waits for settlement, and starts a new session, plus `/exit` for graceful shutdown
54
55
  - Concise system-prompt guidance and supported native concise defaults that preserve explicit provider settings
55
56
 
@@ -70,7 +71,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
70
71
  /exit Quit Pi gracefully
71
72
  ```
72
73
 
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
+ `/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 report completion. For an objective that clearly asks to create, write, save, or generate a named file-like deliverable at one quoted absolute path, KillerOS saves that exact path with the goal and verifies that a regular file exists there before accepting completion. Other objectives retain model-reported 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
 
75
76
  `/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.
76
77
 
@@ -80,7 +81,7 @@ The generated file uses the four behavioral sections adapted from `writing-great
80
81
 
81
82
  ### Interactive questions
82
83
 
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
+ Single-select remains the default. Explicit `minSelections: 1` and `maxSelections: 1` are equivalent to omitting both bounds; other single-select bounds are rejected. An agent opts into multi-select with `mode: "multiple"` and may set `minSelections` and `maxSelections`; the custom answer counts as one selection.
84
85
 
85
86
  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
87
 
@@ -0,0 +1,97 @@
1
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ export type ActivityMessage =
5
+ | { kind: "prompt" }
6
+ | { kind: "tool"; toolName: string }
7
+ | { kind: "tool-result"; failed: boolean }
8
+ | { kind: "responding" };
9
+
10
+ function safeToolName(toolName: string): string {
11
+ const normalized = toolName.replace(/[\u0000-\u001F\u007F]+/gu, " ").replace(/\s+/gu, " ").trim();
12
+ return truncateToWidth(normalized || "tool", 32, "…");
13
+ }
14
+
15
+ export function formatActivityMessage(message: ActivityMessage, theme: Theme): string {
16
+ let verb: string;
17
+ let detail: string;
18
+
19
+ switch (message.kind) {
20
+ case "prompt":
21
+ verb = "Mapping…";
22
+ detail = "understanding request";
23
+ break;
24
+ case "tool-result":
25
+ verb = message.failed ? "Recovering…" : "Reviewing…";
26
+ detail = message.failed ? "tool failed" : "reading the result";
27
+ break;
28
+ case "responding":
29
+ verb = "Responding…";
30
+ detail = "assembling the answer";
31
+ break;
32
+ case "tool":
33
+ switch (message.toolName.trim().toLocaleLowerCase()) {
34
+ case "read":
35
+ case "grep":
36
+ case "find":
37
+ case "ls":
38
+ verb = "Inspecting…";
39
+ detail = "reading relevant code";
40
+ break;
41
+ case "edit":
42
+ case "write":
43
+ verb = "Changing…";
44
+ detail = "editing";
45
+ break;
46
+ case "bash":
47
+ verb = "Running…";
48
+ detail = "command";
49
+ break;
50
+ default:
51
+ verb = "Working…";
52
+ detail = `using ${safeToolName(message.toolName)}`;
53
+ }
54
+ break;
55
+ }
56
+
57
+ return `${theme.fg("accent", verb)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · ${detail})`)}`;
58
+ }
59
+
60
+ export function registerRequestActivity(pi: ExtensionAPI): void {
61
+ let active = false;
62
+
63
+ const clear = (ctx?: ExtensionContext): void => {
64
+ if (ctx?.mode === "tui") {
65
+ ctx.ui.setWorkingMessage();
66
+ }
67
+ active = false;
68
+ };
69
+
70
+ pi.on("agent_start", (_event, ctx) => {
71
+ if (ctx.mode !== "tui") return;
72
+ active = true;
73
+ ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "prompt" }, ctx.ui.theme));
74
+ });
75
+
76
+ pi.on("tool_execution_start", (event, ctx) => {
77
+ if (ctx.mode !== "tui" || !active) return;
78
+ ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "tool", toolName: event.toolName }, ctx.ui.theme));
79
+ });
80
+
81
+ pi.on("tool_execution_end", (event, ctx) => {
82
+ if (ctx.mode !== "tui" || !active) return;
83
+ ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "tool-result", failed: event.isError }, ctx.ui.theme));
84
+ });
85
+
86
+ pi.on("message_update", (event, ctx) => {
87
+ if (ctx.mode !== "tui" || !active || event.assistantMessageEvent.type !== "text_start") return;
88
+ ctx.ui.setWorkingMessage(formatActivityMessage({ kind: "responding" }, ctx.ui.theme));
89
+ });
90
+
91
+ pi.on("agent_settled", (_event, ctx) => {
92
+ if (ctx.mode !== "tui" || !active || !ctx.isIdle?.() || ctx.hasPendingMessages?.()) return;
93
+ clear(ctx);
94
+ });
95
+
96
+ pi.on("session_shutdown", (_event, ctx) => clear(ctx));
97
+ }
@@ -125,6 +125,10 @@ function renderFooterRow(left: string, right: string, width: number): string {
125
125
  return ` ${clippedLeft}${gap}${clippedRight} `;
126
126
  }
127
127
 
128
+ function renderFooter(rows: string[], width: number, theme: Theme): string[] {
129
+ return [theme.fg("borderMuted", "─".repeat(width)), ...rows];
130
+ }
131
+
128
132
  function formatGoalElapsed(milliseconds: number): string {
129
133
  const totalSeconds = Number.isFinite(milliseconds) ? Math.max(0, Math.floor(milliseconds / 1_000)) : 0;
130
134
  if (totalSeconds < 60) return `${totalSeconds}s`;
@@ -138,15 +142,13 @@ function formatGoalElapsed(milliseconds: number): string {
138
142
  return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
139
143
  }
140
144
 
141
- function formatActiveGoalFooter(state: GoalState | undefined, theme: Theme): string {
142
- if (state?.status !== "active") return "";
143
- return theme.fg("warning", `/goal is active (${formatGoalElapsed(goalElapsedMilliseconds(state))})`);
144
- }
145
-
146
145
  function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
147
146
  if (!state) return "";
148
- if (state.status === "paused") return theme.fg("warning", "Ⅱ goal paused");
149
- if (state.status === "blocked") return theme.fg("error", "! goal blocked");
147
+ if (state.status === "active") {
148
+ return theme.fg("warning", `/goal is active (${formatGoalElapsed(goalElapsedMilliseconds(state))})`);
149
+ }
150
+ if (state.status === "paused") return theme.fg("warning", "/goal is paused");
151
+ if (state.status === "blocked") return theme.fg("error", "/goal is blocked");
150
152
  return "";
151
153
  }
152
154
 
@@ -211,52 +213,36 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
211
213
  const signature = formatModel(model, theme);
212
214
  const fullDirectory = theme.fg("dim", cwd);
213
215
  const focusedDirectory = theme.fg("dim", compactDirectory(cwd));
214
- const activeGoal = formatActiveGoalFooter(goalRuntime.state, theme);
215
216
  const goal = formatGoalFooter(goalRuntime.state, theme);
216
- const rich = joinFooterParts([
217
+ const primary = joinFooterParts([
217
218
  signature,
218
219
  level,
219
220
  context,
220
- goal,
221
- branch ? theme.fg("dim", branch) : "",
221
+ ], theme);
222
+ const primaryFocused = joinFooterParts([signature, context], theme);
223
+ const session = joinFooterParts([
222
224
  theme.fg("dim", formatTime(Date.now() - sessionStart)),
223
225
  theme.fg("dim", formatCost(getSessionCost(ctx))),
224
226
  ], theme);
225
- const focused = joinFooterParts([signature, context, goal], theme);
226
-
227
- if (activeGoal) {
228
- if (footerRowFits(rich, activeGoal, width)) {
229
- return [renderFooterRow(rich, activeGoal, width)];
230
- }
231
- if (footerRowFits(focused, activeGoal, width)) {
232
- return [renderFooterRow(focused, activeGoal, width)];
233
- }
234
- if (footerRowFits(context, activeGoal, width)) {
235
- return [renderFooterRow(context, activeGoal, width)];
236
- }
237
- return [renderFooterRow("", activeGoal, width)];
238
- }
239
-
240
- if (footerRowFits(rich, fullDirectory, width)) {
241
- return [renderFooterRow(rich, fullDirectory, width)];
242
- }
243
- if (footerRowFits(rich, focusedDirectory, width)) {
244
- return [renderFooterRow(rich, focusedDirectory, width)];
245
- }
246
- if (footerRowFits(focused, focusedDirectory, width)) {
247
- return [renderFooterRow(focused, focusedDirectory, width)];
248
- }
249
- if (footerRowFits(focused, "", width)) {
250
- return [renderFooterRow(focused, "", width)];
251
- }
252
- if (goal) {
253
- const essentialGoal = joinFooterParts([context, goal], theme);
254
- if (footerRowFits(essentialGoal, "", width)) return [renderFooterRow(essentialGoal, "", width)];
255
- return [renderFooterRow(goal, context, width)];
256
- }
257
-
258
227
  const essentialModel = formatModel(model, theme, false);
259
- return [renderFooterRow(essentialModel, context, width)];
228
+ const primaryRow = footerRowFits(primary, session, width)
229
+ ? renderFooterRow(primary, session, width)
230
+ : footerRowFits(primary, "", width)
231
+ ? renderFooterRow(primary, "", width)
232
+ : footerRowFits(primaryFocused, "", width)
233
+ ? renderFooterRow(primaryFocused, "", width)
234
+ : renderFooterRow(essentialModel, context, width);
235
+ const branchLabel = branch ? theme.fg("dim", branch) : "";
236
+ const workspaceRight = goal || fullDirectory;
237
+ const secondaryRow = footerRowFits(branchLabel, workspaceRight, width)
238
+ ? renderFooterRow(branchLabel, workspaceRight, width)
239
+ : goal
240
+ ? renderFooterRow("", goal, width)
241
+ : footerRowFits(branchLabel, focusedDirectory, width)
242
+ ? renderFooterRow(branchLabel, focusedDirectory, width)
243
+ : renderFooterRow(branchLabel, "", width);
244
+
245
+ return renderFooter([primaryRow, secondaryRow], width, theme);
260
246
  },
261
247
  };
262
248
  });
package/killeros/goals.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { lstatSync } from "node:fs";
3
+ import path from "node:path";
2
4
  import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
3
5
  import { Text } from "@earendil-works/pi-tui";
4
6
  import { Type } from "typebox";
@@ -7,10 +9,11 @@ import { BoundedText } from "./bounded-text.ts";
7
9
  import { formatTime, formatTokens } from "./display.ts";
8
10
  import { reportError } from "./errors.ts";
9
11
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
10
- import type { GoalBlockerAudit, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
12
+ import type { GoalBlockerAudit, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
11
13
 
12
14
  const GOAL_ENTRY_TYPE = "killeros-goal";
13
15
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
16
+ const GOAL_UPDATE_TOOL = "killeros_goal_update";
14
17
  const GOAL_OBJECTIVE_LIMIT = 4_000;
15
18
  const GOAL_VERSION = 1;
16
19
 
@@ -29,7 +32,6 @@ interface GoalTransitionOptions {
29
32
 
30
33
  interface RestoredGoalState {
31
34
  state?: GoalState;
32
- recoveryProven: boolean;
33
35
  }
34
36
 
35
37
  const GoalUpdateParams = Type.Object({
@@ -52,6 +54,7 @@ const GoalUpdateParams = Type.Object({
52
54
  interface GoalUpdateDetails {
53
55
  status: "complete" | "blocked" | "blocker-audit";
54
56
  evidence: string;
57
+ verification?: "file" | "model-reported";
55
58
  blockerKey?: string;
56
59
  streak?: number;
57
60
  }
@@ -64,6 +67,41 @@ function finiteNonNegative(value: unknown): value is number {
64
67
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
65
68
  }
66
69
 
70
+ function isGoalFileVerification(value: unknown): value is GoalFileVerification {
71
+ if (!value || typeof value !== "object") return false;
72
+ const candidate = value as Partial<GoalFileVerification>;
73
+ return candidate.kind === "file"
74
+ && typeof candidate.path === "string"
75
+ && candidate.path === candidate.path.trim()
76
+ && isAbsoluteFilePath(candidate.path);
77
+ }
78
+
79
+ function isAbsoluteFilePath(value: string): boolean {
80
+ if (!value || /^(?:https?|file):\/\//iu.test(value) || /[\\\/]$/u.test(value)) return false;
81
+ return path.isAbsolute(value) || path.win32.isAbsolute(value);
82
+ }
83
+
84
+ function inferGoalVerification(objective: string): GoalFileVerification | undefined {
85
+ const destination = /\b(?:create|write|save|generate)\b[^\r\n]{0,160}?\b(?:file|document|markdown|report|spreadsheet|presentation|image)\b\s+(?:to|at|as|destination(?:\s+is)?|output(?:\s+(?:to|at))?)\b\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z]:\\[^\s,;]+|\/[^\s,;]+))/giu;
86
+ const paths = [...objective.matchAll(destination)]
87
+ .map((match) => (match[1] ?? match[2] ?? match[3] ?? match[4] ?? "").trim())
88
+ .filter(isAbsoluteFilePath);
89
+ const unique = [...new Set(paths)];
90
+ return unique.length === 1 ? { kind: "file", path: unique[0]! } : undefined;
91
+ }
92
+
93
+ function verifyGoalDeliverable(verification: GoalFileVerification): void {
94
+ let artifact: ReturnType<typeof lstatSync>;
95
+ try {
96
+ artifact = lstatSync(verification.path);
97
+ } catch {
98
+ throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
99
+ }
100
+ if (!artifact.isFile()) {
101
+ throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
102
+ }
103
+ }
104
+
67
105
  function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
68
106
  if (!value || typeof value !== "object") return false;
69
107
  const candidate = value as Partial<GoalBlockerAudit>;
@@ -92,6 +130,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
92
130
  || !finiteNonNegative(candidate.baselineTokens)
93
131
  || candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
94
132
  || candidate.result !== undefined && typeof candidate.result !== "string"
133
+ || candidate.verification !== undefined && !isGoalFileVerification(candidate.verification)
95
134
  || candidate.blockerAudit !== undefined && !isGoalBlockerAudit(candidate.blockerAudit, candidate.turns!, candidate.status)
96
135
  || candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
97
136
  || candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
@@ -112,6 +151,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
112
151
  result: candidate.result,
113
152
  resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
114
153
  blockerAudit: candidate.blockerAudit,
154
+ verification: candidate.verification,
115
155
  };
116
156
  }
117
157
 
@@ -130,19 +170,17 @@ function restoreGoalState(ctx: ExtensionContext): RestoredGoalState {
130
170
  if (entry?.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
131
171
  const data = entry.data as Partial<GoalEntryData> | undefined;
132
172
  if (!data || data.version !== GOAL_VERSION || data.state === null) {
133
- return { state: undefined, recoveryProven: false };
173
+ return { state: undefined };
134
174
  }
135
175
  const restored = parseGoalState(data.state);
136
- if (!restored) return { state: undefined, recoveryProven: false };
176
+ if (!restored) return { state: undefined };
137
177
  const state = restored.status === "active"
138
178
  ? { ...restored, activeStartedAt: Date.now() }
139
179
  : { ...restored, activeStartedAt: undefined };
140
- const recoveryProven = state.status === "paused"
141
- && state.resumeAfterManualCompaction === true
142
- && entries.slice(index + 1).some((candidate) => candidate.type === "compaction");
143
- return { state, recoveryProven };
180
+ if (state.status === "paused") state.resumeAfterManualCompaction = undefined;
181
+ return { state };
144
182
  }
145
- return { state: undefined, recoveryProven: false };
183
+ return { state: undefined };
146
184
  }
147
185
 
148
186
  export function goalElapsedMilliseconds(state: GoalState, now = Date.now()): number {
@@ -173,6 +211,21 @@ function sumGoalTokens(ctx: ExtensionContext): number {
173
211
  return total;
174
212
  }
175
213
 
214
+ function setGoalUpdateToolActive(pi: ExtensionAPI, active: boolean): void {
215
+ const api = pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
216
+ if (!api.getActiveTools || !api.setActiveTools) return;
217
+ const activeTools = api.getActiveTools();
218
+ const isActive = activeTools.includes(GOAL_UPDATE_TOOL);
219
+ if (active === isActive) return;
220
+ api.setActiveTools(active
221
+ ? [...activeTools, GOAL_UPDATE_TOOL]
222
+ : activeTools.filter((name) => name !== GOAL_UPDATE_TOOL));
223
+ }
224
+
225
+ function syncGoalUpdateTool(pi: ExtensionAPI, runtime: GoalRuntime): void {
226
+ setGoalUpdateToolActive(pi, runtime.state?.status === "active");
227
+ }
228
+
176
229
  function persistGoalState(
177
230
  pi: ExtensionAPI,
178
231
  runtime: GoalRuntime,
@@ -182,6 +235,7 @@ function persistGoalState(
182
235
  const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
183
236
  pi.appendEntry(GOAL_ENTRY_TYPE, data);
184
237
  runtime.state = state;
238
+ syncGoalUpdateTool(pi, runtime);
185
239
  runtime.persistenceRetryNeeded = false;
186
240
  runtime.requestRender?.();
187
241
  }
@@ -276,6 +330,7 @@ export function pauseGoalAfterFailure(
276
330
  result: reason,
277
331
  resumeAfterManualCompaction: undefined,
278
332
  } : undefined;
333
+ syncGoalUpdateTool(pi, runtime);
279
334
  runtime.persistenceRetryNeeded = true;
280
335
  runtime.continuationScheduled = false;
281
336
  runtime.requestRender?.();
@@ -301,6 +356,7 @@ function pauseGoalForPossibleManualCompaction(
301
356
  result: reason,
302
357
  resumeAfterManualCompaction: true,
303
358
  } : undefined;
359
+ syncGoalUpdateTool(pi, runtime);
304
360
  runtime.persistenceRetryNeeded = true;
305
361
  runtime.continuationScheduled = false;
306
362
  runtime.requestRender?.();
@@ -465,7 +521,7 @@ export function registerGoal(
465
521
  });
466
522
 
467
523
  pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
468
- name: "killeros_goal_update",
524
+ name: GOAL_UPDATE_TOOL,
469
525
  label: "Goal update",
470
526
  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.",
471
527
  parameters: GoalUpdateParams,
@@ -478,10 +534,14 @@ export function registerGoal(
478
534
  const evidence = params.evidence.trim();
479
535
  if (!evidence) throw new Error("Goal evidence must not be empty");
480
536
  if (params.status === "complete") {
537
+ if (state.verification) verifyGoalDeliverable(state.verification);
538
+ const verification = state.verification ? "file" : "model-reported";
481
539
  transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
482
540
  return {
483
- content: [{ type: "text", text: `Goal marked complete: ${evidence}` }],
484
- details: { status: "complete", evidence },
541
+ content: [{ type: "text", text: state.verification
542
+ ? `Goal verified complete at ${state.verification.path}: ${evidence}`
543
+ : `Goal marked complete (model-reported): ${evidence}` }],
544
+ details: { status: "complete", evidence, verification },
485
545
  };
486
546
  }
487
547
  if (!runtime.goalTurnInFlight) throw new Error("A blocker audit can only be recorded during an active KillerOS goal turn");
@@ -516,7 +576,12 @@ export function registerGoal(
516
576
  renderCall(args, theme) {
517
577
  return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", args.status)}`, 0, 0);
518
578
  },
519
- renderResult(result, options, theme) {
579
+ renderResult(result, options, theme, context) {
580
+ if (context?.isError) {
581
+ const first = result.content[0];
582
+ const message = first?.type === "text" ? first.text : "Goal update failed";
583
+ return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 3);
584
+ }
520
585
  const details = result.details;
521
586
  if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
522
587
  const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
@@ -527,7 +592,8 @@ export function registerGoal(
527
592
 
528
593
  const restoreGoal = (ctx: ExtensionContext): void => {
529
594
  const restored = restoreGoalState(ctx);
530
- runtime.state = restored.state;
595
+ runtime.state = isGoalModeSupported(ctx) ? restored.state : undefined;
596
+ syncGoalUpdateTool(pi, runtime);
531
597
  runtime.continuationScheduled = false;
532
598
  runtime.continuationHeld = false;
533
599
  runtime.goalTurnInFlight = false;
@@ -536,9 +602,7 @@ export function registerGoal(
536
602
  runtime.lastStopReason = undefined;
537
603
  runtime.lastError = undefined;
538
604
  runtime.requestRender?.();
539
- if (restored.recoveryProven) {
540
- recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
541
- } else if (runtime.state?.status === "active") {
605
+ if (runtime.state?.status === "active") {
542
606
  setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
543
607
  }
544
608
  };
@@ -561,6 +625,7 @@ export function registerGoal(
561
625
  }
562
626
  }
563
627
  runtime.state = undefined;
628
+ syncGoalUpdateTool(pi, runtime);
564
629
  runtime.continuationScheduled = false;
565
630
  runtime.continuationHeld = false;
566
631
  runtime.goalTurnInFlight = false;
@@ -680,6 +745,7 @@ export function registerGoal(
680
745
  ctx.ui.notify("Goal pause saved. Goal remains paused. Automatic compaction recovery is off.", "info");
681
746
  } catch (error) {
682
747
  runtime.state = checkpoint;
748
+ syncGoalUpdateTool(pi, runtime);
683
749
  runtime.persistenceRetryNeeded = true;
684
750
  runtime.continuationScheduled = false;
685
751
  runtime.requestRender?.();
@@ -798,6 +864,7 @@ export function registerGoal(
798
864
  activeStartedAt: now,
799
865
  blockedAuditStartTurn: current.turns,
800
866
  blockerAudit: undefined,
867
+ verification: inferGoalVerification(objective),
801
868
  result: undefined,
802
869
  resumeAfterManualCompaction: undefined,
803
870
  };
@@ -869,6 +936,7 @@ export function registerGoal(
869
936
  turns: 0,
870
937
  blockedAuditStartTurn: 0,
871
938
  baselineTokens: sumGoalTokens(ctx),
939
+ verification: inferGoalVerification(objective),
872
940
  };
873
941
  try {
874
942
  persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
package/killeros/init.ts CHANGED
@@ -133,8 +133,10 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
133
133
 
134
134
  pi.on("session_start", () => setInitTools(pi, initState, false));
135
135
  pi.on("session_shutdown", () => {
136
+ const settle = initState.settle;
136
137
  setInitTools(pi, initState, false);
137
138
  resetInitRuntime(initState);
139
+ settle?.({ kind: "cancelled" });
138
140
  });
139
141
  pi.on("before_agent_start", () => {
140
142
  if (initState.active) setInitTools(pi, initState, true);
@@ -160,7 +162,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
160
162
  ctx.ui.notify("/init requires interactive TUI mode", "error");
161
163
  return;
162
164
  }
163
- if (initState.active) {
165
+ if (initState.active || initState.starting) {
164
166
  ctx.ui.notify("/init is already running", "warning");
165
167
  return;
166
168
  }
@@ -172,28 +174,45 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
172
174
  ctx.ui.notify("Trust this project before running /init", "error");
173
175
  return;
174
176
  }
175
- await ctx.waitForIdle();
177
+ const starting = Symbol();
178
+ initState.starting = starting;
179
+ try {
180
+ await ctx.waitForIdle();
181
+ } catch (error) {
182
+ if (initState.starting !== starting) return;
183
+ initState.starting = undefined;
184
+ reportError(ctx, "/init could not wait for active work", error);
185
+ return;
186
+ }
187
+ if (initState.starting !== starting) return;
176
188
 
177
189
  let projectRoot: string;
178
190
  try {
179
191
  projectRoot = await fs.realpath(ctx.cwd);
180
192
  } catch (error) {
193
+ if (initState.starting !== starting) return;
194
+ initState.starting = undefined;
181
195
  reportError(ctx, "/init could not resolve the project root", error);
182
196
  return;
183
197
  }
198
+ if (initState.starting !== starting) return;
184
199
  const targetPath = path.join(projectRoot, "AGENTS.md");
185
200
  try {
186
201
  const [{ index: evidence }, baseline] = await Promise.all([
187
202
  buildInitEvidence(projectRoot),
188
203
  captureInitTargetBaseline(targetPath),
189
204
  ]);
205
+ if (initState.starting !== starting) return;
190
206
  initState.active = true;
191
207
  initState.projectRoot = projectRoot;
192
208
  initState.targetPath = targetPath;
193
209
  initState.evidence = evidence;
194
210
  initState.baseline = baseline;
195
211
  initState.outcome = { kind: "pending" };
212
+ initState.starting = undefined;
196
213
  } catch (error) {
214
+ if (initState.starting !== starting) return;
215
+ initState.starting = undefined;
197
216
  reportError(ctx, "/init could not capture safe repository evidence", error);
198
217
  return;
199
218
  }
@@ -234,6 +253,8 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
234
253
  case "policy-conflict":
235
254
  ctx.ui.notify(`/init left AGENTS.md unchanged: ${outcome.reason}`, "warning");
236
255
  break;
256
+ case "cancelled":
257
+ break;
237
258
  default:
238
259
  reportError(ctx, "/init did not generate AGENTS.md", "the model completed without a write or policy-conflict outcome");
239
260
  }
@@ -9,7 +9,7 @@ import {
9
9
  wrapTextWithAnsi,
10
10
  type EditorTheme,
11
11
  } from "@earendil-works/pi-tui";
12
- import { Type } from "typebox";
12
+ import { Type, type Static } from "typebox";
13
13
  import { BoundedText } from "./bounded-text.ts";
14
14
 
15
15
  const OptionSchema = Type.Object({
@@ -31,16 +31,42 @@ const QuestionParams = Type.Object({
31
31
  minSelections: Type.Optional(Type.Integer({
32
32
  minimum: 1,
33
33
  maximum: 10,
34
- description: "Minimum answers required in multiple mode; defaults to 1",
34
+ description: "Minimum answers required in multiple mode; defaults to 1. Single mode accepts only an explicit 1/1 bounds pair",
35
35
  })),
36
36
  maxSelections: Type.Optional(Type.Integer({
37
37
  minimum: 1,
38
38
  maximum: 10,
39
- description: "Maximum answers allowed in multiple mode; defaults to all options plus one custom answer",
39
+ description: "Maximum answers allowed in multiple mode; defaults to all options plus one custom answer. Single mode accepts only an explicit 1/1 bounds pair",
40
40
  })),
41
41
  });
42
42
 
43
- type QuestionMode = "single" | "multiple";
43
+ type QuestionParamsValue = Static<typeof QuestionParams>;
44
+
45
+ type NormalizedQuestionSelection =
46
+ | { mode: "single"; minSelections: 1; maxSelections: 1 }
47
+ | { mode: "multiple"; minSelections: number; maxSelections: number };
48
+
49
+ function normalizeQuestionSelection(params: QuestionParamsValue): NormalizedQuestionSelection {
50
+ const mode = params.mode ?? "single";
51
+ if (mode === "single") {
52
+ const hasSelectionBounds = params.minSelections !== undefined || params.maxSelections !== undefined;
53
+ if (hasSelectionBounds && (params.minSelections !== 1 || params.maxSelections !== 1)) {
54
+ throw new Error("Single-select question bounds must be omitted or both be 1");
55
+ }
56
+ return { mode, minSelections: 1, maxSelections: 1 };
57
+ }
58
+
59
+ const maximumAvailable = params.options.length + 1;
60
+ const minSelections = params.minSelections ?? 1;
61
+ const maxSelections = params.maxSelections ?? maximumAvailable;
62
+ if (minSelections > maxSelections) {
63
+ throw new Error("Question minimum selections cannot exceed maximum selections");
64
+ }
65
+ if (maxSelections > maximumAvailable) {
66
+ throw new Error(`Question allows at most ${maximumAvailable} selections including one custom answer`);
67
+ }
68
+ return { mode, minSelections, maxSelections };
69
+ }
44
70
 
45
71
  interface DisplayOption {
46
72
  label: string;
@@ -229,20 +255,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
229
255
  executionMode: "sequential",
230
256
 
231
257
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
232
- const mode: QuestionMode = params.mode ?? "single";
233
- const hasSelectionBounds = params.minSelections !== undefined || params.maxSelections !== undefined;
234
- if (mode === "single" && hasSelectionBounds) {
235
- throw new Error("Question selection bounds require mode \"multiple\"");
236
- }
237
- const maximumAvailable = params.options.length + 1;
238
- const minSelections = params.minSelections ?? 1;
239
- const maxSelections = params.maxSelections ?? maximumAvailable;
240
- if (minSelections > maxSelections) {
241
- throw new Error("Question minimum selections cannot exceed maximum selections");
242
- }
243
- if (maxSelections > maximumAvailable) {
244
- throw new Error(`Question allows at most ${maximumAvailable} selections including one custom answer`);
245
- }
258
+ const { mode, minSelections, maxSelections } = normalizeQuestionSelection(params);
246
259
  if (ctx.mode !== "tui") throw new Error("The question tool requires interactive TUI mode");
247
260
  if (signal?.aborted) throw new Error("Question cancelled before it opened");
248
261
 
@@ -703,9 +716,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
703
716
  },
704
717
 
705
718
  renderCall(args, theme, context) {
706
- const multiple = args.mode === "multiple";
707
- const minimum = args.minSelections ?? 1;
708
- const maximum = args.maxSelections ?? args.options.length + 1;
719
+ const { mode, minSelections: minimum, maxSelections: maximum } = normalizeQuestionSelection(args);
720
+ const multiple = mode === "multiple";
709
721
  if (!context.expanded) {
710
722
  const title = multiple ? "question (multi-select) " : "question ";
711
723
  const detail = multiple ? `${args.options.length} options · choose ${minimum}–${maximum}` : `${args.options.length} option${args.options.length === 1 ? "" : "s"}`;
@@ -5,10 +5,12 @@ export type InitOutcome =
5
5
  | { kind: "pending" }
6
6
  | { kind: "written" }
7
7
  | { kind: "policy-conflict"; reason: string }
8
+ | { kind: "cancelled" }
8
9
  | { kind: "no-outcome" };
9
10
 
10
11
  export interface InitRuntime {
11
12
  active: boolean;
13
+ starting?: symbol;
12
14
  targetPath?: string;
13
15
  projectRoot?: string;
14
16
  activeTools?: string[];
@@ -26,6 +28,11 @@ export interface GoalBlockerAudit {
26
28
  lastTurn: number;
27
29
  }
28
30
 
31
+ export interface GoalFileVerification {
32
+ kind: "file";
33
+ path: string;
34
+ }
35
+
29
36
  export interface GoalState {
30
37
  version: 1;
31
38
  revision: number;
@@ -41,6 +48,7 @@ export interface GoalState {
41
48
  result?: string;
42
49
  resumeAfterManualCompaction?: true;
43
50
  blockerAudit?: GoalBlockerAudit;
51
+ verification?: GoalFileVerification;
44
52
  }
45
53
 
46
54
  export interface GoalRuntime {
@@ -71,6 +79,7 @@ export function createGoalRuntime(): GoalRuntime {
71
79
 
72
80
  export function resetInitRuntime(state: InitRuntime): void {
73
81
  state.active = false;
82
+ state.starting = undefined;
74
83
  state.targetPath = undefined;
75
84
  state.projectRoot = undefined;
76
85
  state.activeTools = undefined;
@@ -2,7 +2,6 @@ import { execFile } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import {
4
4
  CustomEditor,
5
- DynamicBorder,
6
5
  VERSION,
7
6
  type ExtensionAPI,
8
7
  type ExtensionContext,
@@ -10,9 +9,7 @@ import {
10
9
  type Theme,
11
10
  } from "@earendil-works/pi-coding-agent";
12
11
  import {
13
- Container,
14
12
  CURSOR_MARKER,
15
- Text,
16
13
  truncateToWidth,
17
14
  visibleWidth,
18
15
  wrapTextWithAnsi,
@@ -182,7 +179,7 @@ function isBorderLine(line: string): boolean {
182
179
 
183
180
  function isScrolledTopBorder(line: string): boolean {
184
181
  const unstyled = stripAnsi(line);
185
- return unstyled.includes("↑") || unstyled.includes(".");
182
+ return unstyled.includes("↑");
186
183
  }
187
184
 
188
185
  class PiCodeEditor extends CustomEditor {
@@ -219,10 +216,9 @@ class PiCodeEditor extends CustomEditor {
219
216
 
220
217
  override render(width: number): string[] {
221
218
  if (width <= 0) return [];
222
- if (width < 4) return super.render(width).map((line) => truncateToWidth(line, width, ""));
223
- const innerWidth = width - 2;
219
+ const innerWidth = Math.max(1, width - 2);
224
220
  const lines = super.render(innerWidth);
225
- if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
221
+ if (lines.length < 2) return ["", ...lines.map((line) => truncateToWidth(line, width, ""))];
226
222
  let bottomBorderIndex = lines.length - 1;
227
223
  for (let index = lines.length - 1; index >= 1; index -= 1) {
228
224
  if (isBorderLine(lines[index] ?? "")) {
@@ -231,44 +227,40 @@ class PiCodeEditor extends CustomEditor {
231
227
  }
232
228
  }
233
229
 
234
- const gray = (text: string): string => this.runtimeTheme.fg("dim", text);
235
- const framed: string[] = [];
230
+ const dim = (text: string): string => this.runtimeTheme.fg("dim", text);
231
+ const rendered: string[] = [];
236
232
  const top = stripAnsi(lines[0] ?? "");
237
233
  const isScrolledHeader = isScrolledTopBorder(lines[0] ?? "");
238
234
  if (isScrolledHeader) {
239
235
  const count = top.match(/↑\s*(\d+)/)?.[1] ?? "";
240
- const indicator = `${gray("─── ")}${count}${gray(" more ")}${gray("─".repeat(Math.max(0, width - 12 - count.length)))}`;
241
- framed.push(truncateToWidth(indicator, width, ""));
242
- } else {
243
- framed.push(gray("─".repeat(width)));
236
+ rendered.push(truncateToWidth(dim(` ↑ ${count} more`), width, ""));
244
237
  }
245
238
 
246
239
  for (let index = 1; index < bottomBorderIndex; index += 1) {
247
240
  const isPromptLine = index === 1 && !isScrolledHeader;
248
- const prefix = isPromptLine ? gray("❯\u00A0") : " ";
241
+ const prefix = isPromptLine
242
+ ? this.runtimeTheme.fg(this.focused ? "accent" : "dim", "❯\u00A0")
243
+ : " ";
249
244
  let content = lines[index] ?? "";
250
245
  if (isPromptLine && this.getText() === "") {
251
246
  const first = this.suggestion.slice(0, 1);
252
247
  const rest = this.suggestion.slice(1);
253
248
  const cursorMarker = this.focused ? CURSOR_MARKER : "";
254
- content = `${cursorMarker}\x1B[7m${gray(first)}\x1B[27m${gray(rest)}`;
249
+ content = `${cursorMarker}\x1B[7m${dim(first)}\x1B[27m${dim(rest)}`;
255
250
  }
256
- framed.push(`${prefix}${padRight(content, innerWidth)}`);
251
+ rendered.push(`${prefix}${padRight(content, innerWidth)}`);
257
252
  }
258
253
 
259
254
  const bottom = stripAnsi(lines[bottomBorderIndex] ?? "");
260
255
  if (bottom.includes("↓")) {
261
256
  const count = bottom.match(/↓\s*(\d+)/)?.[1] ?? "";
262
- const indicator = `${gray("─── ")}${count}${gray(" more ")}${gray("─".repeat(Math.max(0, width - 12 - count.length)))}`;
263
- framed.push(truncateToWidth(indicator, width, ""));
264
- } else {
265
- framed.push(gray("─".repeat(width)));
257
+ rendered.push(truncateToWidth(dim(` ↓ ${count} more`), width, ""));
266
258
  }
267
259
 
268
260
  for (let index = bottomBorderIndex + 1; index < lines.length; index += 1) {
269
- framed.push(` ${padRight(lines[index] ?? "", innerWidth)}`);
261
+ rendered.push(` ${padRight(lines[index] ?? "", innerWidth)}`);
270
262
  }
271
- return framed.map((line) => truncateToWidth(line, width, ""));
263
+ return ["", ...rendered.map((line) => truncateToWidth(line, width, ""))];
272
264
  }
273
265
  }
274
266
 
@@ -277,39 +269,11 @@ const ACTIVITY_FRAMES = [
277
269
  "✽", "✻", "✶", "✱", "✢", "·",
278
270
  ] as const;
279
271
  const ACTIVITY_FRAME_INTERVAL_MS = 120;
280
- const ACTIVITY_WORDS = ["Brewing", "Pondering", "Tinkering", "Wrangling", "Noodling", "Cooking"] as const;
281
-
282
- function formatActivityMessage(word: string, theme: Theme): string {
283
- return `${theme.fg("accent", `${word}…`)} ${theme.fg("dim", `(${theme.bold("esc")} to interrupt · thinking)`)}`;
284
- }
285
272
 
286
273
  let killerosEditorFactory: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
287
274
 
288
275
  export function registerShellUi(pi: ExtensionAPI): void {
289
276
  let activeHeader: PiStartupHeader | undefined;
290
- let activityDeck: string[] = [];
291
- let lastActivityWord: string | undefined;
292
- let activityTimer: ReturnType<typeof setInterval> | undefined;
293
- const refillActivityDeck = (): void => {
294
- activityDeck = [...ACTIVITY_WORDS];
295
- for (let index = activityDeck.length - 1; index > 0; index -= 1) {
296
- const swapIndex = Math.floor(Math.random() * (index + 1));
297
- [activityDeck[index], activityDeck[swapIndex]] = [activityDeck[swapIndex]!, activityDeck[index]!];
298
- }
299
- if (activityDeck.length > 1 && activityDeck.at(-1) === lastActivityWord) {
300
- [activityDeck[0], activityDeck[activityDeck.length - 1]] = [activityDeck.at(-1)!, activityDeck[0]!];
301
- }
302
- };
303
- const nextActivityWord = (): string => {
304
- if (activityDeck.length === 0) refillActivityDeck();
305
- const word = activityDeck.pop() ?? ACTIVITY_WORDS[0];
306
- lastActivityWord = word;
307
- return word;
308
- };
309
- const clearActivityTimer = (): void => {
310
- if (activityTimer) clearInterval(activityTimer);
311
- activityTimer = undefined;
312
- };
313
277
 
314
278
  pi.on("session_start", (_event, ctx) => {
315
279
  if (ctx.mode !== "tui") return;
@@ -321,7 +285,6 @@ export function registerShellUi(pi: ExtensionAPI): void {
321
285
  activeHeader = new PiStartupHeader(pi, ctx, startupTip, tui);
322
286
  return activeHeader;
323
287
  });
324
- clearActivityTimer();
325
288
  ctx.ui.setWorkingIndicator({
326
289
  frames: ACTIVITY_FRAMES.map((frame) => ctx.ui.theme.fg("accent", frame)),
327
290
  intervalMs: ACTIVITY_FRAME_INTERVAL_MS,
@@ -339,25 +302,8 @@ export function registerShellUi(pi: ExtensionAPI): void {
339
302
  }
340
303
  });
341
304
 
342
- pi.on("agent_start", (_event, ctx) => {
343
- if (ctx.mode !== "tui") return;
344
- clearActivityTimer();
345
- const updateWorkingWord = (): void => ctx.ui.setWorkingMessage(formatActivityMessage(nextActivityWord(), ctx.ui.theme));
346
- updateWorkingWord();
347
- activityTimer = setInterval(updateWorkingWord, 2_500);
348
- activityTimer.unref?.();
349
- });
350
-
351
- pi.on("agent_end", (_event, ctx) => {
352
- clearActivityTimer();
353
- if (ctx.mode === "tui") ctx.ui.setWorkingMessage();
354
- });
355
-
356
305
  pi.on("session_shutdown", () => {
357
- clearActivityTimer();
358
306
  activeHeader?.dispose();
359
307
  activeHeader = undefined;
360
- activityDeck = [];
361
- lastActivityWord = undefined;
362
308
  });
363
309
  }
@@ -1,9 +1,9 @@
1
- import { DynamicBorder, keyHint, type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
- import { Container, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
1
+ import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { SelectList, truncateToWidth } from "@earendil-works/pi-tui";
3
3
 
4
- export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
4
+ export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
5
5
 
6
- const ALL_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
6
+ const ALL_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
7
7
  const LEVEL_LABELS: Readonly<Record<ThinkingLevel, string>> = {
8
8
  off: "Off",
9
9
  minimal: "Minimal",
@@ -42,7 +42,7 @@ const LEVEL_ALIASES: Readonly<Record<string, ThinkingLevel>> = {
42
42
  };
43
43
 
44
44
  function isThinkingLevel(value: string): value is ThinkingLevel {
45
- return (ALL_LEVELS as readonly string[]).includes(value);
45
+ return ALL_LEVELS.some((level) => level === value);
46
46
  }
47
47
 
48
48
  function resolveThinkingLevel(input: string): ThinkingLevel | undefined {
@@ -96,40 +96,82 @@ export function registerVariants(pi: ExtensionAPI): void {
96
96
  ctx.ui.notify(`${modelLabel(ctx.model)} does not support extended reasoning`, "info");
97
97
  return;
98
98
  }
99
- const current = pi.getThinkingLevel() as ThinkingLevel;
99
+ const current = pi.getThinkingLevel();
100
100
  const items = supported.map((level) => ({
101
101
  value: level,
102
102
  label: level === current ? `${LEVEL_LABELS[level]} ← current` : LEVEL_LABELS[level],
103
103
  description: LEVEL_DESCRIPTIONS[level],
104
104
  }));
105
- const selected = await ctx.ui.custom<ThinkingLevel | null>((tui, theme, _keybindings, done) => {
106
- const container = new Container();
107
- container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
108
- container.addChild(new Text(theme.fg("accent", theme.bold("Thinking variants")), 1, 0));
109
- container.addChild(new Text(theme.fg("dim", `Model: ${modelLabel(ctx.model)}`), 1, 0));
110
- container.addChild(new Text("", 0, 0));
111
- const selectList = new SelectList(items, Math.min(items.length, 10), {
112
- selectedPrefix: (text) => theme.fg("accent", text),
113
- selectedText: (text) => theme.fg("accent", text),
114
- description: (text) => theme.fg("muted", text),
115
- scrollInfo: (text) => theme.fg("dim", text),
116
- noMatch: (text) => theme.fg("warning", text),
117
- });
118
- selectList.onSelect = (item) => done(isThinkingLevel(item.value) ? item.value : null);
119
- selectList.onCancel = () => done(null);
120
- container.addChild(selectList);
121
- container.addChild(new Text("", 0, 0));
122
- container.addChild(new Text(
123
- theme.fg("dim", `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`),
124
- 1,
125
- 0,
126
- ));
127
- container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
105
+ const selected = await ctx.ui.custom<ThinkingLevel | null>((tui, theme, keybindings, done) => {
106
+ const listTheme = {
107
+ selectedPrefix: (text: string) => theme.fg("accent", text),
108
+ selectedText: (text: string) => theme.fg("accent", text),
109
+ description: (text: string) => theme.fg("muted", text),
110
+ scrollInfo: (text: string) => theme.fg("dim", text),
111
+ noMatch: (text: string) => theme.fg("warning", text),
112
+ };
113
+ let selectList: SelectList | undefined;
114
+ let visibleOptionRows = 0;
115
+
116
+ const chromeFor = (rowBudget: number): "full" | "compact" | "none" => (
117
+ rowBudget >= 8 ? "full" : rowBudget >= 4 ? "compact" : "none"
118
+ );
119
+ const visibleRowsFor = (rowBudget: number): number => {
120
+ const chrome = chromeFor(rowBudget);
121
+ const chromeRows = chrome === "full" ? 5 : chrome === "compact" ? 2 : 0;
122
+ const availableListRows = Math.max(1, rowBudget - chromeRows);
123
+ return availableListRows >= items.length
124
+ ? items.length
125
+ : Math.max(1, availableListRows - 1);
126
+ };
127
+ const ensureSelectList = (nextVisibleOptionRows: number): SelectList => {
128
+ if (selectList && visibleOptionRows === nextVisibleOptionRows) return selectList;
129
+ const selectedValue = selectList?.getSelectedItem()?.value ?? current;
130
+ const nextSelectList = new SelectList(items, nextVisibleOptionRows, listTheme);
131
+ const selectedIndex = items.findIndex((item) => item.value === selectedValue);
132
+ nextSelectList.setSelectedIndex(Math.max(0, selectedIndex));
133
+ nextSelectList.onSelect = (item) => done(isThinkingLevel(item.value) ? item.value : null);
134
+ nextSelectList.onCancel = () => done(null);
135
+ selectList = nextSelectList;
136
+ visibleOptionRows = nextVisibleOptionRows;
137
+ return nextSelectList;
138
+ };
139
+
140
+ const border = new DynamicBorder((text: string) => theme.fg("accent", text));
141
+ const title = ` ${theme.fg("accent", theme.bold("Thinking variants"))}`;
142
+ const model = ` ${theme.fg("dim", `Model: ${modelLabel(ctx.model)}`)}`;
143
+ const keyHint = (keybinding: Parameters<typeof keybindings.getKeys>[0], description: string): string => {
144
+ const keyText = keybindings.getKeys(keybinding)
145
+ .join("/")
146
+ .split("/")
147
+ .map((key) => key.split("+").map((part) => process.platform === "darwin" && part.toLocaleLowerCase() === "alt" ? "option" : part).join("+"))
148
+ .join("/");
149
+ return theme.fg("dim", keyText) + theme.fg("muted", ` ${description}`);
150
+ };
151
+ const controls = ` ${theme.fg("dim", `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`)}`;
152
+
153
+ const render = (width: number): string[] => {
154
+ const rowBudget = Math.max(0, tui.terminal.rows);
155
+ if (width <= 0 || rowBudget === 0) return [];
156
+ const chrome = chromeFor(rowBudget);
157
+ const list = ensureSelectList(visibleRowsFor(rowBudget));
158
+ const lines: string[] = [];
159
+
160
+ if (chrome === "full") lines.push(...border.render(width));
161
+ if (chrome !== "none") lines.push(title);
162
+ if (chrome === "full") lines.push(model);
163
+ lines.push(...list.render(width));
164
+ if (chrome !== "none") lines.push(controls);
165
+ if (chrome === "full") lines.push(...border.render(width));
166
+
167
+ return lines.slice(0, rowBudget).map((line) => truncateToWidth(line, width, ""));
168
+ };
169
+
128
170
  return {
129
- render: (width) => container.render(width).map((line) => truncateToWidth(line, width, "")),
130
- invalidate: () => container.invalidate(),
171
+ render,
172
+ invalidate: () => selectList?.invalidate(),
131
173
  handleInput: (data) => {
132
- selectList.handleInput(data);
174
+ ensureSelectList(visibleRowsFor(Math.max(1, tui.terminal.rows))).handleInput(data);
133
175
  tui.requestRender();
134
176
  },
135
177
  };
@@ -1,14 +1,34 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { StopReason } from "@earendil-works/pi-ai";
2
3
  import { Text } from "@earendil-works/pi-tui";
3
4
 
4
5
  const WORKED_FOR_ENTRY_TYPE = "killeros-worked-for";
5
- const WORKED_FOR_ENTRY_VERSION = 1;
6
6
 
7
- interface WorkedForEntryData {
8
- version: typeof WORKED_FOR_ENTRY_VERSION;
7
+ interface WorkedForEntryDataV1 {
8
+ version: 1;
9
9
  milliseconds: number;
10
10
  }
11
11
 
12
+ export type WorkedForOutcome = "done" | "stopped" | "failed";
13
+
14
+ interface WorkedForEntryDataV2 {
15
+ version: 2;
16
+ milliseconds: number;
17
+ outcome: WorkedForOutcome;
18
+ }
19
+
20
+ type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2;
21
+
22
+ const OUTCOMES = {
23
+ done: { marker: "✓", label: "Done", color: "success" },
24
+ stopped: { marker: "■", label: "Stopped", color: "warning" },
25
+ failed: { marker: "×", label: "Failed", color: "error" },
26
+ } as const satisfies Record<WorkedForOutcome, { marker: string; label: string; color: string }>;
27
+
28
+ function isWorkedForOutcome(value: unknown): value is WorkedForOutcome {
29
+ return value === "done" || value === "stopped" || value === "failed";
30
+ }
31
+
12
32
  export function formatWorkedForDuration(milliseconds: number): string {
13
33
  const boundedMilliseconds = Number.isFinite(milliseconds) ? Math.max(0, milliseconds) : 0;
14
34
  const totalSeconds = Math.max(1, Math.floor(boundedMilliseconds / 1_000));
@@ -22,13 +42,23 @@ export function formatWorkedForDuration(milliseconds: number): string {
22
42
  return `${Math.floor(totalMinutes / 60)}h ${(totalMinutes % 60).toString().padStart(2, "0")}m`;
23
43
  }
24
44
 
25
- function isWorkedForEntryData(data: unknown): data is WorkedForEntryData {
26
- if (!data || typeof data !== "object") return false;
27
- const candidate = data as Partial<WorkedForEntryData>;
28
- return candidate.version === WORKED_FOR_ENTRY_VERSION
29
- && typeof candidate.milliseconds === "number"
30
- && Number.isFinite(candidate.milliseconds)
31
- && candidate.milliseconds >= 0;
45
+ function parseWorkedForEntryData(data: unknown): WorkedForEntryData | undefined {
46
+ if (!data || typeof data !== "object" || Array.isArray(data)) return undefined;
47
+ if (!("version" in data) || !("milliseconds" in data)) return undefined;
48
+ if (typeof data.milliseconds !== "number" || !Number.isFinite(data.milliseconds) || data.milliseconds < 0) {
49
+ return undefined;
50
+ }
51
+ if (data.version === 1) return { version: 1, milliseconds: data.milliseconds };
52
+ if (data.version !== 2 || !("outcome" in data) || !isWorkedForOutcome(data.outcome)) {
53
+ return undefined;
54
+ }
55
+ return { version: 2, milliseconds: data.milliseconds, outcome: data.outcome };
56
+ }
57
+
58
+ export function workedForOutcome(stopReason: StopReason | undefined): WorkedForOutcome {
59
+ if (stopReason === "stop") return "done";
60
+ if (stopReason === "aborted") return "stopped";
61
+ return "failed";
32
62
  }
33
63
 
34
64
  function errorMessage(error: unknown): string {
@@ -40,11 +70,21 @@ export function registerWorkedFor(
40
70
  now: () => number = Date.now,
41
71
  ): void {
42
72
  let startedAt: number | undefined;
73
+ let stopReason: StopReason | undefined;
43
74
 
44
75
  pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, _options, theme) => {
45
- if (!isWorkedForEntryData(entry.data)) return undefined;
76
+ const data = parseWorkedForEntryData(entry.data);
77
+ if (!data) return undefined;
78
+ if (data.version === 1) {
79
+ return new Text(
80
+ theme.fg("dim", `✻ Worked for ${formatWorkedForDuration(data.milliseconds)}`),
81
+ 0,
82
+ 0,
83
+ );
84
+ }
85
+ const outcome = OUTCOMES[data.outcome];
46
86
  return new Text(
47
- theme.fg("dim", `✻ Worked for ${formatWorkedForDuration(entry.data.milliseconds)}`),
87
+ `${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}`)}`,
48
88
  0,
49
89
  0,
50
90
  );
@@ -52,6 +92,7 @@ export function registerWorkedFor(
52
92
 
53
93
  pi.on("session_start", () => {
54
94
  startedAt = undefined;
95
+ stopReason = undefined;
55
96
  });
56
97
 
57
98
  pi.on("agent_start", (_event, ctx) => {
@@ -59,15 +100,28 @@ export function registerWorkedFor(
59
100
  startedAt = now();
60
101
  });
61
102
 
103
+ pi.on("agent_end", (event, ctx) => {
104
+ if (ctx.mode !== "tui" || startedAt === undefined) return;
105
+ for (let index = event.messages.length - 1; index >= 0; index -= 1) {
106
+ const message = event.messages[index];
107
+ if (message?.role !== "assistant") continue;
108
+ stopReason = message.stopReason;
109
+ break;
110
+ }
111
+ });
112
+
62
113
  pi.on("agent_settled", (_event, ctx) => {
63
114
  if (ctx.mode !== "tui" || startedAt === undefined) return;
64
115
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
65
116
  const milliseconds = Math.max(0, now() - startedAt);
117
+ const outcome = workedForOutcome(stopReason);
66
118
  startedAt = undefined;
119
+ stopReason = undefined;
67
120
  try {
68
- pi.appendEntry<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, {
69
- version: WORKED_FOR_ENTRY_VERSION,
121
+ pi.appendEntry<WorkedForEntryDataV2>(WORKED_FOR_ENTRY_TYPE, {
122
+ version: 2,
70
123
  milliseconds,
124
+ outcome,
71
125
  });
72
126
  } catch (error) {
73
127
  ctx.ui.notify(`Worked-for timing could not be saved: ${errorMessage(error)}`, "error");
@@ -76,5 +130,6 @@ export function registerWorkedFor(
76
130
 
77
131
  pi.on("session_shutdown", () => {
78
132
  startedAt = undefined;
133
+ stopReason = undefined;
79
134
  });
80
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -42,14 +42,15 @@
42
42
  ]
43
43
  },
44
44
  "peerDependencies": {
45
- "@earendil-works/pi-ai": ">=0.82.1",
46
- "@earendil-works/pi-coding-agent": ">=0.82.1",
47
- "@earendil-works/pi-tui": ">=0.82.1",
45
+ "@earendil-works/pi-ai": ">=0.84.1",
46
+ "@earendil-works/pi-coding-agent": ">=0.84.1",
47
+ "@earendil-works/pi-tui": ">=0.84.1",
48
48
  "typebox": ">=1.1.38 <2"
49
49
  },
50
50
  "devDependencies": {
51
- "@earendil-works/pi-coding-agent": "0.82.1",
52
- "@earendil-works/pi-tui": "0.82.1",
51
+ "@earendil-works/pi-ai": "0.84.1",
52
+ "@earendil-works/pi-coding-agent": "0.84.1",
53
+ "@earendil-works/pi-tui": "0.84.1",
53
54
  "@types/node": "24.12.4",
54
55
  "typebox": "1.1.38",
55
56
  "typescript": "5.9.3"