@oh-my-pi/pi-coding-agent 14.7.6 → 14.7.8

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
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.7.8] - 2026-05-08
6
+
7
+ ### Fixed
8
+
9
+ - Fixed indefinite startup hang on large repos introduced in 14.7.6 ([#975](https://github.com/can1357/oh-my-pi/issues/975)) on two fronts: (1) `createAgentSession` was awaiting `buildAgentsMdSearch` and `buildWorkspaceTree` directly in its blocking `Promise.all`, bypassing the existing 5s preparation deadline that previously protected startup — both scans are now raced against a 5s deadline and fall back to the system-prompt fallback path on timeout; (2) `buildWorkspaceTree` now derives its listing from `git ls-files --cached --others --exclude-standard` when the workspace is a git worktree, which is O(index size) and avoids the per-call full-tree gitignore-aware native scan that the previous implementation triggered. Repos without git, or where the call fails / times out, transparently fall back to the previous native-glob path.
10
+
5
11
  ## [14.7.6] - 2026-05-07
6
12
  ### Changed
7
13
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.7.6",
4
+ "version": "14.7.8",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.7.6",
50
- "@oh-my-pi/pi-agent-core": "14.7.6",
51
- "@oh-my-pi/pi-ai": "14.7.6",
52
- "@oh-my-pi/pi-natives": "14.7.6",
53
- "@oh-my-pi/pi-tui": "14.7.6",
54
- "@oh-my-pi/pi-utils": "14.7.6",
49
+ "@oh-my-pi/omp-stats": "14.7.8",
50
+ "@oh-my-pi/pi-agent-core": "14.7.8",
51
+ "@oh-my-pi/pi-ai": "14.7.8",
52
+ "@oh-my-pi/pi-natives": "14.7.8",
53
+ "@oh-my-pi/pi-tui": "14.7.8",
54
+ "@oh-my-pi/pi-utils": "14.7.8",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@types/turndown": "5.0.6",
@@ -25,7 +25,9 @@ Use `irc` only when you need a quick answer from a peer; do not use it for long-
25
25
  {{SECTION_SEPARATOR "Closure"}}
26
26
  No TODO tracking, no progress updates. Execute, call `yield`, done.
27
27
 
28
- When finished, you **MUST** call `yield` exactly once. This is like writing to a ticket, provide what is required, and close it.
28
+ Every turn **MUST** end with a tool call. A turn whose final block is plain text (or thinking only) is treated as a stop and you will be reminded to yield. While work remains, always continue with another tool call — investigate, edit, run, verify. Save narrative for the final `yield` payload.
29
+
30
+ When finished, you **MUST** call `yield` exactly once. This is like writing to a ticket: provide what is required and close it.
29
31
 
30
32
  This is your only way to return a result. You **MUST NOT** put JSON in plain text, and you **MUST NOT** substitute a text summary for the structured `result.data` parameter.
31
33
 
@@ -1,11 +1,12 @@
1
1
  <system-reminder>
2
- You stopped without calling yield. This is reminder {{retryCount}} of {{maxRetries}}.
2
+ Your last turn ended without a tool call, so the session went idle. This is reminder {{retryCount}} of {{maxRetries}}.
3
3
 
4
- You **MUST** call yield as your only action now. Choose one:
5
- - If task is complete: call yield with your result in `result.data`
6
- - If task failed: call yield with `result.error` describing what happened
4
+ Every turn **MUST** end with a tool call. Pick exactly one of:
5
+ 1. **Resume the work** — if the assignment is not finished, call the next tool you would have called (edit, write, bash, search, etc.). Do **NOT** yield. Do **NOT** treat this reminder as a forced stop.
6
+ 2. **Yield with success** — only if the assignment is genuinely complete: call `yield` with the structured payload in `result.data`.
7
+ 3. **Yield with error** — only if you hit a real, concrete blocker you can name (missing file, unavailable API, contradictory spec). Describe what you tried and the exact blocker. Do **NOT** fabricate a "forced immediate-yield" or "system reminder required termination" reason — this reminder is not a blocker.
7
8
 
8
- You **MUST NOT** give up if you can still complete the task through exploration (using available tools or repo context). If you submit an error, you **MUST** include what you tried and the exact blocker.
9
+ Default to option 1 unless the work is actually done or actually blocked.
9
10
 
10
- You **MUST NOT** output text without a tool call. You **MUST** call yield to finish.
11
+ You **MUST NOT** end this turn with text only.
11
12
  </system-reminder>
package/src/sdk.ts CHANGED
@@ -898,10 +898,28 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
898
898
  return { ttsrManager, rulebookRules, alwaysApplyRules };
899
899
  });
900
900
 
901
+ // Resolve contextFiles up-front (it's needed before tool creation). The agentsMd / workspace tree
902
+ // scans are slowest on large repos and we MUST NOT block startup on them — race them against a
903
+ // short deadline. On timeout we forward `undefined` to ToolSession; buildSystemPromptInternal will
904
+ // re-race them through its own withDeadline path, and subagents will scan independently (still
905
+ // cheaper than an unbounded parent hang). Background work continues so caches still warm.
906
+ const STARTUP_SCAN_DEADLINE_MS = 5000;
907
+ const raceWithDeadline = <T>(name: string, work: Promise<T>): Promise<T | undefined> =>
908
+ Promise.race([
909
+ work,
910
+ Bun.sleep(STARTUP_SCAN_DEADLINE_MS).then(() => {
911
+ logger.warn("Startup scan exceeded deadline; deferring to system prompt fallback", {
912
+ name,
913
+ timeoutMs: STARTUP_SCAN_DEADLINE_MS,
914
+ cwd,
915
+ });
916
+ return undefined;
917
+ }),
918
+ ]);
901
919
  const [contextFiles, resolvedAgentsMdSearch, resolvedWorkspaceTree] = await Promise.all([
902
920
  contextFilesPromise,
903
- agentsMdSearchPromise,
904
- workspaceTreePromise,
921
+ raceWithDeadline("buildAgentsMdSearch", agentsMdSearchPromise),
922
+ raceWithDeadline("buildWorkspaceTree", workspaceTreePromise),
905
923
  ]);
906
924
 
907
925
  let agent: Agent;
@@ -1119,9 +1119,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1119
1119
  maxRetries: MAX_YIELD_RETRIES,
1120
1120
  });
1121
1121
 
1122
+ const isFinalRetry = retryCount >= MAX_YIELD_RETRIES;
1122
1123
  await session.prompt(reminder, {
1123
1124
  attribution: "agent",
1124
- ...(reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
1125
+ ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
1125
1126
  });
1126
1127
  await session.waitForIdle();
1127
1128
  } catch (err) {
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { glob } from "@oh-my-pi/pi-natives";
4
- import { formatAge, formatBytes } from "@oh-my-pi/pi-utils";
4
+ import { $which, formatAge, formatBytes, logger } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  export interface DirectoryTree {
7
7
  rootPath: string;
@@ -35,6 +35,13 @@ export interface DirectoryTreeOptions {
35
35
  cache?: boolean;
36
36
  /** Rendered label for the root line. */
37
37
  rootLabel?: string;
38
+ /**
39
+ * Pre-built map of `parentRelativePath` → child name set used in place of
40
+ * native directory listing. When provided, the tree builder consults this
41
+ * map for child enumeration instead of `glob` / `readdir`. Stat calls per
42
+ * displayed node are still performed for mtime/size/dir-ness.
43
+ */
44
+ childIndex?: ReadonlyMap<string, ReadonlySet<string>>;
38
45
  }
39
46
 
40
47
  const WORKSPACE_TREE_MAX_DEPTH = 3;
@@ -81,6 +88,7 @@ interface ResolvedDirectoryTreeOptions {
81
88
  gitignore: boolean;
82
89
  cache: boolean;
83
90
  rootLabel: string;
91
+ childIndex: ReadonlyMap<string, ReadonlySet<string>> | null;
84
92
  }
85
93
 
86
94
  interface RenderLine {
@@ -122,6 +130,7 @@ function resolveDirectoryTreeOptions(options: DirectoryTreeOptions): ResolvedDir
122
130
  gitignore: options.gitignore ?? false,
123
131
  cache: options.cache ?? true,
124
132
  rootLabel: options.rootLabel ?? ".",
133
+ childIndex: options.childIndex ?? null,
125
134
  };
126
135
  }
127
136
 
@@ -157,6 +166,10 @@ async function listDirectChildNames(
157
166
  parent: DirectoryTreeNode,
158
167
  options: ResolvedDirectoryTreeOptions,
159
168
  ): Promise<string[]> {
169
+ if (options.childIndex) {
170
+ const names = options.childIndex.get(parent.relativePath);
171
+ return names ? Array.from(names) : [];
172
+ }
160
173
  if (!options.gitignore) {
161
174
  const directoryPath = parent.relativePath ? path.join(rootPath, parent.relativePath) : rootPath;
162
175
  return await fs.readdir(directoryPath);
@@ -377,19 +390,96 @@ export async function buildDirectoryTree(rootPath: string, options: DirectoryTre
377
390
  };
378
391
  }
379
392
 
393
+ /**
394
+ * Build a `parentRelativePath` → child name index from a flat list of POSIX
395
+ * paths. Intermediate directory components are inferred from path segments;
396
+ * the index covers every ancestor directory implied by the input.
397
+ */
398
+ function buildChildIndexFromPaths(paths: readonly string[]): Map<string, Set<string>> {
399
+ const index = new Map<string, Set<string>>();
400
+ const ensure = (parent: string): Set<string> => {
401
+ let bucket = index.get(parent);
402
+ if (!bucket) {
403
+ bucket = new Set<string>();
404
+ index.set(parent, bucket);
405
+ }
406
+ return bucket;
407
+ };
408
+ for (const raw of paths) {
409
+ if (!raw) continue;
410
+ const normalized = raw.replace(/\\/g, "/");
411
+ const parts = normalized.split("/").filter(segment => segment.length > 0);
412
+ if (parts.length === 0) continue;
413
+ for (let i = 0; i < parts.length; i += 1) {
414
+ const parent = parts.slice(0, i).join("/");
415
+ const segment = parts[i];
416
+ if (segment !== undefined) ensure(parent).add(segment);
417
+ }
418
+ }
419
+ return index;
420
+ }
421
+
422
+ const GIT_LS_FILES_TIMEOUT_MS = 3000;
423
+
424
+ /**
425
+ * List tracked + untracked-not-ignored files at `rootPath` via `git ls-files`.
426
+ * Returns `null` when git is unavailable, the directory is not inside a
427
+ * worktree, or the call fails / times out — caller falls back to native
428
+ * directory listing.
429
+ */
430
+ async function tryListGitFiles(rootPath: string): Promise<string[] | null> {
431
+ const gitPath = $which("git");
432
+ if (!gitPath) return null;
433
+ const signal = AbortSignal.timeout(GIT_LS_FILES_TIMEOUT_MS);
434
+ try {
435
+ const child = Bun.spawn([gitPath, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
436
+ cwd: rootPath,
437
+ stdout: "pipe",
438
+ stderr: "pipe",
439
+ stdin: "ignore",
440
+ signal,
441
+ });
442
+ const [stdout, exitCode] = await Promise.all([
443
+ new Response(child.stdout as ReadableStream<Uint8Array>).text(),
444
+ child.exited,
445
+ ]);
446
+ if (exitCode !== 0) return null;
447
+ if (!stdout) return [];
448
+ // `-z` separates entries with NUL; trailing NUL after final entry.
449
+ return stdout.split("\0").filter(entry => entry.length > 0);
450
+ } catch (error) {
451
+ logger.debug("git ls-files failed; falling back to native directory listing", {
452
+ rootPath,
453
+ error: error instanceof Error ? error.message : String(error),
454
+ });
455
+ return null;
456
+ }
457
+ }
458
+
380
459
  export async function buildWorkspaceTree(cwd: string): Promise<WorkspaceTree> {
381
460
  const rootPath = path.resolve(cwd);
461
+ const baseOptions = {
462
+ maxDepth: WORKSPACE_TREE_MAX_DEPTH,
463
+ directoryEntryLimit: WORKSPACE_TREE_DIR_LIMIT,
464
+ lineCap: WORKSPACE_TREE_LINE_CAP,
465
+ excludedDirectoryNames: WORKSPACE_TREE_EXCLUDED_DIRS,
466
+ hidden: false,
467
+ cache: true,
468
+ rootLabel: ".",
469
+ } satisfies DirectoryTreeOptions;
470
+
382
471
  try {
383
- return await buildDirectoryTree(rootPath, {
384
- maxDepth: WORKSPACE_TREE_MAX_DEPTH,
385
- directoryEntryLimit: WORKSPACE_TREE_DIR_LIMIT,
386
- lineCap: WORKSPACE_TREE_LINE_CAP,
387
- excludedDirectoryNames: WORKSPACE_TREE_EXCLUDED_DIRS,
388
- hidden: false,
389
- gitignore: true,
390
- cache: true,
391
- rootLabel: ".",
392
- });
472
+ const gitFiles = await tryListGitFiles(rootPath);
473
+ if (gitFiles !== null) {
474
+ // Git already applied gitignore + tracking semantics: bypass native
475
+ // recursive scan and feed the index directly to the tree builder.
476
+ return await buildDirectoryTree(rootPath, {
477
+ ...baseOptions,
478
+ gitignore: false,
479
+ childIndex: buildChildIndexFromPaths(gitFiles),
480
+ });
481
+ }
482
+ return await buildDirectoryTree(rootPath, { ...baseOptions, gitignore: true });
393
483
  } catch {
394
484
  return emptyWorkspaceTree(rootPath);
395
485
  }