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

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 (95) hide show
  1. package/CHANGELOG.md +146 -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 +5 -0
  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/eval/concurrency-bridge.d.ts +26 -0
  12. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  13. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  14. package/dist/types/main.d.ts +5 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  17. package/dist/types/modes/components/session-selector.d.ts +32 -5
  18. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  20. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  21. package/dist/types/modes/interactive-mode.d.ts +9 -2
  22. package/dist/types/modes/types.d.ts +4 -2
  23. package/dist/types/registry/agent-registry.d.ts +1 -1
  24. package/dist/types/sdk.d.ts +2 -2
  25. package/dist/types/session/agent-session.d.ts +4 -2
  26. package/dist/types/session/history-storage.d.ts +16 -1
  27. package/dist/types/session/session-manager.d.ts +4 -0
  28. package/dist/types/task/output-manager.d.ts +6 -15
  29. package/dist/types/tools/find.d.ts +0 -9
  30. package/dist/types/tools/index.d.ts +1 -1
  31. package/dist/types/tools/path-utils.d.ts +16 -0
  32. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  33. package/dist/types/utils/clipboard.d.ts +4 -0
  34. package/dist/types/web/kagi.d.ts +76 -0
  35. package/dist/types/web/search/providers/exa.d.ts +7 -1
  36. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  37. package/package.json +9 -9
  38. package/src/async/job-manager.ts +3 -3
  39. package/src/cli/args.ts +6 -2
  40. package/src/cli/claude-trace-cli.ts +783 -0
  41. package/src/cli/session-picker.ts +36 -10
  42. package/src/cli/update-cli.ts +35 -2
  43. package/src/commands/launch.ts +3 -0
  44. package/src/config/keybindings.ts +6 -0
  45. package/src/config/settings-schema.ts +2 -2
  46. package/src/config/settings.ts +23 -0
  47. package/src/discovery/claude-plugins.ts +7 -9
  48. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  49. package/src/eval/concurrency-bridge.ts +34 -0
  50. package/src/eval/js/shared/prelude.txt +20 -17
  51. package/src/eval/js/tool-bridge.ts +5 -0
  52. package/src/eval/py/prelude.py +23 -15
  53. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  54. package/src/extensibility/skills.ts +0 -1
  55. package/src/internal-urls/docs-index.generated.ts +11 -10
  56. package/src/main.ts +92 -24
  57. package/src/modes/acp/acp-event-mapper.ts +54 -4
  58. package/src/modes/components/custom-editor.ts +10 -0
  59. package/src/modes/components/hook-selector.ts +89 -31
  60. package/src/modes/components/oauth-selector.ts +12 -6
  61. package/src/modes/components/session-selector.ts +179 -24
  62. package/src/modes/components/tool-execution.ts +16 -3
  63. package/src/modes/controllers/command-controller.ts +2 -11
  64. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  65. package/src/modes/controllers/input-controller.ts +19 -1
  66. package/src/modes/controllers/selector-controller.ts +61 -21
  67. package/src/modes/interactive-mode.ts +125 -15
  68. package/src/modes/types.ts +5 -2
  69. package/src/prompts/system/orchestrate-notice.md +5 -3
  70. package/src/prompts/system/workflow-notice.md +2 -2
  71. package/src/prompts/tools/eval.md +5 -5
  72. package/src/prompts/tools/find.md +1 -1
  73. package/src/prompts/tools/irc.md +6 -6
  74. package/src/prompts/tools/search.md +1 -1
  75. package/src/prompts/tools/task.md +1 -1
  76. package/src/registry/agent-registry.ts +1 -1
  77. package/src/sdk.ts +85 -31
  78. package/src/session/agent-session.ts +62 -46
  79. package/src/session/history-storage.ts +56 -12
  80. package/src/session/session-manager.ts +34 -0
  81. package/src/task/output-manager.ts +40 -48
  82. package/src/task/render.ts +3 -8
  83. package/src/tools/browser/tab-worker.ts +8 -5
  84. package/src/tools/find.ts +5 -29
  85. package/src/tools/index.ts +1 -1
  86. package/src/tools/path-utils.ts +144 -1
  87. package/src/tools/read.ts +47 -0
  88. package/src/tools/search.ts +2 -27
  89. package/src/tools/sqlite-reader.ts +92 -9
  90. package/src/utils/clipboard.ts +38 -1
  91. package/src/utils/open.ts +37 -2
  92. package/src/web/kagi.ts +168 -49
  93. package/src/web/search/providers/anthropic.ts +1 -1
  94. package/src/web/search/providers/exa.ts +20 -86
  95. package/src/web/search/providers/kagi.ts +4 -0
@@ -62,6 +62,7 @@ import type {
62
62
  Message,
63
63
  MessageAttribution,
64
64
  Model,
65
+ ProviderResponseMetadata,
65
66
  ProviderSessionState,
66
67
  ServiceTier,
67
68
  SimpleStreamOptions,
@@ -87,6 +88,7 @@ import { countTokens, MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
87
88
  import {
88
89
  extractRetryHint,
89
90
  getAgentDbPath,
91
+ getInstallId,
90
92
  isEnoent,
91
93
  isUnexpectedSocketCloseMessage,
92
94
  logger,
@@ -227,7 +229,7 @@ import type {
227
229
  SessionContext,
228
230
  SessionManager,
229
231
  } from "./session-manager";
230
- import { getLatestCompactionEntry } from "./session-manager";
232
+ import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager";
231
233
  import type { ShakeMode, ShakeResult } from "./shake-types";
232
234
  import { ToolChoiceQueue } from "./tool-choice-queue";
233
235
  import { YieldQueue } from "./yield-queue";
@@ -358,7 +360,7 @@ export interface AgentSessionConfig {
358
360
  * **MUST NOT** dispose it on their own teardown.
359
361
  */
360
362
  ownedAsyncJobManager?: AsyncJobManager;
361
- /** Agent identity (registry id like "0-Main" or "3-Alice") used for IRC routing. */
363
+ /** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
362
364
  agentId?: string;
363
365
  /** Shared agent registry (for forwarding IRC observations to the main session UI). */
364
366
  agentRegistry?: AgentRegistry;
@@ -581,15 +583,14 @@ function buildSessionMetadata(
581
583
  const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
582
584
  if (typeof accountUuid === "string" && accountUuid.length > 0) {
583
585
  userId.account_uuid = accountUuid;
584
- // Derive device_id from account_uuid so the payload matches the real CC
585
- // getAPIMetadata shape without hardware fingerprinting. A SHA-256 of a
586
- // namespaced account UUID produces a stable 64-hex value that is
587
- // indistinguishable from a randomly generated device ID on the wire, is
588
- // deterministic per account (survives reinstalls), and is auditable: it
589
- // is derived solely from the OAuth UUID the user already consented to
590
- // share with Anthropic. Omitted when no OAuth credential is available
591
- // (API-key callers) to avoid sending a hash of an empty string.
592
- userId.device_id = crypto.createHash("sha256").update(`omp-device-id-v1:${accountUuid}`).digest("hex");
586
+ // Claude Code's `device_id` is a stable 64-hex install identifier. Use
587
+ // omp's persistent install id as the root instead of deriving it from
588
+ // `account_uuid`: logging into a different Claude account on the same
589
+ // install should not make the device look new.
590
+ userId.device_id = crypto
591
+ .createHash("sha256")
592
+ .update(`omp-claude-device-id-v1:${getInstallId()}`)
593
+ .digest("hex");
593
594
  }
594
595
  }
595
596
  return { user_id: JSON.stringify(userId) };
@@ -1104,10 +1105,12 @@ export class AgentSession {
1104
1105
  this.#onResponse = configuredOnResponse
1105
1106
  ? async (response, model) => {
1106
1107
  this.rawSseDebugBuffer.recordResponse(response, model);
1108
+ this.#ingestProviderUsageHeaders(response, model);
1107
1109
  await configuredOnResponse(response, model);
1108
1110
  }
1109
1111
  : (response, model) => {
1110
1112
  this.rawSseDebugBuffer.recordResponse(response, model);
1113
+ this.#ingestProviderUsageHeaders(response, model);
1111
1114
  };
1112
1115
  const configuredOnSseEvent = config.onSseEvent;
1113
1116
  this.#onSseEvent = configuredOnSseEvent
@@ -2827,13 +2830,14 @@ export class AgentSession {
2827
2830
 
2828
2831
  /**
2829
2832
  * Set agent.sessionId from the session manager and install a dynamic
2830
- * metadata resolver so every API request carries `metadata.user_id` shaped
2831
- * like real Claude Code's `getAPIMetadata` output: `{ session_id,
2832
- * account_uuid }` (the latter only when an Anthropic OAuth credential with
2833
- * a known account UUID is loaded). Resolving live keeps the value in sync
2834
- * with auth-state changes (login/logout, token refresh that surfaces a new
2835
- * account uuid) without needing to re-call `#syncAgentSessionId()` on every
2836
- * such event.
2833
+ * metadata resolver so every Anthropic API request carries
2834
+ * `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output:
2835
+ * `{ session_id, account_uuid, device_id }`. `account_uuid` is included only
2836
+ * when an Anthropic OAuth credential with a known account UUID is loaded;
2837
+ * `device_id` is derived from the persistent omp install id. Resolving live
2838
+ * keeps the value in sync with auth-state changes (login/logout, token
2839
+ * refresh that surfaces a new account uuid) without needing to re-call
2840
+ * `#syncAgentSessionId()` on every such event.
2837
2841
  */
2838
2842
  #syncAgentSessionId(sessionId?: string): void {
2839
2843
  const sid = this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
@@ -8723,28 +8727,34 @@ export class AgentSession {
8723
8727
  }
8724
8728
 
8725
8729
  // Restore model if saved
8726
- const defaultModelStr = sessionContext.models.default;
8727
- if (defaultModelStr) {
8728
- const slashIdx = defaultModelStr.indexOf("/");
8729
- if (slashIdx > 0) {
8730
- const provider = defaultModelStr.slice(0, slashIdx);
8731
- const modelId = defaultModelStr.slice(slashIdx + 1);
8732
- const availableModels = this.#modelRegistry.getAvailable();
8733
- const match = availableModels.find(m => m.provider === provider && m.id === modelId);
8734
- if (match) {
8735
- const currentModel = this.model;
8736
- const shouldResetProviderState =
8737
- switchingToDifferentSession ||
8738
- (currentModel !== undefined &&
8739
- (currentModel.provider !== match.provider ||
8740
- currentModel.id !== match.id ||
8741
- currentModel.api !== match.api));
8742
- if (shouldResetProviderState) {
8743
- this.#setModelWithProviderSessionReset(match);
8744
- } else {
8745
- this.agent.setModel(match);
8746
- this.#syncToolCallBatchCap(match);
8747
- }
8730
+ const targetModelStrings = getRestorableSessionModels(
8731
+ sessionContext.models,
8732
+ this.sessionManager.getLastModelChangeRole(),
8733
+ );
8734
+ if (targetModelStrings.length > 0) {
8735
+ const availableModels = this.#modelRegistry.getAvailable();
8736
+ let match: Model | undefined;
8737
+ for (const targetModelStr of targetModelStrings) {
8738
+ const slashIdx = targetModelStr.indexOf("/");
8739
+ if (slashIdx <= 0) continue;
8740
+ const provider = targetModelStr.slice(0, slashIdx);
8741
+ const modelId = targetModelStr.slice(slashIdx + 1);
8742
+ match = availableModels.find(m => m.provider === provider && m.id === modelId);
8743
+ if (match) break;
8744
+ }
8745
+ if (match) {
8746
+ const currentModel = this.model;
8747
+ const shouldResetProviderState =
8748
+ switchingToDifferentSession ||
8749
+ (currentModel !== undefined &&
8750
+ (currentModel.provider !== match.provider ||
8751
+ currentModel.id !== match.id ||
8752
+ currentModel.api !== match.api));
8753
+ if (shouldResetProviderState) {
8754
+ this.#setModelWithProviderSessionReset(match);
8755
+ } else {
8756
+ this.agent.setModel(match);
8757
+ this.#syncToolCallBatchCap(match);
8748
8758
  }
8749
8759
  }
8750
8760
  }
@@ -9202,12 +9212,10 @@ export class AgentSession {
9202
9212
  * Uses the last assistant message's usage data when available,
9203
9213
  * otherwise estimates tokens for all messages.
9204
9214
  */
9205
- getContextUsage(): ContextUsage | undefined {
9215
+ getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined {
9206
9216
  const model = this.model;
9207
- if (!model) return undefined;
9208
-
9209
- const contextWindow = model.contextWindow ?? 0;
9210
- if (contextWindow <= 0) return undefined;
9217
+ const contextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
9218
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
9211
9219
 
9212
9220
  // After compaction, the last assistant usage reflects pre-compaction context size.
9213
9221
  // We can only trust usage from an assistant that responded after the latest compaction.
@@ -9248,6 +9256,14 @@ export class AgentSession {
9248
9256
  };
9249
9257
  }
9250
9258
 
9259
+ #ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void {
9260
+ if (model?.provider !== "anthropic") return;
9261
+ this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, {
9262
+ sessionId: this.agent.sessionId,
9263
+ baseUrl: this.#modelRegistry.getProviderBaseUrl?.("anthropic"),
9264
+ });
9265
+ }
9266
+
9251
9267
  async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
9252
9268
  const authStorage = this.#modelRegistry.authStorage;
9253
9269
  if (!authStorage.fetchUsageReports) return null;
@@ -9265,7 +9281,7 @@ export class AgentSession {
9265
9281
  } {
9266
9282
  const messages = this.messages;
9267
9283
 
9268
- // Find last assistant message with usage
9284
+ // Find last assistant message with valid usage.
9269
9285
  let lastUsageIndex: number | null = null;
9270
9286
  let lastUsage: Usage | undefined;
9271
9287
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -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) {