@kevin5251984/guild 0.2.18 → 0.2.20

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/src/store.ts CHANGED
@@ -26,6 +26,11 @@ import { DEFAULT_BOTS } from "./catalog/default-bots.ts";
26
26
  import { CATALOG_SKILLS } from "./catalog/skills.ts";
27
27
  import { CATALOG_SUBAGENTS } from "./catalog/subagents.ts";
28
28
  import { parseAgentFile } from "./agent-file.ts";
29
+ import {
30
+ parseMentionIds,
31
+ sanitizeMentionIds,
32
+ withoutDeferredIds,
33
+ } from "./mention.ts";
29
34
 
30
35
  const MARKDOWN: Record<LibraryKind, string> = {
31
36
  souls: "SOUL.md",
@@ -39,6 +44,10 @@ const GENERAL_CHANNEL_ID = "channel-general";
39
44
  const NAV_PREVIEW_CAP = 120;
40
45
  /** Project channels (not #general). Reuse seats first; human adds specialists. */
41
46
  export const CHANNEL_ROSTER_CAP = 6;
47
+ /** Parent → child → grandchild. Deeper than this hides in the sidebar. */
48
+ export const BRANCH_DEPTH_CAP = 3;
49
+ /** Messages copied from the parent, ending at the branched row. */
50
+ export const BRANCH_CONTEXT_CAP = 20;
42
51
 
43
52
  export function isGeneralChannel(room: { id: string; name: string }): boolean {
44
53
  return room.id === GENERAL_CHANNEL_ID || room.name === "general";
@@ -384,6 +393,7 @@ export class GuildStore {
384
393
  steers?.delete(botId);
385
394
  if (live && live.size === 0) this.liveTurns.delete(roomId);
386
395
  if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
396
+ this.spillTrajectoryIfIdle(roomId);
387
397
  return hadLive;
388
398
  }
389
399
  const group = this.turnGroups.get(controller.signal);
@@ -400,6 +410,7 @@ export class GuildStore {
400
410
  if (live && live.size === 0) this.liveTurns.delete(roomId);
401
411
  if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
402
412
  if (!controller.signal.aborted) controller.abort();
413
+ this.spillTrajectoryIfIdle(roomId);
403
414
  return true;
404
415
  }
405
416
  const room = this.botAborts.get(roomId);
@@ -417,6 +428,7 @@ export class GuildStore {
417
428
  aborted = true;
418
429
  }
419
430
  }
431
+ this.spillTrajectoryIfIdle(roomId);
420
432
  return aborted || Boolean(room);
421
433
  }
422
434
 
@@ -435,10 +447,19 @@ export class GuildStore {
435
447
  if (room && room.size === 0) this.botAborts.delete(roomId);
436
448
  if (live && live.size === 0) this.liveTurns.delete(roomId);
437
449
  if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
438
- return;
450
+ if (!group.controller.signal.aborted) group.controller.abort();
451
+ } else {
452
+ this.botAborts.delete(roomId);
453
+ this.clearLiveTurn(roomId);
439
454
  }
440
- this.botAborts.delete(roomId);
441
- this.clearLiveTurn(roomId);
455
+ this.spillTrajectoryIfIdle(roomId);
456
+ }
457
+
458
+ /** Warehouse overflow once, after every bot in the room has stopped. */
459
+ private spillTrajectoryIfIdle(roomId: string): void {
460
+ if (this.botAborts.get(roomId)?.size) return;
461
+ if (this.liveTurns.get(roomId)?.size) return;
462
+ this.db.spillColdTrajectory(roomId);
442
463
  }
443
464
 
444
465
  pushSteer(roomId: string, text: string, botId?: string): void {
@@ -580,6 +601,9 @@ export class GuildStore {
580
601
  if (room.id === GENERAL_CHANNEL_ID || room.name === "general") {
581
602
  throw new StoreError(400, "cannot delete #general");
582
603
  }
604
+ for (const child of this.listChannels().filter((item) => item.parentId === id)) {
605
+ this.deleteChannel(child.id);
606
+ }
583
607
  this.removeRoomDir(room.id);
584
608
  return { ok: true, id: room.id };
585
609
  }
@@ -605,6 +629,7 @@ export class GuildStore {
605
629
  name?: string;
606
630
  handle?: string;
607
631
  oneLiner?: string;
632
+ portrait?: string | null;
608
633
  skillIds?: string[];
609
634
  soul?: { name: string; body: string };
610
635
  agent?: { name: string; body: string };
@@ -646,6 +671,9 @@ export class GuildStore {
646
671
  handle,
647
672
  skillIds,
648
673
  oneLiner: input.oneLiner?.trim() || bot.oneLiner,
674
+ portrait: Object.hasOwn(input, "portrait")
675
+ ? normalizePortrait(input.portrait)
676
+ : bot.portrait,
649
677
  model: Object.hasOwn(input, "model") ? input.model ?? null : bot.model,
650
678
  };
651
679
  this.writeBot(next);
@@ -789,6 +817,48 @@ export class GuildStore {
789
817
  return room;
790
818
  }
791
819
 
820
+ createBranch(parentId: string, messageId: string, name?: string): Room {
821
+ const parent = this.getRoom(parentId);
822
+ if (!parent) throw new StoreError(404, "channel not found");
823
+ if (parent.kind !== "channel") {
824
+ throw new StoreError(400, "can only branch a channel");
825
+ }
826
+ const source = this.listMessages(parentId).find((item) => item.id === messageId);
827
+ if (!source) throw new StoreError(404, "message not found");
828
+ if (branchDepth(this, parent) >= BRANCH_DEPTH_CAP) {
829
+ throw new StoreError(400, "too many nested branches");
830
+ }
831
+ const trimmed = String(name || "").replace(/^#/, "").trim() || clipBranchName(source.body);
832
+ if (trimmed === "general") {
833
+ throw new StoreError(400, "cannot create #general");
834
+ }
835
+ const taken = this.listChannels().map((room) => room.name);
836
+ const uniqueName = uniqueChannelName(trimmed, taken);
837
+ const existingIds = this.listChannels().map((room) => room.id);
838
+ const slug = slugify(uniqueName);
839
+ const baseId =
840
+ slug && slug !== "item" && slug !== "general"
841
+ ? `channel-${slug}`
842
+ : `channel-${randomUUID().slice(0, 8)}`;
843
+ const room: Room = {
844
+ id: uniqueSlug(baseId, existingIds),
845
+ kind: "channel",
846
+ name: uniqueName,
847
+ memberIds: [...parent.memberIds],
848
+ createdAt: new Date().toISOString(),
849
+ parentId: parent.id,
850
+ branchFromId: source.id,
851
+ };
852
+ this.writeRoom(room);
853
+ this.writeChannelMd(room.id, this.readChannelMd(parent.id));
854
+ const history = this.listMessages(parent.id);
855
+ const at = history.findIndex((item) => item.id === source.id);
856
+ const from = Math.max(0, at + 1 - BRANCH_CONTEXT_CAP);
857
+ const window = at < 0 ? [source] : history.slice(from, at + 1);
858
+ this.writeMessages(room.id, cloneBranchMessages(window, room.id));
859
+ return room;
860
+ }
861
+
792
862
  renameChannel(id: string, name: string): Room {
793
863
  const trimmed = String(name || "").trim();
794
864
  if (!trimmed) throw new StoreError(400, "channel name is required");
@@ -914,6 +984,7 @@ export class GuildStore {
914
984
  usage?: ChatUsage,
915
985
  steer?: boolean,
916
986
  steerBotId?: string,
987
+ mentions?: string[],
917
988
  ): ChatMessage {
918
989
  const room = this.getRoom(roomId);
919
990
  if (!room) throw new StoreError(404, "room not found");
@@ -921,6 +992,15 @@ export class GuildStore {
921
992
  if (!text) throw new StoreError(400, "message is required");
922
993
  const now = new Date().toISOString();
923
994
  const startedAt = author !== "you" ? usage?.startedAt : undefined;
995
+ const bots = this.listBots();
996
+ const mentionIds = withoutDeferredIds(
997
+ (mentions !== undefined
998
+ ? sanitizeMentionIds(mentions, bots)
999
+ : parseMentionIds(text, bots, author === "you" ? "user" : "bot")
1000
+ ).filter((id) => id !== author),
1001
+ text,
1002
+ bots,
1003
+ );
924
1004
  const message: ChatMessage = {
925
1005
  id: randomUUID(),
926
1006
  roomId,
@@ -934,15 +1014,29 @@ export class GuildStore {
934
1014
  ...(author !== "you" ? { finishedAt: now } : {}),
935
1015
  ...(steer ? { steer: true } : {}),
936
1016
  ...(steer && steerBotId ? { steerBotId } : {}),
1017
+ mentions: mentionIds,
937
1018
  };
938
1019
  this.db.appendMessage(message);
939
1020
  return message;
940
1021
  }
941
1022
 
942
- updateMessage(roomId: string, messageId: string, body: string): ChatMessage {
1023
+ updateMessage(
1024
+ roomId: string,
1025
+ messageId: string,
1026
+ body: string,
1027
+ mentions?: string[],
1028
+ ): ChatMessage {
943
1029
  const text = body.trim();
944
1030
  if (!text) throw new StoreError(400, "message is required");
945
- const next = this.db.updateMessageBody(roomId, messageId, text);
1031
+ const bots = this.listBots();
1032
+ const mentionIds = withoutDeferredIds(
1033
+ mentions !== undefined
1034
+ ? sanitizeMentionIds(mentions, bots)
1035
+ : parseMentionIds(text, bots, "user"),
1036
+ text,
1037
+ bots,
1038
+ );
1039
+ const next = this.db.updateMessageBody(roomId, messageId, text, mentionIds);
946
1040
  if (!next) throw new StoreError(404, "message not found");
947
1041
  return next;
948
1042
  }
@@ -957,12 +1051,18 @@ export class GuildStore {
957
1051
  const text = body.trim();
958
1052
  if (!text) throw new StoreError(400, "message is required");
959
1053
  const now = new Date().toISOString();
1054
+ const current = this.listMessages(roomId).find((item) => item.id === messageId);
1055
+ const bots = this.listBots();
1056
+ const mentionIds = parseMentionIds(text, bots, "bot").filter(
1057
+ (id) => id !== current?.author,
1058
+ );
960
1059
  const next = this.db.replaceMessage(roomId, messageId, {
961
1060
  body: text,
962
1061
  parts: parts && parts.length ? parts : undefined,
963
1062
  usage,
964
1063
  createdAt: usage?.startedAt || now,
965
1064
  finishedAt: now,
1065
+ mentions: mentionIds,
966
1066
  });
967
1067
  if (!next) throw new StoreError(404, "message not found");
968
1068
  return next;
@@ -1169,6 +1269,16 @@ export class GuildStore {
1169
1269
  }
1170
1270
  }
1171
1271
 
1272
+ function normalizePortrait(raw: string | null | undefined): string | undefined {
1273
+ if (raw == null) return undefined;
1274
+ const value = raw.trim();
1275
+ if (!value) return undefined;
1276
+ if (!/^\/generated\/[A-Za-z0-9._-]+$/.test(value)) {
1277
+ throw new StoreError(400, "invalid portrait");
1278
+ }
1279
+ return value;
1280
+ }
1281
+
1172
1282
  export class StoreError extends Error {
1173
1283
  constructor(
1174
1284
  readonly status: number,
@@ -1192,3 +1302,58 @@ function uniqueSlug(base: string, existing: string[]): string {
1192
1302
  if (!existing.includes(base)) return base;
1193
1303
  return `${base}-${randomUUID().slice(0, 8)}`;
1194
1304
  }
1305
+
1306
+ function cloneBranchMessages(items: ChatMessage[], roomId: string): ChatMessage[] {
1307
+ const idMap = new Map<string, string>();
1308
+ return items.map((item) => {
1309
+ const id = randomUUID();
1310
+ idMap.set(item.id, id);
1311
+ const next: ChatMessage = {
1312
+ id,
1313
+ roomId,
1314
+ author: item.author,
1315
+ body: item.body,
1316
+ createdAt: item.createdAt,
1317
+ };
1318
+ if (item.parts?.length) next.parts = item.parts;
1319
+ if (item.attachments?.length) next.attachments = item.attachments;
1320
+ if (item.usage) next.usage = item.usage;
1321
+ if (item.finishedAt) next.finishedAt = item.finishedAt;
1322
+ if (item.mentions) next.mentions = item.mentions;
1323
+ if (item.steer) next.steer = true;
1324
+ if (item.steerBotId) next.steerBotId = item.steerBotId;
1325
+ const replyTo = item.replyTo ? idMap.get(item.replyTo) : undefined;
1326
+ if (replyTo) next.replyTo = replyTo;
1327
+ return next;
1328
+ });
1329
+ }
1330
+
1331
+ function clipBranchName(body: string): string {
1332
+ return String(body || "")
1333
+ .replace(/\s+/g, " ")
1334
+ .trim()
1335
+ .slice(0, 28) || "branch";
1336
+ }
1337
+
1338
+ function uniqueChannelName(name: string, taken: string[]): string {
1339
+ if (!taken.includes(name)) return name;
1340
+ for (let i = 2; i < 50; i++) {
1341
+ const next = `${name} ${i}`;
1342
+ if (!taken.includes(next)) return next;
1343
+ }
1344
+ return `${name} ${randomUUID().slice(0, 4)}`;
1345
+ }
1346
+
1347
+ function branchDepth(store: GuildStore, room: Room): number {
1348
+ let depth = 0;
1349
+ let current: Room | null = room;
1350
+ const seen = new Set<string>();
1351
+ while (current?.parentId) {
1352
+ if (seen.has(current.id)) break;
1353
+ seen.add(current.id);
1354
+ depth += 1;
1355
+ current = store.getRoom(current.parentId);
1356
+ if (depth > 16) break;
1357
+ }
1358
+ return depth;
1359
+ }
package/src/subagent.ts CHANGED
@@ -1,8 +1,11 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { parseAgentFile } from "./agent-file.ts";
2
3
  import { listHostAgents, type HostAgent } from "./host-agents.ts";
3
4
  import type { LibraryItem } from "@guild/protocol";
5
+ import { parseSandbox, type Sandbox } from "./harness.ts";
4
6
  import {
5
7
  hostContext,
8
+ type SpawnHandle,
6
9
  type SubAgentRef,
7
10
  type ToolContext,
8
11
  type ToolOutcome,
@@ -70,6 +73,23 @@ function agentKey(value: string): string {
70
73
  return value.trim().replace(/^\/+/, "").toLowerCase();
71
74
  }
72
75
 
76
+ /**
77
+ * A child never outruns its parent, and a read-only agent never gets `run`
78
+ * back: a readOnly child is pinned to read_only even under a full_access
79
+ * parent (that used to be an escalation hole — CHILD_TOOLS_RO advertised run
80
+ * and gateTool let a full_access child through it).
81
+ */
82
+ export function childSpawnPolicy(
83
+ parentSandbox: ToolContext["sandbox"],
84
+ agentReadOnly: boolean,
85
+ ): { sandbox: Sandbox; allowWrite: boolean } {
86
+ const parent = parseSandbox(parentSandbox);
87
+ if (parent === "read_only" || agentReadOnly) {
88
+ return { sandbox: "read_only", allowWrite: false };
89
+ }
90
+ return { sandbox: parent, allowWrite: true };
91
+ }
92
+
73
93
  export function resolveSubagent(
74
94
  name: string,
75
95
  agents: SubAgentRef[],
@@ -89,11 +109,221 @@ export function resolveSubagent(
89
109
 
90
110
  const CHILD_TOOLS = `You ARE already running on the user's local computer (Guild).
91
111
  Tools: run, read, write, list, skill, image_gen. You cannot spawn subagents.
92
- Never say you cannot access this machine. Check [exit code: N] on every run.`;
112
+ Never say you cannot access this machine. Check [exit code: N] on every run.
113
+ Independent searches: emit multiple tool calls in one round; they run in parallel.`;
93
114
 
94
115
  const CHILD_TOOLS_RO = `You ARE already running on the user's local computer (Guild).
95
- Tools: run, read, list, skill. You cannot write files and cannot spawn subagents.
96
- Read-only. Never edit, patch, or create files. Check [exit code: N] on every run.`;
116
+ Tools: read, list, skill. You cannot run shell commands, cannot write files, and cannot spawn subagents.
117
+ Read-only. Never edit, patch, or create files.
118
+ Independent searches: emit multiple tool calls in one round; they run in parallel.`;
119
+
120
+ export const SPAWN_MAX_PARALLEL = 8;
121
+ export const SPAWN_CONCURRENCY = 4;
122
+
123
+ export type SpawnJob = {
124
+ prompt: string;
125
+ name: string;
126
+ description: string;
127
+ };
128
+
129
+ /** Devin luna-explore / Pi scout → Guild explorer. */
130
+ export function spawnProfile(raw: string): string {
131
+ const key = raw.trim().toLowerCase();
132
+ if (!key) return "";
133
+ if (key === "luna-explore" || key === "explore" || key === "scout") {
134
+ return "explorer";
135
+ }
136
+ if (key === "luna-general" || key === "general") return "worker";
137
+ if (key === "luna-reviewer") return "reviewer";
138
+ return raw.trim();
139
+ }
140
+
141
+ function recordOf(value: unknown): Record<string, unknown> | null {
142
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
143
+ return value as Record<string, unknown>;
144
+ }
145
+
146
+ function flagTrue(value: unknown): boolean {
147
+ return value === true || value === "true";
148
+ }
149
+
150
+ function flagFalse(value: unknown): boolean {
151
+ return value === false || value === "false";
152
+ }
153
+
154
+ function oneJob(raw: Record<string, unknown>): SpawnJob {
155
+ const prompt = String(raw.prompt || raw.task || "").trim();
156
+ const name = spawnProfile(
157
+ String(raw.profile || raw.name || raw.agent || raw.subagent_type || ""),
158
+ );
159
+ const description = String(raw.title || raw.description || "").trim();
160
+ return { prompt, name, description };
161
+ }
162
+
163
+ function handlesOf(ctx: ToolContext) {
164
+ if (!ctx.spawnHandles) ctx.spawnHandles = new Map();
165
+ return ctx.spawnHandles;
166
+ }
167
+
168
+ function isAbortError(error: unknown): boolean {
169
+ return Boolean(
170
+ error &&
171
+ typeof error === "object" &&
172
+ "name" in error &&
173
+ (error as { name: string }).name === "AbortError",
174
+ );
175
+ }
176
+
177
+ function startBackground(job: SpawnJob, ctx: ToolContext) {
178
+ const id = randomUUID();
179
+ const title = job.description || job.name || "worker";
180
+ const profile = job.name || "worker";
181
+ const abort = new AbortController();
182
+ const parent = ctx.signal;
183
+ if (parent) {
184
+ if (parent.aborted) abort.abort();
185
+ else parent.addEventListener("abort", () => abort.abort(), { once: true });
186
+ }
187
+ const childCtx: ToolContext = {
188
+ ...ctx,
189
+ signal: abort.signal,
190
+ spawnHandles: undefined,
191
+ };
192
+ const handle: SpawnHandle = {
193
+ id,
194
+ title,
195
+ profile,
196
+ abort,
197
+ done: Promise.resolve({ text: "", isError: false }),
198
+ };
199
+ handle.done = spawnSubagent({ ...job, ctx: childCtx })
200
+ .then((outcome) => {
201
+ handle.outcome = outcome;
202
+ return outcome;
203
+ })
204
+ .catch((error: unknown) => {
205
+ const outcome: ToolOutcome = {
206
+ text:
207
+ isAbortError(error) || abort.signal.aborted
208
+ ? "aborted"
209
+ : error instanceof Error
210
+ ? error.message
211
+ : String(error),
212
+ isError: true,
213
+ };
214
+ handle.outcome = outcome;
215
+ return outcome;
216
+ });
217
+ handlesOf(ctx).set(id, handle);
218
+ return handle;
219
+ }
220
+
221
+ function ackBackground(
222
+ rows: { id: string; title: string; profile: string }[],
223
+ ): string {
224
+ return rows
225
+ .map(
226
+ (row) =>
227
+ `agent_id: ${row.id}\ntitle: ${row.title}\nprofile: ${row.profile}\nstatus: running\nCall read_spawn with this agent_id (block=true) before the final reply.`,
228
+ )
229
+ .join("\n\n");
230
+ }
231
+
232
+ /** Pi subagent: single {prompt|task, name|agent} or parallel tasks[]. */
233
+ export function spawnJobs(args: Record<string, unknown>): SpawnJob[] {
234
+ if (Array.isArray(args.tasks) && args.tasks.length) {
235
+ return args.tasks.map((item) => oneJob(recordOf(item) || {}));
236
+ }
237
+ return [oneJob(args)];
238
+ }
239
+
240
+ async function mapWithConcurrency<T, R>(
241
+ items: T[],
242
+ concurrency: number,
243
+ fn: (item: T, index: number) => Promise<R>,
244
+ ): Promise<R[]> {
245
+ if (!items.length) return [];
246
+ const limit = Math.max(1, Math.min(concurrency, items.length));
247
+ const out: R[] = new Array(items.length);
248
+ let next = 0;
249
+ await Promise.all(
250
+ Array.from({ length: limit }, async () => {
251
+ while (true) {
252
+ const i = next++;
253
+ if (i >= items.length) return;
254
+ out[i] = await fn(items[i], i);
255
+ }
256
+ }),
257
+ );
258
+ return out;
259
+ }
260
+
261
+ export async function runSpawnJobs(
262
+ args: Record<string, unknown>,
263
+ ctx: ToolContext,
264
+ ): Promise<ToolOutcome> {
265
+ const jobs = spawnJobs(args);
266
+ if (!jobs.length || jobs.some((job) => !job.prompt)) {
267
+ return { text: "spawn needs a prompt or task", isError: true };
268
+ }
269
+ if (jobs.length > SPAWN_MAX_PARALLEL) {
270
+ return {
271
+ text: `Too many parallel tasks (${jobs.length}). Max is ${SPAWN_MAX_PARALLEL}.`,
272
+ isError: true,
273
+ };
274
+ }
275
+ const background = flagTrue(args.background) || flagTrue(args.is_background);
276
+ if (background) {
277
+ const started = jobs.map((job) => startBackground(job, ctx));
278
+ return { text: ackBackground(started), isError: false };
279
+ }
280
+ if (jobs.length === 1) {
281
+ return spawnSubagent({ ...jobs[0], ctx });
282
+ }
283
+ const results = await mapWithConcurrency(jobs, SPAWN_CONCURRENCY, (job) =>
284
+ spawnSubagent({ ...job, ctx }),
285
+ );
286
+ const failed = results.filter((row) => row.isError).length;
287
+ const body = results
288
+ .map((row, i) => {
289
+ const label = jobs[i].description || jobs[i].name || "worker";
290
+ const status = row.isError ? "failed" : "completed";
291
+ return `### [${label}] ${status}\n\n${row.text}`;
292
+ })
293
+ .join("\n\n---\n\n");
294
+ return {
295
+ text: `Parallel: ${results.length - failed}/${results.length} succeeded\n\n${body}`,
296
+ isError: failed === results.length,
297
+ };
298
+ }
299
+
300
+ export async function readSpawn(
301
+ args: Record<string, unknown>,
302
+ ctx: ToolContext,
303
+ ): Promise<ToolOutcome> {
304
+ const id = String(args.agent_id || args.id || "").trim();
305
+ if (!id) return { text: "read_spawn needs agent_id", isError: true };
306
+ const handle = handlesOf(ctx).get(id);
307
+ if (!handle) {
308
+ return {
309
+ text: `unknown agent_id ${id}. It must come from a background spawn in this turn.`,
310
+ isError: true,
311
+ };
312
+ }
313
+ if (flagFalse(args.block) && !handle.outcome) {
314
+ return {
315
+ text: `agent_id: ${handle.id}\ntitle: ${handle.title}\nprofile: ${handle.profile}\nstatus: running`,
316
+ isError: false,
317
+ };
318
+ }
319
+ const outcome = await handle.done;
320
+ return {
321
+ text: `# ${handle.title}\nagent_id: ${handle.id}\nprofile: ${handle.profile}\nstatus: ${
322
+ outcome.isError ? "failed" : "completed"
323
+ }\n\n${outcome.text}`,
324
+ isError: outcome.isError,
325
+ };
326
+ }
97
327
 
98
328
  export async function spawnSubagent(input: {
99
329
  prompt: string;
@@ -115,12 +345,13 @@ export async function spawnSubagent(input: {
115
345
  ? input.ctx.subagents
116
346
  : listSpawnRefs([]);
117
347
  const agent = resolveSubagent(input.name || "worker", agents);
348
+ const child = childSpawnPolicy(input.ctx.sandbox, agent.readOnly);
118
349
  const { llmComplete } = await import("./llm.ts");
119
350
  const label = (input.description || agent.name).trim();
120
351
  const system = [
121
352
  agent.instructions,
122
353
  hostContext(),
123
- agent.readOnly ? CHILD_TOOLS_RO : CHILD_TOOLS,
354
+ child.allowWrite ? CHILD_TOOLS : CHILD_TOOLS_RO,
124
355
  ]
125
356
  .filter(Boolean)
126
357
  .join("\n\n");
@@ -130,7 +361,7 @@ export async function spawnSubagent(input: {
130
361
  system,
131
362
  messages: [{ role: "user", content: prompt }],
132
363
  temperature: 0.3,
133
- role: "chat",
364
+ role: "spawn",
134
365
  tools: true,
135
366
  skills: input.ctx.skills,
136
367
  toolCtx: {
@@ -139,10 +370,11 @@ export async function spawnSubagent(input: {
139
370
  dataDir,
140
371
  env: input.ctx.env,
141
372
  spawnDepth: 1,
142
- allowWrite: !agent.readOnly,
143
- sandbox: input.ctx.sandbox,
373
+ allowWrite: child.allowWrite,
374
+ sandbox: child.sandbox,
144
375
  workspace: input.ctx.workspace,
145
376
  dispatch: input.ctx.dispatch,
377
+ signal: input.ctx.signal,
146
378
  },
147
379
  });
148
380
  if (!result) {