@kevin5251984/guild 0.2.17 → 0.2.19

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