killeros 2.0.17 → 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,44 @@ 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
+
7
45
  ## [2.0.17] - 2026-08-23
8
46
 
9
47
  ### Added
package/README.md CHANGED
@@ -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 release by appending its tag, for example `@v2.0.17`. 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
 
@@ -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,8 +1,9 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { safeTerminalText } from "./safe-terminal-text.ts";
2
3
 
3
4
  /** Converts unknown caught values into text suitable for user-facing errors. */
4
5
  export function errorMessage(error: unknown): string {
5
- return error instanceof Error ? error.message : String(error);
6
+ return safeTerminalText(error instanceof Error ? error.message : String(error));
6
7
  }
7
8
 
8
9
  export function reportError(ctx: ExtensionContext, area: string, error: unknown): void {
@@ -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
@@ -118,8 +118,9 @@ function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
118
118
  let artifact: ReturnType<typeof lstatSync>;
119
119
  try {
120
120
  artifact = lstatSync(filePath);
121
- } catch {
122
- return { exists: false };
121
+ } catch (error) {
122
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
123
+ throw error;
123
124
  }
124
125
  const baseline = { exists: true as const, size: artifact.size, mtimeMs: artifact.mtimeMs };
125
126
  if (!artifact.isFile()) return baseline;
@@ -382,7 +383,7 @@ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
382
383
  state.objective,
383
384
  ];
384
385
  if (state.result) lines.push(state.result);
385
- return lines.join("\n");
386
+ return safeTerminalText(lines.join("\n"));
386
387
  }
387
388
 
388
389
  export function pauseGoalAfterFailure(
@@ -394,13 +395,14 @@ export function pauseGoalAfterFailure(
394
395
  notify = true,
395
396
  ): void {
396
397
  if (runtime.state?.status !== "active") return;
398
+ const safeReason = safeTerminalText(reason);
397
399
  try {
398
- transitionGoal(pi, runtime, "error", "paused", reason);
400
+ transitionGoal(pi, runtime, "error", "paused", safeReason);
399
401
  } catch {
400
402
  runtime.state = runtime.state ? {
401
403
  ...stopGoalClock(runtime.state, Date.now()),
402
404
  status: "paused",
403
- result: reason,
405
+ result: safeReason,
404
406
  resumeAfterManualCompaction: undefined,
405
407
  } : undefined;
406
408
  syncGoalUpdateTool(pi, runtime);
@@ -409,7 +411,7 @@ export function pauseGoalAfterFailure(
409
411
  runtime.automaticCompaction = undefined;
410
412
  runtime.requestRender?.();
411
413
  }
412
- if (notify) ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
414
+ if (notify) ctx.ui.notify(`Goal paused: ${safeReason}\n${recoveryInstruction}`, "error");
413
415
  }
414
416
 
415
417
  function pauseGoalForPossibleManualCompaction(
@@ -419,15 +421,16 @@ function pauseGoalForPossibleManualCompaction(
419
421
  reason: string,
420
422
  ): void {
421
423
  if (runtime.state?.status !== "active") return;
424
+ const safeReason = safeTerminalText(reason);
422
425
  try {
423
- transitionGoal(pi, runtime, "error", "paused", reason, {
426
+ transitionGoal(pi, runtime, "error", "paused", safeReason, {
424
427
  resumeAfterManualCompaction: true,
425
428
  });
426
429
  } catch {
427
430
  runtime.state = runtime.state ? {
428
431
  ...stopGoalClock(runtime.state, Date.now()),
429
432
  status: "paused",
430
- result: reason,
433
+ result: safeReason,
431
434
  resumeAfterManualCompaction: true,
432
435
  } : undefined;
433
436
  syncGoalUpdateTool(pi, runtime);
@@ -587,7 +590,7 @@ function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): strin
587
590
  if (ctx.isProjectTrusted()) {
588
591
  const personal = resolvePersonalInstructions(ctx.cwd);
589
592
  if (personal) {
590
- sections.push(`<personal_instructions source=${JSON.stringify(personal.source)}>\n${personal.content}\n</personal_instructions>`);
593
+ sections.push(personal);
591
594
  }
592
595
  }
593
596
  return sections.join("\n\n");
@@ -793,7 +796,7 @@ export function registerGoal(
793
796
  const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
794
797
  const action = actions.find((candidate) => candidate.label === selected);
795
798
  if (!action) return;
796
- 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;
797
800
  await handleGoalCommand(action.control, ctx);
798
801
  return;
799
802
  }
@@ -878,7 +881,7 @@ export function registerGoal(
878
881
  transitionGoal(pi, runtime, "pause", "paused");
879
882
  saved = true;
880
883
  } catch (error) {
881
- 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)}`);
882
885
  pauseGoalAfterFailure(
883
886
  pi,
884
887
  runtime,
@@ -1038,21 +1041,21 @@ export function registerGoal(
1038
1041
  return;
1039
1042
  }
1040
1043
  const now = Date.now();
1041
- const state: GoalState = {
1042
- version: GOAL_VERSION,
1043
- revision: 1,
1044
- objective,
1045
- status: "active",
1046
- createdAt: now,
1047
- updatedAt: now,
1048
- activeMilliseconds: 0,
1049
- activeStartedAt: now,
1050
- turns: 0,
1051
- blockedAuditStartTurn: 0,
1052
- baselineTokens: sumGoalTokens(ctx),
1053
- verification: inferGoalVerification(objective),
1054
- };
1055
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
+ };
1056
1059
  persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
1057
1060
  if (scheduleGoalContinuation(pi, runtime, initState, ctx)) {
1058
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
  }
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";
@@ -124,8 +125,9 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
124
125
  executionMode: "sequential",
125
126
  async execute(_toolCallId, { reason }) {
126
127
  requirePending(initState);
127
- initState.outcome = { kind: "policy-conflict", reason };
128
- 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 } };
129
131
  },
130
132
  });
131
133
 
@@ -5,6 +5,7 @@ import {
5
5
  type ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { errorMessage } from "./errors.ts";
8
+ import { safeTerminalText } from "./safe-terminal-text.ts";
8
9
  import { createKillerosSettingsStore } from "./settings.ts";
9
10
 
10
11
  export interface NotificationPreferenceStore {
@@ -38,7 +39,7 @@ export function formatNotificationTitle(
38
39
  ): string {
39
40
  const directory = basename(cwd) || cwd;
40
41
  const base = sessionName ? `π - ${sessionName} - ${directory}` : `π - ${directory}`;
41
- return enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base;
42
+ return safeTerminalText(enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base).replaceAll("\n", "");
42
43
  }
43
44
 
44
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
  });
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.17",
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"