killeros 2.1.32 → 3.0.1

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,59 @@ All notable changes to KillerOS are documented here. Releases use New Features f
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.0.1] - 2026-10-02
8
+
9
+ ### Changed
10
+
11
+ - Expanded Pi peer ranges to `>=0.99.2 <2` and pinned development packages to 1.0.0. Compatibility CI now runs the full quality suite against the exact 0.99.2 minimum and latest stable 1.x with matched host packages, without changing the tracked manifest or lockfile.
12
+ - Documented Pi 1.0's fullscreen default and the regular-mode override for terminal-owned scrollback.
13
+ - Documented the protected PR-based release process and merge-commit requirement for automatic `main`-to-`dev` synchronization. CI now validates release metadata before package smoke testing, so mismatched versions, missing release notes, and stale README tags fail before merge.
14
+
15
+ ### Fixed
16
+
17
+ - Reused bounded cleanup retries in passive Git footer tests so transient Windows directory locks do not fail CI after the security assertions pass.
18
+ - Preserved an authorized goal continuation when a replacement becomes stale during its idle wait, instead of leaving the surviving goal active but idle. Navigation, clear, turn limits, and competing continuation holds remain respected.
19
+ - Rejected recognized credential assignments in Markdown handoffs, including list items, bold labels, and inline-code labels, before creating a destination session.
20
+ - Discarded pending goal mutations after committed navigation or a competing goal mutation during confirmation, idle waiting, or file-baseline inference, including goals set and then cleared. Overlapping commands retain their own continuation holds. Cancelled navigation preserves valid pending commands.
21
+ - Preserved concurrent changes to independent `/auto-compact` fields by merging the effective preference under the settings lock.
22
+ - Removed C1 terminal controls from custom activity tool names.
23
+ - Clipped collapsed and expanded transcript lines to the terminal width, including wide Unicode characters at one column.
24
+ - Treated failed Git HEAD lookups as unavailable instead of inventing added files, while retaining support for repositories without commits. Footer scans and task receipts share the same HEAD resolution.
25
+ - Paused normally stopped goals without an accepted decision even when automatic compaction succeeds or skips a session-too-small request, preventing repeated same-turn requests from bypassing the turn limit. Interrupted-turn recovery and accepted next-turn decisions remain supported.
26
+ - Saved `/handoff` context before reporting success so the linked session survives immediate exit and resume without another prompt or an automatic agent turn.
27
+ - Discarded pending task receipts after committed tree navigation, preventing late scans from attaching the abandoned branch's receipt to the selected branch. Cancelled navigation still preserves receipts.
28
+
29
+ ## [3.0.0] - 2026-10-01
30
+
31
+ ### Changed
32
+
33
+ - 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.
34
+
35
+ ### Added
36
+
37
+ - 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.
38
+
39
+ ### Fixed
40
+
41
+ - 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.
42
+ - 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.
43
+ - 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.
44
+ - 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.
45
+ - Updated the `brace-expansion` override to patched 5.0.12, fixing recursion and quadratic-time denial of service in development and CI installs.
46
+ - Removed the stale `undici` override so development and CI installs use Pi's patched 8.10.2 dependency, resolving GHSA-3wwx-pv8p-q78v.
47
+ - Refreshed cached change-receipt baselines when `HEAD`, `core.filemode`, or `core.symlinks` changes between responses, while still rejecting `HEAD` changes made during a response.
48
+ - Made passive footer scans and change receipts honor `core.symlinks=false` placeholders and `core.filemode=false` without masking file-type changes.
49
+ - Preserved parent traversal in relative goal paths so directory links cannot redirect file proof to a lexically normalized target or collapse distinct targets.
50
+ - 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.
51
+ - Showed stable provider-reported model IDs on settled receipts when they differ from the selected alias, including GitHub Copilot Claude models.
52
+ - Reported CRLF worktree content as modified when indexed attributes require `eol=lf`, matching Git in footer counts and change receipts.
53
+ - Kept quoted prose goal destinations on model-reported completion; inferred file proof now requires a path-shaped destination.
54
+ - Isolated passive footer, change-receipt, and startup branch Git scans from inherited repository-selection environment variables.
55
+ - Rejected passive Git executables reached through repository-owned directory links while continuing to safe later `PATH` entries.
56
+ - Prevented single-file goal proof from masking unquoted `Makefile`, `Dockerfile`, or `.gitignore` list items.
57
+ - 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.
58
+ - 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.
59
+
7
60
  ## [2.1.32] - 2026-09-24
8
61
 
9
62
  ### 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 below 2.0.0
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.1`. Add `-l` to install only for the current project. Restart Pi after installing.
36
38
 
37
39
  ## Commands
38
40
 
@@ -51,14 +53,24 @@ 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
 
64
+ Pi 1.0 defaults to fullscreen. Use `--tui-mode regular` for one invocation or set `"tuiMode": "regular"` in Pi's [settings](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/settings.md#terminal-and-display) to restore terminal-owned scrollback. KillerOS leaves Pi's `tuiMode` and `quietStartup` preferences unchanged.
65
+
56
66
  | Mode | What works |
57
67
  | --- | --- |
58
68
  | TUI | Everything |
59
69
  | RPC | Goals, proactive compaction; no TUI components, sounds, title indicator |
60
70
  | Print/JSON | No interactive questions, `/goal`, or proactive compaction |
61
71
 
72
+ `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.
73
+
62
74
  ## Configuration
63
75
 
64
76
  The packaged `killeros` theme activates on TUI start. Compaction triggers by default at 15% tokens remaining, stored in global `killeros.json`:
@@ -75,7 +87,9 @@ The packaged `killeros` theme activates on TUI start. Compaction triggers by def
75
87
 
76
88
  Use `/auto-compact status` to inspect the effective KillerOS preference, `/auto-compact on` or `/auto-compact off` to toggle it, and `/auto-compact <percent>` to set an integer threshold from 0 through 100. A threshold of 0 does not disable Pi's own token reserve.
77
89
 
78
- `handoffMaxTokens` caps the `/handoff` summary output at 8192 tokens by default; raise it when long sessions truncate the summary.
90
+ Concurrent `/auto-compact` commands preserve independent changes to the enabled flag and threshold, along with unrelated settings. Malformed settings and failed writes report an error without claiming success or replacing the original file.
91
+
92
+ `handoffMaxTokens` caps the `/handoff` summary output at 8192 tokens by default; raise it when long sessions truncate the summary. The handoff is saved as a visible user-context message before success is reported, so the linked session survives immediate exit and resume without sending another prompt. Creating the handoff does not start an agent turn.
79
93
 
80
94
  State proof in the objective so the agent can verify it with its normal tools:
81
95
 
@@ -83,13 +97,19 @@ State proof in the objective so the agent can verify it with its normal tools:
83
97
  /goal Reduce p95 checkout latency below 120 ms, verified by the checkout benchmark, while keeping the correctness suite green
84
98
  ```
85
99
 
86
- A direct quoted file target binds silent file proof:
100
+ 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
101
 
88
102
  ```text
89
103
  /goal Fix `killeros/footer.ts`, verified by npm test
90
104
  ```
91
105
 
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.
106
+ 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. Same-turn recovery requires an actual interruption; successful or skipped compaction cannot restart a normally stopped goal without a decision. Restored goals keep their persisted limit.
107
+
108
+ Session replacement, reload, and committed tree navigation discard unfinished task receipts. Late receipt results do not write or notify through the old session context or attach to a different branch. Cancelled navigation preserves the pending receipt.
109
+
110
+ Pending `/goal` commands discard their mutation if committed navigation changes the branch or another mutation changes the goal while confirmation, idle waiting, or file-baseline reading is in progress. Cancelled navigation preserves valid pending commands. Discarding a stale replacement after its idle wait preserves the surviving goal's authorized continuation.
111
+
112
+ Failed Git metadata reads keep the footer's last successful file counts and mark task changes unavailable. They are not treated as an empty repository.
93
113
 
94
114
  Completion sounds are off by default; change with `/notification` in TUI mode. The tab-title indicator requires a Nerd Font.
95
115
 
@@ -101,12 +121,24 @@ Strict TypeScript throughout. Tests run on Node's built-in test runner:
101
121
  npm ci && npm run check && npm test
102
122
  ```
103
123
 
104
- Releases go through CI on `main`; do not push version tags manually. The prepublish check rejects ordinary direct `npm publish`, but `--ignore-scripts` can bypass it. Configure npm's trusted publisher for `release.yml`, set package publishing access to "Require two-factor authentication and disallow tokens", and revoke unused publish tokens. npm maintainers can still publish interactively with 2FA, so workflow-only publishing also depends on maintainer policy.
124
+ ### Release process
125
+
126
+ 1. Prepare the release on `dev`: update `package.json` and both root versions in `package-lock.json`, update the README's pinned tag, and move completed changelog entries into a dated version section under `[Unreleased]`.
127
+ 2. Run `npm run check` and `npm test`, commit and push to `dev`, then open a pull request into `main`. Push later fixes to the same PR so CI reruns.
128
+ 3. Wait for every required check to pass, including CodeQL and dependency review, and resolve review conversations. The PR must be up to date with `main`.
129
+ 4. Merge using **Create a merge commit**. Squash and rebase merging are disabled because the automated `main`-to-`dev` sync requires shared ancestry. Avoid advancing `dev` until that sync finishes.
130
+ 5. Check the `main` CI run and the following Release run. Successful CI on the current `main` commit triggers npm publication with provenance, creates the GitHub release, and fast-forwards `dev` to the released commit. A green PR alone never publishes.
131
+
132
+ `main` requires pull requests and the CI workflow's required checks, including for administrators. Force pushes and branch deletion are blocked. If CI job names change, update GitHub's required status checks to match.
133
+
134
+ Releases go through CI on `main`; do not push version tags manually. The package smoke job validates release metadata before packing, and the release workflow checks it again before publication. The prepublish check rejects ordinary direct `npm publish`, but `--ignore-scripts` can bypass it. Configure npm's trusted publisher for `release.yml`, set package publishing access to "Require two-factor authentication and disallow tokens", and revoke unused publish tokens. npm maintainers can still publish interactively with 2FA, so workflow-only publishing also depends on maintainer policy.
105
135
 
106
136
  ## Security
107
137
 
108
138
  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.
109
139
 
140
+ Handoff validation rejects recognized credential assignments in plain text, Markdown lists, bold labels, and inline-code labels before creating a destination session. This pattern check cannot detect every secret or personally identifying value. Rejected values are not included in the error notification.
141
+
110
142
  ## License
111
143
 
112
144
  [MIT](LICENSE) © 2026 KyrosHendrix
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { modelDisplayName } from "./display.ts";
4
+ import { safeTerminalText } from "./safe-terminal-text.ts";
4
5
 
5
6
  export type ActivityMessage =
6
7
  | { kind: "prompt" }
@@ -9,7 +10,7 @@ export type ActivityMessage =
9
10
  | { kind: "responding" };
10
11
 
11
12
  function safeToolName(toolName: string): string {
12
- const normalized = toolName.replace(/[\u0000-\u001F\u007F]+/gu, " ").replace(/\s+/gu, " ").trim();
13
+ const normalized = safeTerminalText(toolName.replace(/[\r\n\t]+/gu, " ")).replace(/\s+/gu, " ").trim();
13
14
  return truncateToWidth(normalized || "tool", 32, "…");
14
15
  }
15
16
 
@@ -102,7 +103,7 @@ export function registerRequestActivity(pi: ExtensionAPI): void {
102
103
  });
103
104
 
104
105
  pi.on("agent_settled", (_event, ctx) => {
105
- if (ctx.mode !== "tui" || !active || !ctx.isIdle?.() || ctx.hasPendingMessages?.()) return;
106
+ if (!active || ctx.mode !== "tui" || !ctx.isIdle?.() || ctx.hasPendingMessages?.()) return;
106
107
  clear(ctx);
107
108
  });
108
109
 
@@ -155,11 +155,15 @@ export function registerAutoCompaction(
155
155
  }
156
156
 
157
157
  try {
158
- const preference = readAutoCompactionPreference(settingsStore.load());
159
- const updated = percent === undefined
160
- ? { ...preference, enabled: argument === "on" }
161
- : { ...preference, percentRemaining: percent };
162
- settingsStore.update({ autoCompaction: updated });
158
+ let updated: AutoCompactionPreference | undefined;
159
+ settingsStore.update((current) => {
160
+ const preference = readAutoCompactionPreference(current);
161
+ updated = percent === undefined
162
+ ? { ...preference, enabled: argument === "on" }
163
+ : { ...preference, percentRemaining: percent };
164
+ return { autoCompaction: updated };
165
+ });
166
+ if (!updated) throw new Error("Settings update did not apply the preference");
163
167
  ctx.ui.notify(percent === undefined
164
168
  ? updated.enabled
165
169
  ? `Automatic compaction: on at ${updated.percentRemaining}% remaining`
@@ -11,7 +11,7 @@ export class BoundedText implements Component {
11
11
 
12
12
  render(width: number): string[] {
13
13
  if (width <= 0) return [];
14
- const lines = new Text(this.text, 0, 0).render(width);
14
+ const lines = new Text(this.text, 0, 0).render(width).map((line) => truncateToWidth(line, width, ""));
15
15
  if (this.maxRows === undefined || lines.length <= this.maxRows) return lines;
16
16
  const rowLimit = Math.max(1, this.maxRows);
17
17
  const visible = lines.slice(0, rowLimit);
@@ -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, resolvePassiveHead, 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,22 +234,11 @@ 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
- async function currentHead(repo: Repository): Promise<string> {
237
- let resolved: Buffer;
238
- try {
239
- resolved = await runGit(repo.root, ["rev-parse", "--verify", "HEAD"], undefined, 4_096);
240
- } catch (error) {
241
- if (!(error instanceof GitFailure) || error.reason !== "error") throw error;
242
- const reference = decode(await runGit(repo.root, ["symbolic-ref", "--quiet", "HEAD"], undefined, 4_096)).trim();
243
- if (!/^refs\/[^\0\r\n]+$/u.test(reference)) throw new GitFailure("error");
244
- return "(initial)";
245
- }
246
- const head = decode(resolved).trim();
247
- if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new GitFailure("error");
248
- return head;
240
+ function currentHead(repo: Repository): Promise<string> {
241
+ return resolvePassiveHead((args) => runGit(repo.root, args, undefined, 4_096));
249
242
  }
250
243
 
251
244
  function observes(filePath: string, observedPaths: readonly string[]): boolean {
@@ -262,7 +255,7 @@ async function refreshSnapshot(monitor: RepositoryMonitor, observedPaths: readon
262
255
  if (observes(filePath, observedPaths)) files.delete(filePath);
263
256
  }
264
257
  for (const [filePath, file] of scoped.files) files.set(filePath, file);
265
- return { head: scoped.head, files };
258
+ return { ...scoped, files };
266
259
  }
267
260
 
268
261
  function createMonitor(repo: Repository, initialSnapshot: Snapshot): RepositoryMonitor {
@@ -626,7 +619,14 @@ export async function beginChangeReceipt(cwd: string, trusted = true): Promise<C
626
619
  const idleChanges = [...monitor.changedPaths];
627
620
  monitor.changedPaths.clear();
628
621
  try {
629
- monitor.snapshot = await refreshSnapshot(monitor, idleChanges);
622
+ const [head, settings] = await Promise.all([
623
+ currentHead(repo),
624
+ passiveWorktreeSettings((args) => runGit(repo.root, args)),
625
+ ]);
626
+ monitor.snapshot = head !== monitor.snapshot.head
627
+ || settings.fileMode !== monitor.snapshot.fileMode || settings.symlinks !== monitor.snapshot.symlinks
628
+ ? await snapshot(repo)
629
+ : await refreshSnapshot(monitor, idleChanges);
630
630
  } catch (error) {
631
631
  discardMonitor(monitor);
632
632
  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();
@@ -310,6 +311,9 @@ export function registerGoalInterface(
310
311
  ctx.ui.notify("/goal requires a saved session", "error");
311
312
  return;
312
313
  }
314
+ const generation = runtime.lifecycleGeneration;
315
+ const initiatingState = runtime.state;
316
+ const ownsGoal = (): boolean => runtime.lifecycleGeneration === generation && runtime.state === initiatingState;
313
317
 
314
318
  if (command.kind === "status") {
315
319
  if (!runtime.state) {
@@ -322,9 +326,11 @@ export function registerGoalInterface(
322
326
  }
323
327
  const actions = goalPanelActions(runtime.state.status);
324
328
  const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
329
+ if (!ownsGoal()) return;
325
330
  const action = actions.find((candidate) => candidate.label === selected);
326
331
  if (!action) return;
327
332
  if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", safeTerminalText(runtime.state.objective))) return;
333
+ if (!ownsGoal()) return;
328
334
  await handleGoalCommand(action.control, ctx);
329
335
  return;
330
336
  }
@@ -486,17 +492,23 @@ export function registerGoalInterface(
486
492
  return;
487
493
  }
488
494
  const replace = await ctx.ui.confirm("Replace active goal", "Replace the current unfinished goal and discard its continuation state?");
489
- if (!replace) return;
495
+ if (!replace || !ownsGoal()) return;
490
496
  }
491
497
 
492
- runtime.continuationHeld = true;
498
+ const hold = Symbol();
499
+ runtime.continuationHeld = hold;
493
500
  let waitError: unknown;
494
501
  try {
495
502
  await ctx.waitForIdle();
496
503
  } catch (error) {
497
504
  waitError = error;
498
505
  } finally {
499
- runtime.continuationHeld = false;
506
+ if (runtime.continuationHeld === hold) runtime.continuationHeld = undefined;
507
+ }
508
+ if (!ownsGoal()) {
509
+ // Settlement may have authorized a turn while this command held continuation.
510
+ if (runtime.lifecycleGeneration === generation) scheduleGoalContinuation(pi, runtime, ctx);
511
+ return;
500
512
  }
501
513
  if (waitError) {
502
514
  reportError(ctx, "Goal could not wait for the active turn", waitError);
@@ -507,6 +519,7 @@ export function registerGoalInterface(
507
519
  try {
508
520
  verification = await inferGoalVerification(objective, ctx.cwd);
509
521
  } catch (error) {
522
+ if (!ownsGoal()) return;
510
523
  if (!unfinished) {
511
524
  reportError(ctx, "Goal could not be started", error);
512
525
  } else {
@@ -515,6 +528,7 @@ export function registerGoalInterface(
515
528
  }
516
529
  return;
517
530
  }
531
+ if (!ownsGoal()) return;
518
532
  try {
519
533
  const state = createNewGoalState(objective, sumGoalTokens(ctx), verification, Date.now(), {
520
534
  maxTurns: DEFAULT_GOAL_MAX_TURNS,
@@ -119,6 +119,10 @@ export function persistGoalState(
119
119
  const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
120
120
  pi.appendEntry(GOAL_ENTRY_TYPE, data);
121
121
  runtime.state = state;
122
+ if (event === "clear") {
123
+ runtime.lifecycleGeneration += 1;
124
+ runtime.continuationHeld = undefined;
125
+ }
122
126
  syncGoalUpdateTool(pi, runtime);
123
127
  runtime.persistenceRetryNeeded = false;
124
128
  runtime.requestRender?.();
@@ -404,7 +408,7 @@ export function registerGoalRuntime(
404
408
  runtime.state = isGoalModeSupported(ctx) ? restored.state : undefined;
405
409
  syncGoalUpdateTool(pi, runtime);
406
410
  runtime.continuationScheduled = false;
407
- runtime.continuationHeld = false;
411
+ runtime.continuationHeld = undefined;
408
412
  runtime.goalTurnInFlight = false;
409
413
  runtime.goalTurn = undefined;
410
414
  runtime.agentEndObserved = false;
@@ -441,7 +445,7 @@ export function registerGoalRuntime(
441
445
  runtime.state = undefined;
442
446
  syncGoalUpdateTool(pi, runtime);
443
447
  clearGoalExecutionFlags(runtime);
444
- runtime.continuationHeld = false;
448
+ runtime.continuationHeld = undefined;
445
449
  runtime.persistenceRetryNeeded = false;
446
450
  });
447
451
 
@@ -254,6 +254,10 @@ export function registerGoalSettlement(
254
254
  );
255
255
  return;
256
256
  }
257
+ if (!expectedInterruption && runtime.automaticCompaction.resumeSameTurn) {
258
+ stopAutomaticCompactionRecovery(pi, runtime, ctx, "no turn decision");
259
+ return;
260
+ }
257
261
  if (settledTurn !== undefined && runtime.automaticCompaction.turn === settledTurn) {
258
262
  runtime.automaticCompaction.turnSettled = true;
259
263
  } else {
@@ -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> {
@@ -300,7 +300,8 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime, hand
300
300
  parentSession: sourceSession,
301
301
  setup: async (sessionManager) => {
302
302
  try {
303
- sessionManager.appendCustomMessageEntry("killeros-handoff", document, true);
303
+ // A regular user message makes Pi persist the otherwise setup-only child session.
304
+ sessionManager.appendMessage({ role: "user", content: document, timestamp: Date.now() });
304
305
  sessionManager.appendSessionInfo(sessionName(sourceName, focus, document));
305
306
  } catch (error) {
306
307
  setupFailure = { error };
@@ -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;
@@ -8,6 +8,13 @@ const PASSIVE_METADATA_ARGS = ["-c", "core.fsmonitor=false"] as const;
8
8
 
9
9
  export type PassiveGitRunner = (args: readonly string[]) => Promise<Buffer>;
10
10
 
11
+ /** Revs-only returns empty output for an unborn HEAD; command failures still reject. */
12
+ export async function resolvePassiveHead(runGit: PassiveGitRunner): Promise<string> {
13
+ const head = decode(await runGit(["rev-parse", "--revs-only", "--end-of-options", "HEAD"])).trim();
14
+ if (head && !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new Error("invalid HEAD");
15
+ return head || "(initial)";
16
+ }
17
+
11
18
  export type PassiveWorktreeFile = {
12
19
  path: string;
13
20
  headMode?: string;
@@ -22,8 +29,30 @@ export type PassiveWorktreeFile = {
22
29
  export type PassiveWorktreeSnapshot = {
23
30
  head: string;
24
31
  files: Map<string, PassiveWorktreeFile>;
32
+ fileMode: boolean;
33
+ symlinks: boolean;
25
34
  };
26
35
 
36
+ export async function passiveWorktreeSettings(runGit: PassiveGitRunner): Promise<Pick<PassiveWorktreeSnapshot, "fileMode" | "symlinks">> {
37
+ const [fileMode, symlinks] = await Promise.all([
38
+ runGit(["config", "--type=bool", "--default", "true", "--get", "core.filemode"]),
39
+ runGit(["config", "--type=bool", "--default", "true", "--get", "core.symlinks"]),
40
+ ]);
41
+ return { fileMode: decode(fileMode).trim() === "true", symlinks: decode(symlinks).trim() === "true" };
42
+ }
43
+
44
+ export function passiveWorktreeMode(
45
+ actual: string,
46
+ indexed: string | undefined,
47
+ settings: Pick<PassiveWorktreeSnapshot, "fileMode" | "symlinks">,
48
+ ): string {
49
+ if (actual === "100644" || actual === "100755") {
50
+ if (indexed === "120000" && !settings.symlinks) return indexed;
51
+ if ((indexed === "100644" || indexed === "100755") && !settings.fileMode) return indexed;
52
+ }
53
+ return actual;
54
+ }
55
+
27
56
  type IndexEntry = {
28
57
  mode: string;
29
58
  objectId: string;
@@ -77,7 +106,7 @@ function inspectedRoot(cwd: string): string | undefined {
77
106
  }
78
107
  let base: string;
79
108
  try {
80
- base = realpathSync(start);
109
+ base = realpathSync.native(start);
81
110
  } catch {
82
111
  base = start;
83
112
  }
@@ -86,7 +115,7 @@ function inspectedRoot(cwd: string): string | undefined {
86
115
  try {
87
116
  if (existsSync(path.join(current, ".git"))) {
88
117
  try {
89
- return realpathSync(current);
118
+ return realpathSync.native(current);
90
119
  } catch {
91
120
  return current;
92
121
  }
@@ -120,6 +149,20 @@ function insideInspected(candidate: string, root: string): boolean {
120
149
  return normalizedCandidate.startsWith(`${trimmed}${path.sep}`);
121
150
  }
122
151
 
152
+ function traversesInspected(candidate: string, root: string): boolean {
153
+ let current = path.dirname(candidate);
154
+ for (;;) {
155
+ try {
156
+ if (insideInspected(realpathSync.native(current), root)) return true;
157
+ } catch {
158
+ return true;
159
+ }
160
+ const parent = path.dirname(current);
161
+ if (parent === current) return false;
162
+ current = parent;
163
+ }
164
+ }
165
+
123
166
  /** Returns an absolute Git binary outside the inspected repository without starting a locator process. */
124
167
  export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
125
168
  const root = inspectedRoot(cwd);
@@ -134,6 +177,7 @@ export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.
134
177
  if (directory === "" || directory.trim() === "" || !path.isAbsolute(directory)) continue;
135
178
  for (const base of baseNames) {
136
179
  const candidate = path.join(directory, base);
180
+ if (insideInspected(candidate, root)) continue;
137
181
  try {
138
182
  if (!statSync(candidate).isFile()) continue;
139
183
  } catch {
@@ -148,27 +192,45 @@ export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.
148
192
  }
149
193
  let resolved: string;
150
194
  try {
151
- resolved = realpathSync(candidate);
195
+ resolved = realpathSync.native(candidate);
152
196
  } catch {
153
197
  continue;
154
198
  }
155
- if (path.isAbsolute(resolved) && !insideInspected(resolved, root)) return resolved;
199
+ if (path.isAbsolute(resolved) && !insideInspected(resolved, root) && !traversesInspected(candidate, root)) return resolved;
156
200
  }
157
201
  }
158
202
  return undefined;
159
203
  }
160
204
 
161
- /** Environment for automatic Git children, with locks, lazy fetches, and PATH command resolution disabled. */
205
+ /** Environment for automatic Git children, without inherited repository selection or PATH command resolution. */
162
206
  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" };
207
+ const env: NodeJS.ProcessEnv = { ...base };
208
+ const selectors = [
209
+ "GIT_DIR",
210
+ "GIT_CEILING_DIRECTORIES",
211
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM",
212
+ "GIT_WORK_TREE",
213
+ "GIT_INDEX_FILE",
214
+ "GIT_OBJECT_DIRECTORY",
215
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
216
+ "GIT_COMMON_DIR",
217
+ "GIT_NAMESPACE",
218
+ ];
164
219
  let hasPath = false;
165
220
  for (const key of Object.keys(env)) {
166
- if (key.toLowerCase() === "path") {
221
+ const normalized = key.toUpperCase();
222
+ if (selectors.includes(normalized)
223
+ || normalized === "GIT_OPTIONAL_LOCKS"
224
+ || normalized === "GIT_NO_LAZY_FETCH") {
225
+ delete env[key];
226
+ } else if (normalized === "PATH") {
167
227
  env[key] = "";
168
228
  hasPath = true;
169
229
  }
170
230
  }
171
231
  if (!hasPath) env.PATH = "";
232
+ env.GIT_OPTIONAL_LOCKS = "0";
233
+ env.GIT_NO_LAZY_FETCH = "1";
172
234
  return env;
173
235
  }
174
236
 
@@ -263,7 +325,7 @@ function matchesIndex(content: Buffer, entry: IndexEntry, autoCrlf: string): boo
263
325
  if (blobObjectId(content, entry.objectId.length) === entry.objectId) return true;
264
326
  if (entry.mode === "120000" || !content.includes(Buffer.from("\r\n"))) return false;
265
327
  const attributes = entry.eolAttributes;
266
- if (attributes === "-text") return false;
328
+ if (attributes.endsWith("eol=lf") || attributes === "-text") return false;
267
329
  const automatic = attributes.startsWith("text=auto") || !attributes;
268
330
  if (!attributes && !["true", "input"].includes(autoCrlf)) return false;
269
331
  if (automatic) {
@@ -288,18 +350,17 @@ function matchesIndex(content: Buffer, entry: IndexEntry, autoCrlf: string): boo
288
350
  * No Git command in this path asks Git to convert worktree content, so clean and process filters cannot start.
289
351
  */
290
352
  export async function inspectWorktreeWithoutFilters(root: string, runGit: PassiveGitRunner): Promise<PassiveWorktreeSnapshot> {
291
- const [indexOutput, untrackedOutput, resolvedHead, autoCrlfOutput] = await Promise.all([
353
+ const [indexOutput, untrackedOutput, head, autoCrlfOutput, settings] = await Promise.all([
292
354
  runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--cached", "--debug", "-z", "--full-name",
293
355
  "--format=%(objectmode) %(objectname) %(stage)%x09%(eolattr)%x09%(path)"]),
294
356
  runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--others", "--exclude-standard", "-z", "--full-name"]),
295
- runGit(["rev-parse", "--verify", "HEAD"]).then(decode, () => ""),
357
+ resolvePassiveHead(runGit),
296
358
  runGit(["config", "--type=bool-or-str", "--default", "false", "--get", "core.autocrlf"]),
359
+ passiveWorktreeSettings(runGit),
297
360
  ]);
298
361
  const autoCrlf = decode(autoCrlfOutput).trim().toLowerCase();
299
- const head = resolvedHead.trim();
300
- if (head && !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new Error("invalid HEAD");
301
362
  const index = parseIndex(indexOutput);
302
- const tree = head ? parseTree(await runGit(["ls-tree", "-r", "-z", "--full-tree", head])) : new Map<string, TreeEntry>();
363
+ const tree = head !== "(initial)" ? parseTree(await runGit(["ls-tree", "-r", "-z", "--full-tree", head])) : new Map<string, TreeEntry>();
303
364
  const files = new Map<string, PassiveWorktreeFile>();
304
365
  let contentBytes = 0;
305
366
 
@@ -314,7 +375,7 @@ export async function inspectWorktreeWithoutFilters(root: string, runGit: Passiv
314
375
  if (indexEntry && !indexEntry.skipWorktree) {
315
376
  try {
316
377
  const state = await readFileState(root, indexEntry, filePath, WORKTREE_CONTENT_LIMIT - contentBytes);
317
- mode = process.platform === "win32" && indexEntry.mode !== "120000" ? indexEntry.mode : state.mode;
378
+ mode = passiveWorktreeMode(state.mode, indexEntry.mode, settings);
318
379
  content = state.content;
319
380
  if (content) contentBytes += content.length;
320
381
  worktreeChanged = !state.statsMatch && (mode !== indexEntry.mode || !content || !matchesIndex(content, indexEntry, autoCrlf));
@@ -342,5 +403,5 @@ export async function inspectWorktreeWithoutFilters(root: string, runGit: Passiv
342
403
  const previous = files.get(filePath);
343
404
  files.set(filePath, { ...previous, path: filePath, mode: state.mode, content: state.content, contentObjectId: undefined });
344
405
  }
345
- return { head: head || "(initial)", files };
406
+ return { head, files, ...settings };
346
407
  }
@@ -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) {
@@ -124,7 +124,7 @@ export interface GoalTurnExecution {
124
124
  export interface GoalRuntime {
125
125
  state?: GoalState;
126
126
  continuationScheduled: boolean;
127
- continuationHeld: boolean;
127
+ continuationHeld?: symbol;
128
128
  goalTurnInFlight: boolean;
129
129
  agentEndObserved: boolean;
130
130
  goalTurn?: GoalTurnExecution;
@@ -139,7 +139,7 @@ export interface GoalRuntime {
139
139
  export function createGoalRuntime(): GoalRuntime {
140
140
  return {
141
141
  continuationScheduled: false,
142
- continuationHeld: false,
142
+ continuationHeld: undefined,
143
143
  goalTurnInFlight: false,
144
144
  agentEndObserved: false,
145
145
  goalTurn: undefined,
@@ -10,7 +10,7 @@ const SECRET_PATTERNS = [
10
10
  /\bsk_live_[A-Za-z0-9]{16,255}\b/u,
11
11
  /\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/u,
12
12
  /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/iu,
13
- /^[\t ]*["']?[\w.-]*(?:api_key|apikey|password|passwd|secret|token)[\w.-]*["']?[\t ]*(?:=|:)[\t ]*(?:"[^"\r\n]+"|'[^'\r\n]+'|[^\s#][^\r\n]*)/imu,
13
+ /^[\t ]*(?:(?:[-*+]|\d+[.)])[\t ]+)?(?:\*\*|`)?["']?[\w.-]*(?:api_key|apikey|password|passwd|secret|token)[\w.-]*["']?(?:\*\*|`)?[\t ]*(?:=|:)[\t ]*(?:"[^"\r\n]+"|'[^'\r\n]+'|[^\s#][^\r\n]*)/imu,
14
14
  ] as const;
15
15
 
16
16
  /** Detects high-confidence credential material without returning the matched value. */
@@ -5,10 +5,13 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { hasErrorCode } from "./errors.ts";
6
6
 
7
7
  export type KillerosSettings = Record<string, unknown>;
8
+ export type KillerosSettingsUpdate = Readonly<Record<string, unknown>>
9
+ | ((current: Readonly<KillerosSettings>) => Readonly<Record<string, unknown>>);
8
10
 
9
11
  export interface KillerosSettingsStore {
10
12
  load(): KillerosSettings;
11
- update(patch: Readonly<Record<string, unknown>>): void;
13
+ /** Derive a field update from the latest settings while holding the writer lock. */
14
+ update(patch: KillerosSettingsUpdate): void;
12
15
  }
13
16
 
14
17
  function isSettings(value: unknown): value is KillerosSettings {
@@ -123,9 +126,10 @@ export function createKillerosSettingsStore(
123
126
  const temporaryPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
124
127
  try {
125
128
  const current = readStoredSettings(settingsPath);
129
+ const changes = typeof patch === "function" ? patch(current) : patch;
126
130
  writeFileSync(
127
131
  temporaryPath,
128
- `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`,
132
+ `${JSON.stringify({ ...current, ...changes }, null, 2)}\n`,
129
133
  { encoding: "utf8", mode: 0o600 },
130
134
  );
131
135
  renameSync(temporaryPath, settingsPath);
@@ -302,6 +302,7 @@ export function workedForOutcome(stopReason: StopReason | undefined): WorkedForO
302
302
  }
303
303
 
304
304
  type ActiveReceipt = {
305
+ leafId: string | null;
305
306
  startedAt: number;
306
307
  startedTokens: number | undefined;
307
308
  stopReason: StopReason | undefined;
@@ -332,8 +333,8 @@ function receiptModelName(
332
333
  model: ExtensionContext["model"],
333
334
  ): string | undefined {
334
335
  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);
336
+ if (!model || model.provider !== settled.modelProvider) return undefined;
337
+ const resolved = modelDisplayName({ id: settled.modelId });
337
338
  if (!resolved || resolved.length > 200) return undefined;
338
339
  return resolved;
339
340
  }
@@ -355,6 +356,7 @@ export function registerWorkedFor(
355
356
  collect: (cwd: string) => Promise<ChangeReceiptCollection> = beginChangeReceipt,
356
357
  ): void {
357
358
  let active: ActiveReceipt | undefined;
359
+ let lifecycleGeneration = 0;
358
360
  let collectionNoticeShown = false;
359
361
 
360
362
  pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, options, theme) => {
@@ -368,16 +370,20 @@ export function registerWorkedFor(
368
370
  return new Text(`${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}${tokens}`)}`, 1, 0);
369
371
  });
370
372
 
371
- pi.on("session_start", async () => {
373
+ const resetForBranch = async (): Promise<void> => {
374
+ lifecycleGeneration += 1;
372
375
  const stale = active;
373
376
  active = undefined;
374
377
  collectionNoticeShown = false;
375
378
  if (stale) await (await stale.collection).dispose();
376
- });
379
+ };
380
+ pi.on("session_start", resetForBranch);
381
+ pi.on("session_tree", resetForBranch);
377
382
 
378
383
  pi.on("agent_start", async (_event, ctx) => {
379
384
  if (ctx.mode !== "tui" || active || !ctx.isProjectTrusted()) return;
380
385
  const state: ActiveReceipt = {
386
+ leafId: ctx.sessionManager.getLeafId(),
381
387
  startedAt: now(),
382
388
  startedTokens: sessionTokenTotal(ctx),
383
389
  stopReason: undefined,
@@ -407,18 +413,18 @@ export function registerWorkedFor(
407
413
  if (ctx.mode !== "tui" || !active) return;
408
414
  if (event.message.role !== "assistant") return;
409
415
  const provider: unknown = event.message.provider;
410
- const modelId: unknown = event.message.model;
411
- if (typeof provider !== "string" || typeof modelId !== "string") active.modelMismatch = true;
416
+ const requestedModelId: unknown = event.message.model;
417
+ const responseModelId: unknown = event.message.responseModel;
418
+ if (typeof provider !== "string" || typeof requestedModelId !== "string"
419
+ || responseModelId !== undefined && typeof responseModelId !== "string") active.modelMismatch = true;
412
420
  else {
421
+ const modelId = responseModelId ?? requestedModelId;
413
422
  if (active.modelProvider === undefined || active.modelId === undefined) {
414
423
  active.modelProvider = provider;
415
424
  active.modelId = modelId;
416
425
  } else if (active.modelProvider !== provider || active.modelId !== modelId) {
417
426
  active.modelMismatch = true;
418
427
  }
419
- if (event.message.responseModel !== undefined && event.message.responseModel !== modelId) {
420
- active.modelMismatch = true;
421
- }
422
428
  }
423
429
  finishAssistantStream(
424
430
  active,
@@ -438,6 +444,8 @@ export function registerWorkedFor(
438
444
 
439
445
  pi.on("agent_end", (event, ctx) => {
440
446
  if (ctx.mode !== "tui" || !active) return;
447
+ // Pi is not idle yet; later settlement handlers can overlap tree navigation.
448
+ active.leafId = ctx.sessionManager.getLeafId();
441
449
  for (let index = event.messages.length - 1; index >= 0; index -= 1) {
442
450
  const message = event.messages[index];
443
451
  if (message?.role !== "assistant") continue;
@@ -447,11 +455,15 @@ export function registerWorkedFor(
447
455
  });
448
456
 
449
457
  pi.on("agent_settled", async (_event, ctx) => {
450
- if (ctx.mode !== "tui" || !active) return;
458
+ if (!active || ctx.mode !== "tui") return;
451
459
  const settled = active;
460
+ const generation = lifecycleGeneration;
452
461
  active = undefined;
453
462
  const model = receiptModelName(settled, ctx.model);
454
463
  const changes = await (await settled.collection).finish();
464
+ // Earlier async session_tree handlers can delay our reset after Pi switches branches.
465
+ if (generation !== lifecycleGeneration
466
+ || !ctx.sessionManager.getBranch().some((entry) => entry.id === settled.leafId)) return;
455
467
  if (changes.state === "unavailable" && changes.reason !== "not-git" && changes.reason !== "timeout" && !collectionNoticeShown) {
456
468
  collectionNoticeShown = true;
457
469
  ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
@@ -481,6 +493,7 @@ export function registerWorkedFor(
481
493
  });
482
494
 
483
495
  pi.on("session_shutdown", async () => {
496
+ lifecycleGeneration += 1;
484
497
  const stale = active;
485
498
  active = undefined;
486
499
  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.1",
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 <2",
49
+ "@earendil-works/pi-coding-agent": ">=0.99.2 <2",
50
+ "@earendil-works/pi-tui": ">=0.99.2 <2",
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": "1.0.0",
55
+ "@earendil-works/pi-coding-agent": "1.0.0",
56
+ "@earendil-works/pi-server": "1.0.0",
57
+ "@earendil-works/pi-tui": "1.0.0",
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
  }