killeros 2.1.26 → 2.1.27

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,26 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.1.27] - 2026-09-12
8
+
9
+ ### Added
10
+
11
+ - Added `/codex-fast status` to report whether Codex fast mode is enabled without changing it.
12
+
13
+ ### Fixed
14
+
15
+ - Restricted automatic startup, footer, and change-receipt Git scans to trusted projects, preventing repository executables and configuration races from launching commands before trust is granted.
16
+ - Resolved passive Git executable discovery without starting a locator or repository executable. Footer and change-receipt scans now use an absolute Git path outside the inspected repository and report unavailable when none exists.
17
+ - Closed the filter-configuration race in automatic Git inspection. Footer and change-receipt scans now run Git without `PATH` resolution, re-check effective filters after status, and skip results the scan cannot produce without an extension process or remote fetch. Change receipts no longer fetch missing objects from partial-clone remotes.
18
+ - Stopped attaching single-file verification to goals that name several files. Multi-file goals now complete through the model-reported path.
19
+ - Shared one passive Git status policy between footer telemetry and change receipts. Both scans now disable filesystem monitors and clean/process filters, and skip status when filter discovery is unavailable or unsafe.
20
+ - Recorded focused Node test runs in response receipts without persisting their test paths.
21
+ - Kept passive footer Git status from running configured filters, bounded loose and packed receipt blobs, and stopped stale `/handoff` summaries from replacing sessions with new work.
22
+
23
+ ### Removed
24
+
25
+ - Removed `/init` and its `killeros_init_*` tools, prompts, workflow APIs, runtime state, and lifecycle handlers. Upgrading leaves existing `AGENTS.md` and recovery files untouched, and commands from other providers named `/init` pass through KillerOS unhandled.
26
+
7
27
  ## [2.1.26] - 2026-09-08
8
28
 
9
29
  ### Fixed
package/Killeros.ts CHANGED
@@ -12,7 +12,6 @@ import { registerGoalRuntime } from "./killeros/goal-runtime.ts";
12
12
  import { registerGoalSettlement } from "./killeros/goal-settlement.ts";
13
13
  import { registerHandoff } from "./killeros/handoff.ts";
14
14
  import { registerLifecycleHooks } from "./killeros/hooks.ts";
15
- import { registerInitCommand, registerInitSettlement } from "./killeros/init.ts";
16
15
  import {
17
16
  registerCompletionNotifications,
18
17
  type CompletionNotificationDependencies,
@@ -20,15 +19,12 @@ import {
20
19
  import { registerCodexFastMode } from "./killeros/codex-fast.ts";
21
20
  import { registerPersonalInstructions } from "./killeros/personal-instructions.ts";
22
21
  import { registerQuestionTool } from "./killeros/question.ts";
23
- import { createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
22
+ import { createGoalRuntime } from "./killeros/runtime.ts";
24
23
  import { registerShellUi } from "./killeros/shell-ui.ts";
25
24
  import { registerWorkedFor } from "./killeros/worked-for.ts";
26
25
 
27
26
  export { contextPercentRemaining, formatCost, formatContextProgress } from "./killeros/footer.ts";
28
27
  export { executeHook } from "./killeros/hooks.ts";
29
- export { INIT_WORKFLOW_PROMPT } from "./killeros/init.ts";
30
- export { buildInitEvidence, listInitEvidence, readInitEvidence } from "./killeros/init-evidence.ts";
31
- export { captureInitTargetBaseline, installInitAgentsFile, validateGeneratedGuidance, writeInitAgentsFile } from "./killeros/init-target.ts";
32
28
  export interface KillerosOptions {
33
29
  completionNotifications?: CompletionNotificationDependencies;
34
30
  /** Output-token budget for /handoff summaries; invalid values fall back to killeros.json, then the default. */
@@ -36,25 +32,22 @@ export interface KillerosOptions {
36
32
  }
37
33
 
38
34
  export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}): void {
39
- const initRuntime = createInitRuntime();
40
35
  const goalRuntime = createGoalRuntime();
41
36
  const commandResolver = createSlashCommandResolver(pi);
42
37
  registerShellUi(pi, commandResolver);
43
- registerGoalInterface(pi, goalRuntime, initRuntime);
44
- registerGoalRuntime(pi, goalRuntime, initRuntime);
45
- registerPersonalInstructions(pi, initRuntime);
38
+ registerGoalInterface(pi, goalRuntime);
39
+ registerGoalRuntime(pi, goalRuntime);
40
+ registerPersonalInstructions(pi);
46
41
  registerQuestionTool(pi);
47
42
  registerAliases(pi);
48
43
  registerHandoff(pi, goalRuntime, options.handoffMaxTokens);
49
44
  registerSlashAutocomplete(pi, commandResolver);
50
45
  registerFooter(pi, goalRuntime);
51
46
  registerCodexFastMode(pi);
52
- registerInitCommand(pi, initRuntime, goalRuntime);
53
47
  registerLifecycleHooks(pi);
54
48
  registerWorkedFor(pi);
55
- const goalCompaction = registerGoalSettlement(pi, goalRuntime, initRuntime);
56
- registerAutoCompaction(pi, { goal: goalCompaction, isInitActive: () => initRuntime.active });
57
- registerInitSettlement(pi, initRuntime);
49
+ const goalCompaction = registerGoalSettlement(pi, goalRuntime);
50
+ registerAutoCompaction(pi, { goal: goalCompaction });
58
51
  registerRequestActivity(pi);
59
52
  registerCompletionNotifications(pi, options.completionNotifications);
60
53
  }
package/README.md CHANGED
@@ -6,8 +6,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
6
6
 
7
7
  - 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
8
  - `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. New goals pause after 20 turns; `/goal resume` grants another 20.
9
- - `/init`: generates a root `AGENTS.md` from repository evidence, preserving compatible existing rules.
10
- - `/codex-fast`: toggles the `priority` service tier on Codex requests.
9
+ - `/codex-fast`: toggles the `priority` service tier on Codex requests or reports its status.
11
10
  - `/handoff`: starts a fresh linked session carrying visible continuation context.
12
11
  - Automatic context compaction when remaining tokens drop below 15% of the window (configurable).
13
12
  - A `question` tool with single-select and multi-select modes.
@@ -19,7 +18,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
19
18
 
20
19
  - Node.js 22.19.0+
21
20
  - Pi 0.85.0 or later within the 0.x release line
22
- - An interactive TUI session for the custom header, editor, footer, `question`, and `/init`
21
+ - An interactive TUI session for the custom header, editor, footer, and `question`
23
22
 
24
23
  ## Install
25
24
 
@@ -33,18 +32,17 @@ Or from GitHub:
33
32
  pi install git:github.com/KyrosHendrix/pi-KillerOS
34
33
  ```
35
34
 
36
- Pin a release by appending its tag, for example `@v2.1.26`. Add `-l` to install only for the current project. Restart Pi after installing.
35
+ Pin a release by appending its tag, for example `@v2.1.27`. Add `-l` to install only for the current project. Restart Pi after installing.
37
36
 
38
37
  ## Commands
39
38
 
40
39
  ```text
41
- /init Generate root AGENTS.md from repository evidence
42
40
  /goal View the current goal
43
41
  /goal <objective> Set an objective
44
42
  /goal pause Stop automatic continuation
45
43
  /goal resume Resume automatic continuation
46
44
  /goal clear Remove the current goal
47
- /codex-fast Toggle Codex fast mode
45
+ /codex-fast [status] Toggle or report Codex fast mode
48
46
  /notification Configure the completion sound
49
47
  /handoff [focus] Fresh session with continuation context
50
48
  /clear New session after confirmation
@@ -56,8 +54,8 @@ Pin a release by appending its tag, for example `@v2.1.26`. Add `-l` to install
56
54
  | Mode | What works |
57
55
  | --- | --- |
58
56
  | TUI | Everything |
59
- | RPC | Goals, proactive compaction; no TUI components, `/init`, sounds, title indicator |
60
- | Print/JSON | No interactive questions, `/goal`, `/init`, or proactive compaction |
57
+ | RPC | Goals, proactive compaction; no TUI components, sounds, title indicator |
58
+ | Print/JSON | No interactive questions, `/goal`, or proactive compaction |
61
59
 
62
60
  ## Configuration
63
61
 
@@ -33,7 +33,6 @@ export interface AutoCompactionDependencies {
33
33
  loadPreference?: (ctx: ExtensionContext) => AutoCompactionPreference;
34
34
  getCompactionSettings?: (ctx: ExtensionContext) => CompactionSettings;
35
35
  goal?: AutoCompactionGoalHandlers;
36
- isInitActive?: () => boolean;
37
36
  }
38
37
 
39
38
  type AutoCompactionRequest = {
@@ -193,7 +192,7 @@ export function registerAutoCompaction(
193
192
  };
194
193
 
195
194
  pi.on("turn_end", (_event, ctx) => {
196
- if (!supportedMode(ctx) || dependencies.isInitActive?.() === true || request) return;
195
+ if (!supportedMode(ctx) || request) return;
197
196
 
198
197
  let preference: AutoCompactionPreference;
199
198
  let compactionSettings: CompactionSettings;
@@ -1,16 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
- import { watch } from "node:fs";
3
- import { lstat, open, readFile, readlink } from "node:fs/promises";
2
+ import { createReadStream, watch } from "node:fs";
3
+ import { PASSIVE_GIT_CONFIG_ARGS, passiveGitCommand, passiveGitEnv, passiveStatusSafetyArgs, samePassiveFilters } from "./passive-git-status.ts";
4
+ import { lstat, open, readlink } from "node:fs/promises";
4
5
  import path from "node:path";
5
- import { promisify } from "node:util";
6
- import { inflate } from "node:zlib";
6
+ import { createInflate } from "node:zlib";
7
7
 
8
8
  const GIT_TIMEOUT_MS = 5_000;
9
9
  const GIT_OUTPUT_LIMIT = 16 * 1024 * 1024;
10
10
  const SNAPSHOT_CONTENT_LIMIT = 128 * 1024 * 1024;
11
11
  const MAX_DIFF_OPERATIONS = 500_000;
12
12
  const MAX_FILES = 20;
13
- const inflateAsync = promisify(inflate);
13
+ const GIT_OBJECT_HEADER_LIMIT = 1_024;
14
14
 
15
15
  export type ChangeUnavailableReason = "not-git" | "timeout" | "too-large" | "error";
16
16
 
@@ -43,7 +43,12 @@ export const CHECK_LABELS = [
43
43
  ...GRADLE_CHECK_LABELS,
44
44
  ] as const;
45
45
 
46
- export type CheckLabel = typeof CHECK_LABELS[number];
46
+ const FOCUSED_CHECK_LABEL = "node --test (focused)";
47
+ const FOCUSED_TEST_UNSAFE_CHARACTERS = [
48
+ '"', "'", "`", "$", ";", "&", "|", "<", ">", "*", "?", "[", "]", "{", "}", "(", ")", "#", "!",
49
+ ] as const;
50
+
51
+ export type CheckLabel = typeof CHECK_LABELS[number] | typeof FOCUSED_CHECK_LABEL;
47
52
 
48
53
  export interface ChangeReceiptCollection {
49
54
  finish(): Promise<ChangeSummary>;
@@ -61,11 +66,19 @@ class GitFailure extends Error {
61
66
  }
62
67
  }
63
68
 
64
- function runGit(cwd: string, args: readonly string[], input?: Buffer): Promise<Buffer> {
69
+ function runGit(cwd: string, args: readonly string[], input?: Buffer, stdoutLimit = GIT_OUTPUT_LIMIT): Promise<Buffer> {
70
+ let gitCommand: string;
71
+ try {
72
+ const found = passiveGitCommand(cwd);
73
+ if (!found) return Promise.reject(new GitFailure("error"));
74
+ gitCommand = found;
75
+ } catch {
76
+ return Promise.reject(new GitFailure("error"));
77
+ }
65
78
  return new Promise((resolve, reject) => {
66
- const child = spawn("git", args, {
79
+ const child = spawn(gitCommand, args, {
67
80
  cwd,
68
- env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
81
+ env: passiveGitEnv(),
69
82
  stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
70
83
  windowsHide: true,
71
84
  });
@@ -82,7 +95,7 @@ function runGit(cwd: string, args: readonly string[], input?: Buffer): Promise<B
82
95
  timer.unref();
83
96
  const capture = (chunks: Buffer[], isStdout: boolean) => (chunk: Buffer): void => {
84
97
  const nextBytes = (isStdout ? stdoutBytes : stderrBytes) + chunk.length;
85
- if (nextBytes > GIT_OUTPUT_LIMIT) {
98
+ if (nextBytes > (isStdout ? stdoutLimit : GIT_OUTPUT_LIMIT)) {
86
99
  failure = new GitFailure("too-large");
87
100
  child.kill();
88
101
  return;
@@ -129,15 +142,6 @@ type Repository = {
129
142
  };
130
143
  const repositoryCache = new Map<string, Promise<Repository>>();
131
144
 
132
- async function loadFilterNames(root: string): Promise<readonly string[]> {
133
- const records = decode(await runGit(root, ["config", "--null", "--name-only", "--list"])).split("\0");
134
- const names = new Set<string>();
135
- for (const key of records) {
136
- if (key && /^filter\..*\.(clean|process)$/u.test(key)) names.add(key.slice("filter.".length, key.lastIndexOf(".")));
137
- }
138
- return [...names];
139
- }
140
-
141
145
  async function repository(cwd: string): Promise<Repository> {
142
146
  const cached = repositoryCache.get(cwd);
143
147
  if (cached) return cached;
@@ -253,13 +257,15 @@ function discardMonitor(monitor: RepositoryMonitor): void {
253
257
  }
254
258
 
255
259
  async function snapshot(repo: Repository, paths?: readonly string[]): Promise<Snapshot> {
256
- const filterNames = await loadFilterNames(repo.root);
260
+ const config = decode(await runGit(repo.root, PASSIVE_GIT_CONFIG_ARGS));
261
+ const safetyArgs = passiveStatusSafetyArgs(config);
262
+ if (!safetyArgs) throw new GitFailure("error");
257
263
  const output = decode(await runGit(repo.root, [
258
- "-c", "core.fsmonitor=false",
259
- ...filterNames.flatMap((name) => ["-c", `filter.${name}.clean=`, "-c", `filter.${name}.process=`, "-c", `filter.${name}.required=false`]),
264
+ ...safetyArgs,
260
265
  "status", "--porcelain=v2", "--branch", "--no-ahead-behind", "-z", "--no-renames", "--untracked-files=all", "--ignore-submodules=all",
261
266
  ...(paths ? ["--", ...paths] : []),
262
267
  ]));
268
+ if (!samePassiveFilters(config, decode(await runGit(repo.root, PASSIVE_GIT_CONFIG_ARGS)))) throw new GitFailure("error");
263
269
  const records = output.split("\0").filter(Boolean);
264
270
  const headRecord = records.find((record) => record.startsWith("# branch.oid "));
265
271
  if (!headRecord) throw new Error("missing HEAD state");
@@ -402,24 +408,60 @@ function createMonitor(repo: Repository, initialSnapshot: Snapshot): RepositoryM
402
408
  return monitor;
403
409
  }
404
410
 
405
- async function looseBlob(objectDirectory: string, id: string): Promise<Buffer | undefined> {
411
+ function boundedBlobSize(value: string, limit: number): number {
412
+ const size = Number(value);
413
+ if (!Number.isSafeInteger(size) || size > limit) throw new GitFailure("too-large");
414
+ return size;
415
+ }
416
+
417
+ async function looseBlob(objectDirectory: string, id: string, limit: number): Promise<Buffer | undefined> {
418
+ const source = createReadStream(path.join(objectDirectory, id.slice(0, 2), id.slice(2)));
419
+ const inflater = createInflate();
420
+ source.once("error", (error) => inflater.destroy(error));
421
+ source.pipe(inflater);
422
+ let header = Buffer.alloc(0);
423
+ let expectedSize: number | undefined;
424
+ let body: Buffer | undefined;
425
+ let bodyBytes = 0;
406
426
  try {
407
- const inflated = await inflateAsync(await readFile(path.join(objectDirectory, id.slice(0, 2), id.slice(2))));
408
- const separator = inflated.indexOf(0);
409
- if (separator < 0 || !inflated.subarray(0, separator).toString("ascii").startsWith("blob ")) throw new Error("invalid blob");
410
- return inflated.subarray(separator + 1);
427
+ for await (const chunk of inflater) {
428
+ let data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
429
+ if (expectedSize === undefined) {
430
+ const separator = data.indexOf(0);
431
+ if (separator < 0) {
432
+ if (header.length + data.length > GIT_OBJECT_HEADER_LIMIT) throw new Error("invalid blob header");
433
+ header = header.length === 0 ? Buffer.from(data) : Buffer.concat([header, data]);
434
+ continue;
435
+ }
436
+ if (header.length + separator > GIT_OBJECT_HEADER_LIMIT) throw new Error("invalid blob header");
437
+ const headerBytes = header.length === 0 ? data.subarray(0, separator) : Buffer.concat([header, data.subarray(0, separator)]);
438
+ const match = /^blob ([0-9]+)$/u.exec(headerBytes.toString("ascii"));
439
+ if (!match) throw new Error("invalid blob");
440
+ expectedSize = boundedBlobSize(match[1], limit);
441
+ body = Buffer.alloc(expectedSize);
442
+ data = data.subarray(separator + 1);
443
+ }
444
+ if (!body || expectedSize === undefined || data.length > expectedSize - bodyBytes) throw new Error("invalid blob");
445
+ data.copy(body, bodyBytes);
446
+ bodyBytes += data.length;
447
+ }
448
+ if (expectedSize === undefined || !body || bodyBytes !== expectedSize) throw new Error("invalid blob");
449
+ return body;
411
450
  } catch (error) {
412
- if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined;
451
+ if (missingFile(error)) return undefined;
413
452
  throw error;
453
+ } finally {
454
+ source.destroy();
455
+ inflater.destroy();
414
456
  }
415
457
  }
416
458
 
417
459
  function cacheBlob(repo: Repository, id: string, content: Buffer): void {
460
+ if (content.length > SNAPSHOT_CONTENT_LIMIT) return;
418
461
  if (repo.blobCacheBytes + content.length > SNAPSHOT_CONTENT_LIMIT) {
419
462
  repo.blobCache.clear();
420
463
  repo.blobCacheBytes = 0;
421
464
  }
422
- if (content.length > SNAPSHOT_CONTENT_LIMIT) return;
423
465
  repo.blobCache.set(id, content);
424
466
  repo.blobCacheBytes += content.length;
425
467
  }
@@ -427,34 +469,42 @@ function cacheBlob(repo: Repository, id: string, content: Buffer): void {
427
469
  async function loadHeadBlobs(repo: Repository, ids: readonly string[]): Promise<Map<string, Buffer>> {
428
470
  const blobs = new Map<string, Buffer>();
429
471
  const missing: string[] = [];
472
+ let blobBytes = 0;
430
473
  for (const id of new Set(ids)) {
431
474
  const cached = repo.blobCache.get(id);
432
- if (cached) {
475
+ if (cached !== undefined) {
476
+ if (cached.length > SNAPSHOT_CONTENT_LIMIT - blobBytes) throw new GitFailure("too-large");
477
+ blobBytes += cached.length;
433
478
  blobs.set(id, cached);
434
479
  continue;
435
480
  }
436
- const content = await looseBlob(repo.objectDirectory, id);
437
- if (content) {
481
+ const content = await looseBlob(repo.objectDirectory, id, SNAPSHOT_CONTENT_LIMIT - blobBytes);
482
+ if (content !== undefined) {
483
+ blobBytes += content.length;
438
484
  blobs.set(id, content);
439
485
  cacheBlob(repo, id, content);
440
486
  } else missing.push(id);
441
487
  }
442
488
  if (missing.length > 0) {
443
- const output = await runGit(repo.root, ["cat-file", "--batch"], Buffer.from(`${missing.join("\n")}\n`));
489
+ const batchOutputLimit = SNAPSHOT_CONTENT_LIMIT + missing.reduce((total, id) => total + id.length + 32, 0);
490
+ const output = await runGit(repo.root, ["cat-file", "--batch"], Buffer.from(`${missing.join("\n")}\n`), batchOutputLimit);
444
491
  let offset = 0;
445
492
  for (const id of missing) {
446
493
  const headerEnd = output.indexOf(10, offset);
447
- const match = /^([0-9a-f]+) blob (\d+)$/u.exec(output.subarray(offset, headerEnd).toString("ascii"));
494
+ if (headerEnd < 0 || headerEnd - offset > GIT_OBJECT_HEADER_LIMIT) throw new Error("invalid batch blob");
495
+ const match = /^([0-9a-f]+) blob ([0-9]+)$/u.exec(output.subarray(offset, headerEnd).toString("ascii"));
448
496
  if (!match || match[1] !== id) throw new Error("invalid batch blob");
449
- const size = Number(match[2]);
497
+ const size = boundedBlobSize(match[2], SNAPSHOT_CONTENT_LIMIT - blobBytes);
450
498
  const start = headerEnd + 1;
451
499
  const end = start + size;
452
500
  if (output[end] !== 10) throw new Error("invalid batch body");
453
501
  const content = Buffer.from(output.subarray(start, end));
502
+ blobBytes += size;
454
503
  blobs.set(id, content);
455
504
  cacheBlob(repo, id, content);
456
505
  offset = end + 1;
457
506
  }
507
+ if (offset !== output.length) throw new Error("invalid batch blob");
458
508
  }
459
509
  return blobs;
460
510
  }
@@ -633,14 +683,36 @@ async function compare(repo: Repository, baseline: Snapshot, settlement: Snapsho
633
683
  return { state: "available", totalFiles: changes.length, additions, deletions, files: changes.slice(0, MAX_FILES), omittedFiles: Math.max(0, changes.length - MAX_FILES) };
634
684
  }
635
685
 
686
+ function isFocusedTestFile(value: string): boolean {
687
+ if (value.startsWith("-") || value.startsWith("~") || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)
688
+ || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value) || /[\s\p{Cc}]/u.test(value)
689
+ || FOCUSED_TEST_UNSAFE_CHARACTERS.some((character) => value.includes(character))) return false;
690
+ const segments = value.split(/[\\/]/u);
691
+ const filename = segments.at(-1);
692
+ return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..")
693
+ && filename !== undefined
694
+ && filename.includes(".test.")
695
+ && /\.(?:js|cjs|mjs|ts|cts|mts)$/u.test(filename);
696
+ }
697
+
636
698
  export function recognizedCheck(command: unknown, failed: boolean): CheckAttempt | undefined {
637
699
  if (typeof command !== "string") return undefined;
638
700
  const normalized = command.replace(/^[\t ]+|[\t ]+$/gu, "");
701
+ const outcome = failed ? "failed" : "passed";
639
702
  const label = CHECK_LABELS.find((candidate) => normalized === candidate);
640
- return label ? { label, outcome: failed ? "failed" : "passed" } : undefined;
703
+ if (label) return { label, outcome };
704
+ const focused = /^node --test(?: --experimental-strip-types)? ([^ \t]+)$/u.exec(normalized);
705
+ if (!focused?.[1] || !isFocusedTestFile(focused[1])) return undefined;
706
+ return { label: FOCUSED_CHECK_LABEL, outcome };
641
707
  }
642
708
 
643
- export async function beginChangeReceipt(cwd: string): Promise<ChangeReceiptCollection> {
709
+ export async function beginChangeReceipt(cwd: string, trusted = true): Promise<ChangeReceiptCollection> {
710
+ if (!trusted) {
711
+ return {
712
+ finish: async () => ({ state: "unavailable", reason: "error" }),
713
+ dispose: async () => undefined,
714
+ };
715
+ }
644
716
  try {
645
717
  const repo = await repository(cwd);
646
718
  let monitor = repositoryMonitors.get(repo.root);
@@ -10,10 +10,15 @@ function isRequestPayload(value: unknown): value is RequestPayload {
10
10
 
11
11
  export function registerCodexFastMode(pi: ExtensionAPI): void {
12
12
  pi.registerCommand("codex-fast", {
13
- description: "Toggle Codex fast mode",
13
+ description: "Toggle or report Codex fast mode",
14
14
  handler: async (args, ctx) => {
15
- if (args.trim()) {
16
- ctx.ui.notify("Usage: /codex-fast", "error");
15
+ const trimmed = args.trim();
16
+ if (trimmed === "status") {
17
+ ctx.ui.notify(`Codex fast mode: ${isCodexFastEnabled() ? "enabled" : "disabled"}`, "info");
18
+ return;
19
+ }
20
+ if (trimmed) {
21
+ ctx.ui.notify("Usage: /codex-fast [status]", "error");
17
22
  return;
18
23
  }
19
24
 
@@ -4,6 +4,7 @@ import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor }
4
4
  import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
5
5
  import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
6
6
  import { formatCwd, formatTime, modelDisplayName, padRight } from "./display.ts";
7
+ import { PASSIVE_GIT_CONFIG_ARGS, passiveGitCommand, passiveGitEnv, passiveStatusSafetyArgs, samePassiveFilters } from "./passive-git-status.ts";
7
8
  import { goalElapsedMilliseconds } from "./goal-state.ts";
8
9
  import type { GoalRuntime, GoalState } from "./runtime.ts";
9
10
  import { safeTerminalText } from "./safe-terminal-text.ts";
@@ -35,42 +36,90 @@ type GitStatusExecutor = (
35
36
  callback: (error: Error | null, stdout: string) => void,
36
37
  ) => unknown;
37
38
 
38
- /** Resolves changed-file counts with a bounded asynchronous Git status process. */
39
+ /** Resolves changed-file counts with a bounded asynchronous Git status process in a trusted project. */
39
40
  export function resolveGitFileChanges(
40
41
  cwd: string,
41
42
  execute: GitStatusExecutor = execFile,
43
+ trusted = true,
42
44
  ): Promise<GitFileChanges | undefined> {
45
+ if (!trusted) return Promise.resolve(undefined);
43
46
  return new Promise((resolve) => {
44
- execute(
45
- "git",
46
- ["-C", cwd, "-c", "core.fsmonitor=false", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
47
- {
48
- encoding: "utf8",
49
- env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
50
- maxBuffer: 4 * 1024 * 1024,
51
- timeout: GIT_STATUS_TIMEOUT_MS,
52
- windowsHide: true,
53
- },
54
- (error, stdout) => {
47
+ let gitCommand: string;
48
+ try {
49
+ const found = passiveGitCommand(cwd);
50
+ if (!found) {
51
+ resolve(undefined);
52
+ return;
53
+ }
54
+ gitCommand = found;
55
+ } catch {
56
+ resolve(undefined);
57
+ return;
58
+ }
59
+ const options = {
60
+ encoding: "utf8" as const,
61
+ env: passiveGitEnv(),
62
+ maxBuffer: 4 * 1024 * 1024,
63
+ timeout: GIT_STATUS_TIMEOUT_MS,
64
+ windowsHide: true as const,
65
+ };
66
+ const verifyFiltersUnchanged = (before: string, done: (unchanged: boolean) => void): void => {
67
+ try {
68
+ execute(gitCommand, ["-C", cwd, ...PASSIVE_GIT_CONFIG_ARGS], options, (error, after) => {
69
+ done(!error && samePassiveFilters(before, after));
70
+ });
71
+ } catch {
72
+ done(false);
73
+ }
74
+ };
75
+ const runStatus = (config: string, args: string[]): void => {
76
+ try {
77
+ execute(gitCommand, args, options, (error, stdout) => {
78
+ if (error) {
79
+ resolve(undefined);
80
+ return;
81
+ }
82
+ verifyFiltersUnchanged(config, (unchanged) => {
83
+ if (!unchanged) {
84
+ resolve(undefined);
85
+ return;
86
+ }
87
+
88
+ const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
89
+ const entries = stdout.split("\0");
90
+ for (let index = 0; index < entries.length; index += 1) {
91
+ const entry = entries[index];
92
+ if (!entry) continue;
93
+ const status = entry.slice(0, 2);
94
+ if (status.includes("D")) changes.deleted += 1;
95
+ else if (status === "??" || status.includes("A")) changes.added += 1;
96
+ else changes.modified += 1;
97
+ if (status.includes("R") || status.includes("C")) index += 1;
98
+ }
99
+ resolve(changes);
100
+ });
101
+ });
102
+ } catch {
103
+ resolve(undefined);
104
+ }
105
+ };
106
+
107
+ try {
108
+ execute(gitCommand, ["-C", cwd, ...PASSIVE_GIT_CONFIG_ARGS], options, (error, config) => {
55
109
  if (error) {
56
110
  resolve(undefined);
57
111
  return;
58
112
  }
59
-
60
- const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
61
- const entries = stdout.split("\0");
62
- for (let index = 0; index < entries.length; index += 1) {
63
- const entry = entries[index];
64
- if (!entry) continue;
65
- const status = entry.slice(0, 2);
66
- if (status.includes("D")) changes.deleted += 1;
67
- else if (status === "??" || status.includes("A")) changes.added += 1;
68
- else changes.modified += 1;
69
- if (status.includes("R") || status.includes("C")) index += 1;
113
+ const safetyArgs = passiveStatusSafetyArgs(config);
114
+ if (!safetyArgs) {
115
+ resolve(undefined);
116
+ return;
70
117
  }
71
- resolve(changes);
72
- },
73
- );
118
+ runStatus(config, ["-C", cwd, ...safetyArgs, "status", "--porcelain=v1", "-z", "--untracked-files=all"]);
119
+ });
120
+ } catch {
121
+ resolve(undefined);
122
+ }
74
123
  });
75
124
  }
76
125
 
@@ -389,7 +438,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
389
438
  if (JSON.stringify(changes) === JSON.stringify(gitFileChanges)) return;
390
439
  gitFileChanges = changes;
391
440
  tui.requestRender();
392
- });
441
+ }, (cwd) => resolveGitFileChanges(cwd, execFile, ctx.isProjectTrusted()));
393
442
  const unsubscribe = footerData.onBranchChange(() => {
394
443
  gitStatus.request();
395
444
  tui.requestRender();
@@ -8,7 +8,7 @@ import { reportError } from "./errors.ts";
8
8
  import { parseGoalCommand } from "./goal-command.ts";
9
9
  import { GOAL_ENTRY_TYPE, GOAL_UPDATE_TOOL, isGoalModeSupported, isSavedSession, pauseGoalAfterFailure, persistGoalState, scheduleGoalContinuation, stopGoalRun, sumGoalTokens, syncGoalUpdateTool, transitionGoal, type GoalEntryData } from "./goal-runtime.ts";
10
10
  import { checkpointPausedGoalState, createNewGoalState, DEFAULT_GOAL_MAX_TURNS, GOAL_MAX_TURNS, goalElapsedMilliseconds, GOAL_VERSION, inferGoalVerification, parseGoalState, recordGoalBlockerAudit, transitionGoalState, verifyGoalDeliverable } from "./goal-state.ts";
11
- import type { GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
11
+ import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
12
12
  import { safeTerminalText } from "./safe-terminal-text.ts";
13
13
 
14
14
  const GoalUpdateParams = Type.Object({
@@ -65,7 +65,6 @@ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
65
65
  export function registerGoalInterface(
66
66
  pi: ExtensionAPI,
67
67
  runtime: GoalRuntime,
68
- initState: InitRuntime,
69
68
  ): void {
70
69
  pi.registerEntryRenderer<GoalEntryData>(GOAL_ENTRY_TYPE, (entry, options, theme) => {
71
70
  const data = entry.data;
@@ -286,10 +285,6 @@ export function registerGoalInterface(
286
285
  }
287
286
 
288
287
  if (command.kind === "resume") {
289
- if (initState.active) {
290
- ctx.ui.notify("Wait for /init to finish before resuming a goal", "error");
291
- return;
292
- }
293
288
  if (!runtime.state) {
294
289
  ctx.ui.notify("No goal is set", "info");
295
290
  return;
@@ -313,7 +308,7 @@ export function registerGoalInterface(
313
308
  const base = transitionGoalState(runtime.state, "active", undefined, { resetBlockedAudit: true }, Date.now());
314
309
  persistGoalState(pi, runtime, "resume", { ...base, maxTurns: renewed });
315
310
  runtime.continuationScheduled = false;
316
- if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal resumed", "info");
311
+ if (scheduleGoalContinuation(pi, runtime, ctx)) ctx.ui.notify("Goal resumed", "info");
317
312
  } catch (error) {
318
313
  reportError(ctx, "Goal could not be resumed", error);
319
314
  }
@@ -322,17 +317,13 @@ export function registerGoalInterface(
322
317
  try {
323
318
  transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
324
319
  runtime.continuationScheduled = false;
325
- if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal resumed", "info");
320
+ if (scheduleGoalContinuation(pi, runtime, ctx)) ctx.ui.notify("Goal resumed", "info");
326
321
  } catch (error) {
327
322
  reportError(ctx, "Goal could not be resumed", error);
328
323
  }
329
324
  return;
330
325
  }
331
326
 
332
- if (initState.active) {
333
- ctx.ui.notify("Wait for /init to finish before starting a goal", "error");
334
- return;
335
- }
336
327
  switch (command.kind) {
337
328
  case "objective":
338
329
  break;
@@ -364,7 +355,7 @@ export function registerGoalInterface(
364
355
  }
365
356
  if (waitError) {
366
357
  reportError(ctx, "Goal could not wait for the active turn", waitError);
367
- scheduleGoalContinuation(pi, runtime, initState, ctx);
358
+ scheduleGoalContinuation(pi, runtime, ctx);
368
359
  return;
369
360
  }
370
361
  let verification: Awaited<ReturnType<typeof inferGoalVerification>>;
@@ -375,7 +366,7 @@ export function registerGoalInterface(
375
366
  reportError(ctx, "Goal could not be started", error);
376
367
  } else {
377
368
  reportError(ctx, "Goal could not be replaced", error);
378
- scheduleGoalContinuation(pi, runtime, initState, ctx);
369
+ scheduleGoalContinuation(pi, runtime, ctx);
379
370
  }
380
371
  return;
381
372
  }
@@ -384,7 +375,7 @@ export function registerGoalInterface(
384
375
  maxTurns: DEFAULT_GOAL_MAX_TURNS,
385
376
  });
386
377
  persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
387
- if (scheduleGoalContinuation(pi, runtime, initState, ctx)) {
378
+ if (scheduleGoalContinuation(pi, runtime, ctx)) {
388
379
  ctx.ui.notify("Goal active. KillerOS will continue until completion, a repeated blocker, or pause.", "info");
389
380
  }
390
381
  } catch (error) {