negotium 0.1.37 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +19 -0
  2. package/dist/agent-helpers.js +15637 -5674
  3. package/dist/agent-helpers.js.map +85 -37
  4. package/dist/browser-runtime.js +1600 -0
  5. package/dist/browser-runtime.js.map +28 -0
  6. package/dist/hosted-agent.js +8 -8
  7. package/dist/hosted-agent.js.map +6 -5
  8. package/dist/main.js +3790 -3113
  9. package/dist/main.js.map +33 -29
  10. package/dist/mcp-factories.js +15141 -301
  11. package/dist/mcp-factories.js.map +100 -7
  12. package/dist/prompts.js +117 -49
  13. package/dist/prompts.js.map +3 -3
  14. package/dist/query-runtime.js +310 -1
  15. package/dist/query-runtime.js.map +10 -5
  16. package/dist/runtime/src/agents/archiver.ts +407 -296
  17. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +440 -274
  18. package/dist/runtime/src/agents/mcp-tools/self-config.ts +139 -65
  19. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +1037 -816
  20. package/dist/runtime/src/agents/public-helpers.ts +82 -0
  21. package/dist/runtime/src/agents/self-config-core.ts +574 -278
  22. package/dist/runtime/src/agents/topic-cleanup.ts +167 -129
  23. package/dist/runtime/src/index.ts +4 -1
  24. package/dist/runtime/src/mcp/factories/index.ts +9 -0
  25. package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +132 -0
  26. package/dist/runtime/src/mcp/session-comm/topics.ts +23 -57
  27. package/dist/runtime/src/platform/mcp-config.ts +2 -4
  28. package/dist/runtime/src/platform/playwright/browser-processes.ts +3 -2
  29. package/dist/runtime/src/platform/playwright/capability.ts +5 -0
  30. package/dist/runtime/src/platform/playwright/manager.ts +380 -66
  31. package/dist/runtime/src/platform/playwright/public-runtime.ts +45 -0
  32. package/dist/runtime/src/platform/playwright/transport-probe.ts +89 -0
  33. package/dist/runtime/src/prompts/builders.ts +192 -51
  34. package/dist/runtime/src/query/active-rooms.ts +2 -0
  35. package/dist/runtime/src/query/public-runtime.ts +8 -0
  36. package/dist/runtime/src/query/state.ts +69 -33
  37. package/dist/runtime/src/query/types.ts +1 -0
  38. package/dist/runtime/src/runtime/playwright-turn-abort.ts +59 -0
  39. package/dist/runtime/src/runtime/turn-event-stream.ts +23 -6
  40. package/dist/runtime/src/runtime/turn-runner.ts +6 -1
  41. package/dist/runtime/src/version.ts +1 -1
  42. package/dist/types/packages/core/src/agents/api-topic-agent-switch.d.ts +38 -0
  43. package/dist/types/packages/core/src/agents/archiver.d.ts +52 -16
  44. package/dist/types/packages/core/src/agents/mcp-tools/ask-user.d.ts +75 -4
  45. package/dist/types/packages/core/src/agents/mcp-tools/self-config.d.ts +17 -0
  46. package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +112 -25
  47. package/dist/types/packages/core/src/agents/public-helpers.d.ts +7 -0
  48. package/dist/types/packages/core/src/agents/self-config-core.d.ts +185 -0
  49. package/dist/types/packages/core/src/agents/topic-cleanup.d.ts +30 -24
  50. package/dist/types/packages/core/src/mcp/factories/index.d.ts +1 -0
  51. package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +46 -0
  52. package/dist/types/packages/core/src/platform/mcp-config.d.ts +2 -1
  53. package/dist/types/packages/core/src/platform/playwright/capability.d.ts +1 -0
  54. package/dist/types/packages/core/src/platform/playwright/manager.d.ts +65 -5
  55. package/dist/types/packages/core/src/platform/playwright/public-runtime.d.ts +4 -0
  56. package/dist/types/packages/core/src/platform/playwright/transport-probe.d.ts +13 -0
  57. package/dist/types/packages/core/src/prompts/builders.d.ts +29 -3
  58. package/dist/types/packages/core/src/query/active-rooms.d.ts +2 -0
  59. package/dist/types/packages/core/src/query/public-runtime.d.ts +1 -0
  60. package/dist/types/packages/core/src/query/state.d.ts +13 -0
  61. package/dist/types/packages/core/src/query/types.d.ts +2 -1
  62. package/dist/types/packages/core/src/runtime/playwright-turn-abort.d.ts +12 -0
  63. package/dist/types/packages/core/src/version.d.ts +1 -1
  64. package/package.json +6 -1
@@ -30,7 +30,7 @@ import { getRuntimeTurnLease, RUNTIME_INSTANCE_ID } from "#storage/runtime-lease
30
30
  import { getRuntimeUserTurnRequest } from "#storage/runtime-turn-requests";
31
31
  import { wikiBriefStorageKey, wikiSummarySlug } from "#storage/wiki-summary-names";
32
32
  import { type AgentKind, isAgentKind } from "#types";
33
- import type { MessageDto, SubagentCardDto, SubagentReportMode } from "#types/api";
33
+ import type { MessageDto, SubagentCardDto, SubagentReportMode, TopicDto } from "#types/api";
34
34
 
35
35
  const MAX_TASK_CHARS = 8000;
36
36
  const MAX_NAME_CHARS = 80;
@@ -39,6 +39,12 @@ const MAX_PREPARED_CHILDREN_PER_PARENT = 10;
39
39
  export const MAX_SUBAGENT_DEPTH = 2;
40
40
  const RESULT_SUMMARY_CHARS = 300;
41
41
 
42
+ export interface SubagentLifecycleLimits {
43
+ readonly maxDepth: number;
44
+ readonly maxLiveChildrenPerParent: number;
45
+ readonly maxPreparedChildrenPerParent: number;
46
+ }
47
+
42
48
  export interface SpawnSubagentToolContext {
43
49
  userId: string;
44
50
  topicId: string;
@@ -61,916 +67,1131 @@ export interface SubagentWatch {
61
67
  running: boolean;
62
68
  }
63
69
 
64
- const watchesByChild = new Map<string, SubagentWatch>();
65
- const childByQueryId = new Map<string, string>();
66
-
67
- function resolveMemoryTopicSelection(
68
- rawSelection: unknown,
69
- userId: string,
70
- ): { topicId?: string; error?: string } {
71
- if (rawSelection === undefined || rawSelection === null) return {};
72
- if (typeof rawSelection !== "string" || !rawSelection.trim()) {
73
- return { error: "Error: memory_topic must be a topic id, title, or topic/*.md path." };
74
- }
75
-
76
- const selection = rawSelection.trim();
77
- if (
78
- selection.includes("\\") ||
79
- selection.startsWith("/") ||
80
- selection.split("/").some((part) => part === "..") ||
81
- (selection.includes("/") && !selection.startsWith("topic/"))
82
- ) {
83
- return { error: "Error: memory_topic must name a file under topic/." };
84
- }
85
- const key = selection.startsWith("topic/") ? selection.slice("topic/".length) : selection;
86
- if (!key || key.includes("/")) {
87
- return { error: "Error: memory_topic must name one topic/*.md file." };
88
- }
89
-
90
- const accessible = listTopics().filter((topic) =>
91
- topic.participants.some((participant) => participant.userId === userId),
92
- );
93
- const matches = accessible.filter((topic) => {
94
- const canonicalFilename = `${wikiBriefStorageKey(topic.title, topic.id)}.md`;
95
- const legacyFilename = `${wikiSummarySlug(topic.title)}.md`;
96
- return (
97
- key === topic.id ||
98
- key.toLowerCase() === topic.title.toLowerCase() ||
99
- key === canonicalFilename ||
100
- key === legacyFilename
101
- );
102
- });
103
- if (matches.length === 0) {
104
- return { error: `Error: memory topic '${selection}' was not found or is not accessible.` };
105
- }
106
- if (matches.length > 1) {
107
- return {
108
- error: `Error: memory topic '${selection}' is ambiguous; use its topic id or canonical topic/*.md filename.`,
109
- };
110
- }
111
- const origin = getTopicMemoryOrigin(matches[0]!.id) ?? matches[0]!;
112
- // The matched topic is accessible, but its memory chain may resolve to a
113
- // root origin the user does not participate in; fail closed like
114
- // listMemoryTopicNames instead of granting memory access transitively.
115
- if (!origin.participants.some((participant) => participant.userId === userId)) {
116
- return { error: `Error: memory topic '${selection}' was not found or is not accessible.` };
117
- }
118
- return { topicId: origin.id };
70
+ export interface SubagentLifecycleHost<
71
+ TContext extends SpawnSubagentToolContext = SpawnSubagentToolContext,
72
+ > {
73
+ storage: {
74
+ getTopic(topicId: string): TopicDto | null | undefined;
75
+ listTopics(): TopicDto[];
76
+ getTopicMemoryOrigin(topicId: string): TopicDto | null;
77
+ upsertTopic(topic: TopicDto): void;
78
+ getMessage(topicId: string, messageId: string): MessageDto | null | undefined;
79
+ listSubagentMessages(): MessageDto[];
80
+ appendMessage(message: MessageDto, options?: { notify?: boolean }): void;
81
+ updateSubagentCard(
82
+ topicId: string,
83
+ messageId: string,
84
+ card: SubagentCardDto,
85
+ editedAt: string,
86
+ ): MessageDto | null;
87
+ publishMessage(topicId: string, message: MessageDto): void;
88
+ publishCardUpdate(
89
+ topicId: string,
90
+ messageId: string,
91
+ update: { subagentCard: SubagentCardDto; editedAt: string },
92
+ ): void;
93
+ };
94
+ topic: {
95
+ createDerived(input: {
96
+ context: TContext;
97
+ parentTopicId: string;
98
+ userId: string;
99
+ name?: string;
100
+ agent?: AgentKind;
101
+ model?: string;
102
+ memoryTopicId?: string;
103
+ }): Promise<
104
+ | { ok: true; topic: TopicDto | null }
105
+ | { ok: false; reason: "busy" | "title-conflict"; message?: string }
106
+ >;
107
+ delete(topic: TopicDto, userId: string): Promise<void>;
108
+ };
109
+ task: {
110
+ dispatch(input: {
111
+ child: TopicDto;
112
+ parent: TopicDto;
113
+ userId: string;
114
+ prompt: string;
115
+ requestId: string;
116
+ onDispatched(queryId: string): void;
117
+ }): Promise<{ accepted: boolean; error?: string }>;
118
+ };
119
+ sessionCommunication: {
120
+ notifyParent(input: {
121
+ watch: SubagentWatch;
122
+ prompt: string;
123
+ requestId: string;
124
+ }): Promise<boolean>;
125
+ listTellTargetIds(sourceTopicId: string): string[];
126
+ grantTellTarget(sourceTopicId: string, targetTopicId: string, grantedByTopicId: string): void;
127
+ revokeTellTarget(sourceTopicId: string, targetTopicId: string): boolean;
128
+ };
129
+ runtime: {
130
+ ownerId: string;
131
+ now(): string;
132
+ ownerIsAlive(ownerId: string): boolean;
133
+ childExecutionQueryId(childTopicId: string): string | null;
134
+ childExecutionIsRecoverable(childTopicId: string): boolean;
135
+ };
136
+ config: {
137
+ limits?: Partial<SubagentLifecycleLimits>;
138
+ resolveAgentModel(input: {
139
+ context: TContext;
140
+ agent: AgentKind;
141
+ model?: string;
142
+ hasOverride: boolean;
143
+ }): Promise<{ model?: string; error?: string }>;
144
+ memoryFilenames(topic: TopicDto): string[];
145
+ };
119
146
  }
120
147
 
121
- function countLiveChildren(parentTopicId: string): number {
122
- let n = 0;
123
- for (const watch of watchesByChild.values()) {
124
- if (watch.parentTopicId === parentTopicId) n += 1;
125
- }
126
- return n;
148
+ export interface SubagentLifecycle<
149
+ TContext extends SpawnSubagentToolContext = SpawnSubagentToolContext,
150
+ > {
151
+ computeSubagentDepth(topicId: string): number;
152
+ canSpawnSubagentsFromTopic(topicId: string): boolean;
153
+ takeSubagentWatch(queryId: string): SubagentWatch | null;
154
+ cancelSubagentWatchForDeletedTopic(childTopicId: string): void;
155
+ settleSubagentSuccess(watch: SubagentWatch, finalText: string): Promise<void>;
156
+ settleSubagentFailure(queryId: string, reason: string): Promise<void>;
157
+ sweepStaleSubagentCards(): void;
158
+ subagentReportMode(childTopicId: string): SubagentReportMode;
159
+ createSubagentManagementToolDefinitions(ctx: SubagentToolContext): SharedMcpTool[];
160
+ createSpawnSubagentToolDefinition(ctx: TContext): SharedMcpTool;
161
+ createPrepareSubagentToolDefinition(ctx: TContext): SharedMcpTool;
127
162
  }
128
163
 
129
- /**
130
- * Walk the parent chain and count how many subagent ancestors this topic has.
131
- * Runs on every turn/MCP build, so it walks per-parent lookups (depth-capped)
132
- * instead of scanning the whole topic table.
133
- */
134
- export function computeSubagentDepth(topicId: string): number {
135
- let depth = 0;
136
- let current = getTopic(topicId);
137
- const visited = new Set<string>();
138
- while (current) {
139
- if (visited.has(current.id)) return MAX_SUBAGENT_DEPTH;
140
- visited.add(current.id);
141
- if (current.isSubagent) depth += 1;
142
- if (!current.parentTopicId || current.parentTopicId === current.id) break;
143
- current = getTopic(current.parentTopicId);
164
+ export function createSubagentLifecycle<TContext extends SpawnSubagentToolContext>(
165
+ host: SubagentLifecycleHost<TContext>,
166
+ ): SubagentLifecycle<TContext> {
167
+ const runtimeOwnerId = host.runtime.ownerId;
168
+ const limits = Object.freeze({
169
+ maxDepth: host.config.limits?.maxDepth ?? MAX_SUBAGENT_DEPTH,
170
+ maxLiveChildrenPerParent:
171
+ host.config.limits?.maxLiveChildrenPerParent ?? MAX_LIVE_CHILDREN_PER_PARENT,
172
+ maxPreparedChildrenPerParent:
173
+ host.config.limits?.maxPreparedChildrenPerParent ?? MAX_PREPARED_CHILDREN_PER_PARENT,
174
+ });
175
+ for (const [name, value] of Object.entries(limits)) {
176
+ if (!Number.isInteger(value) || value < 1) {
177
+ throw new Error(`subagent lifecycle ${name} must be a positive integer`);
178
+ }
144
179
  }
145
- return depth;
146
- }
180
+ const watchesByChild = new Map<string, SubagentWatch>();
181
+ const childByQueryId = new Map<string, string>();
147
182
 
148
- export function canSpawnSubagentsFromTopic(topicId: string): boolean {
149
- const topic = getTopic(topicId);
150
- return topic?.kind === "agent" && computeSubagentDepth(topicId) < MAX_SUBAGENT_DEPTH;
151
- }
183
+ function resolveMemoryTopicSelection(
184
+ rawSelection: unknown,
185
+ userId: string,
186
+ ): { topicId?: string; error?: string } {
187
+ if (rawSelection === undefined || rawSelection === null) return {};
188
+ if (typeof rawSelection !== "string" || !rawSelection.trim()) {
189
+ return { error: "Error: memory_topic must be a topic id, title, or topic/*.md path." };
190
+ }
152
191
 
153
- /** Read-merge-write the parent card and broadcast the patch. */
154
- function patchSubagentCard(
155
- parentTopicId: string,
156
- cardMessageId: string,
157
- patch: Partial<SubagentCardDto>,
158
- ): void {
159
- const current = getApiMessage(parentTopicId, cardMessageId)?.subagentCard;
160
- if (!current) return;
161
- const next: SubagentCardDto = { ...current, ...patch };
162
- const editedAt = new Date().toISOString();
163
- if (!updateApiMessageSubagentCard(parentTopicId, cardMessageId, next, editedAt)) return;
164
- WsHub.get().broadcastMessageUpdated(parentTopicId, cardMessageId, {
165
- subagentCard: next,
166
- editedAt,
167
- });
168
- }
192
+ const selection = rawSelection.trim();
193
+ if (
194
+ selection.includes("\\") ||
195
+ selection.startsWith("/") ||
196
+ selection.split("/").some((part) => part === "..") ||
197
+ (selection.includes("/") && !selection.startsWith("topic/"))
198
+ ) {
199
+ return { error: "Error: memory_topic must name a file under topic/." };
200
+ }
201
+ const key = selection.startsWith("topic/") ? selection.slice("topic/".length) : selection;
202
+ if (!key || key.includes("/")) {
203
+ return { error: "Error: memory_topic must name one topic/*.md file." };
204
+ }
169
205
 
170
- function registerWatchDispatch(watch: SubagentWatch, queryId: string): void {
171
- // Re-dispatch (defer drain, session-expired retry, supersede-requeue) hands
172
- // the same child a new queryId — drop the stale mapping first.
173
- if (watch.queryId) childByQueryId.delete(watch.queryId);
174
- watch.queryId = queryId;
175
- watchesByChild.set(watch.childTopicId, watch);
176
- childByQueryId.set(queryId, watch.childTopicId);
177
- if (!watch.running) {
178
- watch.running = true;
179
- patchSubagentCard(watch.parentTopicId, watch.cardMessageId, { status: "running" });
206
+ const accessible = host.storage
207
+ .listTopics()
208
+ .filter((topic) => topic.participants.some((participant) => participant.userId === userId));
209
+ const matches = accessible.filter((topic) => {
210
+ const [canonicalFilename, legacyFilename] = host.config.memoryFilenames(topic);
211
+ return (
212
+ key === topic.id ||
213
+ key.toLowerCase() === topic.title.toLowerCase() ||
214
+ key === canonicalFilename ||
215
+ key === legacyFilename
216
+ );
217
+ });
218
+ if (matches.length === 0) {
219
+ return { error: `Error: memory topic '${selection}' was not found or is not accessible.` };
220
+ }
221
+ if (matches.length > 1) {
222
+ return {
223
+ error: `Error: memory topic '${selection}' is ambiguous; use its topic id or canonical topic/*.md filename.`,
224
+ };
225
+ }
226
+ const origin = host.storage.getTopicMemoryOrigin(matches[0]!.id) ?? matches[0]!;
227
+ // The matched topic is accessible, but its memory chain may resolve to a
228
+ // root origin the user does not participate in; fail closed like
229
+ // listMemoryTopicNames instead of granting memory access transitively.
230
+ if (!origin.participants.some((participant) => participant.userId === userId)) {
231
+ return { error: `Error: memory topic '${selection}' was not found or is not accessible.` };
232
+ }
233
+ return { topicId: origin.id };
180
234
  }
181
- }
182
235
 
183
- /** Claim the watch for a finished child turn (delete-on-take). */
184
- export function takeSubagentWatch(queryId: string): SubagentWatch | null {
185
- const childTopicId = childByQueryId.get(queryId);
186
- if (!childTopicId) return recoverPersistedSubagentWatch(queryId);
187
- childByQueryId.delete(queryId);
188
- const watch = watchesByChild.get(childTopicId);
189
- if (!watch || watch.queryId !== queryId) return null;
190
- watchesByChild.delete(childTopicId);
191
- return watch;
192
- }
193
-
194
- function childExecutionQueryId(childTopicId: string): string | null {
195
- const lease = getRuntimeTurnLease(childTopicId);
196
- if (lease) return lease.queryId;
197
- return getRuntimeUserTurnRequest(childTopicId)?.runningQueryId ?? null;
198
- }
199
-
200
- function childExecutionIsRecoverable(childTopicId: string): boolean {
201
- if (getRuntimeUserTurnRequest(childTopicId)) return true;
202
- const lease = getRuntimeTurnLease(childTopicId);
203
- if (!lease) return false;
204
- const ownerPid = Number.parseInt(lease.ownerId.split("-", 1)[0] ?? "", 10);
205
- if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0) return true;
206
- try {
207
- process.kill(ownerPid, 0);
208
- return true;
209
- } catch (error) {
210
- return (error as NodeJS.ErrnoException).code !== "ESRCH";
236
+ function countLiveChildren(parentTopicId: string): number {
237
+ let n = 0;
238
+ for (const watch of watchesByChild.values()) {
239
+ if (watch.parentTopicId === parentTopicId) n += 1;
240
+ }
241
+ return n;
211
242
  }
212
- }
213
243
 
214
- function runtimeOwnerIsAlive(ownerId: string): boolean {
215
- const ownerPid = Number.parseInt(ownerId.split("-", 1)[0] ?? "", 10);
216
- if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0) return true;
217
- try {
218
- process.kill(ownerPid, 0);
219
- return true;
220
- } catch (error) {
221
- return (error as NodeJS.ErrnoException).code !== "ESRCH";
244
+ /**
245
+ * Walk the parent chain and count how many subagent ancestors this topic has.
246
+ * Runs on every turn/MCP build, so it walks per-parent lookups (depth-capped)
247
+ * instead of scanning the whole topic table.
248
+ */
249
+ function computeSubagentDepth(topicId: string): number {
250
+ let depth = 0;
251
+ let current = host.storage.getTopic(topicId);
252
+ const visited = new Set<string>();
253
+ while (current) {
254
+ if (visited.has(current.id)) return limits.maxDepth;
255
+ visited.add(current.id);
256
+ if (current.isSubagent) depth += 1;
257
+ if (!current.parentTopicId || current.parentTopicId === current.id) break;
258
+ current = host.storage.getTopic(current.parentTopicId);
259
+ }
260
+ return depth;
222
261
  }
223
- }
224
262
 
225
- function watchFromPersistedCard(
226
- message: MessageDto,
227
- card: SubagentCardDto,
228
- queryId?: string,
229
- ): SubagentWatch | null {
230
- const child = getTopic(card.subagentTopicId);
231
- const userId =
232
- child?.participants.find((participant) => participant.role === "owner")?.userId ??
233
- child?.participants[0]?.userId;
234
- if (!userId) return null;
235
- return {
236
- parentTopicId: message.topicId,
237
- childTopicId: card.subagentTopicId,
238
- cardMessageId: message.id,
239
- name: card.name,
240
- userId,
241
- startedAt: card.startedAt ?? card.createdAt ?? message.createdAt,
242
- reportMode: card.reportMode ?? "auto",
243
- queryId,
244
- running: card.status === "running",
245
- };
246
- }
263
+ function canSpawnSubagentsFromTopic(topicId: string): boolean {
264
+ const topic = host.storage.getTopic(topicId);
265
+ return topic?.kind === "agent" && computeSubagentDepth(topicId) < limits.maxDepth;
266
+ }
247
267
 
248
- function recoverPersistedSubagentWatch(queryId: string): SubagentWatch | null {
249
- for (const message of listApiMessagesByKind("subagent")) {
250
- const card = message.subagentCard;
251
- if (!card || (card.status !== "spawned" && card.status !== "running")) continue;
252
- if (childExecutionQueryId(card.subagentTopicId) !== queryId) continue;
253
- return watchFromPersistedCard(message, card, queryId);
268
+ /** Read-merge-write the parent card and broadcast the patch. */
269
+ function patchSubagentCard(
270
+ parentTopicId: string,
271
+ cardMessageId: string,
272
+ patch: Partial<SubagentCardDto>,
273
+ ): void {
274
+ const current = host.storage.getMessage(parentTopicId, cardMessageId)?.subagentCard;
275
+ if (!current) return;
276
+ const next: SubagentCardDto = { ...current, ...patch };
277
+ const editedAt = host.runtime.now();
278
+ if (!host.storage.updateSubagentCard(parentTopicId, cardMessageId, next, editedAt)) return;
279
+ host.storage.publishCardUpdate(parentTopicId, cardMessageId, {
280
+ subagentCard: next,
281
+ editedAt,
282
+ });
254
283
  }
255
- return null;
256
- }
257
284
 
258
- function dropWatch(watch: SubagentWatch): void {
259
- watchesByChild.delete(watch.childTopicId);
260
- if (watch.queryId) childByQueryId.delete(watch.queryId);
261
- }
285
+ function registerWatchDispatch(watch: SubagentWatch, queryId: string): void {
286
+ // Re-dispatch (defer drain, session-expired retry, supersede-requeue) hands
287
+ // the same child a new queryId — drop the stale mapping first.
288
+ if (watch.queryId) childByQueryId.delete(watch.queryId);
289
+ watch.queryId = queryId;
290
+ watchesByChild.set(watch.childTopicId, watch);
291
+ childByQueryId.set(queryId, watch.childTopicId);
292
+ if (!watch.running) {
293
+ watch.running = true;
294
+ patchSubagentCard(watch.parentTopicId, watch.cardMessageId, { status: "running" });
295
+ }
296
+ }
262
297
 
263
- /** Drop deferred/in-flight bookkeeping when a subagent room is hard-deleted. */
264
- export function cancelSubagentWatchForDeletedTopic(childTopicId: string): void {
265
- const watch = watchesByChild.get(childTopicId);
266
- if (!watch) return;
267
- dropWatch(watch);
268
- patchSubagentCard(watch.parentTopicId, watch.cardMessageId, {
269
- status: "failed",
270
- errorMessage: "subagent room was deleted",
271
- finishedAt: new Date().toISOString(),
272
- });
273
- }
298
+ /** Claim the watch for a finished child turn (delete-on-take). */
299
+ function takeSubagentWatch(queryId: string): SubagentWatch | null {
300
+ const childTopicId = childByQueryId.get(queryId);
301
+ if (!childTopicId) return recoverPersistedSubagentWatch(queryId);
302
+ childByQueryId.delete(queryId);
303
+ const watch = watchesByChild.get(childTopicId);
304
+ if (!watch || watch.queryId !== queryId) return null;
305
+ watchesByChild.delete(childTopicId);
306
+ return watch;
307
+ }
274
308
 
275
- /** Last-resort delivery: a plain system message in the parent room. */
276
- function appendParentSystemNote(watch: SubagentWatch, prompt: string): void {
277
- const now = new Date().toISOString();
278
- const msg: MessageDto = {
279
- id: `subagent-note-${watch.childTopicId}-${now}`,
280
- topicId: watch.parentTopicId,
281
- authorId: "system",
282
- text: prompt,
283
- kind: "system",
284
- createdAt: now,
285
- };
286
- appendApiMessage(msg);
287
- WsHub.get().broadcastMessage(watch.parentTopicId, msg);
288
- }
309
+ function watchFromPersistedCard(
310
+ message: MessageDto,
311
+ card: SubagentCardDto,
312
+ queryId?: string,
313
+ ): SubagentWatch | null {
314
+ const child = host.storage.getTopic(card.subagentTopicId);
315
+ const userId =
316
+ child?.participants.find((participant) => participant.role === "owner")?.userId ??
317
+ child?.participants[0]?.userId;
318
+ if (!userId) return null;
319
+ return {
320
+ parentTopicId: message.topicId,
321
+ childTopicId: card.subagentTopicId,
322
+ cardMessageId: message.id,
323
+ name: card.name,
324
+ userId,
325
+ startedAt: card.startedAt ?? card.createdAt ?? message.createdAt,
326
+ reportMode: card.reportMode ?? "auto",
327
+ queryId,
328
+ running: card.status === "running",
329
+ };
330
+ }
289
331
 
290
- /**
291
- * Deliver a completion/failure notice to the parent room as a hidden AI turn,
292
- * falling back to a system message. NEVER throws — the caller has already
293
- * taken the watch, so a lost exception here would silently swallow the result
294
- * with nothing left to retry.
295
- */
296
- async function notifyParent(
297
- watch: SubagentWatch,
298
- prompt: string,
299
- noteKind: "done" | "fail",
300
- ): Promise<void> {
301
- try {
302
- const parent = getTopic(watch.parentTopicId);
303
- if (!parent) return;
304
- const requestId = `subagent-${noteKind}-${watch.childTopicId}`;
305
- // A stable requestId is required for the defer queue: injects without one are
306
- // DROPPED when the parent room is busy (InterSessionQueue.enqueue). One
307
- // settle per child, so the id also dedups accidental double-delivery.
308
- const { triggerTopicAiTurn } = await import("#runtime/turn-runner");
309
- const queryId = parent.agent
310
- ? triggerTopicAiTurn(watch.parentTopicId, watch.userId, prompt, undefined, {
311
- origin: `subagent:${watch.name}`,
312
- requestId,
313
- hideInjectMessage: true,
314
- })
315
- : null;
316
- if (queryId) return;
317
-
318
- // null return = deferred behind a running parent turn (it will drain) — but
319
- // only when the queue actually accepted it. Anything else falls back to a
320
- // plain system message so the result is never lost.
321
- const { interSessionQueue } = await import("#query/active-rooms");
322
- if (parent.agent && interSessionQueue.hasRequest(watch.parentTopicId, requestId)) {
323
- return;
324
- }
325
-
326
- appendParentSystemNote(watch, prompt);
327
- } catch (err) {
328
- logger.warn(
329
- { err, parentTopicId: watch.parentTopicId, childTopicId: watch.childTopicId, noteKind },
330
- "subagent: parent AI notify failed — falling back to system message",
331
- );
332
- try {
333
- appendParentSystemNote(watch, prompt);
334
- } catch (fallbackErr) {
335
- logger.error(
336
- { err: fallbackErr, parentTopicId: watch.parentTopicId, childTopicId: watch.childTopicId },
337
- "subagent: parent delivery lost — system-message fallback also failed",
338
- );
332
+ function recoverPersistedSubagentWatch(queryId: string): SubagentWatch | null {
333
+ for (const message of host.storage.listSubagentMessages()) {
334
+ const card = message.subagentCard;
335
+ if (!card || (card.status !== "spawned" && card.status !== "running")) continue;
336
+ if (host.runtime.childExecutionQueryId(card.subagentTopicId) !== queryId) continue;
337
+ return watchFromPersistedCard(message, card, queryId);
339
338
  }
339
+ return null;
340
340
  }
341
- }
342
341
 
343
- /** Settle a finished child run. NEVER rejects — call sites fire-and-forget. */
344
- export async function settleSubagentSuccess(
345
- watch: SubagentWatch,
346
- finalText: string,
347
- ): Promise<void> {
348
- const summary = finalText.replace(/\s+/g, " ").trim().slice(0, RESULT_SUMMARY_CHARS);
349
- try {
350
- patchSubagentCard(watch.parentTopicId, watch.cardMessageId, {
351
- status: "completed",
352
- resultSummary: summary || "(no text response)",
353
- finishedAt: new Date().toISOString(),
354
- });
355
- } catch (err) {
356
- logger.warn(
357
- { err, parentTopicId: watch.parentTopicId, cardMessageId: watch.cardMessageId },
358
- "subagent: completed-card update failed",
359
- );
360
- }
361
- if (watch.reportMode === "auto") {
362
- await notifyParent(
363
- watch,
364
- `[Subagent completed ← ${watch.name}]\n이 메시지는 spawn_subagent로 위임한 작업의 자동 완료 회신입니다. 결과를 확인하고 필요하면 이어서 진행하세요.\n\n${finalText || "(no text response)"}`,
365
- "done",
366
- );
342
+ function dropWatch(watch: SubagentWatch): void {
343
+ watchesByChild.delete(watch.childTopicId);
344
+ if (watch.queryId) childByQueryId.delete(watch.queryId);
367
345
  }
368
- }
369
346
 
370
- /** Settle a failed child run. NEVER rejects — call sites fire-and-forget. */
371
- export async function settleSubagentFailure(queryId: string, reason: string): Promise<void> {
372
- const watch = takeSubagentWatch(queryId);
373
- if (!watch) return;
374
- try {
347
+ /** Drop deferred/in-flight bookkeeping when a subagent room is hard-deleted. */
348
+ function cancelSubagentWatchForDeletedTopic(childTopicId: string): void {
349
+ const watch = watchesByChild.get(childTopicId);
350
+ if (!watch) return;
351
+ dropWatch(watch);
375
352
  patchSubagentCard(watch.parentTopicId, watch.cardMessageId, {
376
353
  status: "failed",
377
- errorMessage: reason,
378
- finishedAt: new Date().toISOString(),
354
+ errorMessage: "subagent room was deleted",
355
+ finishedAt: host.runtime.now(),
379
356
  });
380
- } catch (err) {
381
- logger.warn(
382
- { err, parentTopicId: watch.parentTopicId, cardMessageId: watch.cardMessageId },
383
- "subagent: failed-card update failed",
384
- );
385
357
  }
386
- if (watch.reportMode === "auto") {
387
- await notifyParent(
388
- watch,
389
- `[Subagent failed ← ${watch.name}]\nspawn_subagent로 위임한 작업이 실패했습니다: ${reason}`,
390
- "fail",
391
- );
358
+
359
+ /** Last-resort delivery: a plain system message in the parent room. */
360
+ function appendParentSystemNote(watch: SubagentWatch, prompt: string): void {
361
+ const now = host.runtime.now();
362
+ const msg: MessageDto = {
363
+ id: `subagent-note-${watch.childTopicId}-${now}`,
364
+ topicId: watch.parentTopicId,
365
+ authorId: "system",
366
+ text: prompt,
367
+ kind: "system",
368
+ createdAt: now,
369
+ };
370
+ host.storage.appendMessage(msg);
371
+ host.storage.publishMessage(watch.parentTopicId, msg);
392
372
  }
393
- }
394
373
 
395
- /**
396
- * Boot-time sweep: watches don't survive a restart, so any card still marked
397
- * spawned/running belongs to a run whose completion can no longer be tracked.
398
- */
399
- export function sweepStaleSubagentCards(): void {
400
- for (const msg of listApiMessagesByKind("subagent")) {
401
- const card = msg.subagentCard;
402
- if (!card || (card.status !== "spawned" && card.status !== "running")) continue;
403
- if (card.runtimeOwnerId && runtimeOwnerIsAlive(card.runtimeOwnerId)) continue;
404
- const queryId = childExecutionQueryId(card.subagentTopicId);
405
- if (childExecutionIsRecoverable(card.subagentTopicId)) {
406
- const watch = watchFromPersistedCard(msg, card, queryId ?? undefined);
407
- if (watch && queryId) registerWatchDispatch(watch, queryId);
408
- continue;
409
- }
410
- if (!card.runtimeOwnerId) continue;
411
- patchSubagentCard(msg.topicId, msg.id, {
412
- status: "failed",
413
- errorMessage: "server restarted while the subagent was running",
414
- finishedAt: new Date().toISOString(),
415
- });
416
- logger.info(
417
- { topicId: msg.topicId, messageId: msg.id, child: card.subagentTopicId },
418
- "subagent: swept stale card on boot",
419
- );
374
+ /**
375
+ * Deliver a completion/failure notice to the parent room as a hidden AI turn,
376
+ * falling back to a system message. NEVER throws — the caller has already
377
+ * taken the watch, so a lost exception here would silently swallow the result
378
+ * with nothing left to retry.
379
+ */
380
+ async function notifyParent(
381
+ watch: SubagentWatch,
382
+ prompt: string,
383
+ noteKind: "done" | "fail",
384
+ ): Promise<void> {
385
+ try {
386
+ const requestId = `subagent-${noteKind}-${watch.childTopicId}`;
387
+ if (!(await host.sessionCommunication.notifyParent({ watch, prompt, requestId }))) {
388
+ appendParentSystemNote(watch, prompt);
389
+ }
390
+ } catch (err) {
391
+ logger.warn(
392
+ { err, parentTopicId: watch.parentTopicId, childTopicId: watch.childTopicId, noteKind },
393
+ "subagent: parent AI notify failed — falling back to system message",
394
+ );
395
+ try {
396
+ appendParentSystemNote(watch, prompt);
397
+ } catch (fallbackErr) {
398
+ logger.error(
399
+ {
400
+ err: fallbackErr,
401
+ parentTopicId: watch.parentTopicId,
402
+ childTopicId: watch.childTopicId,
403
+ },
404
+ "subagent: parent delivery lost — system-message fallback also failed",
405
+ );
406
+ }
407
+ }
420
408
  }
421
- }
422
409
 
423
- function reportModeFromInput(input: { report_mode?: unknown }): SubagentReportMode {
424
- return input.report_mode === "tell" || input.report_mode === "status-only"
425
- ? input.report_mode
426
- : "auto";
427
- }
410
+ /** Settle a finished child run. NEVER rejects — call sites fire-and-forget. */
411
+ async function settleSubagentSuccess(watch: SubagentWatch, finalText: string): Promise<void> {
412
+ const summary = finalText.replace(/\s+/g, " ").trim().slice(0, RESULT_SUMMARY_CHARS);
413
+ try {
414
+ patchSubagentCard(watch.parentTopicId, watch.cardMessageId, {
415
+ status: "completed",
416
+ resultSummary: summary || "(no text response)",
417
+ finishedAt: host.runtime.now(),
418
+ });
419
+ } catch (err) {
420
+ logger.warn(
421
+ { err, parentTopicId: watch.parentTopicId, cardMessageId: watch.cardMessageId },
422
+ "subagent: completed-card update failed",
423
+ );
424
+ }
425
+ if (watch.reportMode === "auto") {
426
+ await notifyParent(
427
+ watch,
428
+ `[Subagent completed ← ${watch.name}]\n이 메시지는 spawn_subagent로 위임한 작업의 자동 완료 회신입니다. 결과를 확인하고 필요하면 이어서 진행하세요.\n\n${finalText || "(no text response)"}`,
429
+ "done",
430
+ );
431
+ }
432
+ }
428
433
 
429
- function findSubagentCardMessage(childTopicId: string): MessageDto | undefined {
430
- return listApiMessagesByKind("subagent")
431
- .filter((message) => message.subagentCard?.subagentTopicId === childTopicId)
432
- .at(-1);
433
- }
434
+ /** Settle a failed child run. NEVER rejects — call sites fire-and-forget. */
435
+ async function settleSubagentFailure(queryId: string, reason: string): Promise<void> {
436
+ const watch = takeSubagentWatch(queryId);
437
+ if (!watch) return;
438
+ try {
439
+ patchSubagentCard(watch.parentTopicId, watch.cardMessageId, {
440
+ status: "failed",
441
+ errorMessage: reason,
442
+ finishedAt: host.runtime.now(),
443
+ });
444
+ } catch (err) {
445
+ logger.warn(
446
+ { err, parentTopicId: watch.parentTopicId, cardMessageId: watch.cardMessageId },
447
+ "subagent: failed-card update failed",
448
+ );
449
+ }
450
+ if (watch.reportMode === "auto") {
451
+ await notifyParent(
452
+ watch,
453
+ `[Subagent failed ← ${watch.name}]\nspawn_subagent로 위임한 작업이 실패했습니다: ${reason}`,
454
+ "fail",
455
+ );
456
+ }
457
+ }
434
458
 
435
- export function subagentReportMode(childTopicId: string): SubagentReportMode {
436
- return (
437
- getTopic(childTopicId)?.subagentReportMode ??
438
- findSubagentCardMessage(childTopicId)?.subagentCard?.reportMode ??
439
- "auto"
440
- );
441
- }
459
+ /**
460
+ * Boot-time sweep: watches don't survive a restart, so any card still marked
461
+ * spawned/running belongs to a run whose completion can no longer be tracked.
462
+ */
463
+ function sweepStaleSubagentCards(): void {
464
+ for (const msg of host.storage.listSubagentMessages()) {
465
+ const card = msg.subagentCard;
466
+ if (!card || (card.status !== "spawned" && card.status !== "running")) continue;
467
+ if (card.runtimeOwnerId && host.runtime.ownerIsAlive(card.runtimeOwnerId)) continue;
468
+ const queryId = host.runtime.childExecutionQueryId(card.subagentTopicId);
469
+ if (host.runtime.childExecutionIsRecoverable(card.subagentTopicId)) {
470
+ const watch = watchFromPersistedCard(msg, card, queryId ?? undefined);
471
+ if (watch && queryId) registerWatchDispatch(watch, queryId);
472
+ continue;
473
+ }
474
+ if (!card.runtimeOwnerId) continue;
475
+ patchSubagentCard(msg.topicId, msg.id, {
476
+ status: "failed",
477
+ errorMessage: "server restarted while the subagent was running",
478
+ finishedAt: host.runtime.now(),
479
+ });
480
+ logger.info(
481
+ { topicId: msg.topicId, messageId: msg.id, child: card.subagentTopicId },
482
+ "subagent: swept stale card on boot",
483
+ );
484
+ }
485
+ }
442
486
 
443
- async function startSubagentCard(
444
- ctx: SubagentToolContext,
445
- cardMessage: MessageDto,
446
- ): Promise<ReturnType<typeof textResult>> {
447
- const card = cardMessage.subagentCard;
448
- if (!card) return errorResult("Error: subagent card is missing.");
449
- if (card.status !== "ready") {
450
- return errorResult(`Error: subagent cannot start from status '${card.status}'.`);
487
+ function reportModeFromInput(input: { report_mode?: unknown }): SubagentReportMode {
488
+ return input.report_mode === "tell" || input.report_mode === "status-only"
489
+ ? input.report_mode
490
+ : "auto";
451
491
  }
452
- const child = getTopic(card.subagentTopicId);
453
- const parent = getTopic(cardMessage.topicId);
454
- if (!child || !parent)
455
- return errorResult("Error: subagent room or its direct parent is missing.");
456
- if (countLiveChildren(cardMessage.topicId) >= MAX_LIVE_CHILDREN_PER_PARENT) {
457
- return errorResult(
458
- `Error: this room already has ${MAX_LIVE_CHILDREN_PER_PARENT} subagents running.`,
492
+
493
+ function findSubagentCardMessage(childTopicId: string): MessageDto | undefined {
494
+ return host.storage
495
+ .listSubagentMessages()
496
+ .filter((message) => message.subagentCard?.subagentTopicId === childTopicId)
497
+ .at(-1);
498
+ }
499
+
500
+ function subagentReportMode(childTopicId: string): SubagentReportMode {
501
+ return (
502
+ host.storage.getTopic(childTopicId)?.subagentReportMode ??
503
+ findSubagentCardMessage(childTopicId)?.subagentCard?.reportMode ??
504
+ "auto"
459
505
  );
460
506
  }
461
507
 
462
- const startedAt = new Date().toISOString();
463
- const watch: SubagentWatch = {
464
- parentTopicId: cardMessage.topicId,
465
- childTopicId: child.id,
466
- cardMessageId: cardMessage.id,
467
- name: child.title,
468
- userId: ctx.userId,
469
- startedAt,
470
- reportMode: card.reportMode ?? "auto",
471
- running: false,
472
- };
473
- watchesByChild.set(child.id, watch);
474
- patchSubagentCard(cardMessage.topicId, cardMessage.id, {
475
- runtimeOwnerId: RUNTIME_INSTANCE_ID,
476
- status: "spawned",
477
- startedAt,
478
- finishedAt: undefined,
479
- errorMessage: undefined,
480
- resultSummary: undefined,
481
- });
508
+ async function startSubagentCard(
509
+ ctx: SubagentToolContext,
510
+ cardMessage: MessageDto,
511
+ ): Promise<ReturnType<typeof textResult>> {
512
+ const card = cardMessage.subagentCard;
513
+ if (!card) return errorResult("Error: subagent card is missing.");
514
+ if (card.status !== "ready") {
515
+ return errorResult(`Error: subagent cannot start from status '${card.status}'.`);
516
+ }
517
+ const child = host.storage.getTopic(card.subagentTopicId);
518
+ const parent = host.storage.getTopic(cardMessage.topicId);
519
+ if (!child || !parent)
520
+ return errorResult("Error: subagent room or its direct parent is missing.");
521
+ if (countLiveChildren(cardMessage.topicId) >= limits.maxLiveChildrenPerParent) {
522
+ return errorResult(
523
+ `Error: this room already has ${limits.maxLiveChildrenPerParent} subagents running.`,
524
+ );
525
+ }
482
526
 
483
- const reportInstruction =
484
- watch.reportMode === "tell"
485
- ? `\n\nReport your result to "${parent.title}" with tell_session; your final response will not be forwarded automatically.`
486
- : watch.reportMode === "status-only"
487
- ? "\n\nDo not send a completion report to the parent; only the lifecycle card will be updated."
488
- : "";
489
- const childPrompt = `[Delegated task from ${parent.title}]\n\n${card.task}${reportInstruction}`;
490
- const { triggerTopicAiTurn } = await import("#runtime/turn-runner");
491
- const { getRoomQuery } = await import("#query/active-rooms");
492
- const childQueryId = triggerTopicAiTurn(child.id, ctx.userId, childPrompt, undefined, {
493
- origin: `subagent-task:${parent.title}`,
494
- requestId: `subagent-task-${child.id}-${startedAt}`,
495
- injectAuthorId: "ai",
496
- onDispatched: (qid: string) => registerWatchDispatch(watch, qid),
497
- });
498
- const locallyQueued = Boolean(getRoomQuery(child.id));
499
- if (!childQueryId && !watch.queryId && !locallyQueued) {
500
- dropWatch(watch);
527
+ const startedAt = host.runtime.now();
528
+ const watch: SubagentWatch = {
529
+ parentTopicId: cardMessage.topicId,
530
+ childTopicId: child.id,
531
+ cardMessageId: cardMessage.id,
532
+ name: child.title,
533
+ userId: ctx.userId,
534
+ startedAt,
535
+ reportMode: card.reportMode ?? "auto",
536
+ running: false,
537
+ };
538
+ watchesByChild.set(child.id, watch);
501
539
  patchSubagentCard(cardMessage.topicId, cardMessage.id, {
502
- status: "failed",
503
- errorMessage: "the subagent turn could not be dispatched",
504
- finishedAt: new Date().toISOString(),
540
+ runtimeOwnerId,
541
+ status: "spawned",
542
+ startedAt,
543
+ finishedAt: undefined,
544
+ errorMessage: undefined,
545
+ resultSummary: undefined,
505
546
  });
506
- return errorResult("Error: subagent room exists but its AI turn could not start.");
507
- }
508
- return textResult(`Subagent started: ${child.title} (${child.id})`);
509
- }
510
547
 
511
- async function createSubagent(
512
- ctx: SpawnSubagentToolContext,
513
- input: {
514
- task?: unknown;
515
- name?: unknown;
516
- agent?: unknown;
517
- model?: unknown;
518
- report_mode?: unknown;
519
- memory_topic?: unknown;
520
- },
521
- startImmediately: boolean,
522
- ) {
523
- const parent = getTopic(ctx.topicId);
524
- if (!parent) return errorResult(`Error: topic '${ctx.topicId}' not found.`);
525
- if (!parent.participants.some((p) => p.userId === ctx.userId)) {
526
- return errorResult("Error: user is not a member of this topic.");
527
- }
528
- if (parent.kind !== "agent") {
529
- return errorResult("Error: spawn_subagent is only available in agent rooms.");
530
- }
531
- const depth = computeSubagentDepth(ctx.topicId);
532
- if (depth >= MAX_SUBAGENT_DEPTH) {
533
- return errorResult(
534
- `Error: subagent depth limit reached (current ${depth}, max ${MAX_SUBAGENT_DEPTH}).`,
535
- );
548
+ const reportInstruction =
549
+ watch.reportMode === "tell"
550
+ ? `\n\nReport your result to "${parent.title}" with tell_session; your final response will not be forwarded automatically.`
551
+ : watch.reportMode === "status-only"
552
+ ? "\n\nDo not send a completion report to the parent; only the lifecycle card will be updated."
553
+ : "";
554
+ const childPrompt = `[Delegated task from ${parent.title}]\n\n${card.task}${reportInstruction}`;
555
+ const dispatch = await host.task.dispatch({
556
+ child,
557
+ parent,
558
+ userId: ctx.userId,
559
+ prompt: childPrompt,
560
+ requestId: `subagent-task-${child.id}-${startedAt}`,
561
+ onDispatched: (queryId) => registerWatchDispatch(watch, queryId),
562
+ });
563
+ if (!dispatch.accepted && !watch.queryId) {
564
+ dropWatch(watch);
565
+ patchSubagentCard(cardMessage.topicId, cardMessage.id, {
566
+ status: "failed",
567
+ errorMessage: dispatch.error ?? "the subagent turn could not be dispatched",
568
+ finishedAt: host.runtime.now(),
569
+ });
570
+ return errorResult(
571
+ dispatch.error
572
+ ? `Error: ${dispatch.error}`
573
+ : "Error: subagent room exists but its AI turn could not start.",
574
+ );
575
+ }
576
+ return textResult(`Subagent started: ${child.title} (${child.id})`);
536
577
  }
537
578
 
538
- const task = typeof input.task === "string" ? input.task.trim() : "";
539
- if (!task) return errorResult("Error: task is required.");
540
- if (task.length > MAX_TASK_CHARS) {
541
- return errorResult(`Error: task is too long (max ${MAX_TASK_CHARS} chars).`);
542
- }
543
- const name =
544
- typeof input.name === "string"
545
- ? input.name
546
- .replace(/[\n\t]/g, " ")
547
- .trim()
548
- .slice(0, MAX_NAME_CHARS)
549
- : undefined;
550
- const agentOverride = isAgentKind(input.agent) ? input.agent : undefined;
551
- const modelOverride =
552
- typeof input.model === "string" && input.model.trim() ? input.model.trim() : undefined;
553
- const reportMode = reportModeFromInput(input);
554
- const memorySelection = resolveMemoryTopicSelection(input.memory_topic, ctx.userId);
555
- if (memorySelection.error) return errorResult(memorySelection.error);
556
-
557
- if (startImmediately && countLiveChildren(ctx.topicId) >= MAX_LIVE_CHILDREN_PER_PARENT) {
558
- return errorResult(
559
- `Error: this room already has ${MAX_LIVE_CHILDREN_PER_PARENT} subagents running. Wait for one to finish before spawning another.`,
560
- );
561
- }
562
- if (!startImmediately) {
563
- // Prepared-but-unstarted rooms consume real topics/workspaces; cap them so
564
- // a create_subagent loop cannot allocate unbounded resources.
565
- const preparedChildren = listTopics().filter(
566
- (topic) =>
567
- topic.parentTopicId === ctx.topicId &&
568
- topic.isSubagent &&
569
- subagentStatus(ctx.topicId, topic.id) === "ready",
570
- ).length;
571
- if (preparedChildren >= MAX_PREPARED_CHILDREN_PER_PARENT) {
579
+ async function createSubagent(
580
+ ctx: TContext,
581
+ input: {
582
+ task?: unknown;
583
+ name?: unknown;
584
+ agent?: unknown;
585
+ model?: unknown;
586
+ report_mode?: unknown;
587
+ memory_topic?: unknown;
588
+ },
589
+ startImmediately: boolean,
590
+ ) {
591
+ const parent = host.storage.getTopic(ctx.topicId);
592
+ if (!parent) return errorResult(`Error: topic '${ctx.topicId}' not found.`);
593
+ if (!parent.participants.some((p) => p.userId === ctx.userId)) {
594
+ return errorResult("Error: user is not a member of this topic.");
595
+ }
596
+ if (parent.kind !== "agent") {
597
+ return errorResult("Error: spawn_subagent is only available in agent rooms.");
598
+ }
599
+ const depth = computeSubagentDepth(ctx.topicId);
600
+ if (depth >= limits.maxDepth) {
572
601
  return errorResult(
573
- `Error: this room already has ${MAX_PREPARED_CHILDREN_PER_PARENT} prepared subagents. Start or delete one before creating another.`,
602
+ `Error: subagent depth limit reached (current ${depth}, max ${limits.maxDepth}).`,
574
603
  );
575
604
  }
576
- }
577
605
 
578
- // Preflight the overrides before creating anything — a bad agent/model would
579
- // otherwise leave a dead child room with a card that fails on first turn.
580
- const targetAgent = agentOverride ?? ctx.agent;
581
- let resolvedModelOverride = modelOverride;
582
- if (modelOverride) {
583
- const { resolveModelForAgent } = await import("#agents/model-catalog");
584
- const { getRegistry } = await import("#agents/registry");
585
- const registry = getRegistry(targetAgent);
586
- if (!registry.validateModel(modelOverride)) {
606
+ const task = typeof input.task === "string" ? input.task.trim() : "";
607
+ if (!task) return errorResult("Error: task is required.");
608
+ if (task.length > MAX_TASK_CHARS) {
609
+ return errorResult(`Error: task is too long (max ${MAX_TASK_CHARS} chars).`);
610
+ }
611
+ const name =
612
+ typeof input.name === "string"
613
+ ? input.name
614
+ .replace(/[\n\t]/g, " ")
615
+ .trim()
616
+ .slice(0, MAX_NAME_CHARS)
617
+ : undefined;
618
+ const agentOverride = isAgentKind(input.agent) ? input.agent : undefined;
619
+ const modelOverride =
620
+ typeof input.model === "string" && input.model.trim() ? input.model.trim() : undefined;
621
+ const reportMode = reportModeFromInput(input);
622
+ const memorySelection = resolveMemoryTopicSelection(input.memory_topic, ctx.userId);
623
+ if (memorySelection.error) return errorResult(memorySelection.error);
624
+
625
+ if (startImmediately && countLiveChildren(ctx.topicId) >= limits.maxLiveChildrenPerParent) {
587
626
  return errorResult(
588
- `Error: model '${modelOverride}' is not valid for agent '${targetAgent}'.`,
627
+ `Error: this room already has ${limits.maxLiveChildrenPerParent} subagents running. Wait for one to finish before spawning another.`,
589
628
  );
590
629
  }
591
- resolvedModelOverride = resolveModelForAgent(targetAgent, modelOverride, registry);
592
- }
593
- if (agentOverride || modelOverride) {
594
- const { checkAgentModelAuth } = await import("#agents/auth-check");
595
- const { getRegistry } = await import("#agents/registry");
596
- const targetModel = resolvedModelOverride ?? getRegistry(targetAgent).defaultModel;
597
- const auth = checkAgentModelAuth(targetAgent, targetModel, undefined, ctx.userId);
598
- if (!auth.ok) {
599
- return errorResult(`Error: agent '${targetAgent}' is not available: ${auth.error}`);
630
+ if (!startImmediately) {
631
+ // Prepared-but-unstarted rooms consume real topics/workspaces; cap them so
632
+ // a create_subagent loop cannot allocate unbounded resources.
633
+ const preparedChildren = host.storage
634
+ .listTopics()
635
+ .filter(
636
+ (topic) =>
637
+ topic.parentTopicId === ctx.topicId &&
638
+ topic.isSubagent &&
639
+ subagentStatus(ctx.topicId, topic.id) === "ready",
640
+ ).length;
641
+ if (preparedChildren >= limits.maxPreparedChildrenPerParent) {
642
+ return errorResult(
643
+ `Error: this room already has ${limits.maxPreparedChildrenPerParent} prepared subagents. Start or delete one before creating another.`,
644
+ );
645
+ }
600
646
  }
601
- }
602
647
 
603
- const { createDerivedTopic, TopicDeriveBusyError, TopicTitleConflictError } = await import(
604
- "#topics/derive"
605
- );
606
- let child: Awaited<ReturnType<typeof createDerivedTopic>>;
607
- try {
608
- child = await createDerivedTopic(ctx.topicId, ctx.userId, false, {
648
+ // Preflight the overrides before creating anything — a bad agent/model would
649
+ // otherwise leave a dead child room with a card that fails on first turn.
650
+ const targetAgent = agentOverride ?? ctx.agent;
651
+ const resolved = await host.config.resolveAgentModel({
652
+ context: ctx,
653
+ agent: targetAgent,
654
+ model: modelOverride,
655
+ hasOverride: Boolean(agentOverride || modelOverride),
656
+ });
657
+ if (resolved.error) return errorResult(resolved.error);
658
+ const resolvedModelOverride = resolved.model;
659
+
660
+ const derived = await host.topic.createDerived({
661
+ context: ctx,
662
+ parentTopicId: ctx.topicId,
663
+ userId: ctx.userId,
609
664
  name,
610
- subagent: {
611
- agent: agentOverride,
612
- model: resolvedModelOverride,
613
- memoryTopicId: memorySelection.topicId,
614
- },
665
+ agent: agentOverride,
666
+ model: resolvedModelOverride,
667
+ memoryTopicId: memorySelection.topicId,
615
668
  });
616
- } catch (e) {
617
- if (e instanceof TopicTitleConflictError) {
618
- return errorResult(`Error: ${e.message}. Try a different name.`);
619
- }
620
- if (e instanceof TopicDeriveBusyError) {
669
+ if (!derived.ok) {
670
+ if (derived.reason === "title-conflict") {
671
+ return errorResult(
672
+ `Error: ${derived.message ?? "topic title conflict"}. Try a different name.`,
673
+ );
674
+ }
621
675
  return errorResult(
622
676
  "Error: the source topic is busy (active turn or maintenance). Retry shortly.",
623
677
  );
624
678
  }
625
- throw e;
626
- }
627
- if (!child) {
628
- return errorResult(
629
- "Error: failed to create the subagent room (restricted or missing source topic).",
630
- );
631
- }
632
- child.subagentReportMode = reportMode;
633
- upsertTopic(child);
634
-
635
- const now = new Date().toISOString();
636
- const card: SubagentCardDto = {
637
- subagentTopicId: child.id,
638
- name: child.title,
639
- task,
640
- status: "ready",
641
- reportMode,
642
- createdAt: now,
643
- };
644
- const cardMsg: MessageDto = {
645
- id: `subagent-${child.id}`,
646
- topicId: ctx.topicId,
647
- authorId: "ai",
648
- text: `🤖 Subagent "${child.title}" spawned`,
649
- queryId: ctx.queryId,
650
- agentType: ctx.agent,
651
- model: ctx.model,
652
- kind: "subagent",
653
- subagentCard: card,
654
- createdAt: now,
655
- };
656
- appendApiMessage(cardMsg, { notify: false });
657
- WsHub.get().broadcastMessage(ctx.topicId, cardMsg);
679
+ const child = derived.topic;
680
+ if (!child) {
681
+ return errorResult(
682
+ "Error: failed to create the subagent room (restricted or missing source topic).",
683
+ );
684
+ }
685
+ child.subagentReportMode = reportMode;
686
+ host.storage.upsertTopic(child);
687
+
688
+ const now = host.runtime.now();
689
+ const card: SubagentCardDto = {
690
+ subagentTopicId: child.id,
691
+ name: child.title,
692
+ task,
693
+ status: "ready",
694
+ reportMode,
695
+ createdAt: now,
696
+ };
697
+ const cardMsg: MessageDto = {
698
+ id: `subagent-${child.id}`,
699
+ topicId: ctx.topicId,
700
+ authorId: "ai",
701
+ text: `🤖 Subagent "${child.title}" spawned`,
702
+ queryId: ctx.queryId,
703
+ agentType: ctx.agent,
704
+ model: ctx.model,
705
+ kind: "subagent",
706
+ subagentCard: card,
707
+ createdAt: now,
708
+ };
709
+ host.storage.appendMessage(cardMsg, { notify: false });
710
+ host.storage.publishMessage(ctx.topicId, cardMsg);
658
711
 
659
- if (!startImmediately) {
712
+ if (!startImmediately) {
713
+ return textResult(
714
+ `Subagent prepared: ${child.title} (${child.id}). Use start_subagent to run it.`,
715
+ );
716
+ }
717
+ const started = await startSubagentCard(ctx, cardMsg);
718
+ if (started.isError) return started;
719
+
720
+ logger.info(
721
+ { parentTopicId: ctx.topicId, childTopicId: child.id, name: child.title },
722
+ "subagent: spawned",
723
+ );
660
724
  return textResult(
661
- `Subagent prepared: ${child.title} (${child.id}). Use start_subagent to run it.`,
725
+ [
726
+ `Subagent "${child.title}" spawned (room id: ${child.id}) and is now working in the background.`,
727
+ reportMode === "auto"
728
+ ? "Its final result will be delivered back into this room automatically when it finishes."
729
+ : reportMode === "tell"
730
+ ? "It must report with tell_session; its final response is not forwarded automatically."
731
+ : "Only lifecycle status will be reported.",
732
+ "Do NOT wait or poll — finish your current turn normally.",
733
+ ].join("\n"),
662
734
  );
663
735
  }
664
- const started = await startSubagentCard(ctx, cardMsg);
665
- if (started.isError) return started;
666
-
667
- logger.info(
668
- { parentTopicId: ctx.topicId, childTopicId: child.id, name: child.title },
669
- "subagent: spawned",
670
- );
671
- return textResult(
672
- [
673
- `Subagent "${child.title}" spawned (room id: ${child.id}) and is now working in the background.`,
674
- reportMode === "auto"
675
- ? "Its final result will be delivered back into this room automatically when it finishes."
676
- : reportMode === "tell"
677
- ? "It must report with tell_session; its final response is not forwarded automatically."
678
- : "Only lifecycle status will be reported.",
679
- "Do NOT wait or poll — finish your current turn normally.",
680
- ].join("\n"),
681
- );
682
- }
683
736
 
684
- function ownedSubagentTree(ctx: SubagentToolContext) {
685
- const parent = getTopic(ctx.topicId);
686
- if (!parent) return { ok: false, error: `Error: topic '${ctx.topicId}' not found.` } as const;
687
- if (!parent.participants.some((participant) => participant.userId === ctx.userId)) {
688
- return { ok: false, error: "Error: user is not a member of this topic." } as const;
689
- }
690
- if (parent.kind !== "agent") {
737
+ function ownedSubagentTree(ctx: SubagentToolContext) {
738
+ const parent = host.storage.getTopic(ctx.topicId);
739
+ if (!parent) return { ok: false, error: `Error: topic '${ctx.topicId}' not found.` } as const;
740
+ if (!parent.participants.some((participant) => participant.userId === ctx.userId)) {
741
+ return { ok: false, error: "Error: user is not a member of this topic." } as const;
742
+ }
743
+ if (parent.kind !== "agent") {
744
+ return {
745
+ ok: false,
746
+ error: "Error: subagent management is only available in agent rooms.",
747
+ } as const;
748
+ }
691
749
  return {
692
- ok: false,
693
- error: "Error: subagent management is only available in agent rooms.",
750
+ ok: true,
751
+ parent,
752
+ children: (() => {
753
+ const topics = host.storage.listTopics();
754
+ const byParent = new Map<string, typeof topics>();
755
+ for (const topic of topics) {
756
+ if (!topic.parentTopicId || !topic.isSubagent) continue;
757
+ const siblings = byParent.get(topic.parentTopicId) ?? [];
758
+ siblings.push(topic);
759
+ byParent.set(topic.parentTopicId, siblings);
760
+ }
761
+ const descendants: typeof topics = [];
762
+ const visited = new Set<string>([ctx.topicId]);
763
+ const visit = (parentId: string) => {
764
+ for (const child of byParent.get(parentId) ?? []) {
765
+ if (visited.has(child.id)) continue;
766
+ visited.add(child.id);
767
+ if (
768
+ !child.participants.some(
769
+ (participant) => participant.userId === ctx.userId && participant.role === "owner",
770
+ )
771
+ ) {
772
+ continue;
773
+ }
774
+ descendants.push(child);
775
+ visit(child.id);
776
+ }
777
+ };
778
+ visit(ctx.topicId);
779
+ return descendants;
780
+ })(),
694
781
  } as const;
695
782
  }
696
- return {
697
- ok: true,
698
- parent,
699
- children: (() => {
700
- const topics = listTopics();
701
- const byParent = new Map<string, typeof topics>();
702
- for (const topic of topics) {
703
- if (!topic.parentTopicId || !topic.isSubagent) continue;
704
- const siblings = byParent.get(topic.parentTopicId) ?? [];
705
- siblings.push(topic);
706
- byParent.set(topic.parentTopicId, siblings);
707
- }
708
- const descendants: typeof topics = [];
709
- const visited = new Set<string>([ctx.topicId]);
710
- const visit = (parentId: string) => {
711
- for (const child of byParent.get(parentId) ?? []) {
712
- if (visited.has(child.id)) continue;
713
- visited.add(child.id);
714
- if (
715
- !child.participants.some(
716
- (participant) => participant.userId === ctx.userId && participant.role === "owner",
717
- )
718
- ) {
719
- continue;
720
- }
721
- descendants.push(child);
722
- visit(child.id);
723
- }
724
- };
725
- visit(ctx.topicId);
726
- return descendants;
727
- })(),
728
- } as const;
729
- }
730
783
 
731
- function subagentStatus(
732
- parentTopicId: string,
733
- childTopicId: string,
734
- ): SubagentCardDto["status"] | "unknown" {
735
- const watch = watchesByChild.get(childTopicId);
736
- if (watch?.running) return "running";
737
- const card = listApiMessagesByKind("subagent")
738
- .filter(
739
- (message) =>
740
- message.topicId === parentTopicId && message.subagentCard?.subagentTopicId === childTopicId,
741
- )
742
- .at(-1)?.subagentCard;
743
- return card?.status ?? "unknown";
744
- }
745
-
746
- function listMemoryTopicNames(ctx: SubagentToolContext): { names?: string[]; error?: string } {
747
- const current = getTopic(ctx.topicId);
748
- if (!current) return { error: `Error: topic '${ctx.topicId}' not found.` };
749
- if (!current.participants.some((participant) => participant.userId === ctx.userId)) {
750
- return { error: "Error: user is not a member of this topic." };
784
+ function subagentStatus(
785
+ parentTopicId: string,
786
+ childTopicId: string,
787
+ ): SubagentCardDto["status"] | "unknown" {
788
+ const watch = watchesByChild.get(childTopicId);
789
+ if (watch?.running) return "running";
790
+ const card = host.storage
791
+ .listSubagentMessages()
792
+ .filter(
793
+ (message) =>
794
+ message.topicId === parentTopicId &&
795
+ message.subagentCard?.subagentTopicId === childTopicId,
796
+ )
797
+ .at(-1)?.subagentCard;
798
+ return card?.status ?? "unknown";
751
799
  }
752
800
 
753
- const sources = new Map<string, string>();
754
- for (const topic of listTopics()) {
755
- if (!topic.participants.some((participant) => participant.userId === ctx.userId)) continue;
756
- const origin = getTopicMemoryOrigin(topic.id) ?? topic;
757
- if (!origin.participants.some((participant) => participant.userId === ctx.userId)) continue;
758
- sources.set(origin.id, origin.title);
801
+ function listMemoryTopicNames(ctx: SubagentToolContext): { names?: string[]; error?: string } {
802
+ const current = host.storage.getTopic(ctx.topicId);
803
+ if (!current) return { error: `Error: topic '${ctx.topicId}' not found.` };
804
+ if (!current.participants.some((participant) => participant.userId === ctx.userId)) {
805
+ return { error: "Error: user is not a member of this topic." };
806
+ }
807
+
808
+ const sources = new Map<string, string>();
809
+ for (const topic of host.storage.listTopics()) {
810
+ if (!topic.participants.some((participant) => participant.userId === ctx.userId)) continue;
811
+ const origin = host.storage.getTopicMemoryOrigin(topic.id) ?? topic;
812
+ if (!origin.participants.some((participant) => participant.userId === ctx.userId)) continue;
813
+ sources.set(origin.id, origin.title);
814
+ }
815
+ return {
816
+ names: [...sources.values()].sort((left, right) =>
817
+ left.localeCompare(right, undefined, { sensitivity: "base" }),
818
+ ),
819
+ };
759
820
  }
760
- return {
761
- names: [...sources.values()].sort((left, right) =>
762
- left.localeCompare(right, undefined, { sensitivity: "base" }),
763
- ),
764
- };
765
- }
766
821
 
767
- export function createSubagentManagementToolDefinitions(ctx: SubagentToolContext): SharedMcpTool[] {
768
- return [
769
- {
770
- name: "list_memory_topics",
771
- description:
772
- "List the names of accessible topic-memory sources. Pass one returned name as memory_topic when creating or spawning a subagent.",
773
- schema: {},
774
- async handler() {
775
- const result = listMemoryTopicNames(ctx);
776
- if (result.error) return errorResult(result.error);
777
- return textResult(result.names?.length ? result.names.join("\n") : "(no memory topics)");
822
+ function createSubagentManagementToolDefinitions(ctx: SubagentToolContext): SharedMcpTool[] {
823
+ return [
824
+ {
825
+ name: "list_memory_topics",
826
+ description:
827
+ "List the names of accessible topic-memory sources. Pass one returned name as memory_topic when creating or spawning a subagent.",
828
+ schema: {},
829
+ async handler() {
830
+ const result = listMemoryTopicNames(ctx);
831
+ if (result.error) return errorResult(result.error);
832
+ return textResult(result.names?.length ? result.names.join("\n") : "(no memory topics)");
833
+ },
778
834
  },
779
- },
780
- {
781
- name: "list_subagents",
782
- description:
783
- "List the full descendant subagent tree, statuses, and tell_session connections managed by this room.",
784
- schema: {},
785
- async handler() {
786
- const result = ownedSubagentTree(ctx);
787
- if (!result.ok) return errorResult(result.error);
788
- const children = result.children.map((child) => ({
789
- topic_id: child.id,
790
- name: child.title,
791
- parent_topic_id: child.parentTopicId,
792
- status: subagentStatus(child.parentTopicId ?? ctx.topicId, child.id),
793
- agent: child.agent ?? null,
794
- model: child.defaultModel ?? null,
795
- tell_target_topic_ids: listSubagentTellTargetIds(child.id),
796
- created_at: child.createdAt,
797
- }));
798
- return textResult(JSON.stringify({ subagents: children }, null, 2));
835
+ {
836
+ name: "list_subagents",
837
+ description:
838
+ "List the full descendant subagent tree, statuses, and tell_session connections managed by this room.",
839
+ schema: {},
840
+ async handler() {
841
+ const result = ownedSubagentTree(ctx);
842
+ if (!result.ok) return errorResult(result.error);
843
+ const children = result.children.map((child) => ({
844
+ topic_id: child.id,
845
+ name: child.title,
846
+ parent_topic_id: child.parentTopicId,
847
+ status: subagentStatus(child.parentTopicId ?? ctx.topicId, child.id),
848
+ agent: child.agent ?? null,
849
+ model: child.defaultModel ?? null,
850
+ tell_target_topic_ids: host.sessionCommunication.listTellTargetIds(child.id),
851
+ created_at: child.createdAt,
852
+ }));
853
+ return textResult(JSON.stringify({ subagents: children }, null, 2));
854
+ },
799
855
  },
800
- },
801
- {
802
- name: "start_subagent",
803
- description: "Start a prepared descendant subagent task.",
804
- schema: {
805
- topic_id: z.string().describe("Prepared subagent room id."),
856
+ {
857
+ name: "start_subagent",
858
+ description: "Start a prepared descendant subagent task.",
859
+ schema: {
860
+ topic_id: z.string().describe("Prepared subagent room id."),
861
+ },
862
+ async handler(input) {
863
+ const result = ownedSubagentTree(ctx);
864
+ if (!result.ok) return errorResult(result.error);
865
+ const topicId = String(input.topic_id ?? "").trim();
866
+ if (!result.children.some((child) => child.id === topicId)) {
867
+ return errorResult("Error: topic is not a descendant subagent managed by this room.");
868
+ }
869
+ const cardMessage = findSubagentCardMessage(topicId);
870
+ if (!cardMessage)
871
+ return errorResult("Error: no lifecycle card exists for this subagent.");
872
+ return startSubagentCard(ctx, cardMessage);
873
+ },
806
874
  },
807
- async handler(input) {
808
- const result = ownedSubagentTree(ctx);
809
- if (!result.ok) return errorResult(result.error);
810
- const topicId = String(input.topic_id ?? "").trim();
811
- if (!result.children.some((child) => child.id === topicId)) {
812
- return errorResult("Error: topic is not a descendant subagent managed by this room.");
813
- }
814
- const cardMessage = findSubagentCardMessage(topicId);
815
- if (!cardMessage) return errorResult("Error: no lifecycle card exists for this subagent.");
816
- return startSubagentCard(ctx, cardMessage);
875
+ {
876
+ name: "delete_subagent",
877
+ description:
878
+ "Permanently delete one descendant subagent room managed by the current room. " +
879
+ "This removes its conversation, workspace, runtime state, and topic. Keep it when follow-up work is likely.",
880
+ schema: {
881
+ topic_id: z
882
+ .string()
883
+ .describe("Exact subagent room id returned by list_subagents; names are not accepted."),
884
+ },
885
+ async handler(input) {
886
+ const result = ownedSubagentTree(ctx);
887
+ if (!result.ok) return errorResult(result.error);
888
+ const topicId = typeof input.topic_id === "string" ? input.topic_id.trim() : "";
889
+ const child = result.children.find((candidate) => candidate.id === topicId);
890
+ if (!child) {
891
+ return errorResult(
892
+ "Error: no owned descendant subagent with that topic_id exists under this room.",
893
+ );
894
+ }
895
+ try {
896
+ await host.topic.delete(child, ctx.userId);
897
+ return textResult(`Subagent deleted: ${child.title} (${child.id})`);
898
+ } catch (err) {
899
+ const message = err instanceof Error ? err.message : String(err);
900
+ return errorResult(`Error: failed to delete subagent '${child.title}': ${message}`);
901
+ }
902
+ },
817
903
  },
818
- },
819
- {
820
- name: "delete_subagent",
821
- description:
822
- "Permanently delete one descendant subagent room managed by the current room. " +
823
- "This removes its conversation, workspace, runtime state, and topic. Keep it when follow-up work is likely.",
824
- schema: {
825
- topic_id: z
826
- .string()
827
- .describe("Exact subagent room id returned by list_subagents; names are not accepted."),
904
+ {
905
+ name: "grant_subagent_tell",
906
+ description:
907
+ "Allow one descendant subagent to tell_session another topic in this managed subagent tree. " +
908
+ "Direct-parent reporting is always allowed and does not need a grant.",
909
+ schema: {
910
+ subagent_topic_id: z.string().describe("Source descendant subagent topic id."),
911
+ target_topic_id: z
912
+ .string()
913
+ .describe("Target topic id: this manager room or another descendant in its tree."),
914
+ },
915
+ async handler(input) {
916
+ const result = ownedSubagentTree(ctx);
917
+ if (!result.ok) return errorResult(result.error);
918
+ const sourceId = String(input.subagent_topic_id ?? "").trim();
919
+ const targetId = String(input.target_topic_id ?? "").trim();
920
+ const descendantIds = new Set(result.children.map((child) => child.id));
921
+ if (!descendantIds.has(sourceId)) {
922
+ return errorResult("Error: source is not a descendant subagent managed by this room.");
923
+ }
924
+ if (targetId !== ctx.topicId && !descendantIds.has(targetId)) {
925
+ return errorResult("Error: target is outside this room's managed subagent tree.");
926
+ }
927
+ if (host.storage.getTopic(sourceId)?.parentTopicId === targetId) {
928
+ return textResult(
929
+ "No grant needed: a subagent can always report to its direct parent.",
930
+ );
931
+ }
932
+ host.sessionCommunication.grantTellTarget(sourceId, targetId, ctx.topicId);
933
+ return textResult(`tell_session grant added: ${sourceId} -> ${targetId}`);
934
+ },
828
935
  },
829
- async handler(input) {
830
- const result = ownedSubagentTree(ctx);
831
- if (!result.ok) return errorResult(result.error);
832
- const topicId = typeof input.topic_id === "string" ? input.topic_id.trim() : "";
833
- const child = result.children.find((candidate) => candidate.id === topicId);
834
- if (!child) {
835
- return errorResult(
836
- "Error: no owned descendant subagent with that topic_id exists under this room.",
837
- );
838
- }
839
- try {
840
- const { deleteTopicCascade } = await import("#topics/lifecycle");
841
- await deleteTopicCascade(child, ctx.userId);
842
- return textResult(`Subagent deleted: ${child.title} (${child.id})`);
843
- } catch (err) {
844
- const message = err instanceof Error ? err.message : String(err);
845
- return errorResult(`Error: failed to delete subagent '${child.title}': ${message}`);
846
- }
936
+ {
937
+ name: "revoke_subagent_tell",
938
+ description:
939
+ "Remove an extra tell_session connection previously granted to a descendant subagent.",
940
+ schema: {
941
+ subagent_topic_id: z.string().describe("Source descendant subagent topic id."),
942
+ target_topic_id: z.string().describe("Granted target topic id."),
943
+ },
944
+ async handler(input) {
945
+ const result = ownedSubagentTree(ctx);
946
+ if (!result.ok) return errorResult(result.error);
947
+ const sourceId = String(input.subagent_topic_id ?? "").trim();
948
+ const targetId = String(input.target_topic_id ?? "").trim();
949
+ const descendantIds = new Set(result.children.map((child) => child.id));
950
+ if (!descendantIds.has(sourceId)) {
951
+ return errorResult("Error: source is not a descendant subagent managed by this room.");
952
+ }
953
+ if (targetId !== ctx.topicId && !descendantIds.has(targetId)) {
954
+ return errorResult("Error: target is outside this room's managed subagent tree.");
955
+ }
956
+ const removed = host.sessionCommunication.revokeTellTarget(sourceId, targetId);
957
+ return textResult(removed ? "tell_session grant removed." : "No matching grant existed.");
958
+ },
847
959
  },
848
- },
849
- {
850
- name: "grant_subagent_tell",
960
+ ];
961
+ }
962
+
963
+ function createSpawnSubagentToolDefinition(ctx: TContext): SharedMcpTool {
964
+ return {
965
+ name: "spawn_subagent",
851
966
  description:
852
- "Allow one descendant subagent to tell_session another topic in this managed subagent tree. " +
853
- "Direct-parent reporting is always allowed and does not need a grant.",
967
+ "Delegate a self-contained task to a subagent that works in its own new agent room. " +
968
+ "Returns immediately; the subagent runs in the background and reports according to report_mode. " +
969
+ "The fresh room has no parent conversation history; it receives the task plus the selected or inherited topic memory. " +
970
+ "Use for parallelizable or long-running side work. Do not use provider built-in Task/Agent subagents.",
854
971
  schema: {
855
- subagent_topic_id: z.string().describe("Source descendant subagent topic id."),
856
- target_topic_id: z
972
+ task: z
857
973
  .string()
858
- .describe("Target topic id: this manager room or another descendant in its tree."),
974
+ .describe(
975
+ "Self-contained task brief. The worker has topic memory but no parent conversation history, so include all task-specific context.",
976
+ ),
977
+ name: z
978
+ .string()
979
+ .optional()
980
+ .describe("Short name for the subagent room. Auto-generated when omitted."),
981
+ agent: z
982
+ .enum(["claude", "codex", "maestro"])
983
+ .optional()
984
+ .describe("Agent backend override. Defaults to this room's agent."),
985
+ model: z
986
+ .string()
987
+ .optional()
988
+ .describe(
989
+ `Best-fit model override from the system prompt catalog. Omit agent+model to inherit ${ctx.agent}/${ctx.model ?? "default"}; overriding agent without model uses that agent's default.`,
990
+ ),
991
+ memory_topic: z
992
+ .string()
993
+ .optional()
994
+ .describe(
995
+ "Optional knowledge source: an accessible topic id/title or topic/*.md brief. Defaults to the parent room's effective topic brief.",
996
+ ),
997
+ report_mode: z
998
+ .enum(["auto", "tell", "status-only"])
999
+ .optional()
1000
+ .describe(
1001
+ "Completion reporting: auto injects the final result, tell requires tell_session, status-only updates only the lifecycle card.",
1002
+ ),
859
1003
  },
860
1004
  async handler(input) {
861
- const result = ownedSubagentTree(ctx);
862
- if (!result.ok) return errorResult(result.error);
863
- const sourceId = String(input.subagent_topic_id ?? "").trim();
864
- const targetId = String(input.target_topic_id ?? "").trim();
865
- const descendantIds = new Set(result.children.map((child) => child.id));
866
- if (!descendantIds.has(sourceId)) {
867
- return errorResult("Error: source is not a descendant subagent managed by this room.");
868
- }
869
- if (targetId !== ctx.topicId && !descendantIds.has(targetId)) {
870
- return errorResult("Error: target is outside this room's managed subagent tree.");
871
- }
872
- if (getTopic(sourceId)?.parentTopicId === targetId) {
873
- return textResult("No grant needed: a subagent can always report to its direct parent.");
874
- }
875
- grantSubagentTellTarget(sourceId, targetId, ctx.topicId);
876
- return textResult(`tell_session grant added: ${sourceId} -> ${targetId}`);
1005
+ return createSubagent(ctx, input, true);
877
1006
  },
878
- },
879
- {
880
- name: "revoke_subagent_tell",
1007
+ };
1008
+ }
1009
+
1010
+ function createPrepareSubagentToolDefinition(ctx: TContext): SharedMcpTool {
1011
+ return {
1012
+ name: "create_subagent",
881
1013
  description:
882
- "Remove an extra tell_session connection previously granted to a descendant subagent.",
1014
+ "Create a prepared subagent room and task definition without starting its AI turn. " +
1015
+ "Use start_subagent when the team is ready to run it.",
883
1016
  schema: {
884
- subagent_topic_id: z.string().describe("Source descendant subagent topic id."),
885
- target_topic_id: z.string().describe("Granted target topic id."),
1017
+ task: z.string().describe("Self-contained task brief for the subagent."),
1018
+ name: z.string().optional().describe("Short room name. Auto-generated when omitted."),
1019
+ agent: z.enum(["claude", "codex", "maestro"]).optional(),
1020
+ model: z.string().optional(),
1021
+ memory_topic: z
1022
+ .string()
1023
+ .optional()
1024
+ .describe(
1025
+ "Optional accessible topic id/title or topic/*.md brief to use as this worker's knowledge source. Defaults to the parent.",
1026
+ ),
1027
+ report_mode: z.enum(["auto", "tell", "status-only"]).optional(),
886
1028
  },
887
1029
  async handler(input) {
888
- const result = ownedSubagentTree(ctx);
889
- if (!result.ok) return errorResult(result.error);
890
- const sourceId = String(input.subagent_topic_id ?? "").trim();
891
- const targetId = String(input.target_topic_id ?? "").trim();
892
- const descendantIds = new Set(result.children.map((child) => child.id));
893
- if (!descendantIds.has(sourceId)) {
894
- return errorResult("Error: source is not a descendant subagent managed by this room.");
895
- }
896
- if (targetId !== ctx.topicId && !descendantIds.has(targetId)) {
897
- return errorResult("Error: target is outside this room's managed subagent tree.");
898
- }
899
- const removed = revokeSubagentTellTarget(sourceId, targetId);
900
- return textResult(removed ? "tell_session grant removed." : "No matching grant existed.");
1030
+ return createSubagent(ctx, input, false);
901
1031
  },
902
- },
903
- ];
904
- }
1032
+ };
1033
+ }
905
1034
 
906
- export function createSpawnSubagentToolDefinition(ctx: SpawnSubagentToolContext): SharedMcpTool {
907
1035
  return {
908
- name: "spawn_subagent",
909
- description:
910
- "Delegate a self-contained task to a subagent that works in its own new agent room. " +
911
- "Returns immediately; the subagent runs in the background and reports according to report_mode. " +
912
- "The fresh room has no parent conversation history; it receives the task plus the selected or inherited topic memory. " +
913
- "Use for parallelizable or long-running side work. Do not use provider built-in Task/Agent subagents.",
914
- schema: {
915
- task: z
916
- .string()
917
- .describe(
918
- "Self-contained task brief. The worker has topic memory but no parent conversation history, so include all task-specific context.",
919
- ),
920
- name: z
921
- .string()
922
- .optional()
923
- .describe("Short name for the subagent room. Auto-generated when omitted."),
924
- agent: z
925
- .enum(["claude", "codex", "maestro"])
926
- .optional()
927
- .describe("Agent backend override. Defaults to this room's agent."),
928
- model: z
929
- .string()
930
- .optional()
931
- .describe(
932
- `Best-fit model override from the system prompt catalog. Omit agent+model to inherit ${ctx.agent}/${ctx.model ?? "default"}; overriding agent without model uses that agent's default.`,
933
- ),
934
- memory_topic: z
935
- .string()
936
- .optional()
937
- .describe(
938
- "Optional knowledge source: an accessible topic id/title or topic/*.md brief. Defaults to the parent room's effective topic brief.",
939
- ),
940
- report_mode: z
941
- .enum(["auto", "tell", "status-only"])
942
- .optional()
943
- .describe(
944
- "Completion reporting: auto injects the final result, tell requires tell_session, status-only updates only the lifecycle card.",
945
- ),
946
- },
947
- async handler(input) {
948
- return createSubagent(ctx, input, true);
949
- },
1036
+ computeSubagentDepth,
1037
+ canSpawnSubagentsFromTopic,
1038
+ takeSubagentWatch,
1039
+ cancelSubagentWatchForDeletedTopic,
1040
+ settleSubagentSuccess,
1041
+ settleSubagentFailure,
1042
+ sweepStaleSubagentCards,
1043
+ subagentReportMode,
1044
+ createSubagentManagementToolDefinitions,
1045
+ createSpawnSubagentToolDefinition,
1046
+ createPrepareSubagentToolDefinition,
950
1047
  };
951
1048
  }
952
1049
 
953
- export function createPrepareSubagentToolDefinition(ctx: SpawnSubagentToolContext): SharedMcpTool {
954
- return {
955
- name: "create_subagent",
956
- description:
957
- "Create a prepared subagent room and task definition without starting its AI turn. " +
958
- "Use start_subagent when the team is ready to run it.",
959
- schema: {
960
- task: z.string().describe("Self-contained task brief for the subagent."),
961
- name: z.string().optional().describe("Short room name. Auto-generated when omitted."),
962
- agent: z.enum(["claude", "codex", "maestro"]).optional(),
963
- model: z.string().optional(),
964
- memory_topic: z
965
- .string()
966
- .optional()
967
- .describe(
968
- "Optional accessible topic id/title or topic/*.md brief to use as this worker's knowledge source. Defaults to the parent.",
969
- ),
970
- report_mode: z.enum(["auto", "tell", "status-only"]).optional(),
1050
+ function processOwnerIsAlive(ownerId: string): boolean {
1051
+ const ownerPid = Number.parseInt(ownerId.split("-", 1)[0] ?? "", 10);
1052
+ if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0) return true;
1053
+ try {
1054
+ process.kill(ownerPid, 0);
1055
+ return true;
1056
+ } catch (error) {
1057
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
1058
+ }
1059
+ }
1060
+
1061
+ const defaultSubagentLifecycleHost: SubagentLifecycleHost = {
1062
+ storage: {
1063
+ getTopic,
1064
+ listTopics,
1065
+ getTopicMemoryOrigin,
1066
+ upsertTopic,
1067
+ getMessage: getApiMessage,
1068
+ listSubagentMessages: () => listApiMessagesByKind("subagent"),
1069
+ appendMessage: appendApiMessage,
1070
+ updateSubagentCard: updateApiMessageSubagentCard,
1071
+ publishMessage: (topicId, message) => WsHub.get().broadcastMessage(topicId, message),
1072
+ publishCardUpdate: (topicId, messageId, update) =>
1073
+ WsHub.get().broadcastMessageUpdated(topicId, messageId, update),
1074
+ },
1075
+ topic: {
1076
+ async createDerived(input) {
1077
+ const { createDerivedTopic, TopicDeriveBusyError, TopicTitleConflictError } = await import(
1078
+ "#topics/derive"
1079
+ );
1080
+ try {
1081
+ const topic = await createDerivedTopic(input.parentTopicId, input.userId, false, {
1082
+ name: input.name,
1083
+ subagent: {
1084
+ agent: input.agent,
1085
+ model: input.model,
1086
+ memoryTopicId: input.memoryTopicId,
1087
+ },
1088
+ });
1089
+ return { ok: true, topic };
1090
+ } catch (error) {
1091
+ if (error instanceof TopicTitleConflictError) {
1092
+ return { ok: false, reason: "title-conflict", message: error.message };
1093
+ }
1094
+ if (error instanceof TopicDeriveBusyError) return { ok: false, reason: "busy" };
1095
+ throw error;
1096
+ }
971
1097
  },
972
- async handler(input) {
973
- return createSubagent(ctx, input, false);
1098
+ async delete(topic, userId) {
1099
+ const { deleteTopicCascade } = await import("#topics/lifecycle");
1100
+ await deleteTopicCascade(topic, userId);
974
1101
  },
975
- };
976
- }
1102
+ },
1103
+ task: {
1104
+ async dispatch(input) {
1105
+ const { triggerTopicAiTurn } = await import("#runtime/turn-runner");
1106
+ const { getRoomQuery } = await import("#query/active-rooms");
1107
+ const queryId = triggerTopicAiTurn(input.child.id, input.userId, input.prompt, undefined, {
1108
+ origin: `subagent-task:${input.parent.title}`,
1109
+ requestId: input.requestId,
1110
+ injectAuthorId: "ai",
1111
+ onDispatched: input.onDispatched,
1112
+ });
1113
+ return { accepted: Boolean(queryId || getRoomQuery(input.child.id)) };
1114
+ },
1115
+ },
1116
+ sessionCommunication: {
1117
+ async notifyParent({ watch, prompt, requestId }) {
1118
+ const parent = getTopic(watch.parentTopicId);
1119
+ if (!parent) return true;
1120
+ const { triggerTopicAiTurn } = await import("#runtime/turn-runner");
1121
+ const queryId = parent.agent
1122
+ ? triggerTopicAiTurn(watch.parentTopicId, watch.userId, prompt, undefined, {
1123
+ origin: `subagent:${watch.name}`,
1124
+ requestId,
1125
+ hideInjectMessage: true,
1126
+ })
1127
+ : null;
1128
+ if (queryId) return true;
1129
+ const { interSessionQueue } = await import("#query/active-rooms");
1130
+ return Boolean(parent.agent && interSessionQueue.hasRequest(parent.id, requestId));
1131
+ },
1132
+ listTellTargetIds: listSubagentTellTargetIds,
1133
+ grantTellTarget: grantSubagentTellTarget,
1134
+ revokeTellTarget: revokeSubagentTellTarget,
1135
+ },
1136
+ runtime: {
1137
+ ownerId: RUNTIME_INSTANCE_ID,
1138
+ now: () => new Date().toISOString(),
1139
+ ownerIsAlive: processOwnerIsAlive,
1140
+ childExecutionQueryId(childTopicId) {
1141
+ const lease = getRuntimeTurnLease(childTopicId);
1142
+ if (lease) return lease.queryId;
1143
+ return getRuntimeUserTurnRequest(childTopicId)?.runningQueryId ?? null;
1144
+ },
1145
+ childExecutionIsRecoverable(childTopicId) {
1146
+ if (getRuntimeUserTurnRequest(childTopicId)) return true;
1147
+ const lease = getRuntimeTurnLease(childTopicId);
1148
+ return Boolean(lease && processOwnerIsAlive(lease.ownerId));
1149
+ },
1150
+ },
1151
+ config: {
1152
+ async resolveAgentModel({ context, agent, model, hasOverride }) {
1153
+ let resolvedModel = model;
1154
+ if (model) {
1155
+ const { resolveModelForAgent } = await import("#agents/model-catalog");
1156
+ const { getRegistry } = await import("#agents/registry");
1157
+ const registry = getRegistry(agent);
1158
+ if (!registry.validateModel(model)) {
1159
+ return { error: `Error: model '${model}' is not valid for agent '${agent}'.` };
1160
+ }
1161
+ resolvedModel = resolveModelForAgent(agent, model, registry);
1162
+ }
1163
+ if (hasOverride) {
1164
+ const { checkAgentModelAuth } = await import("#agents/auth-check");
1165
+ const { getRegistry } = await import("#agents/registry");
1166
+ const targetModel = resolvedModel ?? getRegistry(agent).defaultModel;
1167
+ const auth = checkAgentModelAuth(agent, targetModel, undefined, context.userId);
1168
+ if (!auth.ok) {
1169
+ return { error: `Error: agent '${agent}' is not available: ${auth.error}` };
1170
+ }
1171
+ }
1172
+ return { model: resolvedModel };
1173
+ },
1174
+ memoryFilenames: (topic) => [
1175
+ `${wikiBriefStorageKey(topic.title, topic.id)}.md`,
1176
+ `${wikiSummarySlug(topic.title)}.md`,
1177
+ ],
1178
+ },
1179
+ };
1180
+
1181
+ const defaultSubagentLifecycle = createSubagentLifecycle(defaultSubagentLifecycleHost);
1182
+
1183
+ export const computeSubagentDepth = defaultSubagentLifecycle.computeSubagentDepth;
1184
+ export const canSpawnSubagentsFromTopic = defaultSubagentLifecycle.canSpawnSubagentsFromTopic;
1185
+ export const takeSubagentWatch = defaultSubagentLifecycle.takeSubagentWatch;
1186
+ export const cancelSubagentWatchForDeletedTopic =
1187
+ defaultSubagentLifecycle.cancelSubagentWatchForDeletedTopic;
1188
+ export const settleSubagentSuccess = defaultSubagentLifecycle.settleSubagentSuccess;
1189
+ export const settleSubagentFailure = defaultSubagentLifecycle.settleSubagentFailure;
1190
+ export const sweepStaleSubagentCards = defaultSubagentLifecycle.sweepStaleSubagentCards;
1191
+ export const subagentReportMode = defaultSubagentLifecycle.subagentReportMode;
1192
+ export const createSubagentManagementToolDefinitions =
1193
+ defaultSubagentLifecycle.createSubagentManagementToolDefinitions;
1194
+ export const createSpawnSubagentToolDefinition =
1195
+ defaultSubagentLifecycle.createSpawnSubagentToolDefinition;
1196
+ export const createPrepareSubagentToolDefinition =
1197
+ defaultSubagentLifecycle.createPrepareSubagentToolDefinition;