pi-agent-squad 0.8.0 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
+ import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { Type } from "typebox";
@@ -16,7 +17,7 @@ import {
16
17
  getCompactMarkdownTheme,
17
18
  normalizeCompactCodeBlockLines,
18
19
  } from "pi-compact-ui";
19
- import { discoverAgents, type AgentConfig } from "./agents.ts";
20
+ import { discoverAgents, isSafeAgentName, type AgentConfig, userAgentsDir } from "./agents.ts";
20
21
  import {
21
22
  createMessageRouter,
22
23
  ENV_ROLE,
@@ -25,6 +26,8 @@ import {
25
26
  writeReply,
26
27
  channelDir,
27
28
  removeRequestFile,
29
+ ensureChannelRoot,
30
+ sweepMessageRoots,
28
31
  type MessageRequest,
29
32
  } from "./message.ts";
30
33
  import { SubagentPool } from "./pool.ts";
@@ -32,8 +35,12 @@ import type { SubagentSessionHandle } from "./session.ts";
32
35
  import { openSubagentSessionOverlay } from "./session-ui.ts";
33
36
  import { getFinalOutput, spawnInteractiveSubagent } from "./spawn.ts";
34
37
  import { deadlockMessage, MessageWaitGraph } from "./wait-graph.ts";
38
+ import { ActiveRunRegistry, type ActiveRun } from "./active-runs.ts";
35
39
 
36
- const MESSAGE_ROOT_BASE = "/tmp/pi-subagents-messages";
40
+ const MESSAGE_ROOT_BASE = path.join(
41
+ os.tmpdir(),
42
+ `pi-subagents-${typeof process.getuid === "function" ? process.getuid() : "user"}-${randomUUID()}`,
43
+ );
37
44
  const DEFAULT_SUBAGENT_TIMEOUT_SECONDS = 6 * 60 * 60;
38
45
  const MIN_SUBAGENT_TIMEOUT_SECONDS = 10;
39
46
  const MAX_SUBAGENT_TIMEOUT_SECONDS = 3 * 24 * 60 * 60;
@@ -56,6 +63,7 @@ interface IncomingMessageDetails {
56
63
 
57
64
  interface BackgroundEventDetails {
58
65
  agent?: string;
66
+ address?: string;
59
67
  status?: "done" | "error";
60
68
  body?: string;
61
69
  elapsedMs?: number;
@@ -222,8 +230,8 @@ function readOrchestratorPrompt(): string {
222
230
 
223
231
  /**
224
232
  * Read the current session's adaptive-orchestration flag (the last entry wins).
225
- * Adaptive orchestration is enabled by default; `/orchestrate off` is an
226
- * explicit per-session opt-out.
233
+ * Adaptive orchestration is disabled by default; `/orchestrate on` is an
234
+ * explicit per-session opt-in.
227
235
  */
228
236
  function isOrchestratorMode(ctx: { sessionManager?: { getEntries?: () => unknown[] } }): boolean {
229
237
  try {
@@ -232,7 +240,7 @@ function isOrchestratorMode(ctx: { sessionManager?: { getEntries?: () => unknown
232
240
  customType?: string;
233
241
  data?: { enabled?: boolean };
234
242
  }>;
235
- let enabled = true;
243
+ let enabled = false;
236
244
  for (const e of entries) {
237
245
  if (e.type === "custom" && e.customType === ORCHESTRATOR_MODE_ENTRY && typeof e.data?.enabled === "boolean") {
238
246
  enabled = e.data.enabled;
@@ -240,7 +248,7 @@ function isOrchestratorMode(ctx: { sessionManager?: { getEntries?: () => unknown
240
248
  }
241
249
  return enabled;
242
250
  } catch {
243
- return true;
251
+ return false;
244
252
  }
245
253
  }
246
254
 
@@ -250,10 +258,15 @@ function agentFilePath(agentName: string): string {
250
258
  return path.join(here, "agents", `${agentName}.md`);
251
259
  }
252
260
 
261
+ function userAgentFilePath(agentName: string): string {
262
+ if (!isSafeAgentName(agentName)) throw new Error(`Invalid agent name "${agentName}".`);
263
+ return path.join(userAgentsDir(), `${agentName}.md`);
264
+ }
265
+
253
266
  /** Available model ids (provider/model) for the current session */
254
267
  function availableModelIds(ctx: {
255
- scopedModels?: Array<{ model?: { provider?: string; id?: string } }>;
256
- modelRegistry?: { getAvailable?: () => Array<{ provider?: string; id?: string }> };
268
+ scopedModels?: ReadonlyArray<{ model?: { provider?: string; id?: string } }>;
269
+ modelRegistry?: { getAvailable?: () => ReadonlyArray<{ provider?: string; id?: string }> };
257
270
  }): string[] {
258
271
  const scoped = ctx.scopedModels;
259
272
  if (Array.isArray(scoped) && scoped.length > 0) {
@@ -269,18 +282,37 @@ function availableModelIds(ctx: {
269
282
 
270
283
  /** Update a `model` or `thinking` line in an agent's frontmatter */
271
284
  function updateAgentConfig(agentName: string, key: "model" | "thinking", value: string): boolean {
272
- const file = agentFilePath(agentName);
273
- if (!fs.existsSync(file)) return false;
285
+ const builtinFile = agentFilePath(agentName);
286
+ const userFile = userAgentFilePath(agentName);
287
+ const sourceFile = fs.existsSync(userFile) ? userFile : builtinFile;
288
+ if (!fs.existsSync(sourceFile)) return false;
274
289
  try {
275
- let content = fs.readFileSync(file, "utf-8");
290
+ let content = fs.readFileSync(sourceFile, "utf-8");
291
+ const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
292
+ if (!frontmatter) return false;
293
+ const header = frontmatter[1] ?? "";
276
294
  const lineRe = new RegExp(`^(${key}:).*$`, "m");
277
- if (lineRe.test(content)) {
278
- content = content.replace(lineRe, `${key}: ${value}`);
279
- } else {
280
- // insert after the opening frontmatter marker
281
- content = content.replace(/^---\r?\n/, `---\n${key}: ${value}\n`);
295
+ const nextHeader = lineRe.test(header)
296
+ ? header.replace(lineRe, `${key}: ${value}`)
297
+ : `${header}\n${key}: ${value}`;
298
+ content = `${content.slice(0, frontmatter.index!)}---\n${nextHeader.trim()}\n---${content.slice(frontmatter[0].length)}`;
299
+ const userDir = userAgentsDir();
300
+ fs.mkdirSync(userDir, { recursive: true, mode: 0o700 });
301
+ fs.chmodSync(userDir, 0o700);
302
+ const dirStat = fs.lstatSync(userDir);
303
+ if (typeof process.getuid === "function" && dirStat.uid !== process.getuid()) return false;
304
+ const tempFile = `${userFile}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
305
+ try {
306
+ fs.writeFileSync(tempFile, content, { encoding: "utf-8", mode: 0o600 });
307
+ fs.renameSync(tempFile, userFile);
308
+ } catch (error) {
309
+ try {
310
+ fs.unlinkSync(tempFile);
311
+ } catch {
312
+ /* ignore cleanup failure */
313
+ }
314
+ throw error;
282
315
  }
283
- fs.writeFileSync(file, content, "utf-8");
284
316
  return true;
285
317
  } catch {
286
318
  return false;
@@ -294,8 +326,9 @@ function updateAgentConfig(agentName: string, key: "model" | "thinking", value:
294
326
  interface AsyncTask {
295
327
  runId: string;
296
328
  agent: string;
297
- status: "running" | "done" | "error";
329
+ address: string;
298
330
  startedAt: number;
331
+ sessionGeneration: number;
299
332
  }
300
333
 
301
334
  export interface RunningSubagentActivity {
@@ -577,8 +610,12 @@ export default function (pi: ExtensionAPI) {
577
610
 
578
611
  let cwd = process.cwd();
579
612
  let messageRoot = sessionRoot("ephemeral");
613
+ let sessionGeneration = 0;
580
614
  const tasks = new Map<string, AsyncTask>();
581
- const pool = new SubagentPool(messageRoot);
615
+ const runControllers = new Map<string, AbortController>();
616
+ let pool = new SubagentPool(messageRoot);
617
+ let poolDisposed = false;
618
+ const activeRuns = new ActiveRunRegistry();
582
619
  const runningWidget = new RunningSubagentWidgetController();
583
620
  const sessionHandles = new Map<string, SubagentSessionHandle>();
584
621
  const waitGraph = new MessageWaitGraph();
@@ -589,9 +626,18 @@ export default function (pi: ExtensionAPI) {
589
626
  let sessionContext: any;
590
627
  let terminalInputUnsubscribe: (() => void) | undefined;
591
628
  let sessionOverlayOpen = false;
629
+ let closeSessionOverlay: (() => void) | undefined;
592
630
  let pendingOpenActivityId: string | undefined;
593
631
  let lastNavigation: { direction: -1 | 1; at: number } | undefined;
594
632
 
633
+ const bindPoolLifecycle = (): void => {
634
+ pool.setProcessExitHandler((agentName, runId) => {
635
+ const run = activeRuns.resolveExact(runId);
636
+ if (run?.agent === agentName) activeRuns.remove(run);
637
+ });
638
+ };
639
+ bindPoolLifecycle();
640
+
595
641
  function moveWidgetSelection(direction: -1 | 1): void {
596
642
  const now = Date.now();
597
643
  if (
@@ -625,7 +671,9 @@ export default function (pi: ExtensionAPI) {
625
671
  pendingOpenActivityId = undefined;
626
672
  runningWidget.clearSelection();
627
673
  sessionOverlayOpen = true;
628
- void openSubagentSessionOverlay(sessionContext, session)
674
+ void openSubagentSessionOverlay(sessionContext, session, (close) => {
675
+ closeSessionOverlay = close;
676
+ })
629
677
  .catch((error) => {
630
678
  sessionContext?.ui?.notify?.(
631
679
  `Failed to open ${selected.agent} session: ${error instanceof Error ? error.message : String(error)}`,
@@ -633,18 +681,27 @@ export default function (pi: ExtensionAPI) {
633
681
  );
634
682
  })
635
683
  .finally(() => {
684
+ closeSessionOverlay = undefined;
636
685
  sessionOverlayOpen = false;
637
686
  });
638
687
  }
639
688
 
640
689
  pi.on("session_start", (event, ctx) => {
690
+ sessionGeneration++;
691
+ if (poolDisposed) {
692
+ pool = new SubagentPool(messageRoot);
693
+ bindPoolLifecycle();
694
+ poolDisposed = false;
695
+ }
641
696
  cwd = ctx.cwd;
642
697
  const sessionId =
643
698
  ctx.sessionManager?.getSessionId?.() ??
644
699
  (event as any).sessionId ??
645
700
  "ephemeral";
646
701
  messageRoot = sessionRoot(sessionId);
647
- fs.mkdirSync(messageRoot, { recursive: true });
702
+ ensureChannelRoot(MESSAGE_ROOT_BASE);
703
+ sweepMessageRoots(MESSAGE_ROOT_BASE);
704
+ ensureChannelRoot(messageRoot);
648
705
  pool.setIntercomRoot(messageRoot);
649
706
  pool.setWorkingDirectory(cwd);
650
707
  runningWidget.attach(ctx);
@@ -677,11 +734,18 @@ export default function (pi: ExtensionAPI) {
677
734
  });
678
735
 
679
736
  pi.on("session_shutdown", () => {
737
+ sessionGeneration++;
738
+ for (const controller of runControllers.values()) controller.abort();
739
+ runControllers.clear();
740
+ closeSessionOverlay?.();
741
+ closeSessionOverlay = undefined;
742
+ sessionOverlayOpen = false;
680
743
  terminalInputUnsubscribe?.();
681
744
  terminalInputUnsubscribe = undefined;
682
745
  sessionContext = undefined;
683
746
  pendingOpenActivityId = undefined;
684
747
  sessionHandles.clear();
748
+ activeRuns.clear();
685
749
  for (const pending of pendingMainReplyEdges.values()) {
686
750
  if (pending.timer) clearTimeout(pending.timer);
687
751
  pending.release();
@@ -690,6 +754,8 @@ export default function (pi: ExtensionAPI) {
690
754
  waitGraph.clear();
691
755
  runningWidget.shutdown();
692
756
  pool.dispose();
757
+ poolDisposed = true;
758
+ sweepMessageRoots(MESSAGE_ROOT_BASE);
693
759
  });
694
760
 
695
761
  function releaseMainReplyEdge(messageId: string): void {
@@ -713,12 +779,76 @@ export default function (pi: ExtensionAPI) {
713
779
  pendingMainReplyEdges.set(msg.id, { release, timer });
714
780
  }
715
781
 
716
- // ---- message router: subagent -> main injects into main session; subagent -> subagent forwards to a resident process ----
782
+ // ---- message router: subagent -> main injects into main session; child
783
+ // messages resolve through the active-run registry before being delivered
784
+ // to a direct/background session or the resident pool. ----
717
785
  let router: ReturnType<typeof createMessageRouter> | undefined;
786
+ const routedTimeout = (msg: MessageRequest): number =>
787
+ Math.min(
788
+ MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
789
+ Math.max(
790
+ MIN_SUBAGENT_TIMEOUT_SECONDS * 1000,
791
+ msg.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000,
792
+ ),
793
+ );
794
+ const routedPrompt = (msg: MessageRequest): string =>
795
+ [
796
+ `You received a message from ${msg.from === MAIN_AGENT ? "the main agent" : `subagent ${msg.from}`} (message id ${msg.id}):`,
797
+ "",
798
+ msg.content,
799
+ "",
800
+ `Process this message and give your reply. Your reply will be sent back to ${msg.from === MAIN_AGENT ? "the main agent" : msg.from}.`,
801
+ ].join("\n");
802
+ const normalizeCwd = (value: string): string => {
803
+ const resolved = path.resolve(value);
804
+ try {
805
+ return fs.realpathSync(resolved);
806
+ } catch {
807
+ return resolved;
808
+ }
809
+ };
810
+
811
+ /**
812
+ * Keep the resident pool in the same address registry as direct/background
813
+ * runs. The logical name remains the resident address; direct runs get a
814
+ * unique address such as actor#01ab23cd.
815
+ */
816
+ function registerResidentRun(agent: AgentConfig): ActiveRun | undefined {
817
+ const residentPool = pool;
818
+ const identity = residentPool.getProcessIdentity(agent.name);
819
+ if (!identity) return undefined;
820
+ const address = agent.name;
821
+ const existing = activeRuns.resolveExact(address);
822
+ if (existing?.mode === "resident" && existing.runId === identity.runId) return existing;
823
+ if (existing?.mode === "resident") activeRuns.remove(existing);
824
+ const run: ActiveRun = {
825
+ runId: identity.runId,
826
+ agent: agent.name,
827
+ address,
828
+ mode: "resident",
829
+ readOnly: agent.readOnly === true,
830
+ cwd: normalizeCwd(cwd),
831
+ startedAt: Date.now(),
832
+ route: async (msg, signal) => {
833
+ const result = await residentPool.runTask(
834
+ agent,
835
+ routedPrompt(msg),
836
+ routedTimeout(msg),
837
+ undefined,
838
+ signal,
839
+ );
840
+ return getFinalOutput(result.messages) || `(subagent ${agent.name} gave no reply)`;
841
+ },
842
+ };
843
+ activeRuns.register(run);
844
+ return run;
845
+ }
846
+
718
847
  pi.on("session_start", () => {
719
848
  router?.dispose();
849
+ const routeRoot = messageRoot;
720
850
  router = createMessageRouter(pi, {
721
- root: messageRoot,
851
+ root: routeRoot,
722
852
  matchesContext: () => true,
723
853
  // to main: inject into the main session; the main model replies with reply_message
724
854
  onMainMessage: (msg: MessageRequest) => {
@@ -726,7 +856,7 @@ export default function (pi: ExtensionAPI) {
726
856
  if (msg.expectsReply) {
727
857
  const acquired = waitGraph.acquire(msg.from, MAIN_AGENT);
728
858
  if (acquired.cycle) {
729
- if (!writeReplyTo(msg, deadlockMessage(acquired.cycle))) {
859
+ if (!writeReplyTo(msg, deadlockMessage(acquired.cycle), routeRoot)) {
730
860
  throw new Error("Failed to write deadlock-prevention reply.");
731
861
  }
732
862
  return;
@@ -764,6 +894,7 @@ export default function (pi: ExtensionAPI) {
764
894
  writeReplyTo(
765
895
  msg,
766
896
  `Failed to deliver message to main: ${error instanceof Error ? error.message : String(error)}`,
897
+ routeRoot,
767
898
  );
768
899
  }
769
900
  throw error;
@@ -771,53 +902,71 @@ export default function (pi: ExtensionAPI) {
771
902
  },
772
903
  // to another subagent: route to its resident process, collect its reply, write it back
773
904
  onChildMessage: async (msg: MessageRequest, signal?: AbortSignal) => {
905
+ const routePool = pool;
774
906
  const agents = discoverAgents(cwd);
775
- const target = agents.find((a) => a.name === msg.to);
776
- if (!target) {
907
+ let targetRun = activeRuns.resolve(msg.to);
908
+ let target = targetRun
909
+ ? agents.find((a) => a.name === targetRun?.agent)
910
+ : agents.find((a) => a.name === msg.to);
911
+ if (!target && targetRun) {
912
+ // The definition may have been reloaded while an existing
913
+ // run is still alive; its route is still unambiguous.
914
+ target = agents.find((a) => a.name === targetRun?.agent);
915
+ }
916
+ if (!target && !targetRun) {
777
917
  const reply = `Unknown subagent "${msg.to}"`;
778
- completeRoutedMessage(msg, reply);
918
+ completeRoutedMessage(msg, reply, routeRoot);
919
+ if (msg.from === MAIN_AGENT) throw new Error(reply);
920
+ return reply;
921
+ }
922
+ if (targetRun?.mode === "resident" && target) {
923
+ await pool.ensureProcess(target);
924
+ targetRun = registerResidentRun(target);
925
+ }
926
+ if (!targetRun && target) {
927
+ await pool.ensureProcess(target);
928
+ targetRun = registerResidentRun(target);
929
+ }
930
+ if (!targetRun) {
931
+ const reply = `Subagent "${msg.to}" has no active process.`;
932
+ completeRoutedMessage(msg, reply, routeRoot);
933
+ if (msg.from === MAIN_AGENT) throw new Error(reply);
934
+ return reply;
935
+ }
936
+ const resolvedTargetRun = targetRun;
937
+ const senderRun = activeRuns.findSender(msg);
938
+ if (senderRun?.runId === resolvedTargetRun.runId) {
939
+ const reply = `Cannot send a message to the same active run (${resolvedTargetRun.address}).`;
940
+ completeRoutedMessage(msg, reply, routeRoot);
779
941
  if (msg.from === MAIN_AGENT) throw new Error(reply);
780
942
  return reply;
781
943
  }
782
944
  let releaseWait: (() => void) | undefined;
783
945
  if (msg.expectsReply) {
784
- const acquired = waitGraph.acquire(msg.from, msg.to);
946
+ const acquired = waitGraph.acquire(msg.from, resolvedTargetRun.address);
785
947
  if (acquired.cycle) {
786
948
  const reply = deadlockMessage(acquired.cycle);
787
- completeRoutedMessage(msg, reply);
949
+ completeRoutedMessage(msg, reply, routeRoot);
788
950
  if (msg.from === MAIN_AGENT) throw new Error(reply);
789
951
  return reply;
790
952
  }
791
953
  releaseWait = acquired.release;
792
954
  }
793
955
  const activityId = `message:${msg.id}`;
794
- runningWidget.start(activityId, target.name, msg.content, "message");
956
+ runningWidget.start(activityId, resolvedTargetRun.address, msg.content, "message");
795
957
  try {
796
- await pool.ensureProcess(target);
797
- registerSessionHandle(activityId, pool.getSessionHandle(target));
798
- const timeoutMs = Math.min(
799
- MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
800
- Math.max(MIN_SUBAGENT_TIMEOUT_SECONDS * 1000, msg.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000),
801
- );
802
- const result = await pool.runTask(
803
- target,
804
- [
805
- `You received a message from ${msg.from === MAIN_AGENT ? "the main agent" : `subagent ${msg.from}`} (message id ${msg.id}):`,
806
- ``,
807
- msg.content,
808
- ``,
809
- `Process this message and give your reply. Your reply will be sent back to ${msg.from === MAIN_AGENT ? "the main agent" : msg.from}.`,
810
- ].join("\n"),
811
- timeoutMs,
812
- undefined,
813
- signal,
814
- );
815
- const replyText = getFinalOutput(result.messages) || `(subagent ${msg.to} gave no reply)`;
816
- completeRoutedMessage(msg, replyText);
958
+ if (resolvedTargetRun.session) {
959
+ const session = await resolvedTargetRun.session;
960
+ registerSessionHandle(activityId, session);
961
+ } else if (resolvedTargetRun.mode === "resident" && target) {
962
+ registerSessionHandle(activityId, routePool.getSessionHandle(target));
963
+ }
964
+ const replyText = await resolvedTargetRun.route(msg, signal);
965
+ completeRoutedMessage(msg, replyText, routeRoot);
817
966
  return replyText;
818
967
  } catch (e) {
819
- const reply = `Target subagent failed to process the message: ${e instanceof Error ? e.message : String(e)}`;
820
- completeRoutedMessage(msg, reply);
968
+ const reply = `Target subagent ${resolvedTargetRun.address} failed to process the message: ${e instanceof Error ? e.message : String(e)}`;
969
+ completeRoutedMessage(msg, reply, routeRoot);
821
970
  if (msg.from === MAIN_AGENT) throw new Error(reply);
822
971
  return reply;
823
972
  } finally {
@@ -839,9 +988,9 @@ export default function (pi: ExtensionAPI) {
839
988
  });
840
989
 
841
990
  /** Write a reply back to the message sender */
842
- function writeReplyTo(msg: MessageRequest, content: string): boolean {
991
+ function writeReplyTo(msg: MessageRequest, content: string, root = messageRoot): boolean {
843
992
  try {
844
- const dir = channelDir(messageRoot, msg.fromRunId, msg.fromAgent, msg.fromChildIndex);
993
+ const dir = channelDir(root, msg.fromRunId, msg.fromAgent, msg.fromChildIndex);
845
994
  writeReply(dir, msg.id, content);
846
995
  removeRequestFile(msg);
847
996
  releaseMainReplyEdge(msg.id);
@@ -851,52 +1000,149 @@ export default function (pi: ExtensionAPI) {
851
1000
  }
852
1001
  }
853
1002
 
854
- function completeRoutedMessage(msg: MessageRequest, content: string) {
1003
+ function completeRoutedMessage(msg: MessageRequest, content: string, root = messageRoot) {
855
1004
  if (msg.from === MAIN_AGENT) return;
856
- if (msg.expectsReply) writeReplyTo(msg, content);
857
- else removeRequestFile(msg);
1005
+ if (msg.expectsReply) {
1006
+ if (!writeReplyTo(msg, content, root)) {
1007
+ throw new Error(`Failed to write reply for message ${msg.id}.`);
1008
+ }
1009
+ return;
1010
+ }
1011
+ removeRequestFile(msg);
1012
+ if (msg.requestFile && fs.existsSync(msg.requestFile)) {
1013
+ throw new Error(`Failed to remove request file for message ${msg.id}.`);
1014
+ }
858
1015
  }
859
1016
 
860
- // ---- background task persistence ----
861
- function persistTasks() {
862
- try {
863
- pi.appendEntry(
864
- "subagent-async-task",
865
- [...tasks.values()].map((t) => ({
866
- runId: t.runId,
867
- agent: t.agent,
868
- status: t.status,
869
- startedAt: t.startedAt,
870
- })),
1017
+ function directRunAddress(agent: AgentConfig, runId: string): string {
1018
+ return `${agent.name}#${runId.slice(0, 8)}`;
1019
+ }
1020
+
1021
+ function createDirectRun(
1022
+ agent: AgentConfig,
1023
+ runId: string,
1024
+ mode: "background" | "task",
1025
+ requestedAddress?: string,
1026
+ readOnly = agent.readOnly === true,
1027
+ runCwd = cwd,
1028
+ ): {
1029
+ address: string;
1030
+ session: Promise<SubagentSessionHandle>;
1031
+ resolveSession: (session: SubagentSessionHandle) => void;
1032
+ rejectSession: (error: Error) => void;
1033
+ } {
1034
+ const address = requestedAddress?.trim() || directRunAddress(agent, runId);
1035
+ if (
1036
+ (requestedAddress &&
1037
+ (!/^[A-Za-z0-9_.-]+$/.test(address) ||
1038
+ address === "." ||
1039
+ address === ".." ||
1040
+ discoverAgents(cwd).some((candidate) => candidate.name === address) ||
1041
+ address === MAIN_AGENT)) ||
1042
+ address === MAIN_AGENT ||
1043
+ activeRuns.hasAddress(address)
1044
+ ) {
1045
+ throw new Error(
1046
+ `Invalid or unavailable runtime address "${address}". Use a unique name matching [A-Za-z0-9_.-] that is not a logical agent name or "main".`,
871
1047
  );
872
- } catch {
873
- /* ignore */
874
1048
  }
1049
+ let resolveSession!: (session: SubagentSessionHandle) => void;
1050
+ let rejectSession!: (error: Error) => void;
1051
+ const session = new Promise<SubagentSessionHandle>((resolve, reject) => {
1052
+ resolveSession = resolve;
1053
+ rejectSession = reject;
1054
+ });
1055
+ // A run can fail before any message is addressed to it. Keep the
1056
+ // bookkeeping promise from becoming an unhandled rejection while still
1057
+ // propagating the error to any router that is waiting on it.
1058
+ void session.catch(() => {});
1059
+ let routeTail = Promise.resolve();
1060
+ const run: ActiveRun = {
1061
+ runId,
1062
+ agent: agent.name,
1063
+ address,
1064
+ mode,
1065
+ readOnly,
1066
+ cwd: normalizeCwd(runCwd),
1067
+ startedAt: Date.now(),
1068
+ session,
1069
+ route: async (msg, signal) => {
1070
+ let release!: () => void;
1071
+ const current = new Promise<void>((resolve) => (release = resolve));
1072
+ const previous = routeTail;
1073
+ routeTail = previous.catch(() => {}).then(() => current);
1074
+ await previous.catch(() => {});
1075
+ try {
1076
+ const handle = await session;
1077
+ if (!handle.sendAndWait) {
1078
+ throw new Error("The active run does not support routed messages.");
1079
+ }
1080
+ const reply = await handle.sendAndWait(routedPrompt(msg), routedTimeout(msg), signal);
1081
+ return reply || `(subagent ${address} gave no reply)`;
1082
+ } finally {
1083
+ release();
1084
+ }
1085
+ },
1086
+ };
1087
+ activeRuns.register(run);
1088
+ return { address, session, resolveSession, rejectSession };
875
1089
  }
876
1090
 
877
- function launchBackground(agent: AgentConfig, taskText: string, cwdOverride?: string, timeoutMs?: number): string {
1091
+ function launchBackground(
1092
+ agent: AgentConfig,
1093
+ taskText: string,
1094
+ cwdOverride?: string,
1095
+ timeoutMs?: number,
1096
+ requestedAddress?: string,
1097
+ readOnly = agent.readOnly === true,
1098
+ ): { runId: string; address: string } {
878
1099
  const runId = randomUUID();
879
- tasks.set(runId, { runId, agent: agent.name, status: "running", startedAt: Date.now() });
880
- persistTasks();
881
- runningWidget.start(runId, agent.name, taskText, "background");
882
-
883
- spawnInteractiveSubagent({
1100
+ const controller = new AbortController();
1101
+ const direct = createDirectRun(
884
1102
  agent,
885
- task: taskText,
886
- cwd: cwdOverride,
887
- messageRoot,
888
1103
  runId,
889
- childIndex: 0,
890
- timeoutMs,
891
- onSession: (session) => registerSessionHandle(runId, session),
892
- })
1104
+ "background",
1105
+ requestedAddress,
1106
+ readOnly,
1107
+ cwdOverride ?? cwd,
1108
+ );
1109
+ runControllers.set(runId, controller);
1110
+ tasks.set(runId, {
1111
+ runId,
1112
+ agent: agent.name,
1113
+ address: direct.address,
1114
+ startedAt: Date.now(),
1115
+ sessionGeneration,
1116
+ });
1117
+ runningWidget.start(runId, agent.name, taskText, "background");
1118
+
1119
+ const start = async () => {
1120
+ return await spawnInteractiveSubagent({
1121
+ agent,
1122
+ task: taskText,
1123
+ address: direct.address,
1124
+ cwd: cwdOverride,
1125
+ messageRoot,
1126
+ runId,
1127
+ childIndex: 0,
1128
+ signal: controller.signal,
1129
+ timeoutMs,
1130
+ onSession: (session) => {
1131
+ direct.resolveSession(session);
1132
+ registerSessionHandle(runId, session);
1133
+ },
1134
+ });
1135
+ };
1136
+ start()
893
1137
  .then((result) => {
1138
+ // spawnInteractiveSubagent stops its direct process before the
1139
+ // promise resolves; remove the address before publishing the
1140
+ // completion event so no new message can target a dead process.
1141
+ activeRuns.remove(runId);
894
1142
  const task = tasks.get(runId);
895
- if (!task) return;
1143
+ if (!task || task.sessionGeneration !== sessionGeneration) return;
896
1144
  const finalStatus: "done" | "error" =
897
1145
  result.exitCode === 0 && result.stopReason !== "error" ? "done" : "error";
898
- task.status = finalStatus;
899
- persistTasks();
900
1146
  const output = getFinalOutput(result.messages) || "(no text output)";
901
1147
  const failureReason =
902
1148
  result.exitCode !== 0
@@ -925,6 +1171,7 @@ export default function (pi: ExtensionAPI) {
925
1171
  display: true,
926
1172
  details: {
927
1173
  agent: agent.name,
1174
+ address: direct.address,
928
1175
  status: finalStatus,
929
1176
  body: displayBody,
930
1177
  elapsedMs: Date.now() - task.startedAt,
@@ -938,13 +1185,12 @@ export default function (pi: ExtensionAPI) {
938
1185
  }
939
1186
  })
940
1187
  .catch((err) => {
1188
+ activeRuns.remove(runId);
1189
+ direct.rejectSession(err instanceof Error ? err : new Error(String(err)));
941
1190
  const task = tasks.get(runId);
942
- if (task) {
943
- task.status = "error";
944
- persistTasks();
945
- }
946
1191
  const errorText = err instanceof Error ? err.message : String(err);
947
1192
  try {
1193
+ if (task && task.sessionGeneration !== sessionGeneration) return;
948
1194
  pi.sendMessage(
949
1195
  {
950
1196
  customType: BACKGROUND_EVENT_TYPE,
@@ -952,6 +1198,7 @@ export default function (pi: ExtensionAPI) {
952
1198
  display: true,
953
1199
  details: {
954
1200
  agent: agent.name,
1201
+ address: direct.address,
955
1202
  status: "error",
956
1203
  body: errorText,
957
1204
  elapsedMs: task ? Date.now() - task.startedAt : undefined,
@@ -965,15 +1212,18 @@ export default function (pi: ExtensionAPI) {
965
1212
  }
966
1213
  })
967
1214
  .finally(() => {
1215
+ runControllers.delete(runId);
1216
+ tasks.delete(runId);
1217
+ activeRuns.remove(runId);
968
1218
  if (pendingOpenActivityId === runId) pendingOpenActivityId = undefined;
969
1219
  sessionHandles.delete(runId);
970
1220
  runningWidget.finish(runId);
971
1221
  });
972
1222
 
973
- return runId;
1223
+ return { runId, address: direct.address };
974
1224
  }
975
1225
 
976
- // ---- adaptive orchestration prompt; `/orchestrate off` installs the explicit opt-out guard ----
1226
+ // ---- adaptive orchestration prompt; `/orchestrate on` installs the opt-in prompt ----
977
1227
  pi.on("before_agent_start", (event, ctx) => {
978
1228
  // Treat an explicitly appended orchestrator prompt (CLI
979
1229
  // --append-system-prompt) as orchestrator mode too.
@@ -993,16 +1243,26 @@ export default function (pi: ExtensionAPI) {
993
1243
  label: "Subagent",
994
1244
  description: [
995
1245
  "Delegate a task to a subagent (isolated context, separate process).",
996
- "agent: subagent name (defined in the agents directory, e.g. planner/reviewer/actor); task: task description; cwd: optional;",
1246
+ "agent: logical subagent identity (defined in the agents directory, e.g. planner/reviewer/actor); task: task description; cwd: optional; as: optional unique runtime address for this process;",
997
1247
  "async=true: run in background, return a runId immediately, inject the result into the conversation when done (non-blocking);",
998
1248
  "async=false (default): wait synchronously for the result.",
999
1249
  `timeoutSeconds: optional; omit unless the user explicitly requested a time. Default ${DEFAULT_SUBAGENT_TIMEOUT_SECONDS}s (6 hours), range ${MIN_SUBAGENT_TIMEOUT_SECONDS}-${MAX_SUBAGENT_TIMEOUT_SECONDS};`,
1000
- "While running, a subagent may send_message (to=main) to reach you, or contact other subagents — reply promptly with reply_message.",
1250
+ "While running, a subagent may send_message (to=main) to reach you, or contact other subagents — reply promptly with reply_message. Synchronous tasks must use wait=false when contacting main.",
1001
1251
  ].join(" "),
1002
1252
  parameters: Type.Object({
1003
1253
  agent: Type.String({ description: "Subagent name" }),
1004
1254
  task: Type.String({ description: "Task description for the subagent" }),
1005
1255
  cwd: Type.Optional(Type.String({ description: "Working directory for the subagent" })),
1256
+ as: Type.Optional(
1257
+ Type.String({
1258
+ description: "Optional unique runtime address for this run (for example actor-a); identity still comes from agent",
1259
+ }),
1260
+ ),
1261
+ readonly: Type.Optional(
1262
+ Type.Boolean({
1263
+ description: "Declare whether this run is read-only; used as orchestration metadata, not as a write sandbox",
1264
+ }),
1265
+ ),
1006
1266
  async: Type.Optional(Type.Boolean({ description: "true=run in background without blocking (default false)" })),
1007
1267
  timeoutSeconds: Type.Optional(
1008
1268
  Type.Integer({
@@ -1020,6 +1280,7 @@ export default function (pi: ExtensionAPI) {
1020
1280
  const available = agents.map((a) => a.name).join(", ") || "none";
1021
1281
  return {
1022
1282
  content: [{ type: "text", text: `Unknown subagent "${params.agent}". Available: ${available}` }],
1283
+ details: undefined,
1023
1284
  };
1024
1285
  }
1025
1286
  const timeoutSeconds = Math.min(
@@ -1030,48 +1291,88 @@ export default function (pi: ExtensionAPI) {
1030
1291
 
1031
1292
  if (params.async) {
1032
1293
  try {
1033
- const runId = launchBackground(agent, params.task, params.cwd, timeoutMs);
1294
+ const readOnly = params.readonly ?? agent.readOnly === true;
1295
+ const launched = launchBackground(agent, params.task, params.cwd, timeoutMs, params.as, readOnly);
1296
+ const { runId, address } = launched;
1034
1297
  return {
1035
1298
  content: [
1036
1299
  {
1037
1300
  type: "text",
1038
- text: `Started background subagent ${params.agent} (run ${runId.slice(0, 8)}). The main session can keep doing other things; the result will be injected when done.`,
1301
+ text: `Started background subagent ${params.agent} at address ${address} (run ${runId.slice(0, 8)}). Send messages to that address to reach this exact process; the logical name ${params.agent} resolves to the newest active run.`,
1039
1302
  },
1040
1303
  ],
1041
- details: { mode: "async", runId },
1304
+ details: { mode: "async", runId, address },
1042
1305
  };
1043
1306
  } catch (e) {
1044
1307
  return {
1045
1308
  content: [{ type: "text", text: `Failed to start background subagent: ${e instanceof Error ? e.message : String(e)}` }],
1309
+ details: undefined,
1046
1310
  };
1047
1311
  }
1048
1312
  }
1049
1313
 
1050
- onUpdate?.({ content: [{ type: "text", text: `Starting ${params.agent} subagent...` }] });
1314
+ onUpdate?.({
1315
+ content: [{ type: "text", text: `Starting ${params.agent} subagent...` }],
1316
+ details: undefined,
1317
+ });
1051
1318
  const runId = randomUUID();
1052
- const acquired = waitGraph.acquire(MAIN_AGENT, agent.name);
1319
+ const readOnly = params.readonly ?? agent.readOnly === true;
1320
+ const runController = new AbortController();
1321
+ const forwardAbort = () => runController.abort();
1322
+ runControllers.set(runId, runController);
1323
+ if (signal?.aborted) forwardAbort();
1324
+ else signal?.addEventListener("abort", forwardAbort, { once: true });
1325
+ let direct: ReturnType<typeof createDirectRun>;
1326
+ try {
1327
+ direct = createDirectRun(agent, runId, "task", params.as, readOnly, params.cwd ?? cwd);
1328
+ } catch (error) {
1329
+ signal?.removeEventListener("abort", forwardAbort);
1330
+ runControllers.delete(runId);
1331
+ return {
1332
+ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
1333
+ details: undefined,
1334
+ };
1335
+ }
1336
+ const acquired = waitGraph.acquire(MAIN_AGENT, direct.address);
1053
1337
  if (acquired.cycle) {
1338
+ signal?.removeEventListener("abort", forwardAbort);
1339
+ runControllers.delete(runId);
1340
+ activeRuns.remove(runId);
1054
1341
  return {
1055
1342
  content: [{ type: "text", text: deadlockMessage(acquired.cycle) }],
1343
+ details: undefined,
1056
1344
  };
1057
1345
  }
1058
1346
  runningWidget.start(runId, agent.name, params.task, "task");
1059
- const result = await spawnInteractiveSubagent({
1060
- agent,
1061
- task: params.task,
1062
- cwd: params.cwd,
1063
- messageRoot,
1064
- runId,
1065
- childIndex: 0,
1066
- signal,
1067
- timeoutMs,
1068
- onSession: (session) => registerSessionHandle(runId, session),
1069
- }).finally(() => {
1347
+ let result: Awaited<ReturnType<typeof spawnInteractiveSubagent>>;
1348
+ try {
1349
+ result = await spawnInteractiveSubagent({
1350
+ agent,
1351
+ task: params.task,
1352
+ address: direct.address,
1353
+ cwd: params.cwd,
1354
+ messageRoot,
1355
+ runId,
1356
+ childIndex: 0,
1357
+ signal: runController.signal,
1358
+ timeoutMs,
1359
+ onSession: (session) => {
1360
+ direct.resolveSession(session);
1361
+ registerSessionHandle(runId, session);
1362
+ },
1363
+ });
1364
+ } catch (error) {
1365
+ direct.rejectSession(error instanceof Error ? error : new Error(String(error)));
1366
+ throw error;
1367
+ } finally {
1368
+ signal?.removeEventListener("abort", forwardAbort);
1369
+ runControllers.delete(runId);
1370
+ activeRuns.remove(runId);
1070
1371
  acquired.release?.();
1071
1372
  if (pendingOpenActivityId === runId) pendingOpenActivityId = undefined;
1072
1373
  sessionHandles.delete(runId);
1073
1374
  runningWidget.finish(runId);
1074
- });
1375
+ }
1075
1376
  const output = getFinalOutput(result.messages);
1076
1377
 
1077
1378
  if (result.exitCode !== 0) {
@@ -1095,7 +1396,7 @@ export default function (pi: ExtensionAPI) {
1095
1396
 
1096
1397
  // ---- commands ----
1097
1398
  pi.registerCommand("orchestrate", {
1098
- description: "Enable/disable adaptive subagent delegation (on|off|status, default on)",
1399
+ description: "Enable/disable adaptive subagent delegation (on|off|status, default off)",
1099
1400
  handler: async (args, ctx) => {
1100
1401
  const first = Array.isArray(args) ? (args[0] ?? "on") : String(args ?? "on").trim().split(/\s+/)[0] ?? "on";
1101
1402
  const arg = String(first).toLowerCase();
@@ -1132,7 +1433,7 @@ export default function (pi: ExtensionAPI) {
1132
1433
  const agents = discoverAgents(ctx.cwd);
1133
1434
  const lines = agents.map(
1134
1435
  (a) =>
1135
- `- ${a.name}: ${a.description}${a.model ? ` (${a.model})` : ""}${a.thinking ? ` [thinking: ${a.thinking}]` : ""}${a.tools ? ` [tools: ${a.tools.join(",")}]` : ""}`,
1436
+ `- ${a.name}: ${a.description}${a.readOnly === true ? " [read-only]" : " [writer]"}${a.model ? ` (${a.model})` : ""}${a.thinking ? ` [thinking: ${a.thinking}]` : ""}${a.tools ? ` [tools: ${a.tools.join(",")}]` : ""}`,
1136
1437
  );
1137
1438
  ctx.ui.notify(lines.length ? `Available subagents:\n${lines.join("\n")}` : "No subagents found", "info");
1138
1439
  },
@@ -1161,6 +1462,7 @@ export default function (pi: ExtensionAPI) {
1161
1462
  ctx.ui.notify(`No built-in agent named "${agentName}"`, "warning");
1162
1463
  return;
1163
1464
  }
1465
+ pool.restartAgent(agentName);
1164
1466
  ctx.ui.notify(`Subagent ${agentName}: thinking -> ${level}`, "info");
1165
1467
  } else {
1166
1468
  const choices = availableModelIds(ctx);
@@ -1174,33 +1476,31 @@ export default function (pi: ExtensionAPI) {
1174
1476
  ctx.ui.notify(`No built-in agent named "${agentName}"`, "warning");
1175
1477
  return;
1176
1478
  }
1479
+ pool.restartAgent(agentName);
1177
1480
  ctx.ui.notify(`Subagent ${agentName}: model -> ${model}`, "info");
1178
1481
  }
1179
1482
  },
1180
1483
  });
1181
1484
 
1182
1485
  pi.registerCommand("subagent-status", {
1183
- description: "Show background subagent tasks and resident processes",
1486
+ description: "Show active subagent runs and runtime addresses",
1184
1487
  handler: async (_args, ctx) => {
1185
1488
  const list = [...tasks.values()];
1186
1489
  const lines = list.map((t) => {
1187
- const age = Math.round((Date.now() - t.startedAt) / 1000);
1188
- return `- run ${t.runId.slice(0, 8)} ${t.agent} ${t.status} (${age}s ago)`;
1490
+ const age = Math.round((Date.now() - t.startedAt) / 1000);
1491
+ return `- ${t.address} run ${t.runId.slice(0, 8)} ${t.agent} running (${age}s ago)`;
1189
1492
  });
1190
- const alive = [...poolAliveNames(pool)];
1493
+ const active = activeRuns.list();
1494
+ const activeLines = active.map(
1495
+ (run) => `- ${run.address} -> ${run.agent} [${run.mode}] (run ${run.runId.slice(0, 8)})`,
1496
+ );
1191
1497
  ctx.ui.notify(
1192
1498
  [
1193
1499
  lines.length ? `Background tasks:\n${lines.join("\n")}` : "Background tasks: none",
1194
- `Resident processes: ${alive.length ? alive.join(", ") : "none"}`,
1500
+ activeLines.length ? `Active run registry:\n${activeLines.join("\n")}` : "Active run registry: none",
1195
1501
  ].join("\n"),
1196
1502
  "info",
1197
1503
  );
1198
1504
  },
1199
1505
  });
1200
1506
  }
1201
-
1202
- function* poolAliveNames(pool: SubagentPool): Generator<string> {
1203
- for (const n of ["planner", "reviewer", "actor"]) {
1204
- if (pool.isAlive(n)) yield n;
1205
- }
1206
- }