@oh-my-pi/pi-coding-agent 16.3.10 → 16.3.12

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 (85) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/cli.js +3368 -3277
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/settings-schema.d.ts +6 -0
  6. package/dist/types/config/settings.d.ts +2 -0
  7. package/dist/types/discovery/helpers.d.ts +9 -0
  8. package/dist/types/exec/bash-executor.d.ts +1 -0
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  11. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  12. package/dist/types/modes/components/model-selector.d.ts +2 -1
  13. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  14. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  15. package/dist/types/modes/workflow.d.ts +5 -1
  16. package/dist/types/session/agent-session.d.ts +5 -0
  17. package/dist/types/system-prompt.d.ts +1 -1
  18. package/dist/types/tools/bash-interactive.d.ts +1 -1
  19. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  20. package/dist/types/tools/bash.d.ts +2 -1
  21. package/dist/types/tools/browser/launch.d.ts +1 -0
  22. package/dist/types/tools/grep.d.ts +2 -0
  23. package/dist/types/tools/index.d.ts +4 -0
  24. package/dist/types/tools/path-utils.d.ts +24 -0
  25. package/dist/types/tools/read.d.ts +2 -0
  26. package/dist/types/utils/local-date.d.ts +2 -0
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +145 -0
  29. package/src/advisor/runtime.ts +19 -0
  30. package/src/config/api-key-resolver.ts +7 -2
  31. package/src/config/model-discovery.ts +18 -2
  32. package/src/config/model-registry.ts +9 -0
  33. package/src/config/settings-schema.ts +11 -1
  34. package/src/config/settings.ts +11 -0
  35. package/src/discovery/builtin.ts +2 -1
  36. package/src/discovery/claude-plugins.ts +167 -46
  37. package/src/discovery/helpers.ts +16 -1
  38. package/src/edit/renderer.ts +20 -6
  39. package/src/eval/js/worker-core.ts +163 -6
  40. package/src/exec/bash-executor.ts +14 -9
  41. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  42. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  43. package/src/extensibility/shared-events.ts +2 -2
  44. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  45. package/src/internal-urls/docs-index.generated.txt +1 -1
  46. package/src/internal-urls/registry-helpers.ts +9 -6
  47. package/src/modes/components/model-selector.ts +30 -6
  48. package/src/modes/components/settings-defs.ts +1 -1
  49. package/src/modes/components/status-line/component.ts +14 -2
  50. package/src/modes/controllers/command-controller.ts +13 -23
  51. package/src/modes/controllers/event-controller.ts +12 -12
  52. package/src/modes/controllers/extension-ui-controller.ts +6 -35
  53. package/src/modes/controllers/input-controller.ts +18 -2
  54. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  55. package/src/modes/controllers/selector-controller.ts +16 -5
  56. package/src/modes/github-ref-autocomplete.ts +75 -0
  57. package/src/modes/interactive-mode.ts +54 -10
  58. package/src/modes/prompt-action-autocomplete.ts +35 -0
  59. package/src/modes/utils/context-usage.ts +58 -5
  60. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  61. package/src/modes/utils/ui-helpers.ts +2 -2
  62. package/src/modes/workflow.ts +14 -8
  63. package/src/prompts/system/plan-mode-active.md +5 -2
  64. package/src/prompts/system/system-prompt.md +1 -2
  65. package/src/prompts/system/title-system.md +10 -10
  66. package/src/prompts/system/workflow-notice.md +69 -50
  67. package/src/prompts/tools/bash.md +18 -7
  68. package/src/prompts/tools/grep.md +1 -1
  69. package/src/prompts/tools/read.md +4 -3
  70. package/src/sdk.ts +11 -0
  71. package/src/session/agent-session.ts +128 -14
  72. package/src/system-prompt.test.ts +34 -1
  73. package/src/system-prompt.ts +27 -10
  74. package/src/tools/bash-interactive.ts +1 -1
  75. package/src/tools/bash-skill-urls.ts +39 -7
  76. package/src/tools/bash.ts +69 -39
  77. package/src/tools/browser/launch.ts +31 -4
  78. package/src/tools/grep.ts +53 -14
  79. package/src/tools/index.ts +11 -0
  80. package/src/tools/path-utils.ts +46 -1
  81. package/src/tools/read.ts +108 -50
  82. package/src/utils/local-date.ts +7 -0
  83. package/src/utils/open.ts +36 -10
  84. package/src/utils/title-generator.ts +40 -65
  85. package/src/prompts/system/title-system-marker.md +0 -17
@@ -1,6 +1,15 @@
1
+ import { isMainThread } from "node:worker_threads";
2
+ import { postmortem } from "@oh-my-pi/pi-utils";
1
3
  import { ToolError } from "../../tools/tool-errors";
2
4
  import { JsRuntime, type RuntimeHooks } from "./shared/runtime";
3
- import type { RunErrorPayload, SessionSnapshot, ToolReply, Transport, WorkerInbound } from "./worker-protocol";
5
+ import type {
6
+ RunErrorPayload,
7
+ SessionSnapshot,
8
+ ToolReply,
9
+ Transport,
10
+ WorkerInbound,
11
+ WorkerOutbound,
12
+ } from "./worker-protocol";
4
13
 
5
14
  interface PendingTool {
6
15
  runId: string;
@@ -10,9 +19,17 @@ interface PendingTool {
10
19
 
11
20
  interface ActiveRun {
12
21
  runId: string;
22
+ filename: string;
13
23
  pendingTools: Map<string, PendingTool>;
24
+ /** Rejections floated by this run's cell code, captured before its result was sent. */
25
+ floatingRejections: unknown[];
14
26
  }
15
27
 
28
+ type RunResult = Extract<WorkerOutbound, { type: "result" }>;
29
+
30
+ /** Finished-cell filenames retained for attributing rejections that surface after the run settled. */
31
+ const RECENT_CELL_FILES_MAX = 256;
32
+
16
33
  function errorPayload(error: unknown): RunErrorPayload {
17
34
  if (error instanceof Error) {
18
35
  return {
@@ -34,15 +51,134 @@ function errorFromPayload(payload: RunErrorPayload): Error {
34
51
  return error;
35
52
  }
36
53
 
54
+ /**
55
+ * Fold rejections floated by cell code into the run result: an otherwise
56
+ * successful run fails with the first floating rejection (an unawaited promise
57
+ * failing is a cell failure, not a success with noise); the rest surface as
58
+ * output text so nothing is silently dropped.
59
+ */
60
+ function foldFloatingRejections(active: ActiveRun, result: RunResult, hooks: RuntimeHooks): RunResult {
61
+ const rejections = active.floatingRejections;
62
+ if (rejections.length === 0) return result;
63
+ let folded = result;
64
+ let reported = rejections;
65
+ if (result.ok) {
66
+ const error = errorPayload(rejections[0]);
67
+ error.message = `Unhandled rejection (missing await?): ${error.message}`;
68
+ folded = { type: "result", runId: active.runId, ok: false, error };
69
+ reported = rejections.slice(1);
70
+ }
71
+ for (const reason of reported) {
72
+ const payload = errorPayload(reason);
73
+ hooks.onText(`[unhandled rejection] ${payload.name ?? "Error"}: ${payload.message}\n`);
74
+ }
75
+ return folded;
76
+ }
77
+
37
78
  export class WorkerCore {
38
79
  #transport: Transport;
39
80
  #runtime: JsRuntime | null = null;
40
81
  #runs = new Map<string, ActiveRun>();
82
+ #recentCellFiles = new Set<string>();
41
83
  #unsubscribe: () => void;
84
+ #uninstallRejectionGuard: () => void;
42
85
 
43
86
  constructor(transport: Transport) {
44
87
  this.#transport = transport;
45
88
  this.#unsubscribe = transport.onMessage(msg => this.#handle(msg));
89
+ this.#uninstallRejectionGuard = this.#installRejectionGuard();
90
+ }
91
+
92
+ /**
93
+ * Capture unhandled rejections floated by eval-cell code (unawaited async
94
+ * calls) so they fail the owning run instead of tearing down the worker or —
95
+ * via the global postmortem handler — the whole session. On the main thread
96
+ * (inline fallback) only cell-attributable rejections are consumed; in the
97
+ * dedicated worker realm a rejection during a live run is cell activity even
98
+ * without a usable stack, while anything else keeps its default fatality.
99
+ */
100
+ #installRejectionGuard(): () => void {
101
+ if (isMainThread) {
102
+ return postmortem.interceptUnhandledRejections(reason => this.#consumeRejection(reason));
103
+ }
104
+ const onRejection = (reason: unknown): void => {
105
+ if (this.#consumeRejection(reason)) return;
106
+ // Not cell-attributable: restore default fatality. Rethrowing from a
107
+ // timer surfaces it as an uncaught exception, which reaches the host
108
+ // as a worker `error` event exactly like an unhandled rejection did
109
+ // before this listener existed.
110
+ setTimeout(() => {
111
+ throw reason;
112
+ }, 0);
113
+ };
114
+ process.on("unhandledRejection", onRejection);
115
+ return () => {
116
+ process.off("unhandledRejection", onRejection);
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Attribute an unhandled rejection to eval-cell code. Live runs are stashed
122
+ * on the run (folded into its result after the settle drain); finished cells
123
+ * downgrade to a host-side warn log. Returns false when the rejection is not
124
+ * cell activity and must keep the default fatal path.
125
+ */
126
+ #consumeRejection(reason: unknown): boolean {
127
+ const stack = reason instanceof Error && typeof reason.stack === "string" ? reason.stack : undefined;
128
+ if (stack) {
129
+ // The stack can name several cells (helper defined by an earlier cell,
130
+ // called from the live one); the outermost matching frame is the caller
131
+ // that owns the floating promise.
132
+ let owner: ActiveRun | undefined;
133
+ let ownerIndex = -1;
134
+ for (const run of this.#runs.values()) {
135
+ const index = stack.lastIndexOf(run.filename);
136
+ if (index > ownerIndex) {
137
+ ownerIndex = index;
138
+ owner = run;
139
+ }
140
+ }
141
+ if (owner) {
142
+ owner.floatingRejections.push(reason);
143
+ return true;
144
+ }
145
+ let recent: string | undefined;
146
+ let recentIndex = -1;
147
+ for (const filename of this.#recentCellFiles) {
148
+ const index = stack.lastIndexOf(filename);
149
+ if (index > recentIndex) {
150
+ recentIndex = index;
151
+ recent = filename;
152
+ }
153
+ }
154
+ if (recent) {
155
+ this.#transport.send({
156
+ type: "log",
157
+ level: "warn",
158
+ msg: "Unhandled rejection from a finished eval cell (missing await?)",
159
+ meta: { filename: recent, error: errorPayload(reason) },
160
+ });
161
+ return true;
162
+ }
163
+ }
164
+ if (!isMainThread && this.#runs.size > 0) {
165
+ // Dedicated eval worker: during a live run, a rejection without a cell
166
+ // frame (e.g. `Promise.reject("msg")` or a library-created reason) is
167
+ // still cell activity — nothing else runs user code in this realm.
168
+ if (this.#runs.size === 1) {
169
+ const only = this.#runs.values().next().value;
170
+ only?.floatingRejections.push(reason);
171
+ return true;
172
+ }
173
+ this.#transport.send({
174
+ type: "log",
175
+ level: "warn",
176
+ msg: "Unhandled rejection during concurrent eval runs; cannot attribute to a cell",
177
+ meta: { error: errorPayload(reason) },
178
+ });
179
+ return true;
180
+ }
181
+ return false;
46
182
  }
47
183
 
48
184
  #handle(msg: WorkerInbound): void {
@@ -77,23 +213,42 @@ export class WorkerCore {
77
213
  }
78
214
 
79
215
  async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise<void> {
80
- const runtime = this.#ensureRuntime(snapshot);
81
- runtime.setCwd(snapshot.cwd);
82
- const active: ActiveRun = { runId, pendingTools: new Map() };
216
+ const active: ActiveRun = { runId, filename, pendingTools: new Map(), floatingRejections: [] };
83
217
  this.#runs.set(runId, active);
84
218
  const hooks: RuntimeHooks = {
85
219
  onText: chunk => this.#transport.send({ type: "text", runId, chunk }),
86
220
  onDisplay: output => this.#transport.send({ type: "display", runId, output }),
87
221
  callTool: (name, args) => this.#callTool(active, name, args),
88
222
  };
223
+ let result: RunResult;
89
224
  try {
225
+ const runtime = this.#ensureRuntime(snapshot);
226
+ runtime.setCwd(snapshot.cwd);
90
227
  const value = await runtime.run(code, filename, hooks, { runId, cwd: snapshot.cwd });
91
228
  runtime.displayValue(value, hooks);
92
- this.#transport.send({ type: "result", runId, ok: true });
229
+ result = { type: "result", runId, ok: true };
93
230
  } catch (error) {
94
- this.#transport.send({ type: "result", runId, ok: false, error: errorPayload(error) });
231
+ result = { type: "result", runId, ok: false, error: errorPayload(error) };
232
+ }
233
+ try {
234
+ // One event-loop turn so rejections the cell already floated surface
235
+ // while this run can still own them (rejection callbacks run before
236
+ // timers fire).
237
+ await Bun.sleep(0);
238
+ result = foldFloatingRejections(active, result, hooks);
95
239
  } finally {
96
240
  this.#runs.delete(runId);
241
+ this.#rememberCellFile(filename);
242
+ this.#transport.send(result);
243
+ }
244
+ }
245
+
246
+ #rememberCellFile(filename: string): void {
247
+ this.#recentCellFiles.delete(filename);
248
+ this.#recentCellFiles.add(filename);
249
+ if (this.#recentCellFiles.size > RECENT_CELL_FILES_MAX) {
250
+ const oldest = this.#recentCellFiles.values().next().value;
251
+ if (oldest !== undefined) this.#recentCellFiles.delete(oldest);
97
252
  }
98
253
  }
99
254
 
@@ -127,6 +282,7 @@ export class WorkerCore {
127
282
  this.#runtime?.dispose?.();
128
283
  this.#runtime = null;
129
284
  this.#transport.send({ type: "closed" });
285
+ this.#uninstallRejectionGuard();
130
286
  this.#unsubscribe();
131
287
  this.#transport.close();
132
288
  }
@@ -141,6 +297,7 @@ export class WorkerCore {
141
297
  this.#runs.clear();
142
298
  this.#runtime?.dispose?.();
143
299
  this.#runtime = null;
300
+ this.#uninstallRejectionGuard();
144
301
  this.#unsubscribe();
145
302
  try {
146
303
  this.#transport.close();
@@ -14,6 +14,7 @@ import { buildNonInteractiveEnv } from "./non-interactive-env";
14
14
 
15
15
  export interface BashExecutorOptions {
16
16
  cwd?: string;
17
+ /** Milliseconds before aborting the command; 0 disables the executor deadline. */
17
18
  timeout?: number;
18
19
  onChunk?: (chunk: string) => void;
19
20
  chunkThrottleMs?: number;
@@ -296,11 +297,15 @@ export async function executeBash(command: string, options?: BashExecutorOptions
296
297
 
297
298
  let timeoutTimer: NodeJS.Timeout | undefined;
298
299
  const timeoutDeferred = Promise.withResolvers<"timeout">();
299
- const baseTimeoutMs = Math.max(1_000, options?.timeout ?? 300_000);
300
- timeoutTimer = setTimeout(() => {
301
- abortCurrentExecution();
302
- timeoutDeferred.resolve("timeout");
303
- }, baseTimeoutMs);
300
+ const requestedTimeoutMs = options?.timeout;
301
+ const deadlineTimeoutMs = requestedTimeoutMs === 0 ? undefined : Math.max(1_000, requestedTimeoutMs ?? 300_000);
302
+ const nativeTimeoutMs = requestedTimeoutMs !== undefined && requestedTimeoutMs > 0 ? requestedTimeoutMs : undefined;
303
+ if (deadlineTimeoutMs !== undefined) {
304
+ timeoutTimer = setTimeout(() => {
305
+ abortCurrentExecution();
306
+ timeoutDeferred.resolve("timeout");
307
+ }, deadlineTimeoutMs);
308
+ }
304
309
 
305
310
  let resetSession = false;
306
311
 
@@ -311,7 +316,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
311
316
  command: finalCommand,
312
317
  cwd: commandCwd,
313
318
  env: commandEnv,
314
- timeoutMs: options?.timeout,
319
+ timeoutMs: nativeTimeoutMs,
315
320
  signal: runAbortController.signal,
316
321
  },
317
322
  (err, chunk) => {
@@ -328,7 +333,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
328
333
  sessionEnv: shellEnv,
329
334
  snapshotPath: snapshotPath ?? undefined,
330
335
  minimizer,
331
- timeoutMs: options?.timeout,
336
+ timeoutMs: nativeTimeoutMs,
332
337
  signal: runAbortController.signal,
333
338
  },
334
339
  (err, chunk) => {
@@ -359,8 +364,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions
359
364
  exitCode: undefined,
360
365
  cancelled: true,
361
366
  ...(await sink.dump(
362
- winner.kind === "timeout"
363
- ? `Command timed out after ${Math.round(baseTimeoutMs / 1000)} seconds`
367
+ winner.kind === "timeout" && deadlineTimeoutMs !== undefined
368
+ ? `Command timed out after ${Math.round(deadlineTimeoutMs / 1000)} seconds`
364
369
  : "Command cancelled",
365
370
  )),
366
371
  };
@@ -1059,7 +1059,8 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
1059
1059
  let resolved: string | null = null;
1060
1060
  let nextFollowsBareDependencies = followBareDependencies;
1061
1061
  if (specifier.startsWith(".")) {
1062
- resolved = await realpathOrSelf(Bun.resolveSync(specifier, dir));
1062
+ const candidate = Bun.resolveSync(specifier, dir);
1063
+ resolved = hasSourceModuleExtension(candidate) ? await realpathOrSelf(candidate) : null;
1063
1064
  } else if (specifier.startsWith("#")) {
1064
1065
  resolved = await resolvePackageImportSpecifier(specifier, file);
1065
1066
  } else if (
@@ -1078,7 +1079,10 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
1078
1079
  dependencyExtension === ".cjs" ||
1079
1080
  dependencyExtension === ".cts" ||
1080
1081
  ((dependencyExtension === ".js" || dependencyExtension === ".jsx") && manifest?.type !== "module");
1081
- resolved = dependencyEntry && !isCommonJsEntry ? await realpathOrSelf(dependencyEntry) : null;
1082
+ resolved =
1083
+ dependencyEntry && hasSourceModuleExtension(dependencyEntry) && !isCommonJsEntry
1084
+ ? await realpathOrSelf(dependencyEntry)
1085
+ : null;
1082
1086
  nextFollowsBareDependencies = false;
1083
1087
  }
1084
1088
  if (resolved && !modules.has(resolved)) {
@@ -196,19 +196,20 @@ export function parseMarketplaceCatalog(content: string, filePath: string): Mark
196
196
  * Catalog paths tried in priority order: omp-namespaced override first, then
197
197
  * the Claude Code-compatible fallback so existing marketplaces keep loading.
198
198
  */
199
- const CATALOG_RELATIVE_PATHS: readonly string[] = [
200
- path.join(".omp-plugin", "marketplace.json"),
201
- path.join(".claude-plugin", "marketplace.json"),
202
- ];
199
+ const CATALOG_RELATIVE_PATHS: readonly string[] = [".omp-plugin/marketplace.json", ".claude-plugin/marketplace.json"];
203
200
 
204
- async function readMarketplaceCatalog(root: string): Promise<{ catalogPath: string; content: string }> {
201
+ async function readMarketplaceCatalog(
202
+ root: string,
203
+ options: { relativeDisplayPaths?: boolean } = {},
204
+ ): Promise<{ catalogPath: string; displayPath: string; content: string }> {
205
205
  const tried: string[] = [];
206
206
  for (const rel of CATALOG_RELATIVE_PATHS) {
207
- const catalogPath = path.join(root, rel);
208
- tried.push(catalogPath);
207
+ const catalogPath = path.join(root, ...rel.split("/"));
208
+ const displayPath = options.relativeDisplayPaths ? rel : catalogPath;
209
+ tried.push(displayPath);
209
210
  try {
210
211
  const content = await Bun.file(catalogPath).text();
211
- return { catalogPath, content };
212
+ return { catalogPath, displayPath, content };
212
213
  } catch (err) {
213
214
  if (isEnoent(err)) continue;
214
215
  throw err;
@@ -252,11 +253,11 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis
252
253
 
253
254
  if (type === "github") {
254
255
  const url = `https://github.com/${source}.git`;
255
- return cloneAndReadCatalog(url, cacheDir);
256
+ return cloneAndReadCatalog(url, source, cacheDir);
256
257
  }
257
258
 
258
259
  if (type === "git") {
259
- return cloneAndReadCatalog(source, cacheDir);
260
+ return cloneAndReadCatalog(source, source, cacheDir);
260
261
  }
261
262
 
262
263
  // type === "url"
@@ -284,7 +285,7 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis
284
285
  * responsible for promoting the clone to its final cache location via
285
286
  * `promoteCloneToCache` after any duplicate/drift checks pass.
286
287
  */
287
- async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<FetchResult> {
288
+ async function cloneAndReadCatalog(url: string, source: string, cacheDir: string): Promise<FetchResult> {
288
289
  const tmpDir = path.join(cacheDir, `.tmp-clone-${Date.now()}`);
289
290
  await fs.mkdir(cacheDir, { recursive: true });
290
291
 
@@ -292,12 +293,12 @@ async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<Fetch
292
293
  await git.clone(url, tmpDir);
293
294
 
294
295
  try {
295
- const { catalogPath, content } = await readMarketplaceCatalog(tmpDir);
296
- const catalog = parseMarketplaceCatalog(content, catalogPath);
296
+ const { displayPath, content } = await readMarketplaceCatalog(tmpDir, { relativeDisplayPaths: true });
297
+ const catalog = parseMarketplaceCatalog(content, displayPath);
297
298
  return { catalog, clonePath: tmpDir };
298
299
  } catch (err) {
299
300
  await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
300
- throw new Error(`Cloned repository ${url}: ${(err as Error).message}`, { cause: err });
301
+ throw new Error(`Cloned repository ${url}: ${(err as Error).message} (source: ${source})`, { cause: err });
301
302
  }
302
303
  }
303
304
 
@@ -33,7 +33,7 @@ export interface SessionStartEvent {
33
33
  export interface SessionBeforeSwitchEvent {
34
34
  type: "session_before_switch";
35
35
  /** Reason for the switch */
36
- reason: "new" | "resume" | "fork";
36
+ reason: "new" | "resume" | "fork" | "handoff";
37
37
  /** Session file we're switching to (only for "resume") */
38
38
  targetSessionFile?: string;
39
39
  }
@@ -42,7 +42,7 @@ export interface SessionBeforeSwitchEvent {
42
42
  export interface SessionSwitchEvent {
43
43
  type: "session_switch";
44
44
  /** Reason for the switch */
45
- reason: "new" | "resume" | "fork";
45
+ reason: "new" | "resume" | "fork" | "handoff";
46
46
  /** Session file we came from */
47
47
  previousSessionFile: string | undefined;
48
48
  }
@@ -0,0 +1,68 @@
1
+ import { afterAll, afterEach, expect, it } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { TempDir } from "@oh-my-pi/pi-utils";
5
+ import { AgentRegistry } from "../../registry/agent-registry";
6
+ import type { AgentSession } from "../../session/agent-session";
7
+ import { ArtifactManager } from "../../session/artifacts";
8
+ import { AgentProtocolHandler } from "../agent-protocol";
9
+ import { resetRegisteredArtifactDirsForTests } from "../registry-helpers";
10
+
11
+ const tempDir = TempDir.createSync("omp-nested-agent-repro-");
12
+ afterEach(() => {
13
+ AgentRegistry.resetGlobalForTests();
14
+ resetRegisteredArtifactDirsForTests();
15
+ });
16
+ afterAll(() => {
17
+ tempDir.removeSync();
18
+ });
19
+
20
+ it("agent:// resolves a depth-2 subagent's .md output while its session is live and artifact-manager-adopted", async () => {
21
+ const root = tempDir.path();
22
+ const rootSessionFile = path.join(root, "session.jsonl");
23
+ const rootArtifactsDir = rootSessionFile.slice(0, -6);
24
+ await fs.mkdir(rootArtifactsDir, { recursive: true });
25
+ // Every subagent adopts the root ArtifactManager and reports its dir.
26
+ const sharedArtifactManager = new ArtifactManager(rootArtifactsDir);
27
+
28
+ // A depth-1 subagent's OWN children are written under its own
29
+ // sessionFile.slice(0, -6) (task/index.ts), i.e. one level deeper.
30
+ const midSessionFile = path.join(rootArtifactsDir, "CodexDeepDive.jsonl");
31
+ const midOwnArtifactsDir = midSessionFile.slice(0, -6);
32
+ await fs.mkdir(midOwnArtifactsDir, { recursive: true });
33
+
34
+ const grandchildId = "CodexDeepDive.GraphStore";
35
+ const grandchildSessionFile = path.join(midOwnArtifactsDir, `${grandchildId}.jsonl`);
36
+ await fs.writeFile(path.join(midOwnArtifactsDir, `${grandchildId}.md`), "full report content");
37
+
38
+ const fakeSession = {
39
+ sessionManager: { getArtifactsDir: () => sharedArtifactManager.dir },
40
+ } as unknown as AgentSession;
41
+ const registry = AgentRegistry.global();
42
+ registry.register({
43
+ id: "Main",
44
+ displayName: "main",
45
+ kind: "main",
46
+ session: fakeSession,
47
+ sessionFile: rootSessionFile,
48
+ });
49
+ registry.register({
50
+ id: "CodexDeepDive",
51
+ displayName: "sub",
52
+ kind: "sub",
53
+ parentId: "Main",
54
+ session: fakeSession,
55
+ sessionFile: midSessionFile,
56
+ });
57
+ registry.register({
58
+ id: grandchildId,
59
+ displayName: "sub",
60
+ kind: "sub",
61
+ parentId: "CodexDeepDive",
62
+ session: fakeSession,
63
+ sessionFile: grandchildSessionFile,
64
+ });
65
+
66
+ const resource = await new AgentProtocolHandler().resolve(new URL(`agent://${grandchildId}`) as never);
67
+ expect(resource.content).toBe("full report content");
68
+ });