typeclaw 0.5.1 → 0.7.0

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 (48) hide show
  1. package/README.md +34 -84
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +80 -8
  4. package/src/agent/live-subagents.ts +215 -0
  5. package/src/agent/plugin-tools.ts +60 -20
  6. package/src/agent/session-origin.ts +15 -0
  7. package/src/agent/subagents.ts +140 -3
  8. package/src/agent/system-prompt.ts +42 -0
  9. package/src/agent/tools/channel-reply.ts +24 -1
  10. package/src/agent/tools/channel-send.ts +26 -1
  11. package/src/agent/tools/spawn-subagent.ts +283 -0
  12. package/src/agent/tools/subagent-cancel.ts +96 -0
  13. package/src/agent/tools/subagent-output.ts +192 -0
  14. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +26 -0
  15. package/src/bundled-plugins/explorer/explorer.ts +103 -0
  16. package/src/bundled-plugins/explorer/index.ts +11 -0
  17. package/src/bundled-plugins/guard/index.ts +12 -1
  18. package/src/bundled-plugins/guard/policies/managed-config.ts +139 -0
  19. package/src/bundled-plugins/guard/policy.ts +1 -0
  20. package/src/bundled-plugins/operator/index.ts +11 -0
  21. package/src/bundled-plugins/operator/operator.ts +76 -0
  22. package/src/bundled-plugins/scout/index.ts +11 -0
  23. package/src/bundled-plugins/scout/scout.ts +94 -0
  24. package/src/channels/router.ts +32 -0
  25. package/src/cli/init.ts +8 -1
  26. package/src/cli/oauth-callbacks.ts +64 -34
  27. package/src/cli/provider.ts +9 -4
  28. package/src/config/config.ts +73 -16
  29. package/src/config/index.ts +3 -0
  30. package/src/config/providers.ts +106 -0
  31. package/src/cron/index.ts +3 -0
  32. package/src/cron/schema.ts +20 -0
  33. package/src/init/dockerfile.ts +44 -5
  34. package/src/init/models-dev.ts +1 -0
  35. package/src/permissions/builtins.ts +23 -2
  36. package/src/plugin/define.ts +2 -0
  37. package/src/plugin/index.ts +2 -0
  38. package/src/plugin/types.ts +15 -22
  39. package/src/run/bundled-plugins.ts +6 -0
  40. package/src/run/channel-session-factory.ts +19 -0
  41. package/src/run/index.ts +56 -6
  42. package/src/server/index.ts +103 -0
  43. package/src/skills/typeclaw-claude-code/SKILL.md +273 -0
  44. package/src/skills/typeclaw-claude-code/references/auth-flow.md +135 -0
  45. package/src/skills/typeclaw-claude-code/references/stop-hook.md +99 -0
  46. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +157 -0
  47. package/src/skills/typeclaw-config/SKILL.md +29 -26
  48. package/typeclaw.schema.json +12 -0
@@ -226,6 +226,21 @@ function renderChannelOrigin(
226
226
  'reply, your entire final visible response must be exactly `NO_REPLY`.',
227
227
  'Any other visible text without a channel tool call is blocked.',
228
228
  '',
229
+ '**Default to ONE reply per inbound.** Send a second `channel_reply` only',
230
+ 'when the user genuinely benefits from it:',
231
+ '',
232
+ '- the user asked multiple distinct things and each deserves its own',
233
+ ' scoped answer,',
234
+ '- your reply exceeds the platform message limit and must be chunked,',
235
+ '- you need to post an attachment AND commentary on it on Discord (on',
236
+ ' Slack, pass `text` and `attachments` in a single `channel_reply` call),',
237
+ '- you are emitting progress updates during a long-running task and the',
238
+ ' channel would otherwise sit silent.',
239
+ '',
240
+ 'Do NOT send a second reply just to rephrase, restate, summarize, or',
241
+ '"confirm in plain language" something you already said. After the first',
242
+ 'reply lands, end your turn — the user will respond if they want more.',
243
+ '',
229
244
  'To reply in this conversation, call `channel_reply({ text })`. Addressing',
230
245
  `is filled in from this session, including the thread${origin.thread !== null ? '' : ' (none here — this is a channel-root session)'}, so you don't`,
231
246
  'need to copy any of these fields:',
@@ -19,17 +19,40 @@ export type SubagentContext<P = unknown> = {
19
19
 
20
20
  export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
21
21
 
22
- export type Subagent<P = unknown> = {
22
+ // Fields shared verbatim between the plugin-author-facing `Subagent` in
23
+ // `@/plugin/types` and the runtime-internal `Subagent` below. Every consumer
24
+ // that reads from `SubagentRegistry` (the spawn_subagent tool, payload
25
+ // validation, default session construction) only touches these fields, so
26
+ // keeping them in a single declaration makes the plugin→internal shim a
27
+ // rest-spread instead of a hand-maintained property list. Adding a new field
28
+ // here surfaces it on both types in one edit, which is the regression class
29
+ // the previous shim shape suffered: `visibility` and `requiresSpecificPermission`
30
+ // existed on the plugin type but were silently dropped by the shim, so every
31
+ // plugin-contributed public subagent appeared internal at the registry layer.
32
+ //
33
+ // The two fields that intentionally diverge — `tools` and `customTools` —
34
+ // live on each concrete `Subagent` type below. The plugin side uses
35
+ // `BuiltinToolRef[]` + `Tool<any>[]` (the public plugin API, decoupled from
36
+ // pi-coding-agent's internal tool shape); the internal side uses the resolved
37
+ // `AgentSessionTools` + `ToolDefinition[]` that pi-coding-agent actually
38
+ // consumes. The boundary is real and load-bearing — collapsing it would
39
+ // expose pi-coding-agent's internal API as part of the plugin contract.
40
+ export type SubagentShared<P = unknown> = {
23
41
  systemPrompt: string
24
42
  // Model profile this subagent prefers. Resolved against `config.models` at
25
43
  // session construction. Unknown profile names fall back to `default` with
26
44
  // a warning. See `Subagent` in `@/plugin/types` for the full contract.
27
45
  profile?: string
28
- tools?: AgentSessionTools
29
- customTools?: ToolDefinition[]
30
46
  payloadSchema?: z.ZodType<P>
31
47
  handler?: (ctx: SubagentContext<P>, runSession: RunSession) => Promise<void>
32
48
  toolResultBudget?: ToolResultBudget
49
+ visibility?: 'public' | 'internal'
50
+ requiresSpecificPermission?: boolean
51
+ }
52
+
53
+ export type Subagent<P = unknown> = SubagentShared<P> & {
54
+ tools?: AgentSessionTools
55
+ customTools?: ToolDefinition[]
33
56
  }
34
57
 
35
58
  export type SubagentRegistry = Readonly<Record<string, Subagent<any>>>
@@ -136,6 +159,17 @@ export type InvokeSubagentOptions = {
136
159
  spawnedByRole?: string
137
160
  spawnedByOrigin?: SessionOrigin
138
161
  onProviderError?: (errorMessage: string) => void
162
+ // Fires synchronously after the AgentSession is created and before
163
+ // session.prompt() is invoked, with both the live session reference and
164
+ // its allocated sessionId. The only consumer in production is the spawn
165
+ // tool's LiveSubagentRegistry path, which uses it to attach a progress
166
+ // subscriber and register the abort handle while invokeSubagent retains
167
+ // its `Promise<void>` external contract.
168
+ onSessionCreated?: (event: {
169
+ session: AgentSession
170
+ sessionId: string | undefined
171
+ abort: () => Promise<void>
172
+ }) => void
139
173
  }
140
174
 
141
175
  export async function invokeSubagent(name: string, options: InvokeSubagentOptions): Promise<void> {
@@ -155,6 +189,15 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
155
189
  const { session, dispose, hooks, sessionId, agentDir, origin, getTranscriptPath } = normalizeSubagentSession(
156
190
  await createSessionForSubagent(subagent, sessionOptions),
157
191
  )
192
+ if (options.onSessionCreated !== undefined) {
193
+ options.onSessionCreated({
194
+ session,
195
+ sessionId,
196
+ abort: async () => {
197
+ await session.abort()
198
+ },
199
+ })
200
+ }
158
201
  const unsubProviderErrors =
159
202
  options.onProviderError !== undefined
160
203
  ? subscribeProviderErrors(session, (err) => options.onProviderError!(err.message))
@@ -204,6 +247,100 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
204
247
  }
205
248
  }
206
249
 
250
+ export type SubagentHandle = {
251
+ taskId: string
252
+ sessionId: string | undefined
253
+ abort: () => Promise<void>
254
+ }
255
+
256
+ export type StartSubagentResult = {
257
+ handle: Promise<SubagentHandle>
258
+ completion: Promise<{ ok: true; finalMessage?: string } | { ok: false; error: string }>
259
+ }
260
+
261
+ export type StartSubagentOptions = InvokeSubagentOptions & {
262
+ taskId: string
263
+ onSession?: (event: { session: AgentSession; sessionId: string | undefined; abort: () => Promise<void> }) => void
264
+ }
265
+
266
+ // Non-blocking alternative to invokeSubagent. Returns immediately with two
267
+ // promises:
268
+ // - `handle` resolves with { taskId, sessionId, abort } once the AgentSession
269
+ // has been created (typically the first microtask). The taskId is what the
270
+ // caller chose; sessionId is allocated by the session factory.
271
+ // - `completion` resolves when the subagent's prompt finishes, ok=true with
272
+ // an optional final message, or ok=false with an error message.
273
+ // The two promises share a single underlying invokeSubagent invocation;
274
+ // `completion` settles after dispose, so the session reference exposed via
275
+ // `handle.abort` becomes a no-op once `completion` resolves.
276
+ export function startSubagent(name: string, options: StartSubagentOptions): StartSubagentResult {
277
+ let resolveHandle: (h: SubagentHandle) => void
278
+ let rejectHandle: (err: Error) => void
279
+ const handle = new Promise<SubagentHandle>((resolve, reject) => {
280
+ resolveHandle = resolve
281
+ rejectHandle = reject
282
+ })
283
+ let handleSettled = false
284
+ let finalMessage: string | undefined
285
+
286
+ const completion = invokeSubagent(name, {
287
+ ...options,
288
+ onSessionCreated: (event) => {
289
+ handleSettled = true
290
+ resolveHandle({ taskId: options.taskId, sessionId: event.sessionId, abort: event.abort })
291
+ if (options.onSession !== undefined) {
292
+ options.onSession(event)
293
+ }
294
+ attachFinalMessageCapture(event.session, (msg) => {
295
+ finalMessage = msg
296
+ })
297
+ },
298
+ })
299
+ .then(() => ({ ok: true as const, ...(finalMessage !== undefined ? { finalMessage } : {}) }))
300
+ .catch((err: unknown) => {
301
+ const error = err instanceof Error ? err.message : String(err)
302
+ if (!handleSettled) {
303
+ rejectHandle(err instanceof Error ? err : new Error(error))
304
+ }
305
+ return { ok: false as const, error }
306
+ })
307
+
308
+ return { handle, completion }
309
+ }
310
+
311
+ function attachFinalMessageCapture(session: AgentSession, onFinalMessage: (msg: string) => void): void {
312
+ try {
313
+ session.subscribe((event: unknown) => {
314
+ const ev = event as { type?: string; message?: { content?: unknown } }
315
+ if (ev?.type !== 'message_end') return
316
+ const text = extractFinalMessageText(ev.message?.content)
317
+ if (text !== null) onFinalMessage(text)
318
+ })
319
+ } catch {
320
+ // session.subscribe is a stable upstream API; defensive try is for test
321
+ // doubles that don't implement it.
322
+ }
323
+ }
324
+
325
+ function extractFinalMessageText(content: unknown): string | null {
326
+ if (typeof content === 'string') {
327
+ const trimmed = content.trim()
328
+ return trimmed ? trimmed : null
329
+ }
330
+ if (Array.isArray(content)) {
331
+ const parts: string[] = []
332
+ for (const part of content) {
333
+ if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {
334
+ const text = (part as { text?: unknown }).text
335
+ if (typeof text === 'string') parts.push(text)
336
+ }
337
+ }
338
+ const joined = parts.join('').trim()
339
+ return joined ? joined : null
340
+ }
341
+ return null
342
+ }
343
+
207
344
  export type SubagentConsumerLogger = {
208
345
  info: (msg: string) => void
209
346
  warn: (msg: string) => void
@@ -50,6 +50,48 @@ Your agent folder is a git repository.
50
50
  - If a request is ambiguous in a way that doubles the effort, ask one clarifying question; otherwise proceed with a reasonable default.
51
51
  - Never suppress errors to make things "work", and never fabricate results. Report failures clearly.
52
52
 
53
+ ## Subagent orchestration
54
+
55
+ You can delegate focused work to subagents via three tools: \`spawn_subagent\`, \`subagent_output\`, \`subagent_cancel\`. Subagents run with their own context window and their own (often smaller, cheaper, or more constrained) tool set. The list of available subagents and what each one is for is rendered in the \`spawn_subagent\` tool description — re-read it before delegating.
56
+
57
+ There are two delegation modes. Pick deliberately.
58
+
59
+ **Mode A — Research fan-out** (in service of the current question)
60
+
61
+ When you need information to answer the user and the search is broad, fire 2-5 subagents in parallel with \`run_in_background: true\` covering different angles. End your response after spawning. The system will deliver a \`<system-reminder>\` for each completion; gather results then answer the user. Do NOT poll \`subagent_output\` in a tight loop.
62
+
63
+ The bundled \`explorer\` subagent is the right tool for **local** reconnaissance — anything reachable on the agent's filesystem: code, past sessions (\`sessions/*.jsonl\`), MEMORY.md and daily memory streams, skills, cron jobs, config, git history, mounts, channels state. It is read-only and runs on a fast/cheap model, so fire liberally. Do NOT ask it to plan, decide, or write code — it finds and reports.
64
+
65
+ The bundled \`scout\` subagent is its external counterpart — web research only. Use it when you need information from public sources (docs, library references, vendor changelogs, news, anything not already in this agent's folder). Scout runs \`websearch\` and \`webfetch\` in a fresh context window so the search churn does not pollute yours; it returns a citation-backed answer with a confidence rating. Prefer scout over running \`websearch\`/\`webfetch\` yourself when the research is non-trivial (more than 1-2 queries) or when you want to save your context for the synthesis step.
66
+
67
+ **Mode B — Delegate-and-converse** (the user asked you to DO something long-running)
68
+
69
+ When the user hands you a task that will take minutes (a multi-step browser session, a long build, a complex external operation), acknowledge in plain language ("Alright, running that in the background — I'll let you know when it's done"), spawn one subagent with \`run_in_background: true\`, then KEEP TALKING. Stay available for follow-ups, related questions, parallel small tasks. When the completion reminder lands, weave the result into your next reply naturally. If the conversation has gone idle, proactively message the user with the result rather than waiting.
70
+
71
+ Before you start an inline operation you expect to take more than ~30 seconds — a chain of \`webfetch\` calls, a \`websearch\` round you'll iterate on, a \`bash\` command that hits a slow API or scrapes a site, an \`agent-browser\` session, any "fetch N things in a loop" — pause and ask whether a subagent should run it instead. Inline long calls block the user from talking to you and pollute your context window with intermediate output; \`scout\` (for research) or \`operator\` (for actions with side effects) keeps the conversation responsive and returns a clean summary. The exception is a single quick call (one \`webfetch\` of a known URL, one \`websearch\` query you already know the shape of) — do those inline.
72
+
73
+ The bundled \`operator\` subagent is the right tool for this mode. It is write-capable (read, write, edit, bash with side effects) and runs on the default model. Use it for: browser sessions, multi-file refactors, deploys, batch API calls, anything that involves taking action on behalf of the user over multiple steps. The operator returns a structured final report (outcome, what changed, what was observed); surface it naturally rather than copy-pasting. Operator is gated by a separate permission (\`subagent.spawn.operator\`) so write-capable spawns are restricted to owner-tier and trusted-tier callers — if the gate denies, fall back to doing the work in your own session rather than reporting failure to the user.
74
+
75
+ **Status queries**
76
+
77
+ If the user asks "how's it going?" or "status?" on a running subagent, call \`subagent_output({ task_id, block: false })\` and report the \`status_summary\` in your own words. Don't pretend to know the status without checking.
78
+
79
+ **Prompt structure for spawns** (mandatory — the subagent does not see this conversation)
80
+
81
+ \`\`\`
82
+ [CONTEXT]: What I'm working on, which files/modules are involved, what approach.
83
+ [GOAL]: The specific decision or output I need to unlock.
84
+ [REQUEST]: Concrete instructions — what to find/do/produce, what format, what to SKIP.
85
+ \`\`\`
86
+
87
+ **Anti-patterns**
88
+
89
+ - Don't fire more than 5 subagents in a single turn.
90
+ - Don't spawn for a known answer or single-file lookup — do it yourself.
91
+ - Don't poll \`subagent_output\` waiting for completion; end your response and the reminder will wake you.
92
+ - Don't ask a research subagent to make architectural decisions for you — they find and report; you decide.
93
+ - Subagents cannot recursively spawn other subagents.
94
+
53
95
  ## Safety
54
96
 
55
97
  You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or influence beyond what the user has asked for. Do not plan beyond the user's request. If instructions conflict or feel unsafe, pause and ask. Comply with stop, pause, and audit requests. Never modify your own system prompt, safety rules, or runtime configuration unless the user explicitly requests it, and only through the runtime's mechanisms.
@@ -1,7 +1,7 @@
1
1
  import { Type } from '@mariozechner/pi-ai'
2
2
  import { defineTool } from '@mariozechner/pi-coding-agent'
3
3
 
4
- import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
4
+ import { isNoReplySignal, isUpstreamEmptyResponseSentinel, type ChannelRouter } from '@/channels/router'
5
5
  import type { AdapterId } from '@/channels/schema'
6
6
 
7
7
  import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
@@ -89,6 +89,15 @@ export function createChannelReplyTool({
89
89
  }
90
90
  }
91
91
 
92
+ const upstreamSentinelError = upstreamEmptyResponseSentinelError(text)
93
+ if (upstreamSentinelError) {
94
+ logger.warn(formatChannelToolFailure('channel_reply', upstreamSentinelError))
95
+ return {
96
+ content: [{ type: 'text' as const, text: `channel_reply denied: ${upstreamSentinelError}` }],
97
+ details: { ok: false, error: upstreamSentinelError },
98
+ }
99
+ }
100
+
92
101
  const result = await router.send({
93
102
  adapter: origin.adapter,
94
103
  workspace: origin.workspace,
@@ -188,6 +197,20 @@ function noReplyMisuseError(text: string | undefined): string {
188
197
  )
189
198
  }
190
199
 
200
+ // Mirror of the same guard used by channel_send. Blocks the upstream
201
+ // `(Empty response: ...)` debug sentinel from being sent verbatim — that
202
+ // payload carries the model's thinking content and signature, never a
203
+ // real user-facing message.
204
+ function upstreamEmptyResponseSentinelError(text: string | undefined): string {
205
+ if (text === undefined) return ''
206
+ if (!isUpstreamEmptyResponseSentinel(text)) return ''
207
+ return (
208
+ 'refusing to forward an upstream `(Empty response: ...)` sentinel; ' +
209
+ "that string is a provider-SDK debug dump containing the model's thinking content and signature, " +
210
+ 'not a message body. End your turn silently (visible text empty or `NO_REPLY`) instead.'
211
+ )
212
+ }
213
+
191
214
  // Mirror of the same hint used by channel_send. Kept identical so the model
192
215
  // sees the same yield signal regardless of which tool it picked.
193
216
  function consecutiveSendHint(countAfterSend: number): string {
@@ -1,7 +1,7 @@
1
1
  import { Type } from '@mariozechner/pi-ai'
2
2
  import { defineTool } from '@mariozechner/pi-coding-agent'
3
3
 
4
- import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
4
+ import { isNoReplySignal, isUpstreamEmptyResponseSentinel, type ChannelRouter } from '@/channels/router'
5
5
  import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
6
6
 
7
7
  import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
@@ -112,6 +112,15 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
112
112
  }
113
113
  }
114
114
 
115
+ const upstreamSentinelError = upstreamEmptyResponseSentinelError(bodyText)
116
+ if (upstreamSentinelError) {
117
+ logger.warn(formatChannelToolFailure('channel_send', upstreamSentinelError))
118
+ return {
119
+ content: [{ type: 'text' as const, text: `channel_send denied: ${upstreamSentinelError}` }],
120
+ details: { ok: false, error: upstreamSentinelError },
121
+ }
122
+ }
123
+
115
124
  const result = await router.send({
116
125
  adapter,
117
126
  workspace: params.workspace,
@@ -208,6 +217,22 @@ function noReplyMisuseError(text: string | undefined): string {
208
217
  )
209
218
  }
210
219
 
220
+ // Defense-in-depth mirror of the recovery-path guard in router.ts. Blocks
221
+ // the upstream "(Empty response: {...})" sentinel from being sent verbatim
222
+ // as a channel message — the body of that sentinel carries the model's
223
+ // thinking content and Anthropic's tamper-proof signature, which must
224
+ // never reach a channel reader. Shape detection lives in
225
+ // `isUpstreamEmptyResponseSentinel` so all call sites stay in lockstep.
226
+ function upstreamEmptyResponseSentinelError(text: string | undefined): string {
227
+ if (text === undefined) return ''
228
+ if (!isUpstreamEmptyResponseSentinel(text)) return ''
229
+ return (
230
+ 'refusing to forward an upstream `(Empty response: ...)` sentinel; ' +
231
+ "that string is a provider-SDK debug dump containing the model's thinking content and signature, " +
232
+ 'not a message body. End your turn silently (visible text empty or `NO_REPLY`) instead.'
233
+ )
234
+ }
235
+
211
236
  // Returns a behavioral hint to nudge the model toward yielding when it has
212
237
  // been the only voice in the conversation for several messages. The router
213
238
  // increments its counter AFTER router.send returns, so a count of 1 means
@@ -0,0 +1,283 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import { Type } from '@mariozechner/pi-ai'
4
+ import { defineTool } from '@mariozechner/pi-coding-agent'
5
+
6
+ import type { PermissionService } from '@/permissions'
7
+ import type { Stream } from '@/stream'
8
+
9
+ import { type LiveSubagentRegistry, type SubagentCompletion } from '../live-subagents'
10
+ import type { SessionOrigin } from '../session-origin'
11
+ import { type CreateSessionForSubagent, type Subagent, type SubagentRegistry, startSubagent } from '../subagents'
12
+
13
+ export const SPAWN_TASK_ID_PREFIX = 'bg_'
14
+
15
+ export type SpawnSubagentToolDetails =
16
+ | {
17
+ ok: true
18
+ mode: 'sync'
19
+ subagent: string
20
+ taskId: string
21
+ sessionId: string | undefined
22
+ durationMs: number
23
+ finalMessage?: string
24
+ }
25
+ | {
26
+ ok: true
27
+ mode: 'background'
28
+ subagent: string
29
+ taskId: string
30
+ sessionId: string | undefined
31
+ }
32
+ | { ok: false; error: string }
33
+
34
+ export type CreateSpawnSubagentToolOptions = {
35
+ registry: SubagentRegistry
36
+ liveRegistry: LiveSubagentRegistry
37
+ createSessionForSubagent: CreateSessionForSubagent
38
+ agentDir: string
39
+ parentSessionId: string
40
+ getOrigin: () => SessionOrigin | undefined
41
+ permissions?: PermissionService
42
+ stream?: Stream
43
+ generateTaskId?: () => string
44
+ now?: () => number
45
+ }
46
+
47
+ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions) {
48
+ const {
49
+ registry,
50
+ liveRegistry,
51
+ createSessionForSubagent,
52
+ agentDir,
53
+ parentSessionId,
54
+ getOrigin,
55
+ permissions,
56
+ stream,
57
+ generateTaskId = () => `${SPAWN_TASK_ID_PREFIX}${randomUUID().replace(/-/g, '').slice(0, 12)}`,
58
+ now = () => Date.now(),
59
+ } = options
60
+
61
+ return defineTool({
62
+ name: 'spawn_subagent',
63
+ label: 'Spawn Subagent',
64
+ description: spawnSubagentDescription(registry),
65
+ parameters: Type.Object({
66
+ subagent_type: Type.String({
67
+ description:
68
+ 'Name of the subagent to spawn. Must be a public subagent registered with this agent. See the system prompt section "Subagent orchestration" for the available list.',
69
+ }),
70
+ prompt: Type.String({
71
+ description:
72
+ 'The full task description for the subagent. Use the [CONTEXT]/[GOAL]/[REQUEST] structure described in the system prompt. The subagent does not see the parent conversation; everything it needs must be in this string.',
73
+ }),
74
+ description: Type.Optional(
75
+ Type.String({
76
+ description: '3-5 word label for this spawn, used for logs and the status_summary. Optional.',
77
+ }),
78
+ ),
79
+ run_in_background: Type.Optional(
80
+ Type.Boolean({
81
+ description:
82
+ 'When true, the spawn returns immediately with a task_id; the subagent runs in the background and a system-reminder is delivered when it completes. ' +
83
+ 'When false (default), the spawn blocks until the subagent finishes and returns its final message synchronously. ' +
84
+ 'Use background mode for long-running tasks where you want to keep the conversation moving (Mode B) or for parallel fan-out (Mode A).',
85
+ }),
86
+ ),
87
+ }),
88
+
89
+ async execute(_toolCallId, params): Promise<ToolReturn> {
90
+ const origin = getOrigin()
91
+ const subagent = lookupPublicSubagent(registry, params.subagent_type)
92
+ if (subagent === null) {
93
+ return errorResult(formatUnknownSubagentError(registry, params.subagent_type))
94
+ }
95
+ if (!hasPermissionForSubagent(permissions, origin, params.subagent_type, subagent)) {
96
+ return errorResult('subagent.spawn denied: insufficient permissions')
97
+ }
98
+
99
+ const taskId = generateTaskId()
100
+ const subagentName = params.subagent_type
101
+ const background = params.run_in_background === true
102
+ const payload: Record<string, unknown> = { requestId: taskId, prompt: params.prompt }
103
+ if (params.description !== undefined) payload.description = params.description
104
+
105
+ const startedAt = now()
106
+ const { handle, completion } = startSubagent(subagentName, {
107
+ registry,
108
+ createSessionForSubagent,
109
+ agentDir,
110
+ userPrompt: params.prompt,
111
+ payload: subagent.payloadSchema ? payload : undefined,
112
+ parentSessionId,
113
+ ...(origin !== undefined ? { spawnedByOrigin: origin } : {}),
114
+ taskId,
115
+ })
116
+
117
+ let resolvedHandle: { taskId: string; sessionId: string | undefined; abort: () => Promise<void> } | undefined
118
+ try {
119
+ resolvedHandle = await handle
120
+ } catch (err) {
121
+ const message = err instanceof Error ? err.message : String(err)
122
+ return errorResult(`failed to spawn ${subagentName}: ${message}`)
123
+ }
124
+
125
+ const live = {
126
+ taskId,
127
+ sessionId: resolvedHandle.sessionId ?? '<pending>',
128
+ subagentName,
129
+ parentSessionId,
130
+ startedAt,
131
+ status: 'running' as const,
132
+ abort: resolvedHandle.abort,
133
+ awaitCompletion: () => completion.then((c) => completionToFinalShape(c, now() - startedAt)),
134
+ }
135
+ liveRegistry.register(live)
136
+
137
+ void completion.then((c) => {
138
+ const durationMs = now() - startedAt
139
+ liveRegistry.recordCompletion(taskId, completionToFinalShape(c, durationMs))
140
+ if (stream && background) {
141
+ stream.publish({
142
+ target: { kind: 'broadcast' },
143
+ payload: {
144
+ kind: 'subagent.completed',
145
+ taskId,
146
+ subagent: subagentName,
147
+ parentSessionId,
148
+ ok: c.ok,
149
+ durationMs,
150
+ ...(c.ok ? {} : { error: c.error }),
151
+ },
152
+ })
153
+ }
154
+ })
155
+
156
+ if (background) {
157
+ const details: SpawnSubagentToolDetails = {
158
+ ok: true,
159
+ mode: 'background',
160
+ subagent: subagentName,
161
+ taskId,
162
+ sessionId: resolvedHandle.sessionId,
163
+ }
164
+ return {
165
+ content: [
166
+ {
167
+ type: 'text' as const,
168
+ text: `Spawned ${subagentName} in background. task_id=${taskId}. You will receive a system-reminder when it completes. Use subagent_output to check progress or fetch results.`,
169
+ },
170
+ ],
171
+ details,
172
+ }
173
+ }
174
+
175
+ const result = await completion
176
+ const durationMs = now() - startedAt
177
+ if (!result.ok) {
178
+ const details: SpawnSubagentToolDetails = { ok: false, error: result.error }
179
+ return {
180
+ content: [{ type: 'text' as const, text: `${subagentName} failed after ${durationMs}ms: ${result.error}` }],
181
+ details,
182
+ }
183
+ }
184
+ const details: SpawnSubagentToolDetails = {
185
+ ok: true,
186
+ mode: 'sync',
187
+ subagent: subagentName,
188
+ taskId,
189
+ sessionId: resolvedHandle.sessionId,
190
+ durationMs,
191
+ ...(result.finalMessage !== undefined ? { finalMessage: result.finalMessage } : {}),
192
+ }
193
+ return {
194
+ content: [
195
+ {
196
+ type: 'text' as const,
197
+ text:
198
+ result.finalMessage !== undefined
199
+ ? result.finalMessage
200
+ : `${subagentName} completed in ${durationMs}ms with no final message.`,
201
+ },
202
+ ],
203
+ details,
204
+ }
205
+ },
206
+ })
207
+ }
208
+
209
+ export function spawnSubagentDescription(registry: SubagentRegistry): string {
210
+ const publicNames = publicSubagentNames(registry)
211
+ const available = publicNames.length > 0 ? publicNames.join(', ') : '(none registered yet)'
212
+ return (
213
+ `Spawn a subagent to do focused work on your behalf. Use this when a task is heavy enough to deserve a fresh context window (research fan-out) ` +
214
+ `or long-running enough that you want to keep the conversation moving while it runs (delegate-and-converse). ` +
215
+ `Available subagents: ${available}. ` +
216
+ `When run_in_background=true (preferred for long-running work), the tool returns a task_id immediately and the subagent runs concurrently — ` +
217
+ `you will receive a system-reminder when it completes; do NOT poll subagent_output. ` +
218
+ `When run_in_background=false (default), the tool blocks and returns the subagent's final message synchronously. ` +
219
+ `Subagents cannot recursively spawn other subagents.`
220
+ )
221
+ }
222
+
223
+ function publicSubagentNames(registry: SubagentRegistry): string[] {
224
+ return Object.entries(registry)
225
+ .filter(([, sub]) => isPublicSubagent(sub))
226
+ .map(([name]) => name)
227
+ .sort()
228
+ }
229
+
230
+ function isPublicSubagent(sub: Subagent<unknown>): boolean {
231
+ return sub.visibility === 'public'
232
+ }
233
+
234
+ function lookupPublicSubagent(registry: SubagentRegistry, name: string): Subagent<unknown> | null {
235
+ const sub = registry[name]
236
+ if (sub === undefined) return null
237
+ if (!isPublicSubagent(sub)) return null
238
+ return sub
239
+ }
240
+
241
+ function formatUnknownSubagentError(registry: SubagentRegistry, requested: string): string {
242
+ const names = publicSubagentNames(registry)
243
+ const available = names.length > 0 ? names.join(', ') : '(none)'
244
+ return `Unknown subagent: ${requested}. Available: ${available}.`
245
+ }
246
+
247
+ function hasPermissionForSubagent(
248
+ permissions: PermissionService | undefined,
249
+ origin: SessionOrigin | undefined,
250
+ subagentName: string,
251
+ subagent: Subagent<unknown>,
252
+ ): boolean {
253
+ if (permissions === undefined) return true
254
+ const specific = `subagent.spawn.${subagentName}`
255
+ if (subagent.requiresSpecificPermission === true) {
256
+ return permissions.has(origin, specific)
257
+ }
258
+ if (permissions.has(origin, specific)) return true
259
+ return permissions.has(origin, 'subagent.spawn')
260
+ }
261
+
262
+ function completionToFinalShape(
263
+ c: { ok: true; finalMessage?: string } | { ok: false; error: string },
264
+ durationMs: number,
265
+ ): SubagentCompletion {
266
+ if (c.ok) {
267
+ return { ok: true, durationMs, ...(c.finalMessage !== undefined ? { finalMessage: c.finalMessage } : {}) }
268
+ }
269
+ return { ok: false, error: c.error, durationMs }
270
+ }
271
+
272
+ type ToolReturn = {
273
+ content: { type: 'text'; text: string }[]
274
+ details: SpawnSubagentToolDetails
275
+ }
276
+
277
+ function errorResult(message: string): ToolReturn {
278
+ const details: SpawnSubagentToolDetails = { ok: false, error: message }
279
+ return {
280
+ content: [{ type: 'text', text: message }],
281
+ details,
282
+ }
283
+ }