pi-subagents 0.49.0 → 0.50.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 (75) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/agents/oracle.md +7 -5
  3. package/agents/researcher.md +1 -1
  4. package/agents/reviewer.md +2 -2
  5. package/agents/scout.md +1 -1
  6. package/agents/worker.md +1 -1
  7. package/docs/agents.md +2 -0
  8. package/docs/configuration.md +47 -3
  9. package/docs/extension-api.md +36 -0
  10. package/docs/missions.md +1 -1
  11. package/docs/observability.md +16 -0
  12. package/docs/tool-reference.md +19 -2
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  16. package/skills/pi-subagents/references/execution-controls.md +5 -4
  17. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  18. package/skills/pi-subagents/references/prompting-and-roles.md +31 -15
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +10 -0
  21. package/src/api/external-runs.ts +174 -84
  22. package/src/api/preflight.ts +12 -6
  23. package/src/extension/config.ts +33 -0
  24. package/src/extension/index.ts +48 -5
  25. package/src/extension/public-execution.ts +3 -1
  26. package/src/extension/rpc.ts +5 -1
  27. package/src/extension/schemas.ts +2 -1
  28. package/src/extension/tool-description.ts +4 -2
  29. package/src/inspectors/herdr/actions.ts +3 -7
  30. package/src/inspectors/herdr/project-panes.ts +2 -6
  31. package/src/inspectors/herdr/shell-command.ts +16 -0
  32. package/src/intercom/intercom-bridge.ts +2 -3
  33. package/src/intercom/native-supervisor-channel.ts +19 -42
  34. package/src/missions/goal-driver.ts +3 -1
  35. package/src/runs/background/active-run-index.ts +71 -1
  36. package/src/runs/background/async-execution.ts +49 -7
  37. package/src/runs/background/async-job-tracker.ts +1 -0
  38. package/src/runs/background/async-resume.ts +3 -5
  39. package/src/runs/background/async-status-snapshot.ts +277 -0
  40. package/src/runs/background/async-status.ts +8 -3
  41. package/src/runs/background/chain-root-attachment.ts +2 -2
  42. package/src/runs/background/completion-replay.ts +11 -1
  43. package/src/runs/background/fleet-view.ts +3 -1
  44. package/src/runs/background/result-files.ts +437 -0
  45. package/src/runs/background/result-watcher.ts +188 -41
  46. package/src/runs/background/retained-children.ts +69 -20
  47. package/src/runs/background/run-id-resolver.ts +30 -24
  48. package/src/runs/background/run-status.ts +7 -2
  49. package/src/runs/background/scheduled-runs.ts +54 -28
  50. package/src/runs/background/stale-run-reconciler.ts +27 -13
  51. package/src/runs/background/subagent-runner.ts +294 -32
  52. package/src/runs/background/subagent-wait.ts +2 -0
  53. package/src/runs/background/wait-completions.ts +5 -2
  54. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  55. package/src/runs/foreground/chain-execution.ts +16 -0
  56. package/src/runs/foreground/execution.ts +219 -15
  57. package/src/runs/foreground/subagent-executor.ts +114 -28
  58. package/src/runs/shared/completion-guard.ts +96 -6
  59. package/src/runs/shared/external-cli-runner.ts +4 -0
  60. package/src/runs/shared/model-fallback.ts +16 -2
  61. package/src/runs/shared/nested-events.ts +66 -62
  62. package/src/runs/shared/orca-progress-tabs.ts +375 -0
  63. package/src/runs/shared/parallel-utils.ts +2 -0
  64. package/src/runs/shared/subagent-control.ts +15 -0
  65. package/src/runs/shared/subagent-prompt-runtime.ts +1 -9
  66. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  67. package/src/runs/shared/tool-timeout.ts +93 -0
  68. package/src/shared/types.ts +29 -2
  69. package/src/slash/slash-commands.ts +34 -25
  70. package/src/slash/slash-live-state.ts +3 -0
  71. package/src/tui/fleet-status.ts +160 -45
  72. package/src/tui/fleet.ts +125 -15
  73. package/src/tui/render.ts +41 -10
  74. package/src/workflows/chat-progress.ts +6 -4
  75. package/src/workflows/scripted-workflow.ts +227 -70
@@ -34,6 +34,7 @@ import { THINKING_LEVELS } from "../../shared/model-info.ts";
34
34
  export const NESTED_EVENTS_DIR = path.join(TEMP_ROOT_DIR, "nested-subagent-events");
35
35
  const ROUTE_FILE = "route.json";
36
36
  const REGISTRY_FILE = "registry.json";
37
+ const ROUTE_INDEX_DIR = ".route-index";
37
38
  const MAX_EVENT_BYTES = 64 * 1024;
38
39
  const MAX_STEPS = 12;
39
40
  const MAX_CHILDREN = 16;
@@ -105,6 +106,18 @@ function commonRouteRoot(route: Pick<NestedRoute, "eventSink" | "controlInbox">)
105
106
  return path.dirname(path.resolve(route.eventSink));
106
107
  }
107
108
 
109
+ function routeIndexRoot(): string {
110
+ return path.join(NESTED_EVENTS_DIR, ROUTE_INDEX_DIR, "roots");
111
+ }
112
+
113
+ function routeIndexDir(rootRunId: string): string {
114
+ return path.join(routeIndexRoot(), encodeURIComponent(rootRunId));
115
+ }
116
+
117
+ function routeIndexPath(rootRunId: string, capabilityToken: string): string {
118
+ return path.join(routeIndexDir(rootRunId), `${encodeURIComponent(capabilityToken)}.json`);
119
+ }
120
+
108
121
  function validateRouteShape(route: NestedRoute): void {
109
122
  assertSafeId("rootRunId", route.rootRunId);
110
123
  assertSafeId("capabilityToken", route.capabilityToken);
@@ -121,7 +134,10 @@ export function createNestedRoute(rootRunId: string): NestedRoute {
121
134
  const controlInbox = path.join(routeRoot, "controls");
122
135
  fs.mkdirSync(eventSink, { recursive: true, mode: 0o700 });
123
136
  fs.mkdirSync(controlInbox, { recursive: true, mode: 0o700 });
124
- fs.writeFileSync(path.join(routeRoot, ROUTE_FILE), `${JSON.stringify({ rootRunId, capabilityToken, createdAt: Date.now() })}\n`, { mode: 0o600 });
137
+ const createdAt = Date.now();
138
+ fs.mkdirSync(routeIndexDir(rootRunId), { recursive: true, mode: 0o700 });
139
+ writeAtomicJson(routeIndexPath(rootRunId, capabilityToken), { rootRunId, capabilityToken, routeRoot: path.basename(routeRoot), createdAt });
140
+ writeAtomicJson(path.join(routeRoot, ROUTE_FILE), { rootRunId, capabilityToken, createdAt });
125
141
  return { rootRunId, eventSink, controlInbox, capabilityToken };
126
142
  }
127
143
 
@@ -515,68 +531,57 @@ function registryPath(route: NestedRoute): string {
515
531
  return path.join(commonRouteRoot(route), REGISTRY_FILE);
516
532
  }
517
533
 
534
+ function routeFromIndexEntry(rootRunId: string, filePath: string): NestedRoute | undefined {
535
+ try {
536
+ const metadata = JSON.parse(fs.readFileSync(filePath, "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown; routeRoot?: unknown; createdAt?: unknown };
537
+ if (metadata.rootRunId !== rootRunId || typeof metadata.capabilityToken !== "string" || typeof metadata.routeRoot !== "string") return undefined;
538
+ const routeRoot = path.join(NESTED_EVENTS_DIR, path.basename(metadata.routeRoot));
539
+ const route = {
540
+ rootRunId,
541
+ eventSink: path.join(routeRoot, "events"),
542
+ controlInbox: path.join(routeRoot, "controls"),
543
+ capabilityToken: metadata.capabilityToken,
544
+ };
545
+ validateRouteShape(route);
546
+ if (!fs.statSync(route.eventSink).isDirectory() || !fs.statSync(route.controlInbox).isDirectory()) return undefined;
547
+ const routeFilePath = path.join(routeRoot, ROUTE_FILE);
548
+ try {
549
+ const routeFile = JSON.parse(fs.readFileSync(routeFilePath, "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
550
+ if (routeFile.rootRunId !== rootRunId || routeFile.capabilityToken !== metadata.capabilityToken) return undefined;
551
+ } catch (error) {
552
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return undefined;
553
+ try {
554
+ writeAtomicJson(routeFilePath, { rootRunId, capabilityToken: metadata.capabilityToken, createdAt: typeof metadata.createdAt === "number" ? metadata.createdAt : Date.now() });
555
+ } catch {
556
+ // The route index already carries enough data for reload lookup.
557
+ }
558
+ }
559
+ return route;
560
+ } catch {
561
+ return undefined;
562
+ }
563
+ }
564
+
518
565
  export function findNestedRouteForRootId(rootRunId: string): NestedRoute | undefined {
519
566
  assertSafeId("rootRunId", rootRunId);
520
567
  let entries: string[];
521
568
  try {
522
- entries = fs.readdirSync(NESTED_EVENTS_DIR);
569
+ entries = fs.readdirSync(routeIndexDir(rootRunId));
523
570
  } catch (error) {
524
571
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
525
572
  throw error;
526
573
  }
527
574
  for (const entry of entries) {
528
- if (!entry.startsWith(`${rootRunId}-`)) continue;
529
- const routeRoot = path.join(NESTED_EVENTS_DIR, entry);
530
- try {
531
- const metadata = JSON.parse(fs.readFileSync(path.join(routeRoot, ROUTE_FILE), "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
532
- if (metadata.rootRunId !== rootRunId || typeof metadata.capabilityToken !== "string") continue;
533
- const route = {
534
- rootRunId,
535
- eventSink: path.join(routeRoot, "events"),
536
- controlInbox: path.join(routeRoot, "controls"),
537
- capabilityToken: metadata.capabilityToken,
538
- };
539
- validateRouteShape(route);
540
- return route;
541
- } catch {
542
- continue;
543
- }
575
+ const route = routeFromIndexEntry(rootRunId, path.join(routeIndexDir(rootRunId), entry));
576
+ if (route) return route;
544
577
  }
545
578
  return undefined;
546
579
  }
547
580
 
548
- /**
549
- * Scan the nested-events directory once and index every route by its root run
550
- * id. Use this when resolving routes for many runs (e.g. listAsyncRuns) so the
551
- * cost is O(routes) total instead of O(runs * routes) from calling
552
- * findNestedRouteForRootId per run.
553
- */
554
581
  export function buildNestedRouteIndex(): Map<string, NestedRoute> {
555
- let entries: string[];
556
- try {
557
- entries = fs.readdirSync(NESTED_EVENTS_DIR);
558
- } catch (error) {
559
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Map();
560
- throw error;
561
- }
562
582
  const index = new Map<string, NestedRoute>();
563
- for (const entry of entries) {
564
- const routeRoot = path.join(NESTED_EVENTS_DIR, entry);
565
- try {
566
- const metadata = JSON.parse(fs.readFileSync(path.join(routeRoot, ROUTE_FILE), "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
567
- if (typeof metadata.rootRunId !== "string" || typeof metadata.capabilityToken !== "string") continue;
568
- if (index.has(metadata.rootRunId)) continue;
569
- const route: NestedRoute = {
570
- rootRunId: metadata.rootRunId,
571
- eventSink: path.join(routeRoot, "events"),
572
- controlInbox: path.join(routeRoot, "controls"),
573
- capabilityToken: metadata.capabilityToken,
574
- };
575
- validateRouteShape(route);
576
- index.set(metadata.rootRunId, route);
577
- } catch {
578
- continue;
579
- }
583
+ for (const route of listNestedRoutes()) {
584
+ if (!index.has(route.rootRunId)) index.set(route.rootRunId, route);
580
585
  }
581
586
  return index;
582
587
  }
@@ -630,27 +635,26 @@ function collectScopedNestedRuns(children: NestedRunSummary[] | undefined, scope
630
635
  }
631
636
 
632
637
  function listNestedRoutes(): NestedRoute[] {
633
- let entries: string[];
638
+ let rootEntries: string[];
634
639
  try {
635
- entries = fs.readdirSync(NESTED_EVENTS_DIR);
640
+ rootEntries = fs.readdirSync(routeIndexRoot());
636
641
  } catch (error) {
637
642
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
638
643
  throw error;
639
644
  }
640
645
  const routes: NestedRoute[] = [];
641
- for (const entry of entries) {
642
- const routeRoot = path.join(NESTED_EVENTS_DIR, entry);
646
+ for (const rootEntry of rootEntries) {
647
+ let rootRunId: string;
643
648
  try {
644
- const metadata = JSON.parse(fs.readFileSync(path.join(routeRoot, ROUTE_FILE), "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
645
- if (typeof metadata.rootRunId !== "string" || typeof metadata.capabilityToken !== "string") continue;
646
- const route = {
647
- rootRunId: metadata.rootRunId,
648
- eventSink: path.join(routeRoot, "events"),
649
- controlInbox: path.join(routeRoot, "controls"),
650
- capabilityToken: metadata.capabilityToken,
651
- };
652
- validateRouteShape(route);
653
- routes.push(route);
649
+ rootRunId = decodeURIComponent(rootEntry);
650
+ } catch {
651
+ continue;
652
+ }
653
+ try {
654
+ for (const entry of fs.readdirSync(path.join(routeIndexRoot(), rootEntry))) {
655
+ const route = routeFromIndexEntry(rootRunId, path.join(routeIndexRoot(), rootEntry, entry));
656
+ if (route) routes.push(route);
657
+ }
654
658
  } catch {
655
659
  continue;
656
660
  }
@@ -0,0 +1,375 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import type { Message } from "@earendil-works/pi-ai";
6
+ import { resolveNodeExecutable } from "../../shared/node-executable.ts";
7
+ import { TEMP_ROOT_DIR, type OrcaProgressTabsConfig } from "../../shared/types.ts";
8
+ import { extractTextFromContent, extractToolArgsPreview, getAgentDir } from "../../shared/utils.ts";
9
+
10
+ const ORCA_CREATE_TIMEOUT_MS = 20_000;
11
+ const ORCA_KILL_GRACE_MS = 2_000;
12
+ const CLEANUP_DELAY_MS = 5 * 60_000;
13
+ const STALE_PROGRESS_MAX_AGE_MS = 24 * 60 * 60_000;
14
+ const VIEWER_POLL_MS = 150;
15
+ const MAX_MIRROR_BYTES = 1024 * 1024;
16
+ const MIRROR_FOOTER_RESERVE_BYTES = 16 * 1024;
17
+ const COUNTER_LOCK_STALE_MS = 30_000;
18
+ const COUNTER_LOCK_RETRIES = 200;
19
+ const COUNTER_LOCK_RETRY_MS = 10;
20
+
21
+ const ORCA_CREATE_WATCHDOG_SCRIPT = [
22
+ "const {spawn}=require('node:child_process');",
23
+ "const timeout=Number(process.argv[1]),grace=Number(process.argv[2]),command=process.argv[3],args=process.argv.slice(4);",
24
+ "const child=spawn(command,args,{stdio:'ignore',windowsHide:true});",
25
+ "let hardKill;",
26
+ "const timer=setTimeout(()=>{child.kill('SIGTERM');hardKill=setTimeout(()=>child.kill('SIGKILL'),grace)},timeout);",
27
+ "const clear=()=>{clearTimeout(timer);if(hardKill)clearTimeout(hardKill)};",
28
+ "child.once('error',()=>{clear();process.exitCode=1});",
29
+ "child.once('close',code=>{clear();process.exitCode=code===0?0:1});",
30
+ ].join("");
31
+
32
+ const ORCA_CLEANUP_WATCHDOG_SCRIPT = [
33
+ "const fs=require('node:fs');",
34
+ "const deadline=Date.now()+Number(process.argv[1]),files=process.argv.slice(2);",
35
+ "function check(){if(!files.some(file=>fs.existsSync(file)))process.exit(0);if(Date.now()<deadline)return;for(const file of files){try{fs.rmSync(file,{force:true})}catch{}}process.exit(0)}",
36
+ "setInterval(check,1000);check();",
37
+ ].join("");
38
+
39
+ export interface OrcaProgressTab {
40
+ append(text: string): void;
41
+ event(event: { type?: string; message?: Message; toolName?: string; args?: unknown }): void;
42
+ finish(status: "completed" | "failed" | "stopped", sessionFile?: string): void;
43
+ }
44
+
45
+ function executableFile(candidate: string): boolean {
46
+ try {
47
+ const stats = fs.statSync(candidate);
48
+ if (!stats.isFile()) return false;
49
+ if (process.platform === "win32") return true;
50
+ fs.accessSync(candidate, fs.constants.X_OK);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function executableNames(command: string, env: NodeJS.ProcessEnv): string[] {
58
+ if (process.platform !== "win32") return [command];
59
+ const extensions = (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean);
60
+ return [command, ...extensions.map((extension) => `${command}${extension.toLowerCase()}`), ...extensions.map((extension) => `${command}${extension.toUpperCase()}`)];
61
+ }
62
+
63
+ export function resolveOrcaCommand(env: NodeJS.ProcessEnv = process.env): string | undefined {
64
+ const override = env.PI_SUBAGENT_ORCA_BINARY?.trim();
65
+ if (override) return executableFile(override) ? override : undefined;
66
+ for (const directory of (env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
67
+ for (const name of executableNames("orca", env)) {
68
+ const candidate = path.join(directory, name);
69
+ if (executableFile(candidate)) return candidate;
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function shellQuote(value: string): string {
76
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
77
+ }
78
+
79
+ const VIEWER_SCRIPT = [
80
+ "const fs=require('fs'),{StringDecoder}=require('string_decoder');",
81
+ "const log=process.argv[1],done=process.argv[2],decoder=new StringDecoder('utf8');",
82
+ "let offset=0,finishing=false,state='text';",
83
+ "function sanitize(input){let output='';for(const char of input){const code=char.charCodeAt(0);",
84
+ "if(state==='text'){if(code===27){state='escape';continue}if(code===155){state='csi';continue}if(code===157){state='osc';continue}if(code===144||code===152||code===158||code===159){state='string';continue}if(code===10||(code>=32&&code!==127&&(code<128||code>159)))output+=char;continue}",
85
+ "if(state==='escape'){if(char==='[')state='csi';else if(char===']')state='osc';else if(char==='P'||char==='X'||char==='^'||char==='_')state='string';else if(code<32||code>47)state='text';continue}",
86
+ "if(state==='csi'){if(code>=64&&code<=126)state='text';continue}",
87
+ "if(state==='osc'){if(code===7)state='text';else if(code===27)state='osc-escape';continue}",
88
+ "if(state==='osc-escape'){state=char==='\\\\'?'text':code===27?'osc-escape':'osc';continue}",
89
+ "if(state==='string'){if(code===27)state='string-escape';continue}",
90
+ "if(state==='string-escape')state=char==='\\\\'?'text':code===27?'string-escape':'string';",
91
+ "}return output}",
92
+ "function write(buffer){const output=sanitize(decoder.write(buffer));if(output)process.stdout.write(output)}",
93
+ "function pump(){",
94
+ " try{const size=fs.statSync(log).size;if(size<offset)offset=0;if(size>offset){const fd=fs.openSync(log,'r');const b=Buffer.alloc(size-offset);fs.readSync(fd,b,0,b.length,offset);fs.closeSync(fd);offset=size;write(b);}}catch{}",
95
+ " if(!finishing&&fs.existsSync(done)){finishing=true;pump();setTimeout(()=>{const output=sanitize(decoder.end());if(output)process.stdout.write(output);try{fs.unlinkSync(done)}catch{}try{fs.unlinkSync(log)}catch{}process.exit(0)},250);}",
96
+ "}",
97
+ `const timer=setInterval(pump,${VIEWER_POLL_MS});`,
98
+ "pump();",
99
+ "process.on('exit',()=>clearInterval(timer));",
100
+ ].join("");
101
+
102
+ function viewerCommand(nodeExecutable: string, logPath: string, donePath: string): string {
103
+ const invocation = [nodeExecutable, "-e", VIEWER_SCRIPT, logPath, donePath].map(shellQuote).join(" ");
104
+ // Do not replace or exit Orca's interactive shell. The short-lived viewer may
105
+ // finish and delete its mirror files, while the completed tab remains open at
106
+ // the shell prompt until the user closes it.
107
+ return invocation;
108
+ }
109
+
110
+ function progressRoot(): string {
111
+ return path.join(TEMP_ROOT_DIR, "orca-progress");
112
+ }
113
+
114
+ function pruneStaleProgressFiles(root: string, now = Date.now()): void {
115
+ try {
116
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
117
+ if (!entry.isFile() || (!entry.name.endsWith(".log") && !entry.name.endsWith(".done"))) continue;
118
+ const file = path.join(root, entry.name);
119
+ try {
120
+ if (now - fs.statSync(file).mtimeMs > STALE_PROGRESS_MAX_AGE_MS) fs.rmSync(file, { force: true });
121
+ } catch { /* best-effort stale cleanup */ }
122
+ }
123
+ } catch {
124
+ // The observer remains optional when temp cleanup is unavailable.
125
+ }
126
+ }
127
+
128
+ function safeSegment(value: unknown, fallback = "subagent"): string {
129
+ if (typeof value !== "string") return fallback;
130
+ return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || fallback;
131
+ }
132
+
133
+ function sleepSync(ms: number): void {
134
+ const signal = new Int32Array(new SharedArrayBuffer(4));
135
+ Atomics.wait(signal, 0, 0, ms);
136
+ }
137
+
138
+ function resolveSequenceScope(cwd: string): string {
139
+ let current = path.resolve(cwd);
140
+ try { current = fs.realpathSync(current); } catch { /* use the lexical cwd */ }
141
+ while (true) {
142
+ try {
143
+ if (fs.existsSync(path.join(current, ".git"))) return current;
144
+ } catch { /* keep walking */ }
145
+ const parent = path.dirname(current);
146
+ if (parent === current) return path.resolve(cwd);
147
+ current = parent;
148
+ }
149
+ }
150
+
151
+ function reserveTabSequence(root: string, cwd: string): number | undefined {
152
+ const key = createHash("sha256").update(resolveSequenceScope(cwd)).digest("hex").slice(0, 20);
153
+ const counterPath = path.join(root, `counter-${key}`);
154
+ const lockPath = `${counterPath}.lock`;
155
+ let locked = false;
156
+ for (let attempt = 0; attempt < COUNTER_LOCK_RETRIES; attempt++) {
157
+ try {
158
+ fs.mkdirSync(lockPath, { mode: 0o700 });
159
+ locked = true;
160
+ break;
161
+ } catch (error) {
162
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") break;
163
+ try {
164
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > COUNTER_LOCK_STALE_MS) {
165
+ fs.rmSync(lockPath, { recursive: true, force: true });
166
+ continue;
167
+ }
168
+ } catch { /* another process released the lock */ }
169
+ sleepSync(COUNTER_LOCK_RETRY_MS);
170
+ }
171
+ }
172
+ if (!locked) return undefined;
173
+ try {
174
+ let current = 0;
175
+ try {
176
+ const parsed = Number.parseInt(fs.readFileSync(counterPath, "utf-8"), 10);
177
+ if (Number.isSafeInteger(parsed) && parsed >= 0) current = parsed;
178
+ } catch { /* first tab for this worktree */ }
179
+ const next = current + 1;
180
+ try {
181
+ fs.writeFileSync(counterPath, `${next}\n`, { encoding: "utf-8", mode: 0o600 });
182
+ return next;
183
+ } catch {
184
+ return undefined;
185
+ }
186
+ } finally {
187
+ try { fs.rmSync(lockPath, { recursive: true, force: true }); } catch { /* stale lock cleanup handles crashes */ }
188
+ }
189
+ }
190
+
191
+ export function resolvePiSessionId(sessionFile: string | undefined): string | undefined {
192
+ if (!sessionFile) return undefined;
193
+ try {
194
+ const fd = fs.openSync(sessionFile, "r");
195
+ try {
196
+ const buffer = Buffer.alloc(16 * 1024);
197
+ const bytes = fs.readSync(fd, buffer, 0, buffer.length, 0);
198
+ const line = buffer.toString("utf-8", 0, bytes).split("\n", 1)[0];
199
+ if (!line) return undefined;
200
+ const header = JSON.parse(line) as { type?: unknown; id?: unknown };
201
+ if (header.type === "session" && typeof header.id === "string" && /^[A-Za-z0-9-]{8,}$/.test(header.id)) return header.id;
202
+ return undefined;
203
+ } finally {
204
+ fs.closeSync(fd);
205
+ }
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ }
210
+
211
+ function scheduleCleanup(nodeExecutable: string, paths: string[]): void {
212
+ try {
213
+ const watchdog = spawn(nodeExecutable, ["-e", ORCA_CLEANUP_WATCHDOG_SCRIPT, String(CLEANUP_DELAY_MS), ...paths], {
214
+ detached: true,
215
+ stdio: "ignore",
216
+ windowsHide: true,
217
+ });
218
+ watchdog.once("error", () => {});
219
+ watchdog.unref();
220
+ } catch {
221
+ // Cleanup remains best effort for this optional observer.
222
+ }
223
+ }
224
+
225
+ function loadOrcaProgressTabsConfig(): OrcaProgressTabsConfig | undefined {
226
+ try {
227
+ const configPath = path.join(getAgentDir(), "extensions", "subagent", "config.json");
228
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as unknown;
229
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
230
+ const value = (parsed as Record<string, unknown>).orcaProgressTabs;
231
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
232
+ const config = value as Record<string, unknown>;
233
+ if (Object.keys(config).some((key) => key !== "enabled") || typeof config.enabled !== "boolean") return undefined;
234
+ return { enabled: config.enabled };
235
+ } catch {
236
+ return undefined;
237
+ }
238
+ }
239
+
240
+ export function createOrcaProgressTab(input: {
241
+ cwd: string;
242
+ runId: string;
243
+ agent: string;
244
+ index: number;
245
+ config?: OrcaProgressTabsConfig;
246
+ env?: NodeJS.ProcessEnv;
247
+ command?: string;
248
+ }): OrcaProgressTab | undefined {
249
+ const config = input.config ?? loadOrcaProgressTabsConfig();
250
+ if (config?.enabled !== true || process.platform === "win32") return undefined;
251
+ const command = input.command ?? resolveOrcaCommand(input.env);
252
+ if (!command) return undefined;
253
+ const nodeExecutable = resolveNodeExecutable();
254
+
255
+ const root = progressRoot();
256
+ try {
257
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
258
+ } catch {
259
+ return undefined;
260
+ }
261
+ pruneStaleProgressFiles(root);
262
+ const runId = safeSegment(input.runId, "run");
263
+ const agent = safeSegment(input.agent, "subagent");
264
+ const index = typeof input.index === "number" && Number.isInteger(input.index) && input.index >= 0 ? input.index : 0;
265
+ const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
266
+ const tabSequence = reserveTabSequence(root, cwd);
267
+ if (tabSequence === undefined) return undefined;
268
+ const stem = `${runId}-${index}-${randomUUID()}`;
269
+ const logPath = path.join(root, `${stem}.log`);
270
+ const donePath = path.join(root, `${stem}.done`);
271
+ const title = `subagent · ${agent} · ${tabSequence}`;
272
+ try {
273
+ fs.writeFileSync(logPath, `pi-subagents / ${agent}\nrun ${runId} · child ${index + 1}\n${"─".repeat(48)}\n`, { encoding: "utf-8", mode: 0o600 });
274
+ fs.rmSync(donePath, { force: true });
275
+ } catch {
276
+ return undefined;
277
+ }
278
+
279
+ let available = true;
280
+ let truncated = false;
281
+ let scheduledBytes = fs.statSync(logPath).size;
282
+ const progressByteLimit = MAX_MIRROR_BYTES - MIRROR_FOOTER_RESERVE_BYTES;
283
+ const logStream = fs.createWriteStream(logPath, { flags: "a", mode: 0o600 });
284
+ const failObserver = () => {
285
+ if (!available) return;
286
+ available = false;
287
+ logStream.destroy();
288
+ try { fs.rmSync(logPath, { force: true }); } catch { /* best effort */ }
289
+ try { fs.rmSync(donePath, { force: true }); } catch { /* best effort */ }
290
+ };
291
+ logStream.once("error", failObserver);
292
+ const writeProgress = (text: string) => {
293
+ if (!available || !text || truncated) return;
294
+ const bytes = Buffer.byteLength(text);
295
+ if (scheduledBytes + bytes > progressByteLimit) {
296
+ truncated = true;
297
+ return;
298
+ }
299
+ scheduledBytes += bytes;
300
+ try {
301
+ logStream.write(text);
302
+ } catch {
303
+ failObserver();
304
+ }
305
+ };
306
+ try {
307
+ const watchdog = spawn(nodeExecutable, [
308
+ "-e", ORCA_CREATE_WATCHDOG_SCRIPT,
309
+ String(ORCA_CREATE_TIMEOUT_MS),
310
+ String(ORCA_KILL_GRACE_MS),
311
+ command,
312
+ "terminal", "create",
313
+ "--worktree", `path:${path.resolve(cwd)}`,
314
+ "--title", title,
315
+ "--command", viewerCommand(nodeExecutable, logPath, donePath),
316
+ "--json",
317
+ ], {
318
+ cwd,
319
+ detached: true,
320
+ stdio: "ignore",
321
+ windowsHide: true,
322
+ env: input.env ?? process.env,
323
+ });
324
+ watchdog.once("close", (code) => {
325
+ if (code !== 0) failObserver();
326
+ });
327
+ watchdog.once("error", failObserver);
328
+ watchdog.unref();
329
+ } catch {
330
+ failObserver();
331
+ return undefined;
332
+ }
333
+
334
+ let finished = false;
335
+ return {
336
+ append(text) {
337
+ if (finished) return;
338
+ writeProgress(text);
339
+ },
340
+ event(event) {
341
+ if (!available || finished) return;
342
+ if (event.type === "tool_execution_start" && event.toolName) {
343
+ const args = event.args && typeof event.args === "object" && !Array.isArray(event.args)
344
+ ? extractToolArgsPreview(event.args as Record<string, unknown>)
345
+ : "";
346
+ writeProgress(`\n› ${event.toolName}${args ? `: ${args}` : ""}\n`);
347
+ return;
348
+ }
349
+ if ((event.type === "message_end" || event.type === "tool_result_end") && event.message) {
350
+ const text = extractTextFromContent(event.message.content);
351
+ if (text.trim()) writeProgress(`${text}${text.endsWith("\n") ? "" : "\n"}`);
352
+ }
353
+ },
354
+ finish(status, sessionFile) {
355
+ if (!available || finished) return;
356
+ finished = true;
357
+ const sessionId = status === "completed" ? resolvePiSessionId(sessionFile) : undefined;
358
+ let verifiedSessionFile: string | undefined;
359
+ if (sessionId && sessionFile) {
360
+ try { verifiedSessionFile = fs.realpathSync(sessionFile); } catch { /* the session is no longer available */ }
361
+ }
362
+ const terminalMessage = verifiedSessionFile
363
+ ? `completed. To remove the Pi session of this subagent, run rm -- ${shellQuote(verifiedSessionFile)}`
364
+ : status;
365
+ const truncation = truncated ? `\n[progress mirror truncated at ${MAX_MIRROR_BYTES} bytes]\n` : "";
366
+ let footer = `${truncation}\n${"─".repeat(48)}\n${terminalMessage}\n`;
367
+ if (scheduledBytes + Buffer.byteLength(footer) > MAX_MIRROR_BYTES) footer = `\n${status}\n`;
368
+ logStream.end(footer, () => {
369
+ if (!available) return;
370
+ try { fs.writeFileSync(donePath, `${status}\n`, { encoding: "utf-8", mode: 0o600 }); } catch { /* best effort */ }
371
+ scheduleCleanup(nodeExecutable, [logPath, donePath]);
372
+ });
373
+ },
374
+ };
375
+ }
@@ -41,6 +41,8 @@ export interface RunnerSubagentStep {
41
41
  sessionFile?: string;
42
42
  maxSubagentDepth?: number;
43
43
  timeoutMs?: number;
44
+ /** Resolved configured hard per-tool-call timeout (ms); fast tools still have a default when undefined. */
45
+ toolTimeoutMs?: number;
44
46
  waitToolEnabled?: boolean;
45
47
  structuredOutput?: {
46
48
  schema: import("../../shared/types.ts").JsonSchemaObject;
@@ -6,6 +6,7 @@ import {
6
6
  type ControlNotificationChannel,
7
7
  type ResolvedControlConfig,
8
8
  } from "../../shared/types.ts";
9
+ import { isToolTimeoutExempt } from "./tool-timeout.ts";
9
10
 
10
11
  const CONTROL_EVENT_TYPES: ControlEventType[] = ["active_long_running", "needs_attention"];
11
12
  const CONTROL_NOTIFICATION_CHANNELS: ControlNotificationChannel[] = ["event", "async", "intercom"];
@@ -84,6 +85,18 @@ export function deriveActivityState(input: {
84
85
  return ageMs > input.config.needsAttentionAfterMs ? "needs_attention" : undefined;
85
86
  }
86
87
 
88
+ export function shouldEmitOpenToolAttention(input: {
89
+ config: ResolvedControlConfig;
90
+ currentTool?: string;
91
+ currentToolStartedAt?: number;
92
+ now?: number;
93
+ }): boolean {
94
+ if (!input.config.enabled || !input.currentTool || input.currentToolStartedAt === undefined) return false;
95
+ if (isToolTimeoutExempt(input.currentTool)) return false;
96
+ const now = input.now ?? Date.now();
97
+ return Math.max(0, now - input.currentToolStartedAt) >= input.config.activeNoticeAfterMs;
98
+ }
99
+
87
100
  export function buildControlEvent(input: {
88
101
  type?: ControlEventType;
89
102
  from?: ActivityState;
@@ -196,10 +209,12 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
196
209
  const supervisorHint = event.reason === "supervisor_request"
197
210
  ? "Supervisor request: reply to the pending request. If subagent_supervisor pending is empty, check intercom pending because an external intercom tool may own the request."
198
211
  : undefined;
212
+ const facts = formatLongRunningFacts(event);
199
213
  return [
200
214
  `Subagent needs attention: ${event.agent}`,
201
215
  `Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
202
216
  `Signal: ${event.message}`,
217
+ facts ? `Facts: ${facts}` : undefined,
203
218
  event.recentFailureSummary ? `Recent failures: ${event.recentFailureSummary}` : undefined,
204
219
  supervisorHint,
205
220
  "Hint: Inspect status first unless the run is clearly blocked. Use steer for a top-level live async child, routed resume for a live nested child, or resume to revive a paused/completed/failed child.",
@@ -580,16 +580,9 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
580
580
  } as unknown as SubagentState;
581
581
  if (typeof pi.registerTool === "function") registerWaitTool(pi, waitState, waitToolEnabled);
582
582
  let nativeSupervisorClientRegistered = false;
583
- let nativeSupervisorFallbackRegistered = false;
584
583
  const registerNativeSupervisorClientOnce = (): void => {
585
584
  if (nativeSupervisorClientRegistered) return;
586
585
  nativeSupervisorClientRegistered = true;
587
- registerNativeSupervisorClient(pi, { includeIntercomFallback: false });
588
- };
589
- const registerNativeSupervisorFallbackOnce = (): void => {
590
- registerNativeSupervisorClientOnce();
591
- if (nativeSupervisorFallbackRegistered) return;
592
- nativeSupervisorFallbackRegistered = true;
593
586
  registerNativeSupervisorClient(pi);
594
587
  };
595
588
  const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: ExtensionContext) => unknown) => void;
@@ -597,7 +590,6 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
597
590
  const sessionManager = (ctx as { sessionManager?: Parameters<typeof resolveCurrentSessionId>[0] } | undefined)?.sessionManager;
598
591
  waitState.currentSessionId = sessionManager ? resolveCurrentSessionId(sessionManager) : null;
599
592
  registerNativeSupervisorClientOnce();
600
- if (readRequiredChildTools()?.includes("intercom")) registerNativeSupervisorFallbackOnce();
601
593
  });
602
594
  onRuntimeEvent("agent_start", () => {
603
595
  refreshChildToolDiagnostic(pi);
@@ -650,7 +642,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
650
642
 
651
643
  onRuntimeEvent("before_agent_start", async (event: unknown) => {
652
644
  if (!event || typeof event !== "object" || !("systemPrompt" in event) || typeof event.systemPrompt !== "string") return undefined;
653
- registerNativeSupervisorFallbackOnce();
645
+ registerNativeSupervisorClientOnce();
654
646
  const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
655
647
  if (intercomSessionName && typeof pi.setSessionName === "function") {
656
648
  pi.setSessionName(intercomSessionName);
@@ -12,6 +12,7 @@ export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
12
12
  export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
13
13
 
14
14
  const SIGKILL_PROCESS_SIGNAL_ERROR = formatProcessSignalError("SIGKILL");
15
+ const EXTENSION_REGISTRATION_CONFLICT_PATTERN = /Failed to load extension\b[^\n]*:\s*(?:Tool|Flag)\s+"[^"]+"\s+conflicts with\b/i;
15
16
 
16
17
  export interface SubagentStartupFailureEvidence {
17
18
  exitCode: number | null | undefined;
@@ -31,6 +32,17 @@ export interface SubagentStartupFailureEvidence {
31
32
  turnBudgetExceeded?: boolean;
32
33
  }
33
34
 
35
+ export function formatSubagentExtensionConflictError(
36
+ error: string | undefined,
37
+ input: { agent: string; ambientExtensionsEnabled: boolean },
38
+ ): string | undefined {
39
+ if (!error || !input.ambientExtensionsEnabled || !EXTENSION_REGISTRATION_CONFLICT_PATTERN.test(error)) return error;
40
+ const settings = JSON.stringify({
41
+ subagents: { agentOverrides: { [input.agent]: { extensions: [] } } },
42
+ });
43
+ return `${error}\n\nSubagent startup loaded conflicting ambient Pi extensions. Retry this run after disabling ambient extension discovery for agent ${JSON.stringify(input.agent)} in project .pi/settings.json: ${settings}. Replace the empty array with only the required extension entry paths when this agent needs extension tools.`;
44
+ }
45
+
34
46
  function hasUsage(usage: Usage): boolean {
35
47
  return usage.input !== 0
36
48
  || usage.output !== 0