killeros 2.0.16 → 2.0.18

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,60 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.18] - 2026-08-24
8
+
9
+ ### Added
10
+
11
+ - Added a real Pi SDK lifecycle test for package activation, shutdown-before-reload, fresh registration, and stale-context rejection.
12
+
13
+ ### Changed
14
+
15
+ - Raised the locked Pi development packages and minimum supported Pi peer version to 0.84.3.
16
+ - Made Pi lifecycle compatibility coverage install the generated npm tarball offline before activation and reload.
17
+
18
+ ### Fixed
19
+
20
+ - Preserved provider-managed OAuth routing during `/handoff`, fixing GitHub Copilot `421 Misdirected Request` failures.
21
+ - Repaired incompatible process-global `/codex-fast` state during extension updates while preserving a valid saved enabled flag.
22
+ - Stopped lifecycle-hook failures from echoing configured shell commands, which could expose inline credentials in Pi diagnostics.
23
+ - Kept lifecycle-hook configuration paths terminal-safe and single-line in warnings.
24
+ - Limited trusted lifecycle-hook configuration to a 64 KiB regular, non-linked file in the project's real `.pi` directory and verified the opened file identity before parsing it.
25
+ - Sanitized dynamic slash-command descriptions and omitted unsafe command names from KillerOS autocomplete.
26
+ - Rejected explicit file goals when their starting filesystem baseline cannot be inspected instead of treating every error as a missing file.
27
+ - Kept custom model and provider labels single-line and terminal-safe in the shell header and footer.
28
+ - Sanitized model-reported `/init` policy conflicts before returning, storing, or notifying with them.
29
+ - Kept session-storage failure details terminal-safe when an explicit goal pause falls back to in-memory state.
30
+ - Stripped terminal commands and unsafe controls from saved goal objectives before clear confirmations.
31
+ - Kept rejected `/variants` arguments terminal-safe and single-line in error notifications.
32
+ - Kept provider and model identifiers terminal-safe and single-line throughout `/variants`.
33
+ - Flushed partial UTF-8 lifecycle-hook output on process close instead of silently dropping final diagnostic bytes.
34
+ - Contained synchronous lifecycle-hook process-start failures inside normal hook failure handling.
35
+ - Kept header and footer paths single-line and terminal-safe for unusual cwd or home-directory names.
36
+ - Sanitized manual-compaction abort diagnostics before saving recovery-eligible goal state.
37
+ - Stripped terminal commands and unsafe controls from saved goal text before showing status panels or notifications.
38
+ - Sanitized provider and storage diagnostics before persisting or announcing an automatically paused goal.
39
+ - Routed handoff generation and session-replacement failures through the shared terminal-safe error reporter.
40
+ - Stripped terminal commands and unsafe control bytes from caught errors before showing KillerOS failure notifications.
41
+ - Stripped terminal commands and control bytes from cwd and session names before emitting the KillerOS terminal title.
42
+ - Removed filesystem source paths from personal-instruction prompt wrappers so unusual project paths cannot alter prompt structure.
43
+ - Stripped terminal commands and unsafe control bytes from lifecycle hook failure notifications and tool block reasons.
44
+
45
+ ## [2.0.17] - 2026-08-23
46
+
47
+ ### Added
48
+
49
+ - Added per-task token usage to settled TUI receipts for ordinary requests and individual goal turns.
50
+
51
+ ### Changed
52
+
53
+ - Limited push CI to `main` and `dev`; feature branches run through pull request CI without duplicate push runs.
54
+ - Removed obsolete Pi tool API casts, shared caught-error formatting, and expanded the non-repeating startup tip and editor suggestion banks.
55
+ - Clarified that the README's pinned Git tag is an example rather than the current package version.
56
+
57
+ ### Fixed
58
+
59
+ - Limited retired-feature repository checks to tracked and non-ignored files so ignored private notes cannot fail the suite.
60
+
7
61
  ## [2.0.16] - 2026-08-23
8
62
 
9
63
  ### Fixed
package/Killeros.ts CHANGED
@@ -48,10 +48,10 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
48
48
  registerCodexFastMode(pi);
49
49
  registerInitCommand(pi, initRuntime, goalRuntime);
50
50
  registerLifecycleHooks(pi);
51
+ registerWorkedFor(pi);
51
52
  const goalCompaction = registerGoalSettlement(pi, goalRuntime, initRuntime);
52
53
  registerAutoCompaction(pi, { goal: goalCompaction });
53
54
  registerInitSettlement(pi, initRuntime);
54
55
  registerRequestActivity(pi);
55
56
  registerCompletionNotifications(pi, options.completionNotifications);
56
- registerWorkedFor(pi);
57
57
  }
package/README.md CHANGED
@@ -4,7 +4,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
4
4
 
5
5
  ## What you get
6
6
 
7
- - A custom TUI: startup card with version, model, provider, working directory, and Git branch; a dark theme with coral accents; a multiline editor with slash-command completion; a footer that tracks model, context, and goal state.
7
+ - A custom TUI: startup card with version, model, provider, working directory, and Git branch; a dark theme with coral accents; a multiline editor with slash-command completion; a footer that tracks model, context, and goal state; settled task receipts with duration and token usage.
8
8
  - `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. Pause, resume, edit, or clear it anytime.
9
9
  - `/init`: generates a root `AGENTS.md` from repository evidence, preserving compatible existing rules.
10
10
  - `/variants`: pick a reasoning level supported by the active model.
@@ -19,7 +19,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
19
19
  ## Requirements
20
20
 
21
21
  - Node.js 22.19.0+
22
- - Pi 0.84.2+
22
+ - Pi 0.84.3+
23
23
  - An interactive TUI session for the custom header, editor, footer, `question`, and `/init`
24
24
 
25
25
  ## Install
@@ -34,7 +34,7 @@ Or from GitHub:
34
34
  pi install git:github.com/KyrosHendrix/pi-KillerOS
35
35
  ```
36
36
 
37
- Pin a version with `@v2.0.16`, add `-l` to install only for the current project. Restart Pi after installing.
37
+ Pin a release by appending its tag, for example `@v2.0.18`. Add `-l` to install only for the current project. Restart Pi after installing.
38
38
 
39
39
  ## Commands
40
40
 
@@ -85,7 +85,7 @@ Releases go through CI on `main`; do not push version tags manually.
85
85
 
86
86
  ## Security
87
87
 
88
- Pi extensions run with your user permissions. Review the source before installing globally. Hook commands run only for projects Pi marks as trusted; check `.pi/killeros-hooks.json` before enabling project trust.
88
+ Pi extensions run with your user permissions. Review the source before installing globally. Hook commands run only for projects Pi marks as trusted; check `.pi/killeros-hooks.json` before enabling project trust. KillerOS accepts that configuration only as a regular, non-linked file no larger than 64 KiB in the project's real `.pi` directory.
89
89
 
90
90
  ## License
91
91
 
@@ -6,6 +6,7 @@ import {
6
6
  type ExtensionAPI,
7
7
  type ExtensionContext,
8
8
  } from "@earendil-works/pi-coding-agent";
9
+ import { errorMessage } from "./errors.ts";
9
10
  import { createKillerosSettingsStore } from "./settings.ts";
10
11
 
11
12
  export const DEFAULT_AUTO_COMPACTION_PERCENT_REMAINING = 15;
@@ -71,6 +72,7 @@ function reserveTokens(settings: Pick<CompactionSettings, "reserveTokens">): num
71
72
  : 0;
72
73
  }
73
74
 
75
+ /** Triggers at the stricter of the user's percentage and Pi's token reserve. */
74
76
  export function shouldTriggerAutoCompaction(
75
77
  usage: Pick<ContextUsage, "tokens" | "contextWindow"> | undefined,
76
78
  preference: AutoCompactionPreference,
@@ -90,10 +92,6 @@ export function shouldTriggerAutoCompaction(
90
92
  return remainingTokens <= threshold;
91
93
  }
92
94
 
93
- function errorMessage(error: unknown): string {
94
- return error instanceof Error ? error.message : String(error);
95
- }
96
-
97
95
  function supportedMode(ctx: ExtensionContext): boolean {
98
96
  return ctx.mode === "tui" || ctx.mode === "rpc";
99
97
  }
@@ -7,14 +7,25 @@ interface CodexFastState {
7
7
 
8
8
  const GLOBAL_STATE_KEY = "__killerosCodexFastState";
9
9
  type GlobalWithCodexFastState = typeof globalThis & {
10
- [GLOBAL_STATE_KEY]?: CodexFastState;
10
+ [GLOBAL_STATE_KEY]?: unknown;
11
11
  };
12
12
 
13
+ // Accepts process-global state only when it is safe to reuse across extension versions.
14
+ function isCodexFastState(value: unknown): value is CodexFastState {
15
+ return typeof value === "object" && value !== null
16
+ && "enabled" in value && typeof value.enabled === "boolean"
17
+ && "listeners" in value && value.listeners instanceof Set
18
+ && [...value.listeners].every((listener) => typeof listener === "function");
19
+ }
20
+
13
21
  const globalState = globalThis as GlobalWithCodexFastState;
14
- const state = globalState[GLOBAL_STATE_KEY] ??= {
15
- enabled: false,
22
+ const savedState = globalState[GLOBAL_STATE_KEY];
23
+ const state: CodexFastState = isCodexFastState(savedState) ? savedState : {
24
+ enabled: typeof savedState === "object" && savedState !== null
25
+ && "enabled" in savedState && savedState.enabled === true,
16
26
  listeners: new Set<CodexFastStateListener>(),
17
27
  };
28
+ globalState[GLOBAL_STATE_KEY] = state;
18
29
 
19
30
  export function isCodexFastEnabled(): boolean {
20
31
  return state.enabled;
@@ -1,5 +1,6 @@
1
1
  import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+ import { safeTerminalText } from "./safe-terminal-text.ts";
3
4
 
4
5
  async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean> {
5
6
  if (!ctx.hasUI) return true;
@@ -87,6 +88,11 @@ interface TaggedAutocompleteItem extends AutocompleteItem {
87
88
  const SLASH_COMMAND_PREFIX_PATTERN = /(?:^|[ \t])\/([^\s/]*)$/u;
88
89
  const SLASH_COMMAND_TOKEN_PATTERN = /(?:^|[ \t])\/([^\s/]+)(?=$|[ \t])/gu;
89
90
 
91
+ function safeCommandName(name: string): string | undefined {
92
+ const safe = safeTerminalText(name).replaceAll("\n", "").trim();
93
+ return safe === name && safe && !/[\s/]/u.test(safe) ? safe : undefined;
94
+ }
95
+
90
96
  export function getSlashCommandPrefix(line: string): { prefix: string; slashIndex: number } | undefined {
91
97
  const match = SLASH_COMMAND_PREFIX_PATTERN.exec(line);
92
98
  if (!match || match.index === undefined) return undefined;
@@ -107,7 +113,8 @@ export function findSlashCommandTokens(line: string): SlashCommandToken[] {
107
113
  }
108
114
 
109
115
  function commandNameFromAutocompleteItem(item: AutocompleteItem): string {
110
- return (item.value || item.label).replace(/^\//u, "").trim().split(/\s+/u)[0] ?? "";
116
+ const name = (item.value || item.label).replace(/^\//u, "").trim().split(/\s+/u)[0] ?? "";
117
+ return safeCommandName(name) ?? "";
111
118
  }
112
119
 
113
120
  export function createSlashCommandResolver(
@@ -124,16 +131,20 @@ export function createSlashCommandResolver(
124
131
  }));
125
132
 
126
133
  for (const command of pi.getCommands()) {
134
+ const name = safeCommandName(command.name);
135
+ if (!name) continue;
127
136
  const category: CommandInfo["category"] = command.source === "skill"
128
137
  ? "Skill"
129
138
  : command.source === "prompt"
130
139
  ? "Prompt"
131
140
  : "Extension";
132
- commands.set(command.name, {
133
- name: command.name,
134
- description: command.description,
141
+ commands.set(name, {
142
+ name,
143
+ description: command.description === undefined
144
+ ? undefined
145
+ : safeTerminalText(command.description).replaceAll("\n", " "),
135
146
  category,
136
- syntaxHint: COMMAND_SYNTAX_HINTS[command.name],
147
+ syntaxHint: COMMAND_SYNTAX_HINTS[name],
137
148
  });
138
149
  }
139
150
 
@@ -142,7 +153,11 @@ export function createSlashCommandResolver(
142
153
  : fallbackCommands;
143
154
  for (const [name, description] of baseCommands) {
144
155
  if (name && !commands.has(name)) {
145
- commands.set(name, { name, description, category: "Built-in" });
156
+ commands.set(name, {
157
+ name,
158
+ description: description === undefined ? undefined : safeTerminalText(description).replaceAll("\n", " "),
159
+ category: "Built-in",
160
+ });
146
161
  }
147
162
  }
148
163
  return commands;
@@ -1,18 +1,21 @@
1
1
  import os from "node:os";
2
2
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { safeTerminalText } from "./safe-terminal-text.ts";
3
4
 
5
+ /** Formats a terminal-safe, single-line project path with home abbreviation. */
4
6
  export function formatCwd(cwd: string): string {
5
- const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
6
- if (!home) return cwd;
7
+ const safeCwd = safeTerminalText(cwd).replaceAll("\n", "");
8
+ const home = safeTerminalText(process.env.HOME || process.env.USERPROFILE || os.homedir()).replaceAll("\n", "");
9
+ if (!home) return safeCwd;
7
10
  const normalizedHome = home.replace(/[\\/]+$/, "");
8
- const normalizedCwd = cwd.replace(/[\\/]+$/, "");
11
+ const normalizedCwd = safeCwd.replace(/[\\/]+$/, "");
9
12
  const comparedHome = process.platform === "win32" ? normalizedHome.toLowerCase() : normalizedHome;
10
13
  const comparedCwd = process.platform === "win32" ? normalizedCwd.toLowerCase() : normalizedCwd;
11
14
  if (comparedCwd === comparedHome) return "~";
12
15
  const separator = normalizedCwd.slice(normalizedHome.length, normalizedHome.length + 1);
13
16
  return comparedCwd.startsWith(comparedHome) && (separator === "/" || separator === "\\")
14
17
  ? `~${normalizedCwd.slice(normalizedHome.length)}`
15
- : cwd;
18
+ : safeCwd;
16
19
  }
17
20
 
18
21
  export function padRight(text: string, width: number): string {
@@ -1,6 +1,11 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { safeTerminalText } from "./safe-terminal-text.ts";
3
+
4
+ /** Converts unknown caught values into text suitable for user-facing errors. */
5
+ export function errorMessage(error: unknown): string {
6
+ return safeTerminalText(error instanceof Error ? error.message : String(error));
7
+ }
2
8
 
3
9
  export function reportError(ctx: ExtensionContext, area: string, error: unknown): void {
4
- const message = error instanceof Error ? error.message : String(error);
5
- ctx.ui.notify(`${area}: ${message}`, "error");
10
+ ctx.ui.notify(`${area}: ${errorMessage(error)}`, "error");
6
11
  }
@@ -4,6 +4,7 @@ import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
4
4
  import { formatCwd, formatTime, formatTokens, padRight } from "./display.ts";
5
5
  import { goalElapsedMilliseconds } from "./goals.ts";
6
6
  import type { GoalRuntime, GoalState } from "./runtime.ts";
7
+ import { safeTerminalText } from "./safe-terminal-text.ts";
7
8
  import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
8
9
 
9
10
  const FOOTER_REFRESH_INTERVAL_MS = 1_000;
@@ -76,7 +77,7 @@ const PROVIDER_WORDS: Readonly<Record<string, string>> = {
76
77
  };
77
78
 
78
79
  function formatProviderName(provider: string): string {
79
- const normalized = provider.trim();
80
+ const normalized = safeTerminalText(provider).replaceAll("\n", "").trim();
80
81
  const known = PROVIDER_LABELS[normalized.toLowerCase()];
81
82
  if (known) return known;
82
83
  return normalized
@@ -87,7 +88,8 @@ function formatProviderName(provider: string): string {
87
88
  }
88
89
 
89
90
  function modelDisplayName(model: NonNullable<ExtensionContext["model"]>): string {
90
- return model.name?.trim() || model.id;
91
+ const name = safeTerminalText(model.name ?? "").replaceAll("\n", "").trim();
92
+ return name || safeTerminalText(model.id).replaceAll("\n", "").trim() || "Unknown model";
91
93
  }
92
94
 
93
95
  export function formatModel(
package/killeros/goals.ts CHANGED
@@ -5,7 +5,6 @@ import path from "node:path";
5
5
  import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
6
6
  import { Text } from "@earendil-works/pi-tui";
7
7
  import { Type } from "typebox";
8
- import { MAX_NODE_TIMER_MS } from "./limits.ts";
9
8
  import { BoundedText } from "./bounded-text.ts";
10
9
  import { formatTime, formatTokens } from "./display.ts";
11
10
  import { reportError } from "./errors.ts";
@@ -119,8 +118,9 @@ function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
119
118
  let artifact: ReturnType<typeof lstatSync>;
120
119
  try {
121
120
  artifact = lstatSync(filePath);
122
- } catch {
123
- return { exists: false };
121
+ } catch (error) {
122
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
123
+ throw error;
124
124
  }
125
125
  const baseline = { exists: true as const, size: artifact.size, mtimeMs: artifact.mtimeMs };
126
126
  if (!artifact.isFile()) return baseline;
@@ -131,6 +131,7 @@ function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
131
131
  }
132
132
  }
133
133
 
134
+ /** Captures one explicit absolute output path so goal completion can verify its creation or modification. */
134
135
  function inferGoalVerification(objective: string): GoalFileVerification | undefined {
135
136
  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;
136
137
  const paths = [...objective.matchAll(destination)]
@@ -283,12 +284,10 @@ function sumGoalTokens(ctx: ExtensionContext): number {
283
284
  }
284
285
 
285
286
  function setGoalUpdateToolActive(pi: ExtensionAPI, active: boolean): void {
286
- const api = pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
287
- if (!api.getActiveTools || !api.setActiveTools) return;
288
- const activeTools = api.getActiveTools();
287
+ const activeTools = pi.getActiveTools();
289
288
  const isActive = activeTools.includes(GOAL_UPDATE_TOOL);
290
289
  if (active === isActive) return;
291
- api.setActiveTools(active
290
+ pi.setActiveTools(active
292
291
  ? [...activeTools, GOAL_UPDATE_TOOL]
293
292
  : activeTools.filter((name) => name !== GOAL_UPDATE_TOOL));
294
293
  }
@@ -384,7 +383,7 @@ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
384
383
  state.objective,
385
384
  ];
386
385
  if (state.result) lines.push(state.result);
387
- return lines.join("\n");
386
+ return safeTerminalText(lines.join("\n"));
388
387
  }
389
388
 
390
389
  export function pauseGoalAfterFailure(
@@ -396,13 +395,14 @@ export function pauseGoalAfterFailure(
396
395
  notify = true,
397
396
  ): void {
398
397
  if (runtime.state?.status !== "active") return;
398
+ const safeReason = safeTerminalText(reason);
399
399
  try {
400
- transitionGoal(pi, runtime, "error", "paused", reason);
400
+ transitionGoal(pi, runtime, "error", "paused", safeReason);
401
401
  } catch {
402
402
  runtime.state = runtime.state ? {
403
403
  ...stopGoalClock(runtime.state, Date.now()),
404
404
  status: "paused",
405
- result: reason,
405
+ result: safeReason,
406
406
  resumeAfterManualCompaction: undefined,
407
407
  } : undefined;
408
408
  syncGoalUpdateTool(pi, runtime);
@@ -411,7 +411,7 @@ export function pauseGoalAfterFailure(
411
411
  runtime.automaticCompaction = undefined;
412
412
  runtime.requestRender?.();
413
413
  }
414
- if (notify) ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
414
+ if (notify) ctx.ui.notify(`Goal paused: ${safeReason}\n${recoveryInstruction}`, "error");
415
415
  }
416
416
 
417
417
  function pauseGoalForPossibleManualCompaction(
@@ -421,15 +421,16 @@ function pauseGoalForPossibleManualCompaction(
421
421
  reason: string,
422
422
  ): void {
423
423
  if (runtime.state?.status !== "active") return;
424
+ const safeReason = safeTerminalText(reason);
424
425
  try {
425
- transitionGoal(pi, runtime, "error", "paused", reason, {
426
+ transitionGoal(pi, runtime, "error", "paused", safeReason, {
426
427
  resumeAfterManualCompaction: true,
427
428
  });
428
429
  } catch {
429
430
  runtime.state = runtime.state ? {
430
431
  ...stopGoalClock(runtime.state, Date.now()),
431
432
  status: "paused",
432
- result: reason,
433
+ result: safeReason,
433
434
  resumeAfterManualCompaction: true,
434
435
  } : undefined;
435
436
  syncGoalUpdateTool(pi, runtime);
@@ -495,6 +496,7 @@ function beginGoalTurn(
495
496
  return next;
496
497
  }
497
498
 
499
+ /** Starts one goal turn only after Pi is idle and all competing workflow gates are clear. */
498
500
  function scheduleGoalContinuation(
499
501
  pi: ExtensionAPI,
500
502
  runtime: GoalRuntime,
@@ -588,7 +590,7 @@ function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): strin
588
590
  if (ctx.isProjectTrusted()) {
589
591
  const personal = resolvePersonalInstructions(ctx.cwd);
590
592
  if (personal) {
591
- sections.push(`<personal_instructions source=${JSON.stringify(personal.source)}>\n${personal.content}\n</personal_instructions>`);
593
+ sections.push(personal);
592
594
  }
593
595
  }
594
596
  return sections.join("\n\n");
@@ -794,7 +796,7 @@ export function registerGoal(
794
796
  const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
795
797
  const action = actions.find((candidate) => candidate.label === selected);
796
798
  if (!action) return;
797
- if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", runtime.state.objective)) return;
799
+ if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", safeTerminalText(runtime.state.objective))) return;
798
800
  await handleGoalCommand(action.control, ctx);
799
801
  return;
800
802
  }
@@ -879,7 +881,7 @@ export function registerGoal(
879
881
  transitionGoal(pi, runtime, "pause", "paused");
880
882
  saved = true;
881
883
  } catch (error) {
882
- failureReason = `the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`;
884
+ failureReason = safeTerminalText(`the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
883
885
  pauseGoalAfterFailure(
884
886
  pi,
885
887
  runtime,
@@ -1039,21 +1041,21 @@ export function registerGoal(
1039
1041
  return;
1040
1042
  }
1041
1043
  const now = Date.now();
1042
- const state: GoalState = {
1043
- version: GOAL_VERSION,
1044
- revision: 1,
1045
- objective,
1046
- status: "active",
1047
- createdAt: now,
1048
- updatedAt: now,
1049
- activeMilliseconds: 0,
1050
- activeStartedAt: now,
1051
- turns: 0,
1052
- blockedAuditStartTurn: 0,
1053
- baselineTokens: sumGoalTokens(ctx),
1054
- verification: inferGoalVerification(objective),
1055
- };
1056
1044
  try {
1045
+ const state: GoalState = {
1046
+ version: GOAL_VERSION,
1047
+ revision: 1,
1048
+ objective,
1049
+ status: "active",
1050
+ createdAt: now,
1051
+ updatedAt: now,
1052
+ activeMilliseconds: 0,
1053
+ activeStartedAt: now,
1054
+ turns: 0,
1055
+ blockedAuditStartTurn: 0,
1056
+ baselineTokens: sumGoalTokens(ctx),
1057
+ verification: inferGoalVerification(objective),
1058
+ };
1057
1059
  persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
1058
1060
  if (scheduleGoalContinuation(pi, runtime, initState, ctx)) {
1059
1061
  ctx.ui.notify("Goal active. KillerOS will continue until completion, a repeated blocker, or pause.", "info");
@@ -1,5 +1,6 @@
1
1
  import { contentText } from "@earendil-works/pi-ai";
2
2
  import { BorderedLoader, convertToLlm, type ExtensionAPI, type ExtensionCommandContext, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
3
+ import { reportError } from "./errors.ts";
3
4
  import type { GoalRuntime } from "./runtime.ts";
4
5
  import { safeTerminalText } from "./safe-terminal-text.ts";
5
6
 
@@ -81,12 +82,6 @@ function hasRequiredHandoffContent(document: string, focus: string): boolean {
81
82
  });
82
83
  }
83
84
 
84
- /** Reports a failed handoff through the session context that remains valid. */
85
- function reportHandoffError(ctx: ExtensionCommandContext, error: unknown): void {
86
- const message = error instanceof Error ? error.message : String(error);
87
- ctx.ui.notify(`Handoff failed: ${message}`, "error");
88
- }
89
-
90
85
  /** Generates and validates a handoff summary with optional cancellation. */
91
86
  async function generateHandoffSummary(
92
87
  ctx: ExtensionCommandContext,
@@ -96,9 +91,7 @@ async function generateHandoffSummary(
96
91
  ): Promise<string> {
97
92
  if (!ctx.model) throw new Error("No current model is available");
98
93
 
99
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
100
94
  signal?.throwIfAborted();
101
- if (!auth.ok) throw new Error(auth.error);
102
95
 
103
96
  const response = await ctx.modelRegistry.complete(ctx.model, {
104
97
  systemPrompt: HANDOFF_SYSTEM_PROMPT,
@@ -108,9 +101,6 @@ async function generateHandoffSummary(
108
101
  timestamp: Date.now(),
109
102
  }],
110
103
  }, {
111
- apiKey: auth.apiKey,
112
- headers: auth.headers,
113
- env: auth.env,
114
104
  maxTokens: 2_048,
115
105
  signal,
116
106
  });
@@ -173,7 +163,7 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
173
163
  throw new Error("The handoff summary did not contain every required section");
174
164
  }
175
165
  } catch (error) {
176
- reportHandoffError(ctx, error);
166
+ reportError(ctx, "Handoff failed", error);
177
167
  return;
178
168
  }
179
169
 
@@ -191,7 +181,7 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
191
181
  },
192
182
  withSession: async (destination) => {
193
183
  if (setupFailure) {
194
- reportHandoffError(destination, setupFailure.error);
184
+ reportError(destination, "Handoff failed", setupFailure.error);
195
185
  return;
196
186
  }
197
187
  destination.ui.notify("Handoff ready in a new session", "info");
@@ -199,7 +189,7 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
199
189
  });
200
190
  } catch (error) {
201
191
  try {
202
- reportHandoffError(ctx, error);
192
+ reportError(ctx, "Handoff failed", error);
203
193
  } catch {
204
194
  throw error;
205
195
  }
package/killeros/hooks.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
5
  import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
- import { reportError } from "./errors.ts";
6
+ import { errorMessage, reportError } from "./errors.ts";
7
+ import { safeTerminalText } from "./safe-terminal-text.ts";
7
8
 
8
9
  type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
9
10
 
@@ -27,20 +28,67 @@ interface HookExecutionResult {
27
28
  }
28
29
 
29
30
  const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
31
+ const HOOK_CONFIG_LIMIT = 64 * 1024;
30
32
  const HOOK_OUTPUT_LIMIT = 16 * 1024;
31
33
  const HOOK_PAYLOAD_LIMIT = 8_000;
32
34
  const HOOK_TIMEOUT_MAX_MS = 300_000;
33
35
 
36
+ // Reads executable project configuration through a bounded, project-local file descriptor.
37
+ function readHookConfig(configPath: string, projectRoot: string): string {
38
+ const actualPath = realpathSync(configPath);
39
+ const expectedPath = path.join(realpathSync(projectRoot), CONFIG_DIR_NAME, "killeros-hooks.json");
40
+ const samePath = process.platform === "win32"
41
+ ? actualPath.toLowerCase() === expectedPath.toLowerCase()
42
+ : actualPath === expectedPath;
43
+ if (!samePath) {
44
+ throw new Error("Hook config must be stored in the real project .pi directory");
45
+ }
46
+
47
+ const linkedFile = lstatSync(configPath);
48
+ if (!linkedFile.isFile() || linkedFile.nlink !== 1) {
49
+ throw new Error("Hook config must be a regular, non-linked file");
50
+ }
51
+ if (linkedFile.size > HOOK_CONFIG_LIMIT) {
52
+ throw new Error(`Hook config exceeds ${HOOK_CONFIG_LIMIT} bytes`);
53
+ }
54
+
55
+ const descriptor = openSync(configPath, "r");
56
+ try {
57
+ const openedFile = fstatSync(descriptor);
58
+ if (!openedFile.isFile() || openedFile.nlink !== 1) {
59
+ throw new Error("Hook config must be a regular, non-linked file");
60
+ }
61
+ if (openedFile.dev !== linkedFile.dev || openedFile.ino !== linkedFile.ino) {
62
+ throw new Error("Hook config changed while being opened");
63
+ }
64
+
65
+ const contents = Buffer.alloc(HOOK_CONFIG_LIMIT + 1);
66
+ let bytesRead = 0;
67
+ while (bytesRead < contents.length) {
68
+ const count = readSync(descriptor, contents, bytesRead, contents.length - bytesRead, null);
69
+ if (count === 0) break;
70
+ bytesRead += count;
71
+ }
72
+ if (bytesRead > HOOK_CONFIG_LIMIT) {
73
+ throw new Error(`Hook config exceeds ${HOOK_CONFIG_LIMIT} bytes`);
74
+ }
75
+ return contents.toString("utf8", 0, bytesRead);
76
+ } finally {
77
+ closeSync(descriptor);
78
+ }
79
+ }
80
+
34
81
  function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
35
82
  const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
36
83
  if (!existsSync(configPath)) return {};
84
+ const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
37
85
  if (!ctx.isProjectTrusted()) {
38
- ctx.ui.notify(`Ignored untrusted project hooks in ${configPath}`, "warning");
86
+ ctx.ui.notify(`Ignored untrusted project hooks in ${displayPath}`, "warning");
39
87
  return {};
40
88
  }
41
89
 
42
90
  try {
43
- const parsed = JSON.parse(readFileSync(configPath, "utf8")) as KillerosHookConfig;
91
+ const parsed = JSON.parse(readHookConfig(configPath, ctx.cwd)) as KillerosHookConfig;
44
92
  const hooks: KillerosHookConfig["hooks"] = {};
45
93
  for (const event of HOOK_EVENTS) {
46
94
  const candidates = parsed.hooks?.[event];
@@ -59,7 +107,7 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
59
107
  && hook.command.trim().length > 0
60
108
  && (hook.matcher === undefined || typeof hook.matcher === "string");
61
109
  if (!valid) {
62
- ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${configPath}`, "warning");
110
+ ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${displayPath}`, "warning");
63
111
  return false;
64
112
  }
65
113
  if (hook.matcher && hook.matcher !== "*") {
@@ -140,8 +188,9 @@ export function executeHook(
140
188
  if (signal?.aborted) {
141
189
  return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
142
190
  }
143
- return new Promise((resolve) => {
144
- const child = spawnProcess(command, {
191
+ let child;
192
+ try {
193
+ child = spawnProcess(command, {
145
194
  cwd,
146
195
  env: { ...process.env, ...environment },
147
196
  detached: process.platform !== "win32",
@@ -149,6 +198,10 @@ export function executeHook(
149
198
  stdio: ["ignore", "pipe", "pipe"],
150
199
  windowsHide: true,
151
200
  });
201
+ } catch (error) {
202
+ return Promise.resolve({ code: 1, stdout: "", stderr: errorMessage(error), timedOut: false, cancelled: false, exitUnconfirmed: false });
203
+ }
204
+ return new Promise((resolve) => {
152
205
  const stdout: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
153
206
  const stderr: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
154
207
  let completed = false;
@@ -163,6 +216,8 @@ export function executeHook(
163
216
  if (forceTimer) clearTimeout(forceTimer);
164
217
  if (settleTimer) clearTimeout(settleTimer);
165
218
  signal?.removeEventListener("abort", abort);
219
+ stdout.text += stdout.decoder.end();
220
+ stderr.text += stderr.decoder.end();
166
221
  resolve({
167
222
  code,
168
223
  stdout: stdout.text,
@@ -212,9 +267,9 @@ function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unkno
212
267
  };
213
268
  }
214
269
 
215
- function hookFailureMessage(hook: KillerosHook, result: HookExecutionResult): string {
270
+ function hookFailureMessage(result: HookExecutionResult): string {
216
271
  const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
217
- return `Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}: ${hook.command}\n${detail}`;
272
+ return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
218
273
  }
219
274
 
220
275
  export function registerLifecycleHooks(pi: ExtensionAPI): void {
@@ -234,7 +289,7 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
234
289
  );
235
290
  if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
236
291
  if (result.code !== 0) {
237
- const reason = hookFailureMessage(hook, result);
292
+ const reason = hookFailureMessage(result);
238
293
  ctx.ui.notify(reason, "error");
239
294
  return { block: true, reason };
240
295
  }
@@ -256,7 +311,7 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
256
311
  ctx.signal,
257
312
  );
258
313
  if (result.cancelled) break;
259
- if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
314
+ if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
260
315
  }
261
316
  });
262
317
 
@@ -271,7 +326,7 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
271
326
  ctx.signal,
272
327
  );
273
328
  if (result.cancelled) break;
274
- if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
329
+ if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
275
330
  }
276
331
  });
277
332
  }
@@ -146,6 +146,7 @@ async function pathExists(filePath: string): Promise<boolean> {
146
146
  }
147
147
  }
148
148
 
149
+ /** Installs generated guidance atomically and preserves any target changed after baseline capture. */
149
150
  export async function installInitAgentsFile(
150
151
  targetPath: string,
151
152
  content: string,
package/killeros/init.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  validateGeneratedGuidance,
18
18
  } from "./init-target.ts";
19
19
  import { resetInitRuntime, type GoalRuntime, type InitOutcome, type InitRuntime } from "./runtime.ts";
20
+ import { safeTerminalText } from "./safe-terminal-text.ts";
20
21
 
21
22
  const INIT_WRITE_TOOL = "killeros_init_write";
22
23
  const INIT_CONFLICT_TOOL = "killeros_init_conflict";
@@ -47,16 +48,14 @@ After a successful write, read generated AGENTS.md once through killeros_init_re
47
48
  `.trim();
48
49
 
49
50
  function setInitTools(pi: ExtensionAPI, initState: InitRuntime, active: boolean): void {
50
- const runtime = pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
51
- if (!runtime.getActiveTools || !runtime.setActiveTools) return;
52
51
  if (active) {
53
- initState.activeTools ??= runtime.getActiveTools().filter((name) => !INIT_SCOPED_TOOLS.includes(name as (typeof INIT_SCOPED_TOOLS)[number]));
54
- runtime.setActiveTools([...INIT_SCOPED_TOOLS]);
52
+ initState.activeTools ??= pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOLS.includes(name as (typeof INIT_SCOPED_TOOLS)[number]));
53
+ pi.setActiveTools([...INIT_SCOPED_TOOLS]);
55
54
  } else if (initState.activeTools) {
56
- runtime.setActiveTools(initState.activeTools);
55
+ pi.setActiveTools(initState.activeTools);
57
56
  initState.activeTools = undefined;
58
57
  } else {
59
- runtime.setActiveTools(runtime.getActiveTools().filter((name) => !INIT_SCOPED_TOOLS.includes(name as (typeof INIT_SCOPED_TOOLS)[number])));
58
+ pi.setActiveTools(pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOLS.includes(name as (typeof INIT_SCOPED_TOOLS)[number])));
60
59
  }
61
60
  }
62
61
 
@@ -126,8 +125,9 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
126
125
  executionMode: "sequential",
127
126
  async execute(_toolCallId, { reason }) {
128
127
  requirePending(initState);
129
- initState.outcome = { kind: "policy-conflict", reason };
130
- return { content: [{ type: "text" as const, text: `Root AGENTS.md was left unchanged: ${reason}` }], details: { reason } };
128
+ const safeReason = safeTerminalText(reason);
129
+ initState.outcome = { kind: "policy-conflict", reason: safeReason };
130
+ return { content: [{ type: "text" as const, text: `Root AGENTS.md was left unchanged: ${safeReason}` }], details: { reason: safeReason } };
131
131
  },
132
132
  });
133
133
 
@@ -4,6 +4,8 @@ import {
4
4
  type ExtensionAPI,
5
5
  type ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { errorMessage } from "./errors.ts";
8
+ import { safeTerminalText } from "./safe-terminal-text.ts";
7
9
  import { createKillerosSettingsStore } from "./settings.ts";
8
10
 
9
11
  export interface NotificationPreferenceStore {
@@ -37,11 +39,7 @@ export function formatNotificationTitle(
37
39
  ): string {
38
40
  const directory = basename(cwd) || cwd;
39
41
  const base = sessionName ? `π - ${sessionName} - ${directory}` : `π - ${directory}`;
40
- return enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base;
41
- }
42
-
43
- function errorMessage(error: unknown): string {
44
- return error instanceof Error ? error.message : String(error);
42
+ return safeTerminalText(enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base).replaceAll("\n", "");
45
43
  }
46
44
 
47
45
  export function registerCompletionNotifications(
@@ -34,20 +34,21 @@ function readBoundedText(filePath: string, limit = PERSONAL_INSTRUCTIONS_LIMIT):
34
34
  }
35
35
  }
36
36
 
37
- export function resolvePersonalInstructions(cwd: string): { content: string; source: string } | undefined {
37
+ export function resolvePersonalInstructions(cwd: string): string | undefined {
38
38
  const localPath = path.join(cwd, PERSONAL_INSTRUCTIONS_FILE);
39
39
  const local = readBoundedText(localPath);
40
40
  if (!local) return undefined;
41
41
 
42
42
  const importMatch = local.trim().match(/^@(.+)$/u);
43
- if (!importMatch) return { content: local, source: localPath };
44
-
45
- const requestedPath = importMatch[1]!.trim();
46
- const importedPath = requestedPath.startsWith("~/") || requestedPath.startsWith("~\\")
47
- ? path.join(os.homedir(), requestedPath.slice(2))
48
- : path.resolve(cwd, requestedPath);
49
- const imported = readBoundedText(importedPath);
50
- return imported ? { content: imported, source: importedPath } : { content: local, source: localPath };
43
+ let content = local;
44
+ if (importMatch) {
45
+ const requestedPath = importMatch[1]!.trim();
46
+ const importedPath = requestedPath.startsWith("~/") || requestedPath.startsWith("~\\")
47
+ ? path.join(os.homedir(), requestedPath.slice(2))
48
+ : path.resolve(cwd, requestedPath);
49
+ content = readBoundedText(importedPath) ?? local;
50
+ }
51
+ return `<personal_instructions>\n${content}\n</personal_instructions>`;
51
52
  }
52
53
 
53
54
  export function registerPersonalInstructions(pi: ExtensionAPI, initState: InitRuntime): void {
@@ -59,9 +60,7 @@ export function registerPersonalInstructions(pi: ExtensionAPI, initState: InitRu
59
60
  systemPrompt: [
60
61
  event.systemPrompt,
61
62
  "",
62
- `<personal_instructions source="${personal.source}">`,
63
- personal.content,
64
- "</personal_instructions>",
63
+ personal,
65
64
  ].join("\n"),
66
65
  };
67
66
  });
@@ -45,6 +45,12 @@ const STARTUP_TIPS = [
45
45
  "Run /variants to tune the model's reasoning depth.",
46
46
  "Type / to browse every command available in this session.",
47
47
  "Run /notification to enable a terminal bell when work settles.",
48
+ "Run /goal <objective> to keep long-running work moving across turns.",
49
+ "Run /init to generate root AGENTS.md from bounded repository evidence.",
50
+ "Run /handoff [focus] to continue work in a fresh linked session.",
51
+ "Run /codex-fast to toggle priority requests for Codex models.",
52
+ "Run /clear to start a fresh session after confirmation.",
53
+ "Ask the agent to show a short choice list when you need to decide.",
48
54
  ] as const;
49
55
 
50
56
  const EDITOR_SUGGESTIONS = [
@@ -52,6 +58,12 @@ const EDITOR_SUGGESTIONS = [
52
58
  'Try "find edge cases in <filepath>"',
53
59
  'Try "simplify <filepath> without changing behavior"',
54
60
  'Try "write tests for <filepath>"',
61
+ 'Try "trace this failure to its first bad state"',
62
+ 'Try "review this diff against <spec>"',
63
+ 'Try "explain why this test fails"',
64
+ 'Try "find the smallest safe fix for <issue>"',
65
+ 'Try "map the data flow through <feature>"',
66
+ 'Try "draft an implementation plan for <feature>"',
55
67
  ] as const;
56
68
 
57
69
  export function resolveGitBranch(cwd: string): Promise<string | undefined> {
@@ -216,6 +228,7 @@ function extractTerminalSequence(text: string, position: number): { code: string
216
228
  return undefined;
217
229
  }
218
230
 
231
+ /** Styles valid slash-command tokens while preserving embedded terminal control sequences. */
219
232
  export function highlightSlashCommands(
220
233
  line: string,
221
234
  isValidCommand: (name: string) => boolean,
@@ -1,5 +1,6 @@
1
1
  import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { SelectList, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { safeTerminalText } from "./safe-terminal-text.ts";
3
4
 
4
5
  export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
5
6
 
@@ -60,7 +61,7 @@ function supportedLevels(model: ExtensionContext["model"]): ThinkingLevel[] {
60
61
  }
61
62
 
62
63
  function modelLabel(model: ExtensionContext["model"]): string {
63
- return model ? `${model.provider}/${model.id}` : "unknown model";
64
+ return model ? safeTerminalText(`${model.provider}/${model.id}`).replaceAll("\n", "") : "unknown model";
64
65
  }
65
66
 
66
67
  export function registerVariants(pi: ExtensionAPI): void {
@@ -80,7 +81,7 @@ export function registerVariants(pi: ExtensionAPI): void {
80
81
  if (args.trim()) {
81
82
  const level = resolveThinkingLevel(args);
82
83
  if (!level) {
83
- ctx.ui.notify(`Unknown reasoning level "${args.trim()}". Use: ${ALL_LEVELS.join(", ")}`, "error");
84
+ ctx.ui.notify(`Unknown reasoning level "${safeTerminalText(args.trim()).replaceAll("\n", "")}". Use: ${ALL_LEVELS.join(", ")}`, "error");
84
85
  return;
85
86
  }
86
87
  setLevel(ctx, level);
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { StopReason } from "@earendil-works/pi-ai";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
+ import { formatTokens } from "./display.ts";
5
+ import { errorMessage } from "./errors.ts";
4
6
 
5
7
  const WORKED_FOR_ENTRY_TYPE = "killeros-worked-for";
6
8
 
@@ -17,7 +19,14 @@ interface WorkedForEntryDataV2 {
17
19
  outcome: WorkedForOutcome;
18
20
  }
19
21
 
20
- type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2;
22
+ interface WorkedForEntryDataV3 {
23
+ version: 3;
24
+ milliseconds: number;
25
+ outcome: WorkedForOutcome;
26
+ tokens: number;
27
+ }
28
+
29
+ type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2 | WorkedForEntryDataV3;
21
30
 
22
31
  const OUTCOMES = {
23
32
  done: { marker: "✓", label: "Done", color: "success" },
@@ -29,6 +38,23 @@ function isWorkedForOutcome(value: unknown): value is WorkedForOutcome {
29
38
  return value === "done" || value === "stopped" || value === "failed";
30
39
  }
31
40
 
41
+ function sessionTokenTotal(ctx: ExtensionContext): number | undefined {
42
+ try {
43
+ let total = 0;
44
+ for (const entry of ctx.sessionManager.getEntries()) {
45
+ const tokens = entry.type === "message" && (entry.message.role === "assistant" || entry.message.role === "toolResult")
46
+ ? entry.message.usage?.totalTokens
47
+ : (entry.type === "compaction" || entry.type === "branch_summary")
48
+ ? entry.usage?.totalTokens
49
+ : undefined;
50
+ if (typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) total += tokens;
51
+ }
52
+ return total;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
32
58
  export function formatWorkedForDuration(milliseconds: number): string {
33
59
  const boundedMilliseconds = Number.isFinite(milliseconds) ? Math.max(0, milliseconds) : 0;
34
60
  const totalSeconds = Math.max(1, Math.floor(boundedMilliseconds / 1_000));
@@ -49,10 +75,11 @@ function parseWorkedForEntryData(data: unknown): WorkedForEntryData | undefined
49
75
  return undefined;
50
76
  }
51
77
  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 };
78
+ if (!("outcome" in data) || !isWorkedForOutcome(data.outcome)) return undefined;
79
+ if (data.version === 2) return { version: 2, milliseconds: data.milliseconds, outcome: data.outcome };
80
+ if (data.version !== 3 || !("tokens" in data)
81
+ || typeof data.tokens !== "number" || !Number.isFinite(data.tokens) || data.tokens < 0) return undefined;
82
+ return { version: 3, milliseconds: data.milliseconds, outcome: data.outcome, tokens: data.tokens };
56
83
  }
57
84
 
58
85
  export function workedForOutcome(stopReason: StopReason | undefined): WorkedForOutcome {
@@ -61,15 +88,12 @@ export function workedForOutcome(stopReason: StopReason | undefined): WorkedForO
61
88
  return "failed";
62
89
  }
63
90
 
64
- function errorMessage(error: unknown): string {
65
- return error instanceof Error ? error.message : String(error);
66
- }
67
-
68
91
  export function registerWorkedFor(
69
92
  pi: ExtensionAPI,
70
93
  now: () => number = Date.now,
71
94
  ): void {
72
95
  let startedAt: number | undefined;
96
+ let startedTokens: number | undefined;
73
97
  let stopReason: StopReason | undefined;
74
98
 
75
99
  pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, _options, theme) => {
@@ -83,8 +107,9 @@ export function registerWorkedFor(
83
107
  );
84
108
  }
85
109
  const outcome = OUTCOMES[data.outcome];
110
+ const tokens = data.version === 3 ? ` · ↑ ${formatTokens(data.tokens)} tokens` : "";
86
111
  return new Text(
87
- `${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}`)}`,
112
+ `${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}${tokens}`)}`,
88
113
  0,
89
114
  0,
90
115
  );
@@ -92,12 +117,14 @@ export function registerWorkedFor(
92
117
 
93
118
  pi.on("session_start", () => {
94
119
  startedAt = undefined;
120
+ startedTokens = undefined;
95
121
  stopReason = undefined;
96
122
  });
97
123
 
98
124
  pi.on("agent_start", (_event, ctx) => {
99
125
  if (ctx.mode !== "tui" || startedAt !== undefined) return;
100
126
  startedAt = now();
127
+ startedTokens = sessionTokenTotal(ctx);
101
128
  });
102
129
 
103
130
  pi.on("agent_end", (event, ctx) => {
@@ -112,16 +139,21 @@ export function registerWorkedFor(
112
139
 
113
140
  pi.on("agent_settled", (_event, ctx) => {
114
141
  if (ctx.mode !== "tui" || startedAt === undefined) return;
115
- if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
116
142
  const milliseconds = Math.max(0, now() - startedAt);
143
+ const settledTokens = sessionTokenTotal(ctx);
144
+ const tokens = startedTokens === undefined || settledTokens === undefined
145
+ ? 0
146
+ : Math.max(0, settledTokens - startedTokens);
117
147
  const outcome = workedForOutcome(stopReason);
118
148
  startedAt = undefined;
149
+ startedTokens = undefined;
119
150
  stopReason = undefined;
120
151
  try {
121
- pi.appendEntry<WorkedForEntryDataV2>(WORKED_FOR_ENTRY_TYPE, {
122
- version: 2,
152
+ pi.appendEntry<WorkedForEntryDataV3>(WORKED_FOR_ENTRY_TYPE, {
153
+ version: 3,
123
154
  milliseconds,
124
155
  outcome,
156
+ tokens,
125
157
  });
126
158
  } catch (error) {
127
159
  ctx.ui.notify(`Worked-for timing could not be saved: ${errorMessage(error)}`, "error");
@@ -130,6 +162,7 @@ export function registerWorkedFor(
130
162
 
131
163
  pi.on("session_shutdown", () => {
132
164
  startedAt = undefined;
165
+ startedTokens = undefined;
133
166
  stopReason = undefined;
134
167
  });
135
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -42,15 +42,15 @@
42
42
  ]
43
43
  },
44
44
  "peerDependencies": {
45
- "@earendil-works/pi-ai": ">=0.84.2",
46
- "@earendil-works/pi-coding-agent": ">=0.84.2",
47
- "@earendil-works/pi-tui": ">=0.84.2",
45
+ "@earendil-works/pi-ai": ">=0.84.3",
46
+ "@earendil-works/pi-coding-agent": ">=0.84.3",
47
+ "@earendil-works/pi-tui": ">=0.84.3",
48
48
  "typebox": ">=1.1.38 <2"
49
49
  },
50
50
  "devDependencies": {
51
- "@earendil-works/pi-ai": "0.84.2",
52
- "@earendil-works/pi-coding-agent": "0.84.2",
53
- "@earendil-works/pi-tui": "0.84.2",
51
+ "@earendil-works/pi-ai": "0.84.3",
52
+ "@earendil-works/pi-coding-agent": "0.84.3",
53
+ "@earendil-works/pi-tui": "0.84.3",
54
54
  "@types/node": "24.12.4",
55
55
  "typebox": "1.1.38",
56
56
  "typescript": "5.9.3"
@@ -1 +0,0 @@
1
- export const MAX_NODE_TIMER_MS = 2_147_483_647;