@oh-my-pi/pi-coding-agent 16.2.5 → 16.2.7

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/cli.js +3089 -3104
  3. package/dist/types/cli/bench-cli.d.ts +6 -1
  4. package/dist/types/commands/bench.d.ts +8 -0
  5. package/dist/types/config/model-discovery.d.ts +6 -1
  6. package/dist/types/config/service-tier.d.ts +39 -24
  7. package/dist/types/config/settings-schema.d.ts +37 -37
  8. package/dist/types/edit/index.d.ts +1 -0
  9. package/dist/types/edit/renderer.d.ts +4 -0
  10. package/dist/types/edit/snapshot-details.d.ts +33 -0
  11. package/dist/types/mcp/config-writer.d.ts +48 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +4 -6
  13. package/dist/types/mcp/types.d.ts +3 -0
  14. package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
  15. package/dist/types/session/agent-session.d.ts +37 -13
  16. package/dist/types/session/messages.d.ts +1 -1
  17. package/dist/types/session/session-context.d.ts +2 -2
  18. package/dist/types/session/session-entries.d.ts +2 -2
  19. package/dist/types/session/session-manager.d.ts +5 -6
  20. package/dist/types/system-prompt.test.d.ts +1 -0
  21. package/dist/types/task/executor.d.ts +6 -6
  22. package/dist/types/task/types.d.ts +6 -0
  23. package/dist/types/task/worktree.d.ts +32 -6
  24. package/dist/types/tiny/title-client.d.ts +5 -1
  25. package/dist/types/tools/index.d.ts +3 -3
  26. package/dist/types/utils/git.d.ts +17 -0
  27. package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
  28. package/dist/types/web/search/types.d.ts +1 -1
  29. package/package.json +12 -12
  30. package/src/cli/args.ts +32 -1
  31. package/src/cli/bench-cli.ts +97 -22
  32. package/src/cli/tiny-models-cli.ts +18 -4
  33. package/src/cli/web-search-cli.ts +6 -1
  34. package/src/commands/bench.ts +10 -1
  35. package/src/config/mcp-schema.json +11 -2
  36. package/src/config/model-discovery.ts +66 -8
  37. package/src/config/model-registry.ts +13 -6
  38. package/src/config/service-tier.ts +85 -56
  39. package/src/config/settings-schema.ts +42 -36
  40. package/src/config/settings.ts +47 -0
  41. package/src/edit/hashline/execute.ts +15 -9
  42. package/src/edit/index.ts +19 -6
  43. package/src/edit/modes/patch.ts +3 -2
  44. package/src/edit/modes/replace.ts +3 -2
  45. package/src/edit/renderer.ts +4 -0
  46. package/src/edit/snapshot-details.ts +77 -0
  47. package/src/eval/agent-bridge.ts +4 -2
  48. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  49. package/src/internal-urls/docs-index.generated.txt +1 -1
  50. package/src/main.ts +1 -1
  51. package/src/mcp/config-writer.ts +121 -0
  52. package/src/mcp/config.ts +10 -6
  53. package/src/mcp/oauth-flow.ts +10 -8
  54. package/src/mcp/transports/stdio.ts +9 -17
  55. package/src/mcp/types.ts +3 -0
  56. package/src/modes/components/extensions/extension-dashboard.ts +46 -0
  57. package/src/modes/components/extensions/state-manager.ts +24 -3
  58. package/src/modes/controllers/event-controller.ts +24 -8
  59. package/src/modes/controllers/tool-args-reveal.ts +100 -22
  60. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  61. package/src/prompts/bench.md +4 -10
  62. package/src/prompts/tools/irc.md +19 -29
  63. package/src/prompts/tools/job.md +8 -14
  64. package/src/prompts/tools/lsp.md +19 -30
  65. package/src/prompts/tools/task.md +42 -62
  66. package/src/sdk.ts +13 -11
  67. package/src/session/agent-session.ts +196 -76
  68. package/src/session/messages.ts +1 -1
  69. package/src/session/session-context.ts +4 -4
  70. package/src/session/session-entries.ts +2 -2
  71. package/src/session/session-listing.ts +9 -8
  72. package/src/session/session-loader.ts +98 -3
  73. package/src/session/session-manager.ts +43 -6
  74. package/src/slash-commands/builtin-registry.ts +2 -10
  75. package/src/subprocess/worker-client.ts +12 -4
  76. package/src/system-prompt.test.ts +158 -0
  77. package/src/system-prompt.ts +69 -26
  78. package/src/task/executor.ts +23 -16
  79. package/src/task/index.ts +7 -5
  80. package/src/task/isolation-runner.ts +15 -1
  81. package/src/task/types.ts +6 -0
  82. package/src/task/worktree.ts +219 -38
  83. package/src/tiny/title-client.ts +19 -13
  84. package/src/tools/index.ts +3 -3
  85. package/src/tools/irc.ts +9 -3
  86. package/src/tools/path-utils.ts +4 -2
  87. package/src/tools/read.ts +28 -28
  88. package/src/utils/file-mentions.ts +10 -1
  89. package/src/utils/git.ts +38 -0
  90. package/src/web/search/index.ts +14 -8
  91. package/src/web/search/providers/duckduckgo.ts +150 -78
  92. package/src/web/search/providers/gemini.ts +268 -185
  93. package/src/web/search/types.ts +1 -1
@@ -1,8 +1,11 @@
1
+ import * as fs from "node:fs";
2
+ import * as readline from "node:readline";
1
3
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
4
  import { getBlobsDir, isEnoent, parseJsonlLenient } from "@oh-my-pi/pi-utils";
3
5
  import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./blob-store";
4
6
  import { buildSessionContext } from "./session-context";
5
7
  import {
8
+ type CompactionEntry,
6
9
  type FileEntry,
7
10
  type RawFileEntry,
8
11
  SESSION_TITLE_SLOT_BYTES,
@@ -20,6 +23,10 @@ import {
20
23
  titleUpdateFromSlot,
21
24
  } from "./session-title-slot";
22
25
 
26
+ const STREAM_LOAD_THRESHOLD_BYTES = 8 * 1024 * 1024;
27
+ const ELIDED_COMPACTION_SUMMARY = "[Superseded compaction summary elided during session load]";
28
+ const ELIDED_COMPACTION_SHORT_SUMMARY = "Superseded compaction elided";
29
+
23
30
  function splitTitleSlot(content: string): { body: string; slot: SessionTitleUpdate | undefined } {
24
31
  const slot = titleUpdateFromSlot(parseTitleSlotFromContent(content));
25
32
  if (!slot) return { body: content, slot: undefined };
@@ -54,6 +61,89 @@ export function parseSessionContent(content: string): {
54
61
  return { entries: foldTitleSlot(entries, slot), titleSlot: slot };
55
62
  }
56
63
 
64
+ function elideCompactionSummary(entry: CompactionEntry | undefined): boolean {
65
+ if (!entry) return false;
66
+ if (
67
+ entry.summary === ELIDED_COMPACTION_SUMMARY &&
68
+ entry.shortSummary === ELIDED_COMPACTION_SHORT_SUMMARY &&
69
+ entry.preserveData === undefined
70
+ ) {
71
+ return false;
72
+ }
73
+ entry.summary = ELIDED_COMPACTION_SUMMARY;
74
+ entry.shortSummary = ELIDED_COMPACTION_SHORT_SUMMARY;
75
+ entry.preserveData = undefined;
76
+ return true;
77
+ }
78
+
79
+ function collectActiveBranchIds(entries: FileEntry[]): Set<string> {
80
+ const byId = new Map<string, SessionEntry>();
81
+ for (const entry of entries) {
82
+ const id = (entry as SessionEntry).id;
83
+ if (typeof id === "string") byId.set(id, entry as SessionEntry);
84
+ }
85
+ const branchIds = new Set<string>();
86
+ let cursor = entries[entries.length - 1] as SessionEntry | undefined;
87
+ while (cursor && typeof cursor.id === "string" && !branchIds.has(cursor.id)) {
88
+ branchIds.add(cursor.id);
89
+ const parentId = cursor.parentId;
90
+ cursor = parentId ? byId.get(parentId) : undefined;
91
+ }
92
+ return branchIds;
93
+ }
94
+
95
+ function elideSupersededCompactionEntries(entries: FileEntry[]): void {
96
+ const branchIds = collectActiveBranchIds(entries);
97
+ let previousCompaction: CompactionEntry | undefined;
98
+ for (const entry of entries) {
99
+ if (entry.type !== "compaction") continue;
100
+ if (!branchIds.has(entry.id)) continue;
101
+ elideCompactionSummary(previousCompaction);
102
+ previousCompaction = entry;
103
+ }
104
+ }
105
+
106
+ async function loadEntriesFromFileStream(filePath: string): Promise<{
107
+ entries: FileEntry[];
108
+ titleSlot: SessionTitleUpdate | undefined;
109
+ }> {
110
+ const entries: FileEntry[] = [];
111
+ let titleSlot: SessionTitleUpdate | undefined;
112
+ let sawBodyLine = false;
113
+ const input = fs.createReadStream(filePath, { encoding: "utf8" });
114
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
115
+
116
+ try {
117
+ for await (const rawLine of lines) {
118
+ const line = rawLine.trim();
119
+ if (!line) continue;
120
+ if (!sawBodyLine) {
121
+ const slot = parseTitleSlotLine(line);
122
+ if (slot) {
123
+ titleSlot = titleUpdateFromSlot(slot);
124
+ sawBodyLine = true;
125
+ continue;
126
+ }
127
+ sawBodyLine = true;
128
+ }
129
+
130
+ let entry: FileEntry;
131
+ try {
132
+ entry = JSON.parse(line) as FileEntry;
133
+ } catch {
134
+ continue;
135
+ }
136
+ entries.push(entry);
137
+ }
138
+ } catch (err) {
139
+ input.destroy();
140
+ if (isEnoent(err)) return { entries: [], titleSlot: undefined };
141
+ throw err;
142
+ }
143
+
144
+ return { entries: foldTitleSlot(entries, titleSlot), titleSlot };
145
+ }
146
+
57
147
  /** Read only the fixed-size head window to detect a physical title slot. */
58
148
  export async function readTitleSlotFromFile(
59
149
  filePath: string,
@@ -80,14 +170,19 @@ export async function loadEntriesFromFile(
80
170
  filePath: string,
81
171
  storage: SessionStorage = new FileSessionStorage(),
82
172
  ): Promise<FileEntry[]> {
83
- let content: string;
173
+ let loaded: { entries: FileEntry[]; titleSlot: SessionTitleUpdate | undefined };
84
174
  try {
85
- content = await storage.readText(filePath);
175
+ const stat = storage.statSync(filePath);
176
+ loaded =
177
+ storage instanceof FileSessionStorage && stat.size >= STREAM_LOAD_THRESHOLD_BYTES
178
+ ? await loadEntriesFromFileStream(filePath)
179
+ : parseSessionContent(await storage.readText(filePath));
86
180
  } catch (err) {
87
181
  if (isEnoent(err)) return [];
88
182
  throw err;
89
183
  }
90
- const { entries } = parseSessionContent(content);
184
+ const { entries } = loaded;
185
+ elideSupersededCompactionEntries(entries);
91
186
 
92
187
  // Validate session header
93
188
  if (entries.length === 0) return entries;
@@ -1,6 +1,13 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { ImageContent, Message, MessageAttribution, ServiceTier, TextContent, Usage } from "@oh-my-pi/pi-ai";
3
+ import type {
4
+ ImageContent,
5
+ Message,
6
+ MessageAttribution,
7
+ ServiceTierByFamily,
8
+ TextContent,
9
+ Usage,
10
+ } from "@oh-my-pi/pi-ai";
4
11
  import {
5
12
  directoryExists,
6
13
  getBlobsDir,
@@ -66,6 +73,8 @@ import {
66
73
  import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot";
67
74
 
68
75
  const JSONL_SUFFIX_LENGTH = ".jsonl".length;
76
+ const SUPERSEDED_COMPACTION_SUMMARY = "[Superseded compaction summary elided after a newer compaction]";
77
+ const SUPERSEDED_COMPACTION_SHORT_SUMMARY = "Superseded compaction elided";
69
78
 
70
79
  function mintSessionId(): string {
71
80
  return Bun.randomUUIDv7();
@@ -502,6 +511,26 @@ export class SessionManager {
502
511
  return this.#forceFileCreation || this.#fileIsCurrent || this.#historyContainsAssistantMessage();
503
512
  }
504
513
 
514
+ #elideSupersededCompactionsOnBranch(leafId: string | null): boolean {
515
+ if (!leafId) return false;
516
+ let changed = false;
517
+ for (const entry of this.#index.pathTo(leafId)) {
518
+ if (entry.type !== "compaction") continue;
519
+ if (
520
+ entry.summary === SUPERSEDED_COMPACTION_SUMMARY &&
521
+ entry.shortSummary === SUPERSEDED_COMPACTION_SHORT_SUMMARY &&
522
+ entry.preserveData === undefined
523
+ ) {
524
+ continue;
525
+ }
526
+ entry.summary = SUPERSEDED_COMPACTION_SUMMARY;
527
+ entry.shortSummary = SUPERSEDED_COMPACTION_SHORT_SUMMARY;
528
+ entry.preserveData = undefined;
529
+ changed = true;
530
+ }
531
+ return changed;
532
+ }
533
+
505
534
  /**
506
535
  * Synchronously rewrite the whole file (header + entries) and keep no open
507
536
  * writer; the next append re-opens one. `writeTextSync` returns with the
@@ -1024,14 +1053,18 @@ export class SessionManager {
1024
1053
  }
1025
1054
 
1026
1055
  /**
1027
- * Synchronously flush all in-memory entries to disk. Use when the process may
1028
- * exit before an async flush settles (Ctrl+C in the TUI). Software-crash
1029
- * durable; not atomic and not power-loss safe a same-process crash never
1030
- * lands mid-`writeFileSync`.
1056
+ * Synchronously makes the current append-only session durable. Avoid rewriting
1057
+ * an already-current file: large restored sessions can contain GiB of compacted
1058
+ * history, and Ctrl+C must not rebuild the whole JSONL string just to flush.
1031
1059
  */
1032
1060
  flushSync(): void {
1033
1061
  if (!this.#persist || !this.#sessionFile) return;
1034
1062
  if (this.#diskFailure) throw this.#diskFailure;
1063
+ if (this.#fileIsCurrent && !this.#rewriteRequired) {
1064
+ const writerError = this.#writer?.getError();
1065
+ if (writerError) throw writerError;
1066
+ return;
1067
+ }
1035
1068
  this.#rewriteSynchronously();
1036
1069
  if (this.#diskFailure) throw this.#diskFailure;
1037
1070
  }
@@ -1260,7 +1293,7 @@ export class SessionManager {
1260
1293
  return entry.id;
1261
1294
  }
1262
1295
 
1263
- appendServiceTierChange(serviceTier: ServiceTier | null): string {
1296
+ appendServiceTierChange(serviceTier: ServiceTierByFamily | null): string {
1264
1297
  const entry: ServiceTierChangeEntry = { type: "service_tier_change", ...this.#freshEntryFields(), serviceTier };
1265
1298
  this.#recordEntry(entry);
1266
1299
  return entry.id;
@@ -1305,6 +1338,7 @@ export class SessionManager {
1305
1338
  fromExtension?: boolean,
1306
1339
  preserveData?: Record<string, unknown>,
1307
1340
  ): string {
1341
+ const elidedSupersededCompactions = this.#elideSupersededCompactionsOnBranch(this.#index.leafId());
1308
1342
  const entry: CompactionEntry<T> = {
1309
1343
  type: "compaction",
1310
1344
  ...this.#freshEntryFields(),
@@ -1317,6 +1351,9 @@ export class SessionManager {
1317
1351
  preserveData,
1318
1352
  };
1319
1353
  this.#recordEntry(entry);
1354
+ if (elidedSupersededCompactions) {
1355
+ void this.#rewriteAtomically().catch(err => this.#noteDiskFailure(err));
1356
+ }
1320
1357
  return entry.id;
1321
1358
  }
1322
1359
 
@@ -73,17 +73,9 @@ function refreshStatusLine(ctx: InteractiveModeContext): void {
73
73
  ctx.ui.requestRender();
74
74
  }
75
75
 
76
- /** `/fast status` label: "off", "on", or scope-qualified "on (… only)". */
76
+ /** `/fast status` label for the active model: "on" when its family is priority, else "off". */
77
77
  function formatFastModeStatus(session: AgentSession): string {
78
- if (!session.isFastModeEnabled()) return "off";
79
- switch (session.serviceTier) {
80
- case "openai-only":
81
- return "on (OpenAI only)";
82
- case "claude-only":
83
- return "on (Claude only)";
84
- default:
85
- return "on";
86
- }
78
+ return session.isFastModeEnabled() ? "on" : "off";
87
79
  }
88
80
 
89
81
  const AUTOCOMPLETE_DETAIL_LIMIT = 48;
@@ -1,5 +1,12 @@
1
1
  import * as path from "node:path";
2
- import { $env, isBunTestRuntime, isCompiledBinary, logger, workerHostEntry } from "@oh-my-pi/pi-utils";
2
+ import {
3
+ $env,
4
+ isBunTestRuntime,
5
+ isCompiledBinary,
6
+ logger,
7
+ stripWindowsExtendedLengthPathPrefix,
8
+ workerHostEntry,
9
+ } from "@oh-my-pi/pi-utils";
3
10
  import type { Subprocess } from "bun";
4
11
 
5
12
  /**
@@ -90,13 +97,14 @@ export const SMOKE_TEST_TIMEOUT_MS = 30_000;
90
97
  * embedding).
91
98
  */
92
99
  export function resolveWorkerSpawnCmd(workerArg: string): WorkerSpawnCommand {
93
- if (isCompiledBinary()) return { cmd: [process.execPath, workerArg] };
100
+ const executable = stripWindowsExtendedLengthPathPrefix(process.execPath);
101
+ if (isCompiledBinary()) return { cmd: [executable, workerArg] };
94
102
  const hostEntry = workerHostEntry();
95
103
  if (hostEntry) {
96
- return { cmd: [process.execPath, path.basename(hostEntry), workerArg], cwd: path.dirname(hostEntry) };
104
+ return { cmd: [executable, path.basename(hostEntry), workerArg], cwd: path.dirname(hostEntry) };
97
105
  }
98
106
  const packageRoot = path.resolve(import.meta.dir, "..", "..");
99
- return { cmd: [process.execPath, "src/cli.ts", workerArg], cwd: packageRoot };
107
+ return { cmd: [executable, "src/cli.ts", workerArg], cwd: packageRoot };
100
108
  }
101
109
 
102
110
  /**
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ interface ProbeRunResult {
7
+ elapsedMs: number;
8
+ childElapsedMs: number;
9
+ cached: unknown;
10
+ count: number;
11
+ }
12
+
13
+ async function runProbeScenario(options: {
14
+ runs: number;
15
+ sleepSeconds?: number;
16
+ holdStdoutOpen?: boolean;
17
+ descendantHoldsStdout?: boolean;
18
+ validOutput?: string;
19
+ }): Promise<ProbeRunResult> {
20
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-gpu-probe-"));
21
+ try {
22
+ const binDir = path.join(tempRoot, "bin");
23
+ const cacheRoot = path.join(tempRoot, "cache");
24
+ const probeCountPath = path.join(tempRoot, "probe-count");
25
+ await fs.mkdir(binDir, { recursive: true });
26
+ await fs.mkdir(path.join(cacheRoot, "omp"), { recursive: true });
27
+ const lspciPath = path.join(binDir, "lspci");
28
+ await Bun.write(
29
+ lspciPath,
30
+ '#!/usr/bin/env sh\nprintf x >> "$OMP_GPU_PROBE_COUNT"\nif [ -n "$OMP_GPU_PROBE_VALID_OUTPUT" ]; then printf "%s\\n" "$OMP_GPU_PROBE_VALID_OUTPUT"; fi\nif [ "$OMP_GPU_PROBE_DESCENDANT_HOLDS_STDOUT" = "true" ]; then sleep "$OMP_GPU_PROBE_SLEEP" & exit 0; fi\nif [ "$OMP_GPU_PROBE_HOLD_STDOUT_OPEN" = "true" ]; then sleep "$OMP_GPU_PROBE_SLEEP" & wait "$!"; fi\nif [ -n "$OMP_GPU_PROBE_SLEEP" ]; then exec sleep "$OMP_GPU_PROBE_SLEEP"; fi\nexit 0\n',
31
+ );
32
+ await fs.chmod(lspciPath, 0o755);
33
+
34
+ const scenarioPath = path.join(tempRoot, "scenario.ts");
35
+ await Bun.write(
36
+ scenarioPath,
37
+ `import { getGpuCachePath, refreshDirsFromEnv } from ${JSON.stringify(path.resolve(import.meta.dir, "../../utils/src/index.ts"))};
38
+ import { buildSystemPrompt } from ${JSON.stringify(path.join(import.meta.dir, "system-prompt.ts"))};
39
+
40
+ refreshDirsFromEnv();
41
+ const buildOptions = {
42
+ contextFiles: [],
43
+ skills: [],
44
+ toolNames: [],
45
+ workspaceTree: {
46
+ rootPath: process.cwd(),
47
+ rendered: "",
48
+ truncated: false,
49
+ totalLines: 0,
50
+ agentsMdFiles: [],
51
+ },
52
+ activeRepoContext: null,
53
+ };
54
+ const startedAt = performance.now();
55
+ for (let index = 0; index < Number(process.env.OMP_GPU_PROBE_RUNS ?? "1"); index += 1) {
56
+ await buildSystemPrompt(buildOptions);
57
+ }
58
+ const cacheFile = Bun.file(getGpuCachePath());
59
+ const cached = await cacheFile.exists() ? await cacheFile.json() : null;
60
+ const countFile = Bun.file(process.env.OMP_GPU_PROBE_COUNT ?? "");
61
+ const count = await countFile.exists() ? (await countFile.text()).length : 0;
62
+ console.log(JSON.stringify({ elapsedMs: Math.round(performance.now() - startedAt), cached, count }));
63
+ `,
64
+ );
65
+
66
+ const env: Record<string, string | undefined> = {
67
+ ...process.env,
68
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
69
+ XDG_CACHE_HOME: cacheRoot,
70
+ OMP_GPU_PROBE_COUNT: probeCountPath,
71
+ OMP_GPU_PROBE_RUNS: String(options.runs),
72
+ };
73
+ // Strip inherited dirs-resolver overrides so XDG_CACHE_HOME above wins and
74
+ // the test cannot touch the developer/CI profile's real gpu_cache.json.
75
+ for (const key of ["PI_CODING_AGENT_DIR", "OMP_PROFILE", "PI_PROFILE", "PI_CONFIG_DIR"]) {
76
+ delete env[key];
77
+ }
78
+ if (options.sleepSeconds === undefined) {
79
+ delete env.OMP_GPU_PROBE_SLEEP;
80
+ } else {
81
+ env.OMP_GPU_PROBE_SLEEP = String(options.sleepSeconds);
82
+ }
83
+ if (options.holdStdoutOpen) {
84
+ env.OMP_GPU_PROBE_HOLD_STDOUT_OPEN = "true";
85
+ } else {
86
+ delete env.OMP_GPU_PROBE_HOLD_STDOUT_OPEN;
87
+ }
88
+ if (options.descendantHoldsStdout) {
89
+ env.OMP_GPU_PROBE_DESCENDANT_HOLDS_STDOUT = "true";
90
+ } else {
91
+ delete env.OMP_GPU_PROBE_DESCENDANT_HOLDS_STDOUT;
92
+ }
93
+ if (options.validOutput !== undefined) {
94
+ env.OMP_GPU_PROBE_VALID_OUTPUT = options.validOutput;
95
+ } else {
96
+ delete env.OMP_GPU_PROBE_VALID_OUTPUT;
97
+ }
98
+
99
+ const childStartedAt = performance.now();
100
+ const child = Bun.spawn([process.execPath, scenarioPath], { stdout: "pipe", stderr: "pipe", env });
101
+ const [stdout, stderr, exitCode] = await Promise.all([
102
+ new Response(child.stdout).text(),
103
+ new Response(child.stderr).text(),
104
+ child.exited,
105
+ ]);
106
+ const childElapsedMs = Math.round(performance.now() - childStartedAt);
107
+ if (exitCode !== 0) {
108
+ throw new Error(`GPU probe scenario failed with exit ${exitCode}: ${stderr}`);
109
+ }
110
+ return { ...JSON.parse(stdout.trim()), childElapsedMs };
111
+ } finally {
112
+ await fs.rm(tempRoot, { recursive: true, force: true });
113
+ }
114
+ }
115
+
116
+ describe.skipIf(process.platform !== "linux")("system prompt GPU probe", () => {
117
+ it("caches empty GPU probe results", async () => {
118
+ const result = await runProbeScenario({ runs: 2 });
119
+
120
+ expect(result.cached).toEqual({ gpu: null });
121
+ expect(result.count).toBe(1);
122
+ }, 15_000);
123
+
124
+ it("kills the GPU probe at the prep deadline", async () => {
125
+ const result = await runProbeScenario({ runs: 1, sleepSeconds: 7, holdStdoutOpen: true });
126
+
127
+ expect(result.cached).toEqual({ gpu: null });
128
+ expect(result.elapsedMs).toBeLessThan(6500);
129
+ // Codex#3838: the child process MUST exit shortly after the deadline,
130
+ // not linger until a descendant holding stdout (sleep 7) exits on its own.
131
+ expect(result.childElapsedMs).toBeLessThan(6500);
132
+ }, 15_000);
133
+
134
+ it("does not wait on stdout held by a descendant after a successful probe", async () => {
135
+ const result = await runProbeScenario({ runs: 1, sleepSeconds: 3, descendantHoldsStdout: true });
136
+
137
+ expect(result.cached).toEqual({ gpu: null });
138
+ // Probe exits 0 immediately but leaves a backgrounded sleep holding the stdout
139
+ // pipe. The success path MUST bound the drain wait, not block until sleep exits.
140
+ expect(result.elapsedMs).toBeLessThan(2000);
141
+ expect(result.childElapsedMs).toBeLessThan(2000);
142
+ }, 15_000);
143
+
144
+ it("keeps probe output captured before a descendant delays EOF", async () => {
145
+ const result = await runProbeScenario({
146
+ runs: 1,
147
+ sleepSeconds: 3,
148
+ descendantHoldsStdout: true,
149
+ validOutput: "00:02.0 VGA compatible controller: NVIDIA TestGPU",
150
+ });
151
+
152
+ // Probe exited 0 with valid output before bg sleep held stdout open.
153
+ // Captured stdout MUST be cached, not discarded as if the probe failed.
154
+ expect(result.cached).toEqual({ gpu: "02.0 VGA compatible controller: NVIDIA TestGPU" });
155
+ expect(result.elapsedMs).toBeLessThan(2000);
156
+ expect(result.childElapsedMs).toBeLessThan(2000);
157
+ }, 15_000);
158
+ });
@@ -7,7 +7,6 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
7
7
  import type { ToolExample, TSchema } from "@oh-my-pi/pi-ai";
8
8
  import { renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
9
9
  import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
10
- import { $ } from "bun";
11
10
  import { contextFileCapability } from "./capability/context-file";
12
11
  import { systemPromptCapability } from "./capability/system-prompt";
13
12
  import { findConfigFile } from "./config";
@@ -112,21 +111,60 @@ function parseWmicTable(output: string, header: string): string | null {
112
111
  }
113
112
 
114
113
  const SYSTEM_PROMPT_PREP_TIMEOUT_MS = 5000;
114
+ /** Kept below prep timeout so timed-out probes can still write the null cache before fallback. */
115
+ const GPU_PROBE_TIMEOUT_MS = SYSTEM_PROMPT_PREP_TIMEOUT_MS - 500;
116
+ /** Drop stdout from a probe descendant that inherited the pipe after the probe exited. */
117
+ const GPU_PROBE_STDOUT_DRAIN_MS = 250;
118
+
119
+ async function runGpuProbe(cmd: string[]): Promise<string | null> {
120
+ try {
121
+ const proc = Bun.spawn({
122
+ cmd,
123
+ stdout: "pipe",
124
+ stderr: "ignore",
125
+ stdin: "ignore",
126
+ timeout: GPU_PROBE_TIMEOUT_MS,
127
+ // SIGKILL so a probe ignoring SIGTERM (PATH wrapper, wedged WMI) still
128
+ // dies at the deadline and lets getCachedGpu reach the null-cache write.
129
+ killSignal: "SIGKILL",
130
+ });
131
+ const stdoutReader = proc.stdout.getReader();
132
+ let stdout = "";
133
+ const decoder = new TextDecoder();
134
+ const stdoutDone = (async () => {
135
+ while (true) {
136
+ const chunk = await stdoutReader.read();
137
+ if (chunk.done) break;
138
+ stdout += decoder.decode(chunk.value, { stream: true });
139
+ }
140
+ stdout += decoder.decode();
141
+ })();
142
+ const exitCode = await proc.exited;
143
+ // Even on exit 0, a probe wrapper can leave a descendant holding stdout open.
144
+ // Bound the EOF wait so getCachedGpu cannot outlive the probe in either path;
145
+ // keep whatever bytes the reader already captured before cancelling.
146
+ const drained = await Promise.race([
147
+ stdoutDone.then(() => "ok" as const).catch(() => "err" as const),
148
+ Bun.sleep(GPU_PROBE_STDOUT_DRAIN_MS).then(() => "timeout" as const),
149
+ ]);
150
+ if (drained !== "ok") {
151
+ await stdoutReader.cancel().catch(() => undefined);
152
+ await stdoutDone.catch(() => undefined);
153
+ }
154
+ return exitCode === 0 ? stdout : null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
115
159
 
116
160
  async function getGpuModel(): Promise<string | null> {
117
161
  switch (process.platform) {
118
162
  case "win32": {
119
- const output = await $`wmic path win32_VideoController get name`
120
- .quiet()
121
- .text()
122
- .catch(() => null);
163
+ const output = await runGpuProbe(["wmic", "path", "win32_VideoController", "get", "name"]);
123
164
  return output ? parseWmicTable(output, "Name") : null;
124
165
  }
125
166
  case "linux": {
126
- const output = await $`lspci`
127
- .quiet()
128
- .text()
129
- .catch(() => null);
167
+ const output = await runGpuProbe(["lspci"]);
130
168
  if (!output) return null;
131
169
  const gpus: Array<{ name: string; priority: number }> = [];
132
170
  for (const line of output.split("\n")) {
@@ -176,20 +214,20 @@ function getTerminalName(): string | undefined {
176
214
  return term ?? undefined;
177
215
  }
178
216
 
179
- /** Cached system info structure */
217
+ /** Cached GPU probe result. */
180
218
  interface GpuCache {
181
- gpu: string;
182
- }
183
-
184
- function getSystemInfoCachePath(): string {
185
- return getGpuCachePath();
219
+ gpu: string | null;
186
220
  }
187
221
 
188
222
  async function loadGpuCache(): Promise<GpuCache | null> {
189
223
  try {
190
- const cachePath = getSystemInfoCachePath();
224
+ const cachePath = getGpuCachePath();
191
225
  const content = await Bun.file(cachePath).json();
192
- return content as GpuCache;
226
+ if (content && typeof content === "object" && "gpu" in content) {
227
+ const gpu = content.gpu;
228
+ return { gpu: typeof gpu === "string" ? gpu : null };
229
+ }
230
+ return null;
193
231
  } catch {
194
232
  return null;
195
233
  }
@@ -197,7 +235,7 @@ async function loadGpuCache(): Promise<GpuCache | null> {
197
235
 
198
236
  async function saveGpuCache(info: GpuCache): Promise<void> {
199
237
  try {
200
- const cachePath = getSystemInfoCachePath();
238
+ const cachePath = getGpuCachePath();
201
239
  await Bun.write(cachePath, JSON.stringify(info, null, "\t"));
202
240
  } catch {
203
241
  // Silently ignore cache write failures
@@ -206,15 +244,12 @@ async function saveGpuCache(info: GpuCache): Promise<void> {
206
244
 
207
245
  async function getCachedGpu(): Promise<string | undefined> {
208
246
  const cached = await logger.time("getCachedGpu:loadGpuCache", loadGpuCache);
209
- if (cached) return cached.gpu;
247
+ if (cached) return cached.gpu ?? undefined;
210
248
  const gpu = await logger.time("getCachedGpu:getGpuModel", getGpuModel);
211
- if (gpu) {
212
- await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu });
213
- }
249
+ await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu });
214
250
  return gpu ?? undefined;
215
251
  }
216
- async function getEnvironmentInfo(): Promise<Array<{ label: string; value: string }>> {
217
- const gpu = await getCachedGpu();
252
+ function getEnvironmentInfo(gpu: string | undefined): Array<{ label: string; value: string }> {
218
253
  let cpuModel: string | undefined;
219
254
  try {
220
255
  cpuModel = os.cpus()[0]?.model;
@@ -500,9 +535,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
500
535
  agentsMdFiles: [],
501
536
  } satisfies WorkspaceTree,
502
537
  activeRepoContext: null as ActiveRepoContext | null,
538
+ gpu: undefined as string | undefined,
503
539
  };
504
540
 
505
- const deadline = Bun.sleep(SYSTEM_PROMPT_PREP_TIMEOUT_MS).then(() => "__timeout__" as const);
541
+ const { promise: deadline, resolve: fireDeadline } = Promise.withResolvers<"__timeout__">();
542
+ const deadlineTimer = setTimeout(() => fireDeadline("__timeout__"), SYSTEM_PROMPT_PREP_TIMEOUT_MS);
543
+ // Unref so a fast prep does not hold a one-shot CLI alive waiting for this timer.
544
+ deadlineTimer.unref();
506
545
  const timedOut: string[] = [];
507
546
  const failed: Array<{ name: string; error: unknown }> = [];
508
547
 
@@ -566,6 +605,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
566
605
  providedActiveRepoContext !== undefined
567
606
  ? Promise.resolve(providedActiveRepoContext)
568
607
  : logger.time("resolveActiveRepoContext", () => resolveActiveRepoContext(resolvedCwd));
608
+ const gpuPromise = logger.time("getCachedGpu", getCachedGpu);
569
609
 
570
610
  const [
571
611
  resolvedCustomPrompt,
@@ -575,6 +615,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
575
615
  skills,
576
616
  workspaceTree,
577
617
  activeRepoContext,
618
+ gpu,
578
619
  ] = await Promise.all([
579
620
  withDeadline(
580
621
  "customPrompt",
@@ -597,7 +638,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
597
638
  withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
598
639
  withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
599
640
  withDeadline("resolveActiveRepoContext", activeRepoContextPromise, prepDefaults.activeRepoContext),
641
+ withDeadline("getCachedGpu", gpuPromise, prepDefaults.gpu),
600
642
  ]);
643
+ clearTimeout(deadlineTimer);
601
644
  const agentsMdFiles = Array.from(new Set(workspaceTree.agentsMdFiles)).sort().slice(0, AGENTS_MD_LIMIT);
602
645
 
603
646
  if (timedOut.length > 0) {
@@ -675,7 +718,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
675
718
  ];
676
719
  const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
677
720
 
678
- const environment = await logger.time("getEnvironmentInfo", getEnvironmentInfo);
721
+ const environment = getEnvironmentInfo(gpu);
679
722
  const data = {
680
723
  systemPromptCustomization: effectiveSystemPromptCustomization,
681
724
  customPrompt: resolvedCustomPrompt,