pi-agent-squad 0.7.0 → 0.8.1

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;
@@ -185,21 +193,19 @@ function sessionRoot(sessionId: string): string {
185
193
 
186
194
  const ORCHESTRATOR_MODE_ENTRY = "orchestrator-mode";
187
195
  const ORCHESTRATOR_PROMPT_MARKER = "# You are the Orchestrator";
188
- const SUBAGENT_USAGE_GUARD = [
189
- "# Subagent Usage Gate",
196
+ const SUBAGENT_USAGE_DISABLED_GUARD = [
197
+ "# Subagent Usage Policy: Automatic Delegation Disabled",
190
198
  "",
191
- "Do not use subagents unless at least one of these conditions is true:",
192
- "1. The user's current request explicitly asks you to use, call, spawn, delegate to, or communicate with a subagent.",
193
- "2. Orchestrator mode is enabled.",
199
+ "Automatic subagent delegation has been explicitly disabled for this session.",
200
+ "Do not use subagents unless the user's current request explicitly asks you to use, call, spawn, delegate to, or communicate with one.",
194
201
  "",
195
- "When neither condition is true:",
202
+ "When the user has not explicitly requested subagent involvement:",
196
203
  "- Do not call the `subagent` tool.",
197
204
  "- Do not call `send_message` to contact or assign work to a subagent.",
198
205
  "- Do not initiate or continue a planner/actor/reviewer workflow.",
199
206
  "- Perform the task yourself using the normal tools available to the main agent.",
200
207
  "",
201
- "Task complexity, convenience, a desire for planning/review, the availability of subagent tools, or prior subagent use are not authorization.",
202
- "A generic request to plan, review, test, or implement something is not authorization unless the user explicitly requests subagent involvement.",
208
+ "The user can still explicitly request any combination of planner, actor, or reviewer while automatic delegation is disabled.",
203
209
  ].join("\n");
204
210
 
205
211
  function orchestratorPromptPath(): string {
@@ -222,7 +228,11 @@ function readOrchestratorPrompt(): string {
222
228
  return orchestratorPromptCache;
223
229
  }
224
230
 
225
- /** Read the current session's orchestrator-mode flag (the last entry wins) */
231
+ /**
232
+ * Read the current session's adaptive-orchestration flag (the last entry wins).
233
+ * Adaptive orchestration is disabled by default; `/orchestrate on` is an
234
+ * explicit per-session opt-in.
235
+ */
226
236
  function isOrchestratorMode(ctx: { sessionManager?: { getEntries?: () => unknown[] } }): boolean {
227
237
  try {
228
238
  const entries = (ctx.sessionManager?.getEntries?.() ?? []) as Array<{
@@ -248,10 +258,15 @@ function agentFilePath(agentName: string): string {
248
258
  return path.join(here, "agents", `${agentName}.md`);
249
259
  }
250
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
+
251
266
  /** Available model ids (provider/model) for the current session */
252
267
  function availableModelIds(ctx: {
253
- scopedModels?: Array<{ model?: { provider?: string; id?: string } }>;
254
- modelRegistry?: { getAvailable?: () => Array<{ provider?: string; id?: string }> };
268
+ scopedModels?: ReadonlyArray<{ model?: { provider?: string; id?: string } }>;
269
+ modelRegistry?: { getAvailable?: () => ReadonlyArray<{ provider?: string; id?: string }> };
255
270
  }): string[] {
256
271
  const scoped = ctx.scopedModels;
257
272
  if (Array.isArray(scoped) && scoped.length > 0) {
@@ -267,18 +282,37 @@ function availableModelIds(ctx: {
267
282
 
268
283
  /** Update a `model` or `thinking` line in an agent's frontmatter */
269
284
  function updateAgentConfig(agentName: string, key: "model" | "thinking", value: string): boolean {
270
- const file = agentFilePath(agentName);
271
- 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;
272
289
  try {
273
- 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] ?? "";
274
294
  const lineRe = new RegExp(`^(${key}:).*$`, "m");
275
- if (lineRe.test(content)) {
276
- content = content.replace(lineRe, `${key}: ${value}`);
277
- } else {
278
- // insert after the opening frontmatter marker
279
- 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;
280
315
  }
281
- fs.writeFileSync(file, content, "utf-8");
282
316
  return true;
283
317
  } catch {
284
318
  return false;
@@ -292,8 +326,9 @@ function updateAgentConfig(agentName: string, key: "model" | "thinking", value:
292
326
  interface AsyncTask {
293
327
  runId: string;
294
328
  agent: string;
295
- status: "running" | "done" | "error";
329
+ address: string;
296
330
  startedAt: number;
331
+ sessionGeneration: number;
297
332
  }
298
333
 
299
334
  export interface RunningSubagentActivity {
@@ -575,8 +610,12 @@ export default function (pi: ExtensionAPI) {
575
610
 
576
611
  let cwd = process.cwd();
577
612
  let messageRoot = sessionRoot("ephemeral");
613
+ let sessionGeneration = 0;
578
614
  const tasks = new Map<string, AsyncTask>();
579
- 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();
580
619
  const runningWidget = new RunningSubagentWidgetController();
581
620
  const sessionHandles = new Map<string, SubagentSessionHandle>();
582
621
  const waitGraph = new MessageWaitGraph();
@@ -587,9 +626,18 @@ export default function (pi: ExtensionAPI) {
587
626
  let sessionContext: any;
588
627
  let terminalInputUnsubscribe: (() => void) | undefined;
589
628
  let sessionOverlayOpen = false;
629
+ let closeSessionOverlay: (() => void) | undefined;
590
630
  let pendingOpenActivityId: string | undefined;
591
631
  let lastNavigation: { direction: -1 | 1; at: number } | undefined;
592
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
+
593
641
  function moveWidgetSelection(direction: -1 | 1): void {
594
642
  const now = Date.now();
595
643
  if (
@@ -623,7 +671,9 @@ export default function (pi: ExtensionAPI) {
623
671
  pendingOpenActivityId = undefined;
624
672
  runningWidget.clearSelection();
625
673
  sessionOverlayOpen = true;
626
- void openSubagentSessionOverlay(sessionContext, session)
674
+ void openSubagentSessionOverlay(sessionContext, session, (close) => {
675
+ closeSessionOverlay = close;
676
+ })
627
677
  .catch((error) => {
628
678
  sessionContext?.ui?.notify?.(
629
679
  `Failed to open ${selected.agent} session: ${error instanceof Error ? error.message : String(error)}`,
@@ -631,18 +681,27 @@ export default function (pi: ExtensionAPI) {
631
681
  );
632
682
  })
633
683
  .finally(() => {
684
+ closeSessionOverlay = undefined;
634
685
  sessionOverlayOpen = false;
635
686
  });
636
687
  }
637
688
 
638
689
  pi.on("session_start", (event, ctx) => {
690
+ sessionGeneration++;
691
+ if (poolDisposed) {
692
+ pool = new SubagentPool(messageRoot);
693
+ bindPoolLifecycle();
694
+ poolDisposed = false;
695
+ }
639
696
  cwd = ctx.cwd;
640
697
  const sessionId =
641
698
  ctx.sessionManager?.getSessionId?.() ??
642
699
  (event as any).sessionId ??
643
700
  "ephemeral";
644
701
  messageRoot = sessionRoot(sessionId);
645
- fs.mkdirSync(messageRoot, { recursive: true });
702
+ ensureChannelRoot(MESSAGE_ROOT_BASE);
703
+ sweepMessageRoots(MESSAGE_ROOT_BASE);
704
+ ensureChannelRoot(messageRoot);
646
705
  pool.setIntercomRoot(messageRoot);
647
706
  pool.setWorkingDirectory(cwd);
648
707
  runningWidget.attach(ctx);
@@ -675,11 +734,18 @@ export default function (pi: ExtensionAPI) {
675
734
  });
676
735
 
677
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;
678
743
  terminalInputUnsubscribe?.();
679
744
  terminalInputUnsubscribe = undefined;
680
745
  sessionContext = undefined;
681
746
  pendingOpenActivityId = undefined;
682
747
  sessionHandles.clear();
748
+ activeRuns.clear();
683
749
  for (const pending of pendingMainReplyEdges.values()) {
684
750
  if (pending.timer) clearTimeout(pending.timer);
685
751
  pending.release();
@@ -688,6 +754,8 @@ export default function (pi: ExtensionAPI) {
688
754
  waitGraph.clear();
689
755
  runningWidget.shutdown();
690
756
  pool.dispose();
757
+ poolDisposed = true;
758
+ sweepMessageRoots(MESSAGE_ROOT_BASE);
691
759
  });
692
760
 
693
761
  function releaseMainReplyEdge(messageId: string): void {
@@ -711,12 +779,76 @@ export default function (pi: ExtensionAPI) {
711
779
  pendingMainReplyEdges.set(msg.id, { release, timer });
712
780
  }
713
781
 
714
- // ---- 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. ----
715
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
+
716
847
  pi.on("session_start", () => {
717
848
  router?.dispose();
849
+ const routeRoot = messageRoot;
718
850
  router = createMessageRouter(pi, {
719
- root: messageRoot,
851
+ root: routeRoot,
720
852
  matchesContext: () => true,
721
853
  // to main: inject into the main session; the main model replies with reply_message
722
854
  onMainMessage: (msg: MessageRequest) => {
@@ -724,7 +856,7 @@ export default function (pi: ExtensionAPI) {
724
856
  if (msg.expectsReply) {
725
857
  const acquired = waitGraph.acquire(msg.from, MAIN_AGENT);
726
858
  if (acquired.cycle) {
727
- if (!writeReplyTo(msg, deadlockMessage(acquired.cycle))) {
859
+ if (!writeReplyTo(msg, deadlockMessage(acquired.cycle), routeRoot)) {
728
860
  throw new Error("Failed to write deadlock-prevention reply.");
729
861
  }
730
862
  return;
@@ -762,6 +894,7 @@ export default function (pi: ExtensionAPI) {
762
894
  writeReplyTo(
763
895
  msg,
764
896
  `Failed to deliver message to main: ${error instanceof Error ? error.message : String(error)}`,
897
+ routeRoot,
765
898
  );
766
899
  }
767
900
  throw error;
@@ -769,53 +902,71 @@ export default function (pi: ExtensionAPI) {
769
902
  },
770
903
  // to another subagent: route to its resident process, collect its reply, write it back
771
904
  onChildMessage: async (msg: MessageRequest, signal?: AbortSignal) => {
905
+ const routePool = pool;
772
906
  const agents = discoverAgents(cwd);
773
- const target = agents.find((a) => a.name === msg.to);
774
- 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) {
775
917
  const reply = `Unknown subagent "${msg.to}"`;
776
- 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);
777
941
  if (msg.from === MAIN_AGENT) throw new Error(reply);
778
942
  return reply;
779
943
  }
780
944
  let releaseWait: (() => void) | undefined;
781
945
  if (msg.expectsReply) {
782
- const acquired = waitGraph.acquire(msg.from, msg.to);
946
+ const acquired = waitGraph.acquire(msg.from, resolvedTargetRun.address);
783
947
  if (acquired.cycle) {
784
948
  const reply = deadlockMessage(acquired.cycle);
785
- completeRoutedMessage(msg, reply);
949
+ completeRoutedMessage(msg, reply, routeRoot);
786
950
  if (msg.from === MAIN_AGENT) throw new Error(reply);
787
951
  return reply;
788
952
  }
789
953
  releaseWait = acquired.release;
790
954
  }
791
955
  const activityId = `message:${msg.id}`;
792
- runningWidget.start(activityId, target.name, msg.content, "message");
956
+ runningWidget.start(activityId, resolvedTargetRun.address, msg.content, "message");
793
957
  try {
794
- await pool.ensureProcess(target);
795
- registerSessionHandle(activityId, pool.getSessionHandle(target));
796
- const timeoutMs = Math.min(
797
- MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
798
- Math.max(MIN_SUBAGENT_TIMEOUT_SECONDS * 1000, msg.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000),
799
- );
800
- const result = await pool.runTask(
801
- target,
802
- [
803
- `You received a message from ${msg.from === MAIN_AGENT ? "the main agent" : `subagent ${msg.from}`} (message id ${msg.id}):`,
804
- ``,
805
- msg.content,
806
- ``,
807
- `Process this message and give your reply. Your reply will be sent back to ${msg.from === MAIN_AGENT ? "the main agent" : msg.from}.`,
808
- ].join("\n"),
809
- timeoutMs,
810
- undefined,
811
- signal,
812
- );
813
- const replyText = getFinalOutput(result.messages) || `(subagent ${msg.to} gave no reply)`;
814
- 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);
815
966
  return replyText;
816
967
  } catch (e) {
817
- const reply = `Target subagent failed to process the message: ${e instanceof Error ? e.message : String(e)}`;
818
- 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);
819
970
  if (msg.from === MAIN_AGENT) throw new Error(reply);
820
971
  return reply;
821
972
  } finally {
@@ -837,9 +988,9 @@ export default function (pi: ExtensionAPI) {
837
988
  });
838
989
 
839
990
  /** Write a reply back to the message sender */
840
- function writeReplyTo(msg: MessageRequest, content: string): boolean {
991
+ function writeReplyTo(msg: MessageRequest, content: string, root = messageRoot): boolean {
841
992
  try {
842
- const dir = channelDir(messageRoot, msg.fromRunId, msg.fromAgent, msg.fromChildIndex);
993
+ const dir = channelDir(root, msg.fromRunId, msg.fromAgent, msg.fromChildIndex);
843
994
  writeReply(dir, msg.id, content);
844
995
  removeRequestFile(msg);
845
996
  releaseMainReplyEdge(msg.id);
@@ -849,52 +1000,149 @@ export default function (pi: ExtensionAPI) {
849
1000
  }
850
1001
  }
851
1002
 
852
- function completeRoutedMessage(msg: MessageRequest, content: string) {
1003
+ function completeRoutedMessage(msg: MessageRequest, content: string, root = messageRoot) {
853
1004
  if (msg.from === MAIN_AGENT) return;
854
- if (msg.expectsReply) writeReplyTo(msg, content);
855
- 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
+ }
856
1015
  }
857
1016
 
858
- // ---- background task persistence ----
859
- function persistTasks() {
860
- try {
861
- pi.appendEntry(
862
- "subagent-async-task",
863
- [...tasks.values()].map((t) => ({
864
- runId: t.runId,
865
- agent: t.agent,
866
- status: t.status,
867
- startedAt: t.startedAt,
868
- })),
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".`,
869
1047
  );
870
- } catch {
871
- /* ignore */
872
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 };
873
1089
  }
874
1090
 
875
- 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 } {
876
1099
  const runId = randomUUID();
877
- tasks.set(runId, { runId, agent: agent.name, status: "running", startedAt: Date.now() });
878
- persistTasks();
879
- runningWidget.start(runId, agent.name, taskText, "background");
880
-
881
- spawnInteractiveSubagent({
1100
+ const controller = new AbortController();
1101
+ const direct = createDirectRun(
882
1102
  agent,
883
- task: taskText,
884
- cwd: cwdOverride,
885
- messageRoot,
886
1103
  runId,
887
- childIndex: 0,
888
- timeoutMs,
889
- onSession: (session) => registerSessionHandle(runId, session),
890
- })
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()
891
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);
892
1142
  const task = tasks.get(runId);
893
- if (!task) return;
1143
+ if (!task || task.sessionGeneration !== sessionGeneration) return;
894
1144
  const finalStatus: "done" | "error" =
895
1145
  result.exitCode === 0 && result.stopReason !== "error" ? "done" : "error";
896
- task.status = finalStatus;
897
- persistTasks();
898
1146
  const output = getFinalOutput(result.messages) || "(no text output)";
899
1147
  const failureReason =
900
1148
  result.exitCode !== 0
@@ -923,6 +1171,7 @@ export default function (pi: ExtensionAPI) {
923
1171
  display: true,
924
1172
  details: {
925
1173
  agent: agent.name,
1174
+ address: direct.address,
926
1175
  status: finalStatus,
927
1176
  body: displayBody,
928
1177
  elapsedMs: Date.now() - task.startedAt,
@@ -936,13 +1185,12 @@ export default function (pi: ExtensionAPI) {
936
1185
  }
937
1186
  })
938
1187
  .catch((err) => {
1188
+ activeRuns.remove(runId);
1189
+ direct.rejectSession(err instanceof Error ? err : new Error(String(err)));
939
1190
  const task = tasks.get(runId);
940
- if (task) {
941
- task.status = "error";
942
- persistTasks();
943
- }
944
1191
  const errorText = err instanceof Error ? err.message : String(err);
945
1192
  try {
1193
+ if (task && task.sessionGeneration !== sessionGeneration) return;
946
1194
  pi.sendMessage(
947
1195
  {
948
1196
  customType: BACKGROUND_EVENT_TYPE,
@@ -950,6 +1198,7 @@ export default function (pi: ExtensionAPI) {
950
1198
  display: true,
951
1199
  details: {
952
1200
  agent: agent.name,
1201
+ address: direct.address,
953
1202
  status: "error",
954
1203
  body: errorText,
955
1204
  elapsedMs: task ? Date.now() - task.startedAt : undefined,
@@ -963,15 +1212,18 @@ export default function (pi: ExtensionAPI) {
963
1212
  }
964
1213
  })
965
1214
  .finally(() => {
1215
+ runControllers.delete(runId);
1216
+ tasks.delete(runId);
1217
+ activeRuns.remove(runId);
966
1218
  if (pendingOpenActivityId === runId) pendingOpenActivityId = undefined;
967
1219
  sessionHandles.delete(runId);
968
1220
  runningWidget.finish(runId);
969
1221
  });
970
1222
 
971
- return runId;
1223
+ return { runId, address: direct.address };
972
1224
  }
973
1225
 
974
- // ---- system prompt gate: subagents require explicit user authorization or orchestrator mode ----
1226
+ // ---- adaptive orchestration prompt; `/orchestrate on` installs the opt-in prompt ----
975
1227
  pi.on("before_agent_start", (event, ctx) => {
976
1228
  // Treat an explicitly appended orchestrator prompt (CLI
977
1229
  // --append-system-prompt) as orchestrator mode too.
@@ -982,7 +1234,7 @@ export default function (pi: ExtensionAPI) {
982
1234
  if (!prompt) return;
983
1235
  return { systemPrompt: event.systemPrompt + "\n\n" + prompt };
984
1236
  }
985
- return { systemPrompt: event.systemPrompt + "\n\n" + SUBAGENT_USAGE_GUARD };
1237
+ return { systemPrompt: event.systemPrompt + "\n\n" + SUBAGENT_USAGE_DISABLED_GUARD };
986
1238
  });
987
1239
 
988
1240
  // ---- subagent tool ----
@@ -991,16 +1243,26 @@ export default function (pi: ExtensionAPI) {
991
1243
  label: "Subagent",
992
1244
  description: [
993
1245
  "Delegate a task to a subagent (isolated context, separate process).",
994
- "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;",
995
1247
  "async=true: run in background, return a runId immediately, inject the result into the conversation when done (non-blocking);",
996
1248
  "async=false (default): wait synchronously for the result.",
997
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};`,
998
- "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.",
999
1251
  ].join(" "),
1000
1252
  parameters: Type.Object({
1001
1253
  agent: Type.String({ description: "Subagent name" }),
1002
1254
  task: Type.String({ description: "Task description for the subagent" }),
1003
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
+ ),
1004
1266
  async: Type.Optional(Type.Boolean({ description: "true=run in background without blocking (default false)" })),
1005
1267
  timeoutSeconds: Type.Optional(
1006
1268
  Type.Integer({
@@ -1018,6 +1280,7 @@ export default function (pi: ExtensionAPI) {
1018
1280
  const available = agents.map((a) => a.name).join(", ") || "none";
1019
1281
  return {
1020
1282
  content: [{ type: "text", text: `Unknown subagent "${params.agent}". Available: ${available}` }],
1283
+ details: undefined,
1021
1284
  };
1022
1285
  }
1023
1286
  const timeoutSeconds = Math.min(
@@ -1028,48 +1291,88 @@ export default function (pi: ExtensionAPI) {
1028
1291
 
1029
1292
  if (params.async) {
1030
1293
  try {
1031
- 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;
1032
1297
  return {
1033
1298
  content: [
1034
1299
  {
1035
1300
  type: "text",
1036
- 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.`,
1037
1302
  },
1038
1303
  ],
1039
- details: { mode: "async", runId },
1304
+ details: { mode: "async", runId, address },
1040
1305
  };
1041
1306
  } catch (e) {
1042
1307
  return {
1043
1308
  content: [{ type: "text", text: `Failed to start background subagent: ${e instanceof Error ? e.message : String(e)}` }],
1309
+ details: undefined,
1044
1310
  };
1045
1311
  }
1046
1312
  }
1047
1313
 
1048
- onUpdate?.({ content: [{ type: "text", text: `Starting ${params.agent} subagent...` }] });
1314
+ onUpdate?.({
1315
+ content: [{ type: "text", text: `Starting ${params.agent} subagent...` }],
1316
+ details: undefined,
1317
+ });
1049
1318
  const runId = randomUUID();
1050
- 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);
1051
1337
  if (acquired.cycle) {
1338
+ signal?.removeEventListener("abort", forwardAbort);
1339
+ runControllers.delete(runId);
1340
+ activeRuns.remove(runId);
1052
1341
  return {
1053
1342
  content: [{ type: "text", text: deadlockMessage(acquired.cycle) }],
1343
+ details: undefined,
1054
1344
  };
1055
1345
  }
1056
1346
  runningWidget.start(runId, agent.name, params.task, "task");
1057
- const result = await spawnInteractiveSubagent({
1058
- agent,
1059
- task: params.task,
1060
- cwd: params.cwd,
1061
- messageRoot,
1062
- runId,
1063
- childIndex: 0,
1064
- signal,
1065
- timeoutMs,
1066
- onSession: (session) => registerSessionHandle(runId, session),
1067
- }).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);
1068
1371
  acquired.release?.();
1069
1372
  if (pendingOpenActivityId === runId) pendingOpenActivityId = undefined;
1070
1373
  sessionHandles.delete(runId);
1071
1374
  runningWidget.finish(runId);
1072
- });
1375
+ }
1073
1376
  const output = getFinalOutput(result.messages);
1074
1377
 
1075
1378
  if (result.exitCode !== 0) {
@@ -1093,7 +1396,7 @@ export default function (pi: ExtensionAPI) {
1093
1396
 
1094
1397
  // ---- commands ----
1095
1398
  pi.registerCommand("orchestrate", {
1096
- description: "Turn multi-agent orchestration mode on/off (on|off|status, default on)",
1399
+ description: "Enable/disable adaptive subagent delegation (on|off|status, default off)",
1097
1400
  handler: async (args, ctx) => {
1098
1401
  const first = Array.isArray(args) ? (args[0] ?? "on") : String(args ?? "on").trim().split(/\s+/)[0] ?? "on";
1099
1402
  const arg = String(first).toLowerCase();
@@ -1102,8 +1405,8 @@ export default function (pi: ExtensionAPI) {
1102
1405
  const on = isOrchestratorMode(ctx);
1103
1406
  ctx.ui.notify(
1104
1407
  on
1105
- ? "Orchestrator mode: ON (runs as orchestrator each turn; use /orchestrate off to disable)"
1106
- : "Orchestrator mode: OFF (use /orchestrate on to enable)",
1408
+ ? "Adaptive orchestration: ON (main decides whether and how to delegate; use /orchestrate off to disable automatic delegation)"
1409
+ : "Adaptive orchestration: OFF (subagents require an explicit user request; use /orchestrate on to restore main-agent discretion)",
1107
1410
  "info",
1108
1411
  );
1109
1412
  return;
@@ -1117,8 +1420,8 @@ export default function (pi: ExtensionAPI) {
1117
1420
  }
1118
1421
  ctx.ui.notify(
1119
1422
  enabled
1120
- ? "Orchestrator mode enabled — every turn will run as the orchestrator (triage + orchestrate subagents)"
1121
- : "Orchestrator mode disabled",
1423
+ ? "Adaptive orchestration enabled — main will decide whether to work directly or delegate to any useful combination of specialists"
1424
+ : "Adaptive orchestration disabled — subagents now require an explicit user request",
1122
1425
  "info",
1123
1426
  );
1124
1427
  },
@@ -1130,7 +1433,7 @@ export default function (pi: ExtensionAPI) {
1130
1433
  const agents = discoverAgents(ctx.cwd);
1131
1434
  const lines = agents.map(
1132
1435
  (a) =>
1133
- `- ${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(",")}]` : ""}`,
1134
1437
  );
1135
1438
  ctx.ui.notify(lines.length ? `Available subagents:\n${lines.join("\n")}` : "No subagents found", "info");
1136
1439
  },
@@ -1159,6 +1462,7 @@ export default function (pi: ExtensionAPI) {
1159
1462
  ctx.ui.notify(`No built-in agent named "${agentName}"`, "warning");
1160
1463
  return;
1161
1464
  }
1465
+ pool.restartAgent(agentName);
1162
1466
  ctx.ui.notify(`Subagent ${agentName}: thinking -> ${level}`, "info");
1163
1467
  } else {
1164
1468
  const choices = availableModelIds(ctx);
@@ -1172,33 +1476,31 @@ export default function (pi: ExtensionAPI) {
1172
1476
  ctx.ui.notify(`No built-in agent named "${agentName}"`, "warning");
1173
1477
  return;
1174
1478
  }
1479
+ pool.restartAgent(agentName);
1175
1480
  ctx.ui.notify(`Subagent ${agentName}: model -> ${model}`, "info");
1176
1481
  }
1177
1482
  },
1178
1483
  });
1179
1484
 
1180
1485
  pi.registerCommand("subagent-status", {
1181
- description: "Show background subagent tasks and resident processes",
1486
+ description: "Show active subagent runs and runtime addresses",
1182
1487
  handler: async (_args, ctx) => {
1183
1488
  const list = [...tasks.values()];
1184
1489
  const lines = list.map((t) => {
1185
- const age = Math.round((Date.now() - t.startedAt) / 1000);
1186
- 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)`;
1187
1492
  });
1188
- 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
+ );
1189
1497
  ctx.ui.notify(
1190
1498
  [
1191
1499
  lines.length ? `Background tasks:\n${lines.join("\n")}` : "Background tasks: none",
1192
- `Resident processes: ${alive.length ? alive.join(", ") : "none"}`,
1500
+ activeLines.length ? `Active run registry:\n${activeLines.join("\n")}` : "Active run registry: none",
1193
1501
  ].join("\n"),
1194
1502
  "info",
1195
1503
  );
1196
1504
  },
1197
1505
  });
1198
1506
  }
1199
-
1200
- function* poolAliveNames(pool: SubagentPool): Generator<string> {
1201
- for (const n of ["planner", "reviewer", "actor"]) {
1202
- if (pool.isAlive(n)) yield n;
1203
- }
1204
- }