killeros 2.1.32 → 3.0.0

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,37 @@ All notable changes to KillerOS are documented here. Releases use New Features f
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.0.0] - 2026-10-01
8
+
9
+ ### Changed
10
+
11
+ - Raised the minimum supported Pi version and locked Pi development packages to 0.99.2, with matching CI and lockfile contracts. Users on older Pi versions must upgrade.
12
+
13
+ ### Added
14
+
15
+ - Added a Windows CI job on Node.js 22.19.0 that runs the full quality suite, with a repository contract protecting the job's required checks.
16
+
17
+ ### Fixed
18
+
19
+ - Canonicalized Windows short-path aliases in passive Git executable checks, preventing repository-local shims from being selected through paths such as `RUNNER~1` and fixing the Node.js 22.19.0 Windows CI failure. Removed temporary receipt debug logging.
20
+ - Applied `/codex-fast` priority requests and the footer's `fast` label to eligible native OpenAI Responses models at the official endpoint, for both API-key and ChatGPT subscription authentication. Legacy Codex behavior, command strings, and the process-local opt-in preference are unchanged; priority acceptance and billing remain OpenAI-controlled.
21
+ - Kept `question` and active `killeros_goal_update` directly declared in codemode-only sessions using Pi's native `model-only` exposure, which also prevents scripts and other tools from executing them. Goal activation and TUI-only question behavior are unchanged.
22
+ - Discarded pending change receipts after session replacement or reload and kept late activity and completion-sound handlers from using the old context, preventing stale-context extension errors during receipt finalization.
23
+ - Updated the `brace-expansion` override to patched 5.0.12, fixing recursion and quadratic-time denial of service in development and CI installs.
24
+ - Removed the stale `undici` override so development and CI installs use Pi's patched 8.10.2 dependency, resolving GHSA-3wwx-pv8p-q78v.
25
+ - Refreshed cached change-receipt baselines when `HEAD`, `core.filemode`, or `core.symlinks` changes between responses, while still rejecting `HEAD` changes made during a response.
26
+ - Made passive footer scans and change receipts honor `core.symlinks=false` placeholders and `core.filemode=false` without masking file-type changes.
27
+ - Preserved parent traversal in relative goal paths so directory links cannot redirect file proof to a lexically normalized target or collapse distinct targets.
28
+ - Made the Windows hook cleanup test wait for its child to start and verify that both processes terminate, avoiding false failures when `taskkill` cannot confirm an already-finished cleanup.
29
+ - Showed stable provider-reported model IDs on settled receipts when they differ from the selected alias, including GitHub Copilot Claude models.
30
+ - Reported CRLF worktree content as modified when indexed attributes require `eol=lf`, matching Git in footer counts and change receipts.
31
+ - Kept quoted prose goal destinations on model-reported completion; inferred file proof now requires a path-shaped destination.
32
+ - Isolated passive footer, change-receipt, and startup branch Git scans from inherited repository-selection environment variables.
33
+ - Rejected passive Git executables reached through repository-owned directory links while continuing to safe later `PATH` entries.
34
+ - Prevented single-file goal proof from masking unquoted `Makefile`, `Dockerfile`, or `.gitignore` list items.
35
+ - Prevented single-file goal proof from overlooking additional extensionless targets extracted by goal action and destination clauses. Equivalent path spellings still share one exact-path verification contract.
36
+ - Prevented single-file goal proof from masking additional filenames that extend version or Node.js prose. Goal parsing now keeps spaces and trailing punctuation in quoted paths and matches exact Node.js prose case-insensitively.
37
+
7
38
  ## [2.1.32] - 2026-09-24
8
39
 
9
40
  ### Fixed
package/README.md CHANGED
@@ -2,11 +2,13 @@
2
2
 
3
3
  A TypeScript extension for the [Pi coding agent](https://github.com/earendil-works/pi) that replaces the stock TUI and adds long-running goals, reasoning controls, and workflow commands.
4
4
 
5
+ ![KillerOS TUI showing the startup masthead, an active goal, a settled task receipt, and the goal footer](.github/assets/killeros-preview.png)
6
+
5
7
  ## What you get
6
8
 
7
9
  - A custom TUI: startup masthead with versions, model, working directory, and Git branch; a dark theme with coral accents; a multiline editor with slash-command completion; a footer that tracks model, context, and goal state; settled task receipts with duration and token usage.
8
10
  - `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. Each turn must record `continue` with evidence and one next action, `complete`, or the existing blocker decision; otherwise the goal pauses. New goals pause after 20 turns; `/goal resume` grants another 20.
9
- - `/codex-fast`: toggles the `priority` service tier on Codex requests or reports its status.
11
+ - `/codex-fast`: toggles the `priority` service tier on legacy Codex and eligible native OpenAI Responses requests or reports its status.
10
12
  - `/handoff`: starts a fresh linked session carrying visible continuation context.
11
13
  - `/auto-compact`: reports, enables, disables, or tunes automatic context compaction.
12
14
  - A `question` tool with single-select and multi-select modes.
@@ -17,7 +19,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
17
19
  ## Requirements
18
20
 
19
21
  - Node.js 22.19.0+
20
- - Pi 0.85.0 or later within the 0.x release line
22
+ - Pi 0.99.2 or later within the 0.x release line
21
23
  - An interactive TUI session for the custom header, editor, footer, and `question`
22
24
 
23
25
  ## Install
@@ -32,7 +34,7 @@ Or from GitHub:
32
34
  pi install git:github.com/KyrosHendrix/pi-KillerOS
33
35
  ```
34
36
 
35
- Pin a release by appending its tag, for example `@v2.1.32`. Add `-l` to install only for the current project. Restart Pi after installing.
37
+ Pin a release by appending its tag, for example `@v3.0.0`. Add `-l` to install only for the current project. Restart Pi after installing.
36
38
 
37
39
  ## Commands
38
40
 
@@ -51,6 +53,12 @@ Pin a release by appending its tag, for example `@v2.1.32`. Add `-l` to install
51
53
  /exit Quit Pi gracefully
52
54
  ```
53
55
 
56
+ Fast mode is off by default. `/codex-fast` toggles it, and `/codex-fast status` reports the preference without changing it. The preference stays in the current Pi process across model switches and extension reloads, but is not saved between processes. The command name and notification strings retain their legacy Codex wording.
57
+
58
+ Fast mode applies to legacy `openai-codex` requests and selected native models with provider `openai`, API `openai-responses`, and base URL `https://api.openai.com/v1`, with an optional trailing slash. Native request payloads must also name the selected model's exact ID. Both API-key and ChatGPT subscription authentication use this rule. Custom endpoints, Azure, other APIs, and ambiguous native requests pass through unchanged. When enabled, fast mode overrides an eligible request's existing service tier with `priority`; when disabled, it leaves the request unchanged.
59
+
60
+ The TUI footer's `fast` label means the enabled preference applies to the selected model, not that OpenAI accepted priority service. OpenAI controls account and model eligibility, the actual service tier, and charges. Priority can affect billing; subscription login does not guarantee accepted or free priority service. KillerOS leaves provider errors to Pi and does not silently retry with a different tier.
61
+
54
62
  ## Behavior by mode
55
63
 
56
64
  | Mode | What works |
@@ -59,6 +67,8 @@ Pin a release by appending its tag, for example `@v2.1.32`. Add `-l` to install
59
67
  | RPC | Goals, proactive compaction; no TUI components, sounds, title indicator |
60
68
  | Print/JSON | No interactive questions, `/goal`, or proactive compaction |
61
69
 
70
+ `question` and the active `killeros_goal_update` tool use Pi's `model-only` exposure. They stay directly declared to the model when codemode is disabled or enabled in `on` or `only` mode, but scripts and other tools cannot call them through `ctx.executeTool()`. The goal tool remains inactive without an active goal. Questions still require TUI mode; a direct RPC call fails with `The question tool requires interactive TUI mode`. KillerOS does not enable or configure codemode.
71
+
62
72
  ## Configuration
63
73
 
64
74
  The packaged `killeros` theme activates on TUI start. Compaction triggers by default at 15% tokens remaining, stored in global `killeros.json`:
@@ -83,13 +93,15 @@ State proof in the objective so the agent can verify it with its normal tools:
83
93
  /goal Reduce p95 checkout latency below 120 ms, verified by the checkout benchmark, while keeping the correctness suite green
84
94
  ```
85
95
 
86
- A direct quoted file target binds silent file proof:
96
+ A direct quoted path-shaped file target binds silent file proof. For an extensionless relative file, use an explicit path such as `./summary` or `.\summary`:
87
97
 
88
98
  ```text
89
99
  /goal Fix `killeros/footer.ts`, verified by npm test
90
100
  ```
91
101
 
92
- KillerOS captures the file baseline at goal start and only completes when the file is created or changed. A normal response never continues a goal by itself: the agent must record `continue`, `complete`, or a blocker decision through `killeros_goal_update`. Repeated continuation reports and unavailable goal tools pause the goal. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Restored goals keep their persisted limit.
102
+ Bare quoted prose remains model-reported. KillerOS captures the file baseline at goal start and only completes when the file is created or changed. A normal response never continues a goal by itself: the agent must record `continue`, `complete`, or a blocker decision through `killeros_goal_update`. Repeated continuation reports and unavailable goal tools pause the goal. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Restored goals keep their persisted limit.
103
+
104
+ Session replacement and reload discard unfinished task receipts. Late receipt results do not write or notify through the old session context.
93
105
 
94
106
  Completion sounds are off by default; change with `/notification` in TUI mode. The tab-title indicator requires a Nerd Font.
95
107
 
@@ -102,7 +102,7 @@ export function registerRequestActivity(pi: ExtensionAPI): void {
102
102
  });
103
103
 
104
104
  pi.on("agent_settled", (_event, ctx) => {
105
- if (ctx.mode !== "tui" || !active || !ctx.isIdle?.() || ctx.hasPendingMessages?.()) return;
105
+ if (!active || ctx.mode !== "tui" || !ctx.isIdle?.() || ctx.hasPendingMessages?.()) return;
106
106
  clear(ctx);
107
107
  });
108
108
 
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createReadStream, watch } from "node:fs";
3
- import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, type PassiveWorktreeFile as DirtyFile } from "./passive-git-status.ts";
3
+ import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, passiveWorktreeMode, passiveWorktreeSettings, type PassiveWorktreeFile as DirtyFile, type PassiveWorktreeSnapshot } from "./passive-git-status.ts";
4
4
  import { lstat, open, readlink } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { createInflate } from "node:zlib";
@@ -176,7 +176,7 @@ async function readBoundedFile(filePath: string, limit: number): Promise<Buffer>
176
176
  }
177
177
  }
178
178
 
179
- type Snapshot = { head: string; files: Map<string, DirtyFile> };
179
+ type Snapshot = PassiveWorktreeSnapshot;
180
180
  type RepositoryMonitor = {
181
181
  changedPaths: Set<string>;
182
182
  inUse: boolean;
@@ -218,7 +218,11 @@ async function snapshotKnownPaths(repo: Repository, baseline: Snapshot, paths: r
218
218
  try {
219
219
  const absolutePath = path.join(repo.root, ...filePath.split("/"));
220
220
  const stats = await lstat(absolutePath);
221
- const mode = stats.isSymbolicLink() ? "120000" : stats.mode & 0o111 ? "100755" : "100644";
221
+ const mode = passiveWorktreeMode(
222
+ stats.isSymbolicLink() ? "120000" : stats.mode & 0o111 ? "100755" : "100644",
223
+ previous.indexMode,
224
+ baseline,
225
+ );
222
226
  const content = stats.isSymbolicLink()
223
227
  ? Buffer.from(await readlink(absolutePath))
224
228
  : await readBoundedFile(absolutePath, SNAPSHOT_CONTENT_LIMIT - totalBytes);
@@ -230,7 +234,7 @@ async function snapshotKnownPaths(repo: Repository, baseline: Snapshot, paths: r
230
234
  files.set(filePath, { ...previous, mode: undefined, content: undefined });
231
235
  }
232
236
  }
233
- return { head: baseline.head, files };
237
+ return { ...baseline, files };
234
238
  }
235
239
 
236
240
  async function currentHead(repo: Repository): Promise<string> {
@@ -262,7 +266,7 @@ async function refreshSnapshot(monitor: RepositoryMonitor, observedPaths: readon
262
266
  if (observes(filePath, observedPaths)) files.delete(filePath);
263
267
  }
264
268
  for (const [filePath, file] of scoped.files) files.set(filePath, file);
265
- return { head: scoped.head, files };
269
+ return { ...scoped, files };
266
270
  }
267
271
 
268
272
  function createMonitor(repo: Repository, initialSnapshot: Snapshot): RepositoryMonitor {
@@ -626,7 +630,14 @@ export async function beginChangeReceipt(cwd: string, trusted = true): Promise<C
626
630
  const idleChanges = [...monitor.changedPaths];
627
631
  monitor.changedPaths.clear();
628
632
  try {
629
- monitor.snapshot = await refreshSnapshot(monitor, idleChanges);
633
+ const [head, settings] = await Promise.all([
634
+ currentHead(repo),
635
+ passiveWorktreeSettings((args) => runGit(repo.root, args)),
636
+ ]);
637
+ monitor.snapshot = head !== monitor.snapshot.head
638
+ || settings.fileMode !== monitor.snapshot.fileMode || settings.symlinks !== monitor.snapshot.symlinks
639
+ ? await snapshot(repo)
640
+ : await refreshSnapshot(monitor, idleChanges);
630
641
  } catch (error) {
631
642
  discardMonitor(monitor);
632
643
  throw error;
@@ -1,3 +1,5 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
1
3
  type CodexFastStateListener = () => void;
2
4
 
3
5
  interface CodexFastState {
@@ -25,6 +27,12 @@ const state: CodexFastState = isCodexFastState(savedState) ? savedState : {
25
27
  };
26
28
  globalThis.__killerosCodexFastState = state;
27
29
 
30
+ export function isCodexFastModel(model: ExtensionContext["model"]): boolean {
31
+ return model?.provider === "openai-codex"
32
+ || (model?.provider === "openai" && model.api === "openai-responses"
33
+ && (model.baseUrl === "https://api.openai.com/v1" || model.baseUrl === "https://api.openai.com/v1/"));
34
+ }
35
+
28
36
  export function isCodexFastEnabled(): boolean {
29
37
  return state.enabled;
30
38
  }
@@ -1,7 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { isCodexFastEnabled, toggleCodexFast } from "./codex-fast-state.ts";
2
+ import { isCodexFastEnabled, isCodexFastModel, toggleCodexFast } from "./codex-fast-state.ts";
3
3
 
4
- const CODEX_PROVIDER = "openai-codex";
5
4
  type RequestPayload = Record<string, unknown>;
6
5
 
7
6
  function isRequestPayload(value: unknown): value is RequestPayload {
@@ -28,9 +27,11 @@ export function registerCodexFastMode(pi: ExtensionAPI): void {
28
27
  });
29
28
 
30
29
  pi.on("before_provider_request", (event, ctx) => {
31
- if (!isCodexFastEnabled() || ctx.model?.provider !== CODEX_PROVIDER || !isRequestPayload(event.payload)) {
30
+ if (!isCodexFastEnabled() || !isCodexFastModel(ctx.model) || !isRequestPayload(event.payload)) {
32
31
  return event.payload;
33
32
  }
33
+ // The hook exposes no dispatched provider/API. Native requests must match the selected model.
34
+ if (ctx.model?.provider === "openai" && event.payload.model !== ctx.model.id) return event.payload;
34
35
  return { ...event.payload, service_tier: "priority" };
35
36
  });
36
37
  }
@@ -3,7 +3,7 @@ import { watch } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
5
5
  import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
6
- import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
6
+ import { isCodexFastEnabled, isCodexFastModel, subscribeCodexFast } from "./codex-fast-state.ts";
7
7
  import { formatCwd, formatTime, modelDisplayName, padRight } from "./display.ts";
8
8
  import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, type PassiveGitRunner } from "./passive-git-status.ts";
9
9
  import { goalElapsedMilliseconds } from "./goal-state.ts";
@@ -15,7 +15,6 @@ const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
15
15
  const GIT_STATUS_TIMEOUT_MS = 5_000;
16
16
  const GIT_STATUS_WATCH_DEBOUNCE_MS = 250;
17
17
  const GIT_STATUS_WATCH_INTERVAL_MS = 5_000;
18
- const CODEX_PROVIDER = "openai-codex";
19
18
  export const colorDirectory = (text: string): string => `\x1B[38;2;240;248;154m${text}\x1B[39m`;
20
19
 
21
20
  export interface GitFileChanges {
@@ -309,7 +308,7 @@ export function formatModel(
309
308
  if (!model) return theme.fg("dim", "no model");
310
309
  const displayName = modelDisplayName(model) || "unknown model";
311
310
  const name = theme.fg("text", displayName);
312
- const fast = showCodexFast && model.provider === CODEX_PROVIDER ? theme.fg("text", "fast") : "";
311
+ const fast = showCodexFast && isCodexFastModel(model) ? theme.fg("text", "fast") : "";
313
312
  const provider = includeProvider ? theme.fg("dim", formatProviderName(model.provider)) : "";
314
313
  return [name, fast, provider].filter(Boolean).join(" ");
315
314
  }
@@ -150,6 +150,7 @@ export function registerGoalInterface(
150
150
  label: "Goal update",
151
151
  description: "Record exactly one active-goal decision: complete after verification, continue with evidence and one next action, or audit the same blocker before blocking it.",
152
152
  parameters: GoalUpdateParams,
153
+ exposure: "model-only",
153
154
  executionMode: "sequential",
154
155
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
155
156
  signal?.throwIfAborted();
@@ -144,11 +144,19 @@ function isAbsoluteFilePath(value: string): boolean {
144
144
  }
145
145
 
146
146
  function stripUnquotedPathPunctuation(value: string): string {
147
- const pathWithoutMarks = value.replace(/[.!?]+$/u, "");
148
- const trailingClosers = pathWithoutMarks.match(/\)+$/u)?.[0].length ?? 0;
149
- const unmatchedClosers = Math.max(0, pathWithoutMarks.split(")").length - pathWithoutMarks.split("(").length);
150
- const punctuationLength = Math.min(trailingClosers, unmatchedClosers);
151
- return pathWithoutMarks.slice(0, punctuationLength ? -punctuationLength : undefined);
147
+ let stripped = value;
148
+ while (true) {
149
+ const withoutMarks = stripped.replace(/[.,;:!?]+$/u, "");
150
+ if (withoutMarks !== stripped) {
151
+ stripped = withoutMarks;
152
+ continue;
153
+ }
154
+ const closer = stripped.at(-1);
155
+ const opener = closer === ")" ? "(" : closer === "]" ? "[" : closer === "}" ? "{" : undefined;
156
+ if (closer === undefined || opener === undefined
157
+ || stripped.split(closer).length <= stripped.split(opener).length) return stripped;
158
+ stripped = stripped.slice(0, -1);
159
+ }
152
160
  }
153
161
 
154
162
  function isGoalFileVerification(value: unknown): value is GoalFileVerification {
@@ -348,41 +356,80 @@ export async function captureGoalFileBaseline(
348
356
  const GOAL_URL_PATTERN = /(?:https?|file):\/\/[^\s"'`]+/giu;
349
357
  const GOAL_QUOTED_PATTERN = /`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'/gu;
350
358
  const GOAL_ABSOLUTE_PATTERN = /(?:^|[\s"'`(\[{,;])([A-Za-z]:[\\/][^\s,;'"`]+|\/[^\s,;'"`]+)/gu;
351
- const GOAL_LIST_FILE_PATTERN = /(?:\band\b|\bor\b|[,;&+])\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z0-9_][A-Za-z0-9_.-]*(?:[\\/][A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12})\b)/giu;
359
+ const GOAL_LIST_FILE_PATTERN = /(?:\band\b|\bor\b|\bplus\b|\balong\s+with\b|[,;&+])\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|((?:\.{1,2}[\\/])*[A-Za-z0-9_][A-Za-z0-9_.-]*(?:[\\/][A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12})\b|(Makefile|Dockerfile|\.gitignore)\b)/giu;
352
360
  const GOAL_RELATIVE_FILE_PATTERN = /(?:^|[\s"'`(\[{,;])((?:\.{1,2}[\\/])*[A-Za-z0-9_][A-Za-z0-9_.-]*(?:[\\/][A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12})\b/gu;
353
361
 
354
362
  function isQuotedGoalFileMention(raw: string): boolean {
355
363
  const value = raw.trim();
356
- if (!value || /[\s\p{Cc}]/u.test(value) || /^(?:https?|file):\/\//iu.test(value) || /[\\/]$/u.test(value)) return false;
364
+ if (!value || /\p{Cc}/u.test(value) || /^(?:https?|file):\/\//iu.test(value) || /[\\/]$/u.test(value)) return false;
357
365
  return /[\/\\.:]/u.test(value);
358
366
  }
359
367
 
360
- function normalizeGoalFileMention(raw: string, cwd: string): string | undefined {
361
- const value = raw.trim().replace(/[.,;:!?)\]}]+$/u, "");
362
- if (!value || /[\s\p{Cc}]/u.test(value)) return undefined;
363
- const absolute = path.isAbsolute(value) || path.win32.isAbsolute(value) ? value : path.resolve(cwd, value);
368
+ function isPathShapedGoalCandidate(raw: string): boolean {
369
+ if (/\p{Cc}/u.test(raw)) return false;
370
+ const value = raw.trim();
371
+ if (!value || /[\\\/]$/u.test(value)) return false;
372
+ if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return true;
373
+ if (URL.canParse(value)) return false;
374
+ return /[\\\/]/u.test(value)
375
+ || value.startsWith(".") && value !== "." && value !== ".."
376
+ || /\.[A-Za-z0-9]{1,12}$/u.test(value);
377
+ }
378
+
379
+ function goalPathIdentity(filePath: string): string {
380
+ const nativePath = process.platform === "win32" ? filePath.replaceAll("/", "\\") : filePath;
381
+ // Parent segments may traverse symlinks; keep them distinct without filesystem proof.
382
+ const normalized = nativePath.split(path.sep).includes("..") ? nativePath : path.normalize(nativePath);
383
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
384
+ }
385
+
386
+ function absoluteGoalFilePath(value: string, cwd: string): string {
387
+ if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return value;
388
+ const nativePath = process.platform === "win32" ? value.replaceAll("/", "\\") : value;
389
+ // Resolving parent segments lexically can select a different file across a directory symlink.
390
+ return nativePath.split(path.sep).includes("..")
391
+ ? `${path.resolve(cwd)}${path.sep}${nativePath}`
392
+ : path.resolve(cwd, value);
393
+ }
394
+
395
+ function normalizeGoalFileMention(raw: string, cwd: string, quoted = false): string | undefined {
396
+ const trimmed = raw.trim();
397
+ const value = quoted ? trimmed : stripUnquotedPathPunctuation(trimmed);
398
+ if (!value || /\p{Cc}/u.test(value) || !quoted && /\s/u.test(value)) return undefined;
399
+ const absolute = absoluteGoalFilePath(value, cwd);
364
400
  if (!isAbsoluteFilePath(absolute)) return undefined;
365
- return process.platform === "win32" ? absolute.toLowerCase() : absolute;
401
+ return goalPathIdentity(absolute);
366
402
  }
367
403
 
368
404
  /** Counts distinct path-shaped mentions so several files cannot bind proof to one. */
369
- function countGoalTargetFiles(objective: string, cwd: string, target: string): number {
370
- const prose = objective.replace(GOAL_URL_PATTERN, " ");
405
+ function countGoalTargetFiles(objective: string, cwd: string, target: string, targetQuoted: boolean): number {
406
+ const source = objective.replace(GOAL_URL_PATTERN, " ");
407
+ // ponytail: Only these exact prose forms are excluded; quote ambiguous bare filenames for exact proof.
408
+ const prose = source.replace(GOAL_QUOTED_PATTERN, " ")
409
+ .replace(/(?<![A-Za-z0-9_.\\/-])version\s+\d+(?:\.\d+)+(?=$|[\s,;:!?)\]}]|\.(?=$|[\s,;:!?)\]}]))/giu, " ")
410
+ .replace(/(?<![A-Za-z0-9_.\\/-])for\s+Node\.js(?!\s+compatibility\.[A-Za-z0-9])(?=$|[\s,;:!?)\]}]|\.(?=$|[\s,;:!?)\]}]))|(?<![A-Za-z0-9_.\\/-])Node\.js\s+compatibility(?=$|[\s,;:!?)\]}]|\.(?=$|[\s,;:!?)\]}]))/giu, " ");
371
411
  const mentions = new Set<string>();
372
- const add = (raw: string): void => {
373
- const normalized = normalizeGoalFileMention(raw, cwd);
412
+ const add = (raw: string, quoted = false): void => {
413
+ const normalized = normalizeGoalFileMention(raw, cwd, quoted);
374
414
  if (normalized) mentions.add(normalized);
375
415
  };
376
- add(target);
377
- for (const match of prose.matchAll(GOAL_QUOTED_PATTERN)) {
416
+ add(target, targetQuoted);
417
+ for (const match of source.matchAll(GOAL_QUOTED_PATTERN)) {
378
418
  const raw = match[1] ?? match[2] ?? match[3] ?? "";
379
- if (isQuotedGoalFileMention(raw)) add(raw);
419
+ if (isQuotedGoalFileMention(raw)) add(raw, true);
420
+ }
421
+ for (const match of source.matchAll(GOAL_LIST_FILE_PATTERN)) {
422
+ const quoted = match[1] ?? match[2] ?? match[3];
423
+ if (quoted !== undefined) add(quoted, true);
380
424
  }
381
425
  for (const match of prose.matchAll(GOAL_ABSOLUTE_PATTERN)) {
382
426
  if (match[1]) add(stripUnquotedPathPunctuation(match[1].trim()));
383
427
  }
384
- for (const match of prose.matchAll(GOAL_LIST_FILE_PATTERN)) add(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "");
385
- for (const match of prose.replace(GOAL_QUOTED_PATTERN, " ").matchAll(GOAL_RELATIVE_FILE_PATTERN)) {
428
+ for (const match of prose.matchAll(GOAL_LIST_FILE_PATTERN)) {
429
+ if (match[4]) add(match[4]);
430
+ if (match[5]) add(match[5]);
431
+ }
432
+ for (const match of prose.matchAll(GOAL_RELATIVE_FILE_PATTERN)) {
386
433
  if (match[1]) add(match[1]);
387
434
  }
388
435
  return mentions.size;
@@ -390,30 +437,43 @@ function countGoalTargetFiles(objective: string, cwd: string, target: string): n
390
437
 
391
438
  /** Captures one explicit output path so goal completion can verify its creation or modification. */
392
439
  export async function inferGoalVerification(objective: string, cwd: string): Promise<GoalFileVerification | undefined> {
393
- const candidates: string[] = [];
440
+ const candidates: Array<{ raw: string; quoted: boolean }> = [];
394
441
  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;
395
442
  for (const match of objective.matchAll(destination)) {
396
443
  const quoted = match[1] ?? match[2] ?? match[3];
397
- candidates.push(quoted !== undefined ? quoted.trim() : stripUnquotedPathPunctuation((match[4] ?? "").trim()));
444
+ candidates.push({
445
+ raw: quoted !== undefined ? quoted : stripUnquotedPathPunctuation((match[4] ?? "").trim()),
446
+ quoted: quoted !== undefined,
447
+ });
398
448
  }
399
449
  const direct = /\b(?:update|edit|fix|refactor|migrate)\s+(`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)')/giu;
400
450
  for (const match of objective.matchAll(direct)) {
401
451
  const quoted = match[2] ?? match[3] ?? match[4];
402
- if (quoted !== undefined) candidates.push(quoted.trim());
452
+ if (quoted !== undefined) candidates.push({ raw: quoted, quoted: true });
403
453
  }
404
- const resolved: string[] = [];
405
- for (const raw of candidates) {
406
- if (!raw || /^(?:https?|file):\/\//iu.test(raw) || /[\\\/]$/u.test(raw)) continue;
407
- const absolute = path.isAbsolute(raw) || path.win32.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
408
- if (isAbsoluteFilePath(absolute)) resolved.push(absolute);
454
+ const operands = new Set<string>();
455
+ const resolved = new Map<string, { filePath: string; quoted: boolean }>();
456
+ for (const candidate of candidates) {
457
+ const value = candidate.raw.trim();
458
+ if (!value || /\p{Cc}/u.test(value)) continue;
459
+ const alreadyAbsolute = path.isAbsolute(value) || path.win32.isAbsolute(value);
460
+ if (!alreadyAbsolute && URL.canParse(value)) continue;
461
+ const absolute = absoluteGoalFilePath(value, cwd);
462
+ const identity = goalPathIdentity(absolute);
463
+ operands.add(identity);
464
+ if (!isPathShapedGoalCandidate(candidate.raw) || !isAbsoluteFilePath(absolute)) continue;
465
+ const existing = resolved.get(identity);
466
+ resolved.set(identity, {
467
+ filePath: existing?.filePath ?? absolute,
468
+ quoted: candidate.quoted || existing?.quoted === true,
469
+ });
409
470
  }
410
- const unique = [...new Set(resolved)];
411
- if (unique.length !== 1) return undefined;
471
+ if (operands.size !== 1 || resolved.size !== 1) return undefined;
412
472
  // A goal that names several files must not verify from one file alone.
413
473
  // Repeated references to the same file count as one target.
414
- const filePath = unique[0];
415
- if (countGoalTargetFiles(objective, cwd, filePath) > 1) return undefined;
416
- return { kind: "file", path: filePath, baseline: await captureGoalFileBaseline(filePath) };
474
+ const target = resolved.values().next().value;
475
+ if (target === undefined || countGoalTargetFiles(objective, cwd, target.filePath, target.quoted) > 1) return undefined;
476
+ return { kind: "file", path: target.filePath, baseline: await captureGoalFileBaseline(target.filePath) };
417
477
  }
418
478
 
419
479
  export async function verifyGoalDeliverable(verification: GoalFileVerification): Promise<void> {
@@ -117,7 +117,7 @@ export function registerCompletionNotifications(
117
117
  });
118
118
 
119
119
  pi.on("agent_settled", (_event, ctx) => {
120
- if (ctx.mode !== "tui" || !requestPending) return;
120
+ if (!requestPending || ctx.mode !== "tui") return;
121
121
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
122
122
  requestPending = false;
123
123
  if (!enabled || lastStopReason === "aborted") return;
@@ -22,8 +22,30 @@ export type PassiveWorktreeFile = {
22
22
  export type PassiveWorktreeSnapshot = {
23
23
  head: string;
24
24
  files: Map<string, PassiveWorktreeFile>;
25
+ fileMode: boolean;
26
+ symlinks: boolean;
25
27
  };
26
28
 
29
+ export async function passiveWorktreeSettings(runGit: PassiveGitRunner): Promise<Pick<PassiveWorktreeSnapshot, "fileMode" | "symlinks">> {
30
+ const [fileMode, symlinks] = await Promise.all([
31
+ runGit(["config", "--type=bool", "--default", "true", "--get", "core.filemode"]),
32
+ runGit(["config", "--type=bool", "--default", "true", "--get", "core.symlinks"]),
33
+ ]);
34
+ return { fileMode: decode(fileMode).trim() === "true", symlinks: decode(symlinks).trim() === "true" };
35
+ }
36
+
37
+ export function passiveWorktreeMode(
38
+ actual: string,
39
+ indexed: string | undefined,
40
+ settings: Pick<PassiveWorktreeSnapshot, "fileMode" | "symlinks">,
41
+ ): string {
42
+ if (actual === "100644" || actual === "100755") {
43
+ if (indexed === "120000" && !settings.symlinks) return indexed;
44
+ if ((indexed === "100644" || indexed === "100755") && !settings.fileMode) return indexed;
45
+ }
46
+ return actual;
47
+ }
48
+
27
49
  type IndexEntry = {
28
50
  mode: string;
29
51
  objectId: string;
@@ -77,7 +99,7 @@ function inspectedRoot(cwd: string): string | undefined {
77
99
  }
78
100
  let base: string;
79
101
  try {
80
- base = realpathSync(start);
102
+ base = realpathSync.native(start);
81
103
  } catch {
82
104
  base = start;
83
105
  }
@@ -86,7 +108,7 @@ function inspectedRoot(cwd: string): string | undefined {
86
108
  try {
87
109
  if (existsSync(path.join(current, ".git"))) {
88
110
  try {
89
- return realpathSync(current);
111
+ return realpathSync.native(current);
90
112
  } catch {
91
113
  return current;
92
114
  }
@@ -120,6 +142,20 @@ function insideInspected(candidate: string, root: string): boolean {
120
142
  return normalizedCandidate.startsWith(`${trimmed}${path.sep}`);
121
143
  }
122
144
 
145
+ function traversesInspected(candidate: string, root: string): boolean {
146
+ let current = path.dirname(candidate);
147
+ for (;;) {
148
+ try {
149
+ if (insideInspected(realpathSync.native(current), root)) return true;
150
+ } catch {
151
+ return true;
152
+ }
153
+ const parent = path.dirname(current);
154
+ if (parent === current) return false;
155
+ current = parent;
156
+ }
157
+ }
158
+
123
159
  /** Returns an absolute Git binary outside the inspected repository without starting a locator process. */
124
160
  export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
125
161
  const root = inspectedRoot(cwd);
@@ -134,6 +170,7 @@ export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.
134
170
  if (directory === "" || directory.trim() === "" || !path.isAbsolute(directory)) continue;
135
171
  for (const base of baseNames) {
136
172
  const candidate = path.join(directory, base);
173
+ if (insideInspected(candidate, root)) continue;
137
174
  try {
138
175
  if (!statSync(candidate).isFile()) continue;
139
176
  } catch {
@@ -148,27 +185,45 @@ export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.
148
185
  }
149
186
  let resolved: string;
150
187
  try {
151
- resolved = realpathSync(candidate);
188
+ resolved = realpathSync.native(candidate);
152
189
  } catch {
153
190
  continue;
154
191
  }
155
- if (path.isAbsolute(resolved) && !insideInspected(resolved, root)) return resolved;
192
+ if (path.isAbsolute(resolved) && !insideInspected(resolved, root) && !traversesInspected(candidate, root)) return resolved;
156
193
  }
157
194
  }
158
195
  return undefined;
159
196
  }
160
197
 
161
- /** Environment for automatic Git children, with locks, lazy fetches, and PATH command resolution disabled. */
198
+ /** Environment for automatic Git children, without inherited repository selection or PATH command resolution. */
162
199
  export function passiveGitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
163
- const env: NodeJS.ProcessEnv = { ...base, GIT_OPTIONAL_LOCKS: "0", GIT_NO_LAZY_FETCH: "1" };
200
+ const env: NodeJS.ProcessEnv = { ...base };
201
+ const selectors = [
202
+ "GIT_DIR",
203
+ "GIT_CEILING_DIRECTORIES",
204
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM",
205
+ "GIT_WORK_TREE",
206
+ "GIT_INDEX_FILE",
207
+ "GIT_OBJECT_DIRECTORY",
208
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
209
+ "GIT_COMMON_DIR",
210
+ "GIT_NAMESPACE",
211
+ ];
164
212
  let hasPath = false;
165
213
  for (const key of Object.keys(env)) {
166
- if (key.toLowerCase() === "path") {
214
+ const normalized = key.toUpperCase();
215
+ if (selectors.includes(normalized)
216
+ || normalized === "GIT_OPTIONAL_LOCKS"
217
+ || normalized === "GIT_NO_LAZY_FETCH") {
218
+ delete env[key];
219
+ } else if (normalized === "PATH") {
167
220
  env[key] = "";
168
221
  hasPath = true;
169
222
  }
170
223
  }
171
224
  if (!hasPath) env.PATH = "";
225
+ env.GIT_OPTIONAL_LOCKS = "0";
226
+ env.GIT_NO_LAZY_FETCH = "1";
172
227
  return env;
173
228
  }
174
229
 
@@ -263,7 +318,7 @@ function matchesIndex(content: Buffer, entry: IndexEntry, autoCrlf: string): boo
263
318
  if (blobObjectId(content, entry.objectId.length) === entry.objectId) return true;
264
319
  if (entry.mode === "120000" || !content.includes(Buffer.from("\r\n"))) return false;
265
320
  const attributes = entry.eolAttributes;
266
- if (attributes === "-text") return false;
321
+ if (attributes.endsWith("eol=lf") || attributes === "-text") return false;
267
322
  const automatic = attributes.startsWith("text=auto") || !attributes;
268
323
  if (!attributes && !["true", "input"].includes(autoCrlf)) return false;
269
324
  if (automatic) {
@@ -288,12 +343,13 @@ function matchesIndex(content: Buffer, entry: IndexEntry, autoCrlf: string): boo
288
343
  * No Git command in this path asks Git to convert worktree content, so clean and process filters cannot start.
289
344
  */
290
345
  export async function inspectWorktreeWithoutFilters(root: string, runGit: PassiveGitRunner): Promise<PassiveWorktreeSnapshot> {
291
- const [indexOutput, untrackedOutput, resolvedHead, autoCrlfOutput] = await Promise.all([
346
+ const [indexOutput, untrackedOutput, resolvedHead, autoCrlfOutput, settings] = await Promise.all([
292
347
  runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--cached", "--debug", "-z", "--full-name",
293
348
  "--format=%(objectmode) %(objectname) %(stage)%x09%(eolattr)%x09%(path)"]),
294
349
  runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--others", "--exclude-standard", "-z", "--full-name"]),
295
350
  runGit(["rev-parse", "--verify", "HEAD"]).then(decode, () => ""),
296
351
  runGit(["config", "--type=bool-or-str", "--default", "false", "--get", "core.autocrlf"]),
352
+ passiveWorktreeSettings(runGit),
297
353
  ]);
298
354
  const autoCrlf = decode(autoCrlfOutput).trim().toLowerCase();
299
355
  const head = resolvedHead.trim();
@@ -314,7 +370,7 @@ export async function inspectWorktreeWithoutFilters(root: string, runGit: Passiv
314
370
  if (indexEntry && !indexEntry.skipWorktree) {
315
371
  try {
316
372
  const state = await readFileState(root, indexEntry, filePath, WORKTREE_CONTENT_LIMIT - contentBytes);
317
- mode = process.platform === "win32" && indexEntry.mode !== "120000" ? indexEntry.mode : state.mode;
373
+ mode = passiveWorktreeMode(state.mode, indexEntry.mode, settings);
318
374
  content = state.content;
319
375
  if (content) contentBytes += content.length;
320
376
  worktreeChanged = !state.statsMatch && (mode !== indexEntry.mode || !content || !matchesIndex(content, indexEntry, autoCrlf));
@@ -342,5 +398,5 @@ export async function inspectWorktreeWithoutFilters(root: string, runGit: Passiv
342
398
  const previous = files.get(filePath);
343
399
  files.set(filePath, { ...previous, path: filePath, mode: state.mode, content: state.content, contentObjectId: undefined });
344
400
  }
345
- return { head: head || "(initial)", files };
401
+ return { head: head || "(initial)", files, ...settings };
346
402
  }
@@ -123,6 +123,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
123
123
  "Use multiple mode only when the user may need to choose more than one answer; ordinary either/or decisions remain single-select.",
124
124
  ],
125
125
  parameters: QuestionParams,
126
+ exposure: "model-only",
126
127
  executionMode: "sequential",
127
128
 
128
129
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -332,8 +332,8 @@ function receiptModelName(
332
332
  model: ExtensionContext["model"],
333
333
  ): string | undefined {
334
334
  if (settled.modelMismatch || settled.modelProvider === undefined || settled.modelId === undefined) return undefined;
335
- if (!model || model.provider !== settled.modelProvider || model.id !== settled.modelId) return undefined;
336
- const resolved = modelDisplayName(model);
335
+ if (!model || model.provider !== settled.modelProvider) return undefined;
336
+ const resolved = modelDisplayName({ id: settled.modelId });
337
337
  if (!resolved || resolved.length > 200) return undefined;
338
338
  return resolved;
339
339
  }
@@ -355,6 +355,7 @@ export function registerWorkedFor(
355
355
  collect: (cwd: string) => Promise<ChangeReceiptCollection> = beginChangeReceipt,
356
356
  ): void {
357
357
  let active: ActiveReceipt | undefined;
358
+ let lifecycleGeneration = 0;
358
359
  let collectionNoticeShown = false;
359
360
 
360
361
  pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, options, theme) => {
@@ -369,6 +370,7 @@ export function registerWorkedFor(
369
370
  });
370
371
 
371
372
  pi.on("session_start", async () => {
373
+ lifecycleGeneration += 1;
372
374
  const stale = active;
373
375
  active = undefined;
374
376
  collectionNoticeShown = false;
@@ -407,18 +409,18 @@ export function registerWorkedFor(
407
409
  if (ctx.mode !== "tui" || !active) return;
408
410
  if (event.message.role !== "assistant") return;
409
411
  const provider: unknown = event.message.provider;
410
- const modelId: unknown = event.message.model;
411
- if (typeof provider !== "string" || typeof modelId !== "string") active.modelMismatch = true;
412
+ const requestedModelId: unknown = event.message.model;
413
+ const responseModelId: unknown = event.message.responseModel;
414
+ if (typeof provider !== "string" || typeof requestedModelId !== "string"
415
+ || responseModelId !== undefined && typeof responseModelId !== "string") active.modelMismatch = true;
412
416
  else {
417
+ const modelId = responseModelId ?? requestedModelId;
413
418
  if (active.modelProvider === undefined || active.modelId === undefined) {
414
419
  active.modelProvider = provider;
415
420
  active.modelId = modelId;
416
421
  } else if (active.modelProvider !== provider || active.modelId !== modelId) {
417
422
  active.modelMismatch = true;
418
423
  }
419
- if (event.message.responseModel !== undefined && event.message.responseModel !== modelId) {
420
- active.modelMismatch = true;
421
- }
422
424
  }
423
425
  finishAssistantStream(
424
426
  active,
@@ -447,11 +449,14 @@ export function registerWorkedFor(
447
449
  });
448
450
 
449
451
  pi.on("agent_settled", async (_event, ctx) => {
450
- if (ctx.mode !== "tui" || !active) return;
452
+ if (!active || ctx.mode !== "tui") return;
451
453
  const settled = active;
454
+ const generation = lifecycleGeneration;
452
455
  active = undefined;
453
456
  const model = receiptModelName(settled, ctx.model);
454
457
  const changes = await (await settled.collection).finish();
458
+ // Shutdown can invalidate ctx while the final Git scan is still running.
459
+ if (generation !== lifecycleGeneration) return;
455
460
  if (changes.state === "unavailable" && changes.reason !== "not-git" && changes.reason !== "timeout" && !collectionNoticeShown) {
456
461
  collectionNoticeShown = true;
457
462
  ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
@@ -481,6 +486,7 @@ export function registerWorkedFor(
481
486
  });
482
487
 
483
488
  pi.on("session_shutdown", async () => {
489
+ lifecycleGeneration += 1;
484
490
  const stale = active;
485
491
  active = undefined;
486
492
  if (stale) await (await stale.collection).dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.1.32",
3
+ "version": "3.0.0",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -45,16 +45,16 @@
45
45
  ]
46
46
  },
47
47
  "peerDependencies": {
48
- "@earendil-works/pi-ai": ">=0.85.0 <1",
49
- "@earendil-works/pi-coding-agent": ">=0.85.0 <1",
50
- "@earendil-works/pi-tui": ">=0.85.0 <1",
48
+ "@earendil-works/pi-ai": ">=0.99.2 <1",
49
+ "@earendil-works/pi-coding-agent": ">=0.99.2 <1",
50
+ "@earendil-works/pi-tui": ">=0.99.2 <1",
51
51
  "typebox": ">=1.1.38 <2"
52
52
  },
53
53
  "devDependencies": {
54
- "@earendil-works/pi-ai": "0.85.0",
55
- "@earendil-works/pi-coding-agent": "0.85.0",
56
- "@earendil-works/pi-server": "0.85.0",
57
- "@earendil-works/pi-tui": "0.85.0",
54
+ "@earendil-works/pi-ai": "0.99.2",
55
+ "@earendil-works/pi-coding-agent": "0.99.2",
56
+ "@earendil-works/pi-server": "0.99.2",
57
+ "@earendil-works/pi-tui": "0.99.2",
58
58
  "@types/node": "24.12.4",
59
59
  "eslint": "^10.9.1",
60
60
  "typebox": "1.3.20",
@@ -62,7 +62,6 @@
62
62
  "typescript-eslint": "^8.68.0"
63
63
  },
64
64
  "overrides": {
65
- "brace-expansion": "5.0.9",
66
- "undici": "8.9.0"
65
+ "brace-expansion": "5.0.12"
67
66
  }
68
67
  }