@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.2

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 (117) hide show
  1. package/CHANGELOG.md +172 -198
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +10 -1
  9. package/dist/types/config/settings-schema.d.ts +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/edit/index.d.ts +6 -0
  12. package/dist/types/edit/streaming.d.ts +8 -0
  13. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/export/ttsr.d.ts +9 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  17. package/dist/types/main.d.ts +5 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +19 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  20. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  21. package/dist/types/modes/components/session-selector.d.ts +32 -5
  22. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  23. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  24. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  25. package/dist/types/modes/interactive-mode.d.ts +9 -2
  26. package/dist/types/modes/types.d.ts +4 -2
  27. package/dist/types/registry/agent-registry.d.ts +1 -1
  28. package/dist/types/sdk.d.ts +2 -2
  29. package/dist/types/session/agent-session.d.ts +4 -2
  30. package/dist/types/session/history-storage.d.ts +16 -1
  31. package/dist/types/session/session-manager.d.ts +4 -0
  32. package/dist/types/task/output-manager.d.ts +6 -15
  33. package/dist/types/tools/find.d.ts +0 -9
  34. package/dist/types/tools/index.d.ts +1 -1
  35. package/dist/types/tools/path-utils.d.ts +16 -0
  36. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  37. package/dist/types/tools/write.d.ts +2 -0
  38. package/dist/types/utils/clipboard.d.ts +4 -0
  39. package/dist/types/utils/jj.d.ts +49 -0
  40. package/dist/types/web/kagi.d.ts +76 -0
  41. package/dist/types/web/search/providers/exa.d.ts +7 -1
  42. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  43. package/package.json +9 -9
  44. package/src/async/job-manager.ts +3 -3
  45. package/src/cli/args.ts +6 -2
  46. package/src/cli/claude-trace-cli.ts +783 -0
  47. package/src/cli/session-picker.ts +36 -10
  48. package/src/cli/update-cli.ts +35 -2
  49. package/src/commands/launch.ts +3 -0
  50. package/src/config/keybindings.ts +14 -1
  51. package/src/config/model-registry.ts +18 -7
  52. package/src/config/settings-schema.ts +2 -2
  53. package/src/config/settings.ts +23 -0
  54. package/src/discovery/builtin-rules/index.ts +2 -0
  55. package/src/discovery/builtin-rules/ts-no-deprecated-leftovers.md +44 -0
  56. package/src/discovery/claude-plugins.ts +7 -9
  57. package/src/edit/index.ts +10 -0
  58. package/src/edit/streaming.ts +65 -0
  59. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  60. package/src/eval/concurrency-bridge.ts +34 -0
  61. package/src/eval/js/shared/prelude.txt +20 -17
  62. package/src/eval/js/tool-bridge.ts +5 -0
  63. package/src/eval/py/prelude.py +23 -15
  64. package/src/export/ttsr.ts +18 -1
  65. package/src/extensibility/custom-commands/bundled/review/index.ts +74 -45
  66. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  67. package/src/extensibility/skills.ts +0 -1
  68. package/src/internal-urls/docs-index.generated.ts +12 -11
  69. package/src/main.ts +92 -24
  70. package/src/mcp/transports/stdio.ts +55 -22
  71. package/src/modes/acp/acp-event-mapper.ts +54 -4
  72. package/src/modes/components/custom-editor.ts +10 -0
  73. package/src/modes/components/hook-selector.ts +89 -31
  74. package/src/modes/components/oauth-selector.ts +12 -6
  75. package/src/modes/components/session-selector.ts +179 -24
  76. package/src/modes/components/tool-execution.ts +16 -3
  77. package/src/modes/controllers/command-controller.ts +2 -11
  78. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  79. package/src/modes/controllers/input-controller.ts +19 -1
  80. package/src/modes/controllers/selector-controller.ts +61 -21
  81. package/src/modes/interactive-mode.ts +125 -15
  82. package/src/modes/types.ts +5 -2
  83. package/src/prompts/agents/reviewer.md +2 -2
  84. package/src/prompts/review-request.md +1 -1
  85. package/src/prompts/system/empty-stop-retry.md +6 -0
  86. package/src/prompts/system/orchestrate-notice.md +5 -3
  87. package/src/prompts/system/workflow-notice.md +2 -2
  88. package/src/prompts/tools/eval.md +5 -5
  89. package/src/prompts/tools/find.md +1 -1
  90. package/src/prompts/tools/irc.md +6 -6
  91. package/src/prompts/tools/search-tool-bm25.md +9 -2
  92. package/src/prompts/tools/search.md +1 -1
  93. package/src/prompts/tools/task.md +1 -1
  94. package/src/registry/agent-registry.ts +1 -1
  95. package/src/sdk.ts +85 -31
  96. package/src/session/agent-session.ts +209 -54
  97. package/src/session/history-storage.ts +56 -12
  98. package/src/session/session-manager.ts +34 -0
  99. package/src/task/output-manager.ts +40 -48
  100. package/src/task/render.ts +3 -8
  101. package/src/tools/browser/tab-worker.ts +8 -5
  102. package/src/tools/find.ts +5 -29
  103. package/src/tools/index.ts +1 -1
  104. package/src/tools/path-utils.ts +144 -1
  105. package/src/tools/read.ts +47 -0
  106. package/src/tools/search-tool-bm25.ts +7 -1
  107. package/src/tools/search.ts +2 -27
  108. package/src/tools/sqlite-reader.ts +92 -9
  109. package/src/tools/write.ts +6 -0
  110. package/src/utils/clipboard.ts +38 -1
  111. package/src/utils/git.ts +19 -23
  112. package/src/utils/jj.ts +225 -0
  113. package/src/utils/open.ts +37 -2
  114. package/src/web/kagi.ts +168 -49
  115. package/src/web/search/providers/anthropic.ts +1 -1
  116. package/src/web/search/providers/exa.ts +20 -86
  117. package/src/web/search/providers/kagi.ts +4 -0
@@ -8,6 +8,8 @@ export interface HistoryEntry {
8
8
  prompt: string;
9
9
  created_at: number;
10
10
  cwd?: string;
11
+ /** ID of the session the prompt was submitted from, if known. */
12
+ sessionId?: string;
11
13
  }
12
14
 
13
15
  type HistoryRow = {
@@ -15,6 +17,7 @@ type HistoryRow = {
15
17
  prompt: string;
16
18
  created_at: number;
17
19
  cwd: string | null;
20
+ session_id: string | null;
18
21
  };
19
22
 
20
23
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
@@ -62,7 +65,8 @@ class AsyncDrain<T> {
62
65
  export class HistoryStorage {
63
66
  #db: Database;
64
67
  static #instance?: HistoryStorage;
65
- #drain = new AsyncDrain<Pick<HistoryEntry, "prompt" | "cwd">>(100);
68
+ #drain = new AsyncDrain<Pick<HistoryEntry, "prompt" | "cwd" | "sessionId">>(100);
69
+ #sessionResolver?: () => string | undefined;
66
70
 
67
71
  // Prepared statements
68
72
  #insertRowStmt: Statement;
@@ -91,7 +95,8 @@ CREATE TABLE IF NOT EXISTS history (
91
95
  id INTEGER PRIMARY KEY AUTOINCREMENT,
92
96
  prompt TEXT NOT NULL,
93
97
  created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
94
- cwd TEXT
98
+ cwd TEXT,
99
+ session_id TEXT
95
100
  );
96
101
  CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
97
102
 
@@ -106,6 +111,10 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
106
111
  this.#migrateHistorySchema();
107
112
  }
108
113
 
114
+ if (!this.#historySchemaHasColumn("session_id")) {
115
+ this.#db.run("ALTER TABLE history ADD COLUMN session_id TEXT");
116
+ }
117
+
109
118
  if (!hasFts) {
110
119
  try {
111
120
  this.#db.run("INSERT INTO history_fts(history_fts) VALUES('rebuild')");
@@ -115,14 +124,14 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
115
124
  }
116
125
 
117
126
  this.#recentStmt = this.#db.prepare(
118
- "SELECT id, prompt, created_at, cwd FROM history ORDER BY created_at DESC, id DESC LIMIT ?",
127
+ "SELECT id, prompt, created_at, cwd, session_id FROM history ORDER BY created_at DESC, id DESC LIMIT ?",
119
128
  );
120
129
  this.#searchStmt = this.#db.prepare(
121
- "SELECT h.id, h.prompt, h.created_at, h.cwd FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?",
130
+ "SELECT h.id, h.prompt, h.created_at, h.cwd, h.session_id FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?",
122
131
  );
123
132
  this.#lastPromptStmt = this.#db.prepare("SELECT prompt FROM history ORDER BY id DESC LIMIT 1");
124
133
 
125
- this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)");
134
+ this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd, session_id) VALUES (?, ?, ?)");
126
135
 
127
136
  const last = this.#lastPromptStmt.get() as { prompt?: string } | undefined;
128
137
  this.#lastPromptCache = last?.prompt ?? null;
@@ -140,20 +149,30 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
140
149
  HistoryStorage.#instance = undefined;
141
150
  }
142
151
 
143
- #insertBatch(rows: Array<Pick<HistoryEntry, "prompt" | "cwd">>): void {
144
- this.#db.transaction((rows: Array<Pick<HistoryEntry, "prompt" | "cwd">>) => {
152
+ #insertBatch(rows: Array<Pick<HistoryEntry, "prompt" | "cwd" | "sessionId">>): void {
153
+ this.#db.transaction((rows: Array<Pick<HistoryEntry, "prompt" | "cwd" | "sessionId">>) => {
145
154
  for (const row of rows) {
146
- this.#insertRowStmt.run(row.prompt, row.cwd ?? null);
155
+ this.#insertRowStmt.run(row.prompt, row.cwd ?? null, row.sessionId ?? null);
147
156
  }
148
157
  })(rows);
149
158
  }
150
159
 
151
- add(prompt: string, cwd?: string): Promise<void> {
160
+ /**
161
+ * Register a resolver that supplies the current session ID for prompts added
162
+ * without an explicit `sessionId`. Evaluated synchronously at `add()` time so
163
+ * batched writes capture the session active when the prompt was submitted.
164
+ */
165
+ setSessionResolver(resolver: () => string | undefined): void {
166
+ this.#sessionResolver = resolver;
167
+ }
168
+
169
+ add(prompt: string, cwd?: string, sessionId?: string): Promise<void> {
152
170
  const trimmed = prompt.trim();
153
171
  if (!trimmed) return Promise.resolve();
154
172
  if (this.#lastPromptCache === trimmed) return Promise.resolve();
155
173
  this.#lastPromptCache = trimmed;
156
- return this.#drain.push({ prompt: trimmed, cwd: cwd ?? undefined }, rows => {
174
+ const session = sessionId ?? this.#sessionResolver?.();
175
+ return this.#drain.push({ prompt: trimmed, cwd: cwd ?? undefined, sessionId: session || undefined }, rows => {
157
176
  this.#insertBatch(rows);
158
177
  });
159
178
  }
@@ -224,6 +243,24 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
224
243
  return merged;
225
244
  }
226
245
 
246
+ /**
247
+ * IDs of the sessions whose stored prompts match `query`, ordered by match
248
+ * relevance (most relevant/recent first) and de-duplicated. Prompts with no
249
+ * recorded session are skipped. Used to augment session ranking in the
250
+ * resume picker with prompts that the 4KB session-list prefix never sees.
251
+ */
252
+ matchingSessionIds(query: string, limit = 500): string[] {
253
+ const seen = new Set<string>();
254
+ const ids: string[] = [];
255
+ for (const entry of this.search(query, limit)) {
256
+ const id = entry.sessionId;
257
+ if (!id || seen.has(id)) continue;
258
+ seen.add(id);
259
+ ids.push(id);
260
+ }
261
+ return ids;
262
+ }
263
+
227
264
  #ensureDir(dbPath: string): void {
228
265
  const dir = path.dirname(dbPath);
229
266
  fs.mkdirSync(dir, { recursive: true });
@@ -236,6 +273,11 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
236
273
  return row?.sql?.includes("unixepoch(") ?? false;
237
274
  }
238
275
 
276
+ #historySchemaHasColumn(column: string): boolean {
277
+ const columns = this.#db.prepare("PRAGMA table_info(history)").all() as Array<{ name: string }>;
278
+ return columns.some(col => col.name === column);
279
+ }
280
+
239
281
  #migrateHistorySchema(): void {
240
282
  const migrate = this.#db.transaction(() => {
241
283
  this.#db.run("ALTER TABLE history RENAME TO history_legacy");
@@ -247,7 +289,8 @@ CREATE TABLE history (
247
289
  id INTEGER PRIMARY KEY AUTOINCREMENT,
248
290
  prompt TEXT NOT NULL,
249
291
  created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
250
- cwd TEXT
292
+ cwd TEXT,
293
+ session_id TEXT
251
294
  );
252
295
  CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
253
296
  INSERT INTO history (id, prompt, created_at, cwd)
@@ -294,7 +337,7 @@ END;
294
337
  if (stmt) return stmt;
295
338
  const whereClause = Array(tokenCount).fill("prompt LIKE ? ESCAPE '\\' COLLATE NOCASE").join(" AND ");
296
339
  stmt = this.#db.prepare(
297
- `SELECT id, prompt, created_at, cwd FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`,
340
+ `SELECT id, prompt, created_at, cwd, session_id FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`,
298
341
  );
299
342
  this.#substringStmts.set(tokenCount, stmt);
300
343
  return stmt;
@@ -306,6 +349,7 @@ END;
306
349
  prompt: row.prompt,
307
350
  created_at: row.created_at,
308
351
  cwd: row.cwd ?? undefined,
352
+ sessionId: row.session_id ?? undefined,
309
353
  };
310
354
  }
311
355
  }
@@ -254,6 +254,22 @@ export interface SessionContext {
254
254
  modeData?: Record<string, unknown>;
255
255
  }
256
256
 
257
+ /** Lists session model strings to try when restoring, in fallback order. */
258
+ export function getRestorableSessionModels(
259
+ models: Readonly<Record<string, string>>,
260
+ lastModelChangeRole: string | undefined,
261
+ ): string[] {
262
+ const defaultModel = models.default;
263
+ if (!lastModelChangeRole || lastModelChangeRole === "default" || lastModelChangeRole === "temporary") {
264
+ return defaultModel ? [defaultModel] : [];
265
+ }
266
+
267
+ const roleModel = models[lastModelChangeRole];
268
+ if (!roleModel) return defaultModel ? [defaultModel] : [];
269
+ if (!defaultModel || roleModel === defaultModel) return [roleModel];
270
+ return [roleModel, defaultModel];
271
+ }
272
+
257
273
  export interface SessionInfo {
258
274
  path: string;
259
275
  id: string;
@@ -1808,6 +1824,8 @@ export async function resolveResumableSession(
1808
1824
  return { session: globalMatch, scope: "global" };
1809
1825
  }
1810
1826
  interface SessionManagerStateSnapshot {
1827
+ cwd: string;
1828
+ sessionDir: string;
1811
1829
  sessionId: string;
1812
1830
  sessionName: string | undefined;
1813
1831
  titleSource: "auto" | "user" | undefined;
@@ -1880,6 +1898,8 @@ export class SessionManager {
1880
1898
 
1881
1899
  captureState(): SessionManagerStateSnapshot {
1882
1900
  return {
1901
+ cwd: this.cwd,
1902
+ sessionDir: this.sessionDir,
1883
1903
  sessionId: this.#sessionId,
1884
1904
  sessionName: this.#sessionName,
1885
1905
  titleSource: this.#titleSource,
@@ -1893,6 +1913,8 @@ export class SessionManager {
1893
1913
  }
1894
1914
 
1895
1915
  restoreState(snapshot: SessionManagerStateSnapshot): void {
1916
+ this.cwd = snapshot.cwd;
1917
+ this.sessionDir = snapshot.sessionDir;
1896
1918
  this.#sessionId = snapshot.sessionId;
1897
1919
  this.#sessionName = snapshot.sessionName;
1898
1920
  this.#titleSource = snapshot.titleSource;
@@ -1938,6 +1960,18 @@ export class SessionManager {
1938
1960
  this.#sessionName = header?.title;
1939
1961
  this.#titleSource = header?.titleSource;
1940
1962
 
1963
+ // Adopt the loaded session's own working directory. Sessions are stored in
1964
+ // a directory keyed by their cwd, so resuming a session from another
1965
+ // project (e.g. global review in the picker) must re-point cwd/sessionDir
1966
+ // at that project. Same-cwd resumes and in-place reloads are a no-op; old
1967
+ // sessions with no recorded cwd keep the current cwd.
1968
+ const headerCwd = header?.cwd ? path.resolve(header.cwd) : undefined;
1969
+ if (headerCwd && headerCwd !== this.cwd) {
1970
+ this.cwd = headerCwd;
1971
+ this.sessionDir = path.resolve(this.#sessionFile, "..");
1972
+ writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
1973
+ }
1974
+
1941
1975
  this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
1942
1976
 
1943
1977
  await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
@@ -1,29 +1,28 @@
1
1
  /**
2
2
  * Session-scoped manager for agent output IDs.
3
3
  *
4
- * Ensures unique output IDs across task tool invocations within a session.
5
- * Prefixes each ID with a sequential number (e.g., "0-AuthProvider", "1-AuthApi").
6
- * If a parent prefix is provided, IDs are nested (e.g., "0-Auth.1-Subtask").
4
+ * Keeps every subagent output id unique within a session without polluting the
5
+ * common case with bookkeeping. A requested name is used verbatim the first
6
+ * time it appears; only a *repeated* name gets a numeric suffix to disambiguate
7
+ * it (e.g. "Anna", "Anna-2", "Anna-3"). When a parent prefix is configured, ids
8
+ * are nested under it (e.g. "Anna.Bob") so hierarchical outputs stay grouped.
7
9
  *
8
- * This enables reliable agent:// URL resolution and prevents artifact collisions.
10
+ * This enables reliable agent:// URL resolution and prevents artifact
11
+ * collisions across repeated or nested task invocations.
9
12
  */
10
13
  import * as fs from "node:fs/promises";
11
14
 
12
- function escapeRegExp(value: string): string {
13
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
- }
15
-
16
15
  /**
17
16
  * Manages agent output ID allocation to ensure uniqueness.
18
17
  *
19
- * Each allocated ID gets a numeric prefix based on allocation order.
20
- * If configured with a parent prefix, the numeric prefix is appended after
21
- * the parent (e.g., "0-Parent.0-Child").
22
- * On resume, scans existing files to find the next available index.
18
+ * The first allocation of a given name keeps the name as-is; subsequent
19
+ * allocations of the same name get a `-2`, `-3`, suffix. On resume, scans
20
+ * existing output files so previously written outputs are never overwritten.
23
21
  */
24
22
  export class AgentOutputManager {
25
- #nextId = 0;
26
23
  #initialized = false;
24
+ /** Final ids already handed out, relative to this manager's scope. */
25
+ readonly #taken = new Set<string>();
27
26
  readonly #getArtifactsDir: () => string | null;
28
27
  readonly #parentPrefix: string | undefined;
29
28
 
@@ -33,8 +32,8 @@ export class AgentOutputManager {
33
32
  }
34
33
 
35
34
  /**
36
- * Scan existing agent output files to find the next available ID.
37
- * This ensures we don't overwrite outputs when resuming a session.
35
+ * Seed the taken-id set from output files already on disk so a resumed
36
+ * session never reuses a name that would clobber a prior subagent's output.
38
37
  */
39
38
  async #ensureInitialized(): Promise<void> {
40
39
  if (this.#initialized) return;
@@ -50,31 +49,41 @@ export class AgentOutputManager {
50
49
  return; // Directory doesn't exist yet
51
50
  }
52
51
 
53
- const pattern = this.#parentPrefix
54
- ? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.md$`)
55
- : /^(\d+)-.*\.md$/;
56
-
57
- let maxId = -1;
52
+ const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
58
53
  for (const file of files) {
59
- const match = file.match(pattern);
60
- if (match) {
61
- const id = Number.parseInt(match[1], 10);
62
- if (id > maxId) maxId = id;
54
+ if (!file.endsWith(".md")) continue;
55
+ let rest = file.slice(0, -3); // drop ".md"
56
+ if (prefix) {
57
+ if (!rest.startsWith(prefix)) continue;
58
+ rest = rest.slice(prefix.length);
63
59
  }
60
+ // Requested ids never contain "."; a dot marks a nested child, so this
61
+ // manager only owns the first segment of whatever remains.
62
+ const dot = rest.indexOf(".");
63
+ const segment = dot === -1 ? rest : rest.slice(0, dot);
64
+ if (segment) this.#taken.add(segment);
64
65
  }
65
- this.#nextId = maxId + 1;
66
+ }
67
+
68
+ /** Pick the first free name (base, then `base-2`, `base-3`, …) and reserve it. */
69
+ #allocateUnique(id: string): string {
70
+ let candidate = id;
71
+ for (let n = 2; this.#taken.has(candidate); n++) {
72
+ candidate = `${id}-${n}`;
73
+ }
74
+ this.#taken.add(candidate);
75
+ return this.#parentPrefix ? `${this.#parentPrefix}.${candidate}` : candidate;
66
76
  }
67
77
 
68
78
  /**
69
- * Allocate a unique ID with numeric prefix.
79
+ * Allocate a unique ID.
70
80
  *
71
- * @param id Requested ID (e.g., "AuthProvider")
72
- * @returns Unique ID with prefix (e.g., "0-AuthProvider")
81
+ * @param id Requested ID (e.g., "Anna")
82
+ * @returns Unique ID ("Anna" first, then "Anna-2", "Anna-3", …)
73
83
  */
74
84
  async allocate(id: string): Promise<string> {
75
85
  await this.#ensureInitialized();
76
- const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
77
- return `${prefix}${this.#nextId++}-${id}`;
86
+ return this.#allocateUnique(id);
78
87
  }
79
88
 
80
89
  /**
@@ -85,23 +94,6 @@ export class AgentOutputManager {
85
94
  */
86
95
  async allocateBatch(ids: string[]): Promise<string[]> {
87
96
  await this.#ensureInitialized();
88
- const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
89
- return ids.map(id => `${prefix}${this.#nextId++}-${id}`);
90
- }
91
-
92
- /**
93
- * Get the next ID that would be allocated (without allocating).
94
- */
95
- async peekNextIndex(): Promise<number> {
96
- await this.#ensureInitialized();
97
- return this.#nextId;
98
- }
99
-
100
- /**
101
- * Reset state (primarily for testing).
102
- */
103
- reset(): void {
104
- this.#nextId = 0;
105
- this.#initialized = false;
97
+ return ids.map(id => this.#allocateUnique(id));
106
98
  }
107
99
  }
@@ -128,15 +128,10 @@ function formatJsonScalar(value: unknown, _theme: Theme): string {
128
128
  }
129
129
 
130
130
  function formatTaskId(id: string): string {
131
+ // Ids are name-based (e.g. "Anna", "Anna-2"); a "." separates nesting levels
132
+ // (e.g. "Anna.Bob"). Render the hierarchy with a ">" breadcrumb.
131
133
  const segments = id.split(".");
132
- if (segments.length < 2) return id;
133
-
134
- const parsed = segments.map(segment => segment.match(/^(\d+)-(.+)$/));
135
- if (parsed.some(match => !match)) return id;
136
-
137
- const indices = parsed.map(match => match![1]).join(".");
138
- const labels = parsed.map(match => match![2]).join(">");
139
- return `${indices} ${labels}`;
134
+ return segments.length < 2 ? id : segments.join(">");
140
135
  }
141
136
 
142
137
  const MISSING_YIELD_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling yield tool";
@@ -815,18 +815,21 @@ export class WorkerCore {
815
815
  { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 },
816
816
  );
817
817
  const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
818
+ const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
819
+ const savedBuffer = saveFullRes ? buffer : resized.buffer;
820
+ const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
821
+ // Auto-generated names must match the bytes we actually write: full-res is always
822
+ // PNG, but the resized buffer is whichever of PNG/JPEG/WebP encoded smallest.
823
+ const ext = savedMimeType === "image/webp" ? "webp" : savedMimeType === "image/jpeg" ? "jpg" : "png";
818
824
  const dest =
819
825
  explicitPath ??
820
826
  (session.browserScreenshotDir
821
827
  ? path.join(
822
828
  session.browserScreenshotDir,
823
- `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.png`,
829
+ `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
824
830
  )
825
- : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.png`));
831
+ : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`));
826
832
  await fs.promises.mkdir(path.dirname(dest), { recursive: true });
827
- const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
828
- const savedBuffer = saveFullRes ? buffer : resized.buffer;
829
- const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
830
833
  await Bun.write(dest, savedBuffer);
831
834
  const info: ScreenshotResult = {
832
835
  dest,
package/src/tools/find.ts CHANGED
@@ -16,6 +16,7 @@ import type { ToolSession } from ".";
16
16
  import { applyListLimit } from "./list-limit";
17
17
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
18
18
  import {
19
+ expandDelimitedPathEntries,
19
20
  formatPathRelativeToCwd,
20
21
  hasGlobPathChars,
21
22
  normalizePathLikeInput,
@@ -52,33 +53,6 @@ const DEFAULT_GLOB_TIMEOUT_MS = 5000;
52
53
  const MIN_GLOB_TIMEOUT_MS = 500;
53
54
  const MAX_GLOB_TIMEOUT_MS = 60_000;
54
55
 
55
- /**
56
- * Reject comma-separated path lists packed into a single array element
57
- * (`["a.py,b.py"]`). The schema is array-of-string; agents that pass a
58
- * single comma-joined element get silent no-matches otherwise.
59
- *
60
- * Commas inside brace expansion (`{a,b}`) are legitimate glob syntax and
61
- * must pass through.
62
- */
63
- export function validateFindPathInputs(paths: readonly string[]): void {
64
- for (const entry of paths) {
65
- let braceDepth = 0;
66
- for (let i = 0; i < entry.length; i++) {
67
- const ch = entry.charCodeAt(i);
68
- if (ch === 0x5c /* \ */ && i + 1 < entry.length) {
69
- i++;
70
- continue;
71
- }
72
- if (ch === 0x7b /* { */) braceDepth++;
73
- else if (ch === 0x7d /* } */) {
74
- if (braceDepth > 0) braceDepth--;
75
- } else if (ch === 0x2c /* , */ && braceDepth === 0) {
76
- throw new ToolError(`paths is an array — pass ["a", "b"] not ["a,b"] (got ${JSON.stringify(entry)})`);
77
- }
78
- }
79
- }
80
- }
81
-
82
56
  /**
83
57
  * Group find matches by their directory so the model doesn't pay repeated
84
58
  * tokens for shared path prefixes. Preserves the input order: groups appear in
@@ -180,8 +154,10 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
180
154
 
181
155
  return untilAborted(signal, async () => {
182
156
  const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
183
- validateFindPathInputs(paths);
184
- const rawPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
157
+ const rawPatternInputs = this.#customOps
158
+ ? paths
159
+ : await expandDelimitedPathEntries(paths, this.session.cwd, { splitter: parseFindPattern });
160
+ const rawPatterns = rawPatternInputs.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
185
161
  const internalRouter = InternalUrlRouter.instance();
186
162
  const normalizedPatterns: string[] = [];
187
163
  for (const rawPattern of rawPatterns) {
@@ -159,7 +159,7 @@ export interface ToolSession {
159
159
  getHindsightSessionState?: () => HindsightSessionState | undefined;
160
160
  /** Get Mnemopi runtime state for this agent session. */
161
161
  getMnemopiSessionState?: () => MnemopiSessionState | undefined;
162
- /** Agent identity used for IRC routing. Returns the registry id (e.g. "0-Main", "0-AuthLoader"). */
162
+ /** Agent identity used for IRC routing. Returns the registry id (e.g. "Main", "AuthLoader"). */
163
163
  getAgentId?: () => string | null;
164
164
  /** Look up a registered tool by name (used by the eval js backend's tool bridge). */
165
165
  getToolByName?: (name: string) => AgentTool | undefined;
@@ -379,6 +379,145 @@ export function hasGlobPathChars(filePath: string): boolean {
379
379
  return GLOB_PATH_CHARS.some(char => filePath.includes(char));
380
380
  }
381
381
 
382
+ type PathEntrySplitter = (item: string) => { basePath: string };
383
+
384
+ const TOP_LEVEL_WHITESPACE_RE = /\s/;
385
+
386
+ type DelimitedPathSplitMode = "comma" | "semicolon" | "whitespace" | "mixed";
387
+
388
+ function isDelimitedPathSeparator(ch: string, mode: DelimitedPathSplitMode): boolean {
389
+ if (mode === "comma") return ch === ",";
390
+ if (mode === "semicolon") return ch === ";";
391
+ if (mode === "whitespace") return TOP_LEVEL_WHITESPACE_RE.test(ch);
392
+ return ch === "," || ch === ";" || TOP_LEVEL_WHITESPACE_RE.test(ch);
393
+ }
394
+
395
+ function hasTopLevelPathDelimiter(entry: string): boolean {
396
+ let braceDepth = 0;
397
+ for (let i = 0; i < entry.length; i++) {
398
+ const ch = entry[i];
399
+ if (ch === "\\" && i + 1 < entry.length) {
400
+ i++;
401
+ continue;
402
+ }
403
+ if (ch === "{") {
404
+ braceDepth++;
405
+ continue;
406
+ }
407
+ if (ch === "}") {
408
+ if (braceDepth > 0) braceDepth--;
409
+ continue;
410
+ }
411
+ if (braceDepth === 0 && (ch === "," || ch === ";" || TOP_LEVEL_WHITESPACE_RE.test(ch))) {
412
+ return true;
413
+ }
414
+ }
415
+ return false;
416
+ }
417
+
418
+ function splitTopLevelDelimitedPath(entry: string, mode: DelimitedPathSplitMode): string[] {
419
+ const parts: string[] = [];
420
+ let braceDepth = 0;
421
+ let start = 0;
422
+ for (let i = 0; i < entry.length; i++) {
423
+ const ch = entry[i];
424
+ if (ch === "\\" && i + 1 < entry.length) {
425
+ i++;
426
+ continue;
427
+ }
428
+ if (ch === "{") {
429
+ braceDepth++;
430
+ continue;
431
+ }
432
+ if (ch === "}") {
433
+ if (braceDepth > 0) braceDepth--;
434
+ continue;
435
+ }
436
+ if (braceDepth !== 0 || !isDelimitedPathSeparator(ch, mode)) continue;
437
+ parts.push(entry.slice(start, i));
438
+ start = i + 1;
439
+ }
440
+ parts.push(entry.slice(start));
441
+ return parts;
442
+ }
443
+
444
+ async function delimitedPathPartResolves(entry: string, cwd: string, splitter: PathEntrySplitter): Promise<boolean> {
445
+ if (isInternalUrlPath(entry)) return true;
446
+ const peeled = splitPathAndSel(entry).path;
447
+ const { basePath } = splitter(peeled);
448
+ const absoluteBasePath = resolveToCwd(basePath, cwd);
449
+ try {
450
+ await fs.promises.stat(absoluteBasePath);
451
+ return true;
452
+ } catch (err) {
453
+ if (isEnoent(err)) return false;
454
+ throw err;
455
+ }
456
+ }
457
+
458
+ async function tryDelimitedPathSplit(
459
+ entry: string,
460
+ cwd: string,
461
+ splitter: PathEntrySplitter,
462
+ mode: DelimitedPathSplitMode,
463
+ requireAllParts: boolean,
464
+ ): Promise<string[] | null> {
465
+ const rawParts = splitTopLevelDelimitedPath(entry, mode);
466
+ if (rawParts.length < 2) return null;
467
+
468
+ const parts = rawParts.map(normalizePathLikeInput).filter(part => part.length > 0);
469
+ if (parts.length === 0) return null;
470
+ if (parts.length < 2 && rawParts.length === parts.length) return null;
471
+
472
+ const resolved = await Promise.all(parts.map(part => delimitedPathPartResolves(part, cwd, splitter)));
473
+ const valid = requireAllParts ? resolved.every(Boolean) : resolved.some(Boolean);
474
+ return valid ? parts : null;
475
+ }
476
+
477
+ /**
478
+ * Split one path-like entry whose multiple targets were flattened into one
479
+ * string. Existing paths are kept intact, so real filenames containing spaces,
480
+ * commas, or semicolons win over delimiter recovery.
481
+ */
482
+ export async function splitDelimitedPathEntry(
483
+ entry: string,
484
+ cwd: string,
485
+ options: { splitter?: PathEntrySplitter } = {},
486
+ ): Promise<string[] | null> {
487
+ const normalizedEntry = normalizePathLikeInput(entry);
488
+ if (!hasTopLevelPathDelimiter(normalizedEntry)) return null;
489
+ if (isInternalUrlPath(normalizedEntry)) return null;
490
+
491
+ const splitter = options.splitter ?? parseSearchPath;
492
+ const peeledEntry = splitPathAndSel(normalizedEntry).path;
493
+ if (!hasGlobPathChars(peeledEntry) && (await delimitedPathPartResolves(normalizedEntry, cwd, splitter))) {
494
+ return null;
495
+ }
496
+
497
+ return (
498
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "comma", false)) ??
499
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "semicolon", false)) ??
500
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "whitespace", true)) ??
501
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "mixed", true))
502
+ );
503
+ }
504
+
505
+ /** Expand delimited entries in-place while preserving unsplit entries. */
506
+ export async function expandDelimitedPathEntries(
507
+ entries: readonly string[],
508
+ cwd: string,
509
+ options: { splitter?: PathEntrySplitter } = {},
510
+ ): Promise<string[]> {
511
+ const expanded: string[] = [];
512
+ for (const entry of entries) {
513
+ const normalizedEntry = normalizePathLikeInput(entry);
514
+ const split = await splitDelimitedPathEntry(normalizedEntry, cwd, options);
515
+ if (split) expanded.push(...split);
516
+ else expanded.push(normalizedEntry);
517
+ }
518
+ return expanded;
519
+ }
520
+
382
521
  export interface ParsedSearchPath {
383
522
  basePath: string;
384
523
  glob?: string;
@@ -769,7 +908,11 @@ export interface ToolScopeResolution {
769
908
  */
770
909
  export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<ToolScopeResolution> {
771
910
  const { rawPaths: inputs, cwd, internalUrlAction } = opts;
772
- const rawPaths = inputs.map(normalizePathLikeInput);
911
+ const normalizedRawPaths = inputs.map(normalizePathLikeInput);
912
+ if (normalizedRawPaths.some(rawPath => rawPath.length === 0)) {
913
+ throw new ToolError("`paths` must contain non-empty paths or globs");
914
+ }
915
+ const rawPaths = await expandDelimitedPathEntries(normalizedRawPaths, cwd);
773
916
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
774
917
  throw new ToolError("`paths` must contain non-empty paths or globs");
775
918
  }