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
@@ -151,6 +151,26 @@ function describeCronStep(step: CronMigrationStep): string {
151
151
  }
152
152
  }
153
153
 
154
+ export type ParseCronJsonOptions = ParseCronOptions & {
155
+ // Apply `migrateLegacyCronShape` before schema validation. Defaults to true
156
+ // so the guard accepts the same legacy shapes `loadCron` would auto-migrate
157
+ // on disk; pass false to validate the exact bytes (used in tests).
158
+ migrate?: boolean
159
+ }
160
+
161
+ export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}): ParseCronResult {
162
+ let json: unknown
163
+ try {
164
+ json = JSON.parse(raw)
165
+ } catch (err) {
166
+ return { ok: false, reason: `cron.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
167
+ }
168
+
169
+ const shouldMigrate = options.migrate ?? true
170
+ const migrated = shouldMigrate ? migrateLegacyCronShape(json) : { json, changed: false, applied: [] }
171
+ return parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
172
+ }
173
+
154
174
  export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
155
175
  const parsed = cronFileSchema.safeParse(raw)
156
176
  if (!parsed.success) {
@@ -377,6 +377,33 @@ RUN echo "${encoded}" | base64 -d > ${TYPECLAW_ENTRYPOINT_PATH} \\
377
377
  && chmod +x ${TYPECLAW_ENTRYPOINT_PATH}`
378
378
  }
379
379
 
380
+ // Claude Code's official installer is `curl | bash`, not apt — can't live
381
+ // in APT_FEATURES. Layer placed after the toggle apt install (so curl + ca-
382
+ // certificates from the baseline are guaranteed present) and before the
383
+ // entrypoint shim (which is always last). Omitted entirely when disabled.
384
+ //
385
+ // The Anthropic installer drops `claude` at `$HOME/.local/bin/claude` and
386
+ // emits a "~/.local/bin is not in your PATH" warning on every install on
387
+ // bun:1-slim (PATH out of the box is `/usr/local/sbin:/usr/local/bin:/usr/
388
+ // sbin:/usr/bin:/sbin:/bin:/usr/local/bun-node-fallback-bin`, no
389
+ // `~/.local/bin`). Without intervention, every `which claude` from the
390
+ // agent (and from the typeclaw-claude-code skill's verification step)
391
+ // returns empty. Symlink into `/usr/local/bin/` — already on PATH, matches
392
+ // what `cloudflared` does, survives `/root/.local/bin` getting rewritten
393
+ // by the installer's "update" path. The symlink resolves to the
394
+ // `~/.local/bin/claude` shim, which itself dereferences to the versioned
395
+ // binary under `~/.local/share/claude/versions/<ver>/`, so upgrades via
396
+ // `claude update` keep working without re-running this layer.
397
+ function renderClaudeCodeInstallLayer(enabled: boolean): string {
398
+ if (!enabled) return ''
399
+ return `# Layer 5.6 (toggle): install Anthropic's Claude Code CLI. Opt-in via
400
+ # typeclaw.json#docker.file.claudeCode. The skill \`typeclaw-claude-code\`
401
+ # documents the auth + usage flow.
402
+ RUN curl -fsSL https://claude.ai/install.sh | bash \\
403
+ && ln -sf "$HOME/.local/bin/claude" /usr/local/bin/claude \\
404
+ && claude --version > /dev/null`
405
+ }
406
+
380
407
  // Shared-library runtime deps Chrome for Testing needs to launch on amd64
381
408
  // Debian trixie (base of `oven/bun:1-slim`). `agent-browser install
382
409
  // --with-deps` (v0.27.0) is supposed to install these but silently no-ops:
@@ -454,10 +481,11 @@ export function buildDockerfile(
454
481
  const customLines = renderCustomDockerfileLines(config.append)
455
482
  const baseImageVersion = options.baseImageVersion ?? null
456
483
 
484
+ const claudeCodeLayer = renderClaudeCodeInstallLayer(config.claudeCode)
457
485
  const fromAndHeavyLayers =
458
486
  baseImageVersion !== null
459
- ? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs, cloudflaredLayer)
460
- : renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer)
487
+ ? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer)
488
+ : renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer)
461
489
 
462
490
  return `${BUILDKIT_HEADER}
463
491
  # AUTOGENERATED by typeclaw — do not edit.
@@ -504,15 +532,18 @@ function renderVersionedHead(
504
532
  ghKeyringLayer: string,
505
533
  toggleAptArgs: string[],
506
534
  cloudflaredLayer: string,
535
+ claudeCodeLayer: string,
507
536
  ): string {
508
537
  const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs)}\n\n`
538
+ const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
539
+ const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
509
540
  return `FROM ${GHCR_BASE_IMAGE_REPO}:${baseImageVersion}
510
541
 
511
542
  WORKDIR /agent
512
543
 
513
544
  ARG TARGETARCH
514
545
 
515
- ${ghKeyringLayer}${toggleAptLayer}${cloudflaredLayer}${renderEntrypointShimLayer()}
546
+ ${ghKeyringLayer}${toggleAptLayer}${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
516
547
 
517
548
  `
518
549
  }
@@ -521,8 +552,15 @@ ${ghKeyringLayer}${toggleAptLayer}${cloudflaredLayer}${renderEntrypointShimLayer
521
552
  // dev-mode runs (typeclaw installed via file: / link: spec) where the
522
553
  // matching :version GHCR tag does not yet exist, and by the test suite to
523
554
  // keep coverage of the full-stack layers independent of GHCR availability.
524
- function renderInlineHead(ghKeyringLayer: string, toggleAptArgs: string[], cloudflaredLayer: string): string {
555
+ function renderInlineHead(
556
+ ghKeyringLayer: string,
557
+ toggleAptArgs: string[],
558
+ cloudflaredLayer: string,
559
+ claudeCodeLayer: string,
560
+ ): string {
525
561
  const baselineAndToggleArgs = [...BASELINE_APT_PACKAGES, ...toggleAptArgs]
562
+ const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
563
+ const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
526
564
  return `${FROM_AND_WORKDIR}
527
565
 
528
566
  # Layers are ordered most-stable first to maximize Docker layer cache hits on
@@ -565,7 +603,7 @@ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
565
603
 
566
604
  ${LAYER_5_CHROME_FOR_TESTING}
567
605
 
568
- ${cloudflaredLayer}${renderEntrypointShimLayer()}
606
+ ${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
569
607
 
570
608
  `
571
609
  }
@@ -833,6 +871,7 @@ function defaultConfig(): DockerfileConfig {
833
871
  cjkFonts: true,
834
872
  cloudflared: true,
835
873
  xvfb: true,
874
+ claudeCode: false,
836
875
  append: [],
837
876
  }
838
877
  }
@@ -13,6 +13,7 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
13
13
  // (Codex is a backend, not a separate provider in their taxonomy). Curated
14
14
  // entries are surfaced regardless of upstream membership.
15
15
  'openai-codex': 'openai',
16
+ anthropic: 'anthropic',
16
17
  fireworks: 'fireworks-ai',
17
18
  zai: 'zai',
18
19
  // zai-coding (GLM Coding Plan) is a billing surface, not a separate model
@@ -12,6 +12,10 @@ export const CORE_PERMISSIONS = {
12
12
  channelRespond: 'channel.respond',
13
13
  cronSchedule: 'cron.schedule',
14
14
  cronModify: 'cron.modify',
15
+ subagentSpawn: 'subagent.spawn',
16
+ subagentCancel: 'subagent.cancel',
17
+ subagentOutput: 'subagent.output',
18
+ subagentSpawnOperator: 'subagent.spawn.operator',
15
19
  } as const
16
20
 
17
21
  // Sentinel that `expandOwnerWildcard` swaps for the concrete union of
@@ -47,6 +51,10 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
47
51
  CORE_PERMISSIONS.channelRespond,
48
52
  CORE_PERMISSIONS.cronSchedule,
49
53
  CORE_PERMISSIONS.cronModify,
54
+ CORE_PERMISSIONS.subagentSpawn,
55
+ CORE_PERMISSIONS.subagentCancel,
56
+ CORE_PERMISSIONS.subagentOutput,
57
+ CORE_PERMISSIONS.subagentSpawnOperator,
50
58
  'security.bypass.low',
51
59
  'security.bypass.medium',
52
60
  OWNER_SECURITY_WILDCARD,
@@ -54,11 +62,24 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
54
62
  },
55
63
  trusted: {
56
64
  match: [],
57
- permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.low'],
65
+ permissions: [
66
+ CORE_PERMISSIONS.channelRespond,
67
+ CORE_PERMISSIONS.cronSchedule,
68
+ CORE_PERMISSIONS.subagentSpawn,
69
+ CORE_PERMISSIONS.subagentCancel,
70
+ CORE_PERMISSIONS.subagentOutput,
71
+ CORE_PERMISSIONS.subagentSpawnOperator,
72
+ 'security.bypass.low',
73
+ ],
58
74
  },
59
75
  member: {
60
76
  match: [],
61
- permissions: [CORE_PERMISSIONS.channelRespond],
77
+ permissions: [
78
+ CORE_PERMISSIONS.channelRespond,
79
+ CORE_PERMISSIONS.subagentSpawn,
80
+ CORE_PERMISSIONS.subagentCancel,
81
+ CORE_PERMISSIONS.subagentOutput,
82
+ ],
62
83
  },
63
84
  guest: {
64
85
  match: [],
@@ -78,3 +78,5 @@ export const writeTool: BuiltinToolRef = { __builtinTool: 'write' }
78
78
  export const grepTool: BuiltinToolRef = { __builtinTool: 'grep' }
79
79
  export const findTool: BuiltinToolRef = { __builtinTool: 'find' }
80
80
  export const lsTool: BuiltinToolRef = { __builtinTool: 'ls' }
81
+ export const websearchTool: BuiltinToolRef = { __builtinTool: 'websearch' }
82
+ export const webfetchTool: BuiltinToolRef = { __builtinTool: 'webfetch' }
@@ -9,6 +9,8 @@ export {
9
9
  grepTool,
10
10
  lsTool,
11
11
  readTool,
12
+ webfetchTool,
13
+ websearchTool,
12
14
  writeTool,
13
15
  } from './define'
14
16
 
@@ -1,7 +1,7 @@
1
1
  import type { z } from 'zod'
2
2
 
3
3
  import type { SessionOrigin } from '@/agent/session-origin'
4
- import type { ToolResultBudget } from '@/agent/tool-result-budget'
4
+ import type { SubagentShared } from '@/agent/subagents'
5
5
  import type { PermissionService } from '@/permissions'
6
6
 
7
7
  export type ContentPart = { type: 'text'; text: string } | { type: 'image'; mimeType: string; data: string }
@@ -40,35 +40,28 @@ export type SubagentContext<P = unknown> = {
40
40
 
41
41
  export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
42
42
 
43
- export type Subagent<P = unknown> = {
44
- systemPrompt: string
45
- // Model profile this subagent prefers. Resolved against `models` in
46
- // typeclaw.json at session construction. Unknown profile names fall back to
47
- // `default` with a warning. Well-known names: `default`, `fast`, `deep`,
48
- // `vision`. Subagents that want a specific tier (e.g. memory-logger wants
49
- // `fast`, dreaming wants `deep`) declare it here so the user only has to
50
- // map tier model in config rather than wire each subagent individually.
51
- profile?: string
43
+ // The plugin-author-facing subagent declaration. Differs from
44
+ // `@/agent/subagents`'s `Subagent` only in the shape of `tools`/`customTools`:
45
+ // plugins reference builtin tools via tagged `BuiltinToolRef` strings (the
46
+ // stable plugin API) and contribute their own `Tool<any>[]`; the runtime
47
+ // resolves those refs to pi-coding-agent's wrapped tool shapes before the
48
+ // session sees them. Every other field is inherited from `SubagentShared`
49
+ // so a new shared field surfaces on both types in one edit. See
50
+ // `SubagentShared`'s doc-comment for the regression history.
51
+ //
52
+ // `inFlightKey` lives here only (not on the shared shape) because it is
53
+ // consumed exclusively by the `SubagentConsumer` via the
54
+ // `pluginSubagentByName` map, which holds the original plugin reference —
55
+ // the registry-flowing shim never needs to carry it.
56
+ export type Subagent<P = unknown> = SubagentShared<P> & {
52
57
  tools?: BuiltinToolRef[]
53
58
  customTools?: Tool<any>[]
54
- payloadSchema?: z.ZodType<P>
55
- handler?: (ctx: SubagentContext<P>, runSession: RunSession) => Promise<void>
56
59
  // Coalescing key for the SubagentConsumer's in-flight set. Default is the
57
60
  // subagent name alone (only one instance of the subagent runs at a time).
58
61
  // Override to allow per-payload concurrency, e.g. memory-logger keyed by
59
62
  // parentSessionId so different parent sessions run in parallel while
60
63
  // duplicate runs against the same session deduplicate.
61
64
  inFlightKey?: (payload: P) => string
62
- // Defensive ceiling on cumulative bytes of tool-result text per subagent
63
- // run, applied to the named tools only. Once exceeded, subsequent calls to
64
- // those tools short-circuit with a fixed message instructing the agent to
65
- // stop reading. See `src/agent/tool-result-budget.ts` for the full
66
- // rationale; the short version is: a single broken tool (e.g. find_entry
67
- // failing because of a schema mismatch) can cause an agent to fall back to
68
- // chunked reads of huge files, ballooning subagent token cost. The budget
69
- // bounds the blast radius without changing per-call semantics for healthy
70
- // runs.
71
- toolResultBudget?: ToolResultBudget
72
65
  }
73
66
 
74
67
  // Cron job map keys are local; the runtime prefixes with `__plugin_<plugin-name>_`
@@ -1,7 +1,10 @@
1
1
  import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
2
2
  import backupPlugin from '@/bundled-plugins/backup'
3
+ import explorerPlugin from '@/bundled-plugins/explorer'
3
4
  import guardPlugin from '@/bundled-plugins/guard'
4
5
  import memoryPlugin from '@/bundled-plugins/memory'
6
+ import operatorPlugin from '@/bundled-plugins/operator'
7
+ import scoutPlugin from '@/bundled-plugins/scout'
5
8
  import securityPlugin from '@/bundled-plugins/security'
6
9
  import toolResultCapPlugin from '@/bundled-plugins/tool-result-cap'
7
10
  import type { ResolvedPlugin } from '@/plugin'
@@ -36,4 +39,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
36
39
  { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
37
40
  { name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
38
41
  { name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
42
+ { name: 'explorer', version: undefined, source: '<bundled>', defined: explorerPlugin },
43
+ { name: 'scout', version: undefined, source: '<bundled>', defined: scoutPlugin },
44
+ { name: 'operator', version: undefined, source: '<bundled>', defined: operatorPlugin },
39
45
  ]
@@ -1,6 +1,8 @@
1
1
  import { SessionManager } from '@mariozechner/pi-coding-agent'
2
2
 
3
3
  import { createSession as defaultCreateSession } from '@/agent'
4
+ import type { LiveSubagentRegistry } from '@/agent/live-subagents'
5
+ import type { CreateSessionForSubagent, SubagentRegistry } from '@/agent/subagents'
4
6
  import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
5
7
  import type { CapOptions } from '@/bundled-plugins/tool-result-cap/cap-result'
6
8
  import type { CreateSessionForChannel, ChannelRouter } from '@/channels'
@@ -48,6 +50,18 @@ export type BuildChannelSessionFactoryDeps = {
48
50
  // can assert exactly which CreateSessionOptions the factory builds without
49
51
  // needing a live LLM, plugin runtime, or session manager on disk.
50
52
  createSession?: typeof defaultCreateSession
53
+ // Subagent orchestration plumbing. All three (or none) are forwarded to
54
+ // createSession so the TUI/channel session exposes spawn_subagent,
55
+ // subagent_output, subagent_cancel. Subagent sessions never receive these
56
+ // — that branch is gated by pluginSubagent in createSessionWithDispose.
57
+ //
58
+ // `getCreateSessionForSubagent` is late-bound to break the construction
59
+ // cycle: channelManager owns the channel-session factory, which needs
60
+ // createSessionForSubagent, which needs channelManager.router. Same shape
61
+ // as `getChannelRouter` above.
62
+ liveSubagentRegistry?: LiveSubagentRegistry
63
+ subagentRegistry?: SubagentRegistry
64
+ getCreateSessionForSubagent?: () => CreateSessionForSubagent
51
65
  }
52
66
 
53
67
  // Tight basename validation so a tampered or corrupt channels/sessions.json
@@ -108,6 +122,11 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
108
122
  ...(deps.containerName !== undefined ? { containerName: deps.containerName } : {}),
109
123
  ...(deps.runtimeVersion !== undefined ? { runtimeVersion: deps.runtimeVersion } : {}),
110
124
  ...(deps.permissions !== undefined ? { permissions: deps.permissions } : {}),
125
+ ...(deps.liveSubagentRegistry !== undefined ? { liveSubagentRegistry: deps.liveSubagentRegistry } : {}),
126
+ ...(deps.subagentRegistry !== undefined ? { subagentRegistry: deps.subagentRegistry } : {}),
127
+ ...(deps.getCreateSessionForSubagent !== undefined
128
+ ? { createSessionForSubagent: deps.getCreateSessionForSubagent() }
129
+ : {}),
111
130
  })
112
131
 
113
132
  return {
package/src/run/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SessionManager } from '@mariozechner/pi-coding-agent'
2
2
 
3
3
  import { createSession, createSessionWithDispose } from '@/agent'
4
+ import { LiveSubagentRegistry } from '@/agent/live-subagents'
4
5
  import type { SessionOrigin } from '@/agent/session-origin'
5
6
  import {
6
7
  createSubagentConsumer,
@@ -9,6 +10,7 @@ import {
9
10
  type Subagent as InternalSubagent,
10
11
  type SubagentConsumer,
11
12
  type SubagentRegistry,
13
+ type SubagentShared,
12
14
  } from '@/agent/subagents'
13
15
  import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
14
16
  import { createChannelManager, createChannelsReloadable, type ChannelManager } from '@/channels'
@@ -176,6 +178,8 @@ export async function startAgent({
176
178
  },
177
179
  })
178
180
 
181
+ const liveSubagentRegistry = new LiveSubagentRegistry()
182
+
179
183
  const channelManager = createChannelManagerFor({
180
184
  agentDir: cwd,
181
185
  channelsConfigRef: () => getConfig().channels,
@@ -191,6 +195,9 @@ export async function startAgent({
191
195
  getChannelRouter: () => channelManager.router,
192
196
  rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
193
197
  permissions: pluginsLoaded.permissions,
198
+ liveSubagentRegistry,
199
+ subagentRegistry: pluginRuntime.get().subagents,
200
+ getCreateSessionForSubagent: () => createSessionForSubagent,
194
201
  ...containerNameOpt,
195
202
  ...runtimeVersionOpt,
196
203
  }),
@@ -347,6 +354,9 @@ export async function startAgent({
347
354
  },
348
355
  }
349
356
  : {}),
357
+ liveSubagentRegistry,
358
+ subagentRegistry: pluginRuntime.get().subagents,
359
+ createSessionForSubagent,
350
360
  ...containerNameOpt,
351
361
  ...runtimeVersionOpt,
352
362
  })
@@ -465,6 +475,8 @@ export async function startAgent({
465
475
  claimController,
466
476
  commandRunnerFactory,
467
477
  tunnelManager,
478
+ liveSubagentRegistry,
479
+ createSessionForSubagent,
468
480
  ...containerNameOpt,
469
481
  ...runtimeVersionOpt,
470
482
  ...tuiTokenOpt,
@@ -593,7 +605,15 @@ function makeDefaultSchedulerFactory(internalJobs: () => CronJob[]): SchedulerFa
593
605
  return ({ file, onFire }) => createScheduler({ jobs: [...file.jobs, ...internalJobs()], onFire })
594
606
  }
595
607
 
596
- function mergeSubagents(pluginRegistry: PluginRegistry): {
608
+ // Exported for the regression test in `merge-subagents.test.ts`. The shim
609
+ // layer between the plugin-author-facing `Subagent` (`@/plugin/types`) and
610
+ // the runtime-internal `Subagent` (`@/agent/subagents`) is the load-bearing
611
+ // translation point for visibility, payload-schema, and permission gating —
612
+ // fields that flow through the `SubagentRegistry` without going through the
613
+ // `pluginSubagentByShim` recovery path. Previous regressions silently
614
+ // dropped fields here, hiding every public bundled subagent (scout,
615
+ // explorer, operator) from the `spawn_subagent` tool surface.
616
+ export function mergeSubagents(pluginRegistry: PluginRegistry): {
597
617
  registry: SubagentRegistry
598
618
  pluginSubagentByShim: WeakMap<InternalSubagent<any>, PluginSubagentEntry>
599
619
  pluginSubagentByName: Map<string, PluginSubagentEntry>
@@ -620,10 +640,40 @@ function mergeSubagents(pluginRegistry: PluginRegistry): {
620
640
  return { registry: merged, pluginSubagentByShim, pluginSubagentByName }
621
641
  }
622
642
 
643
+ // Compile-time proof that every plugin-only key on `@/plugin`'s `Subagent`
644
+ // (i.e. every key NOT inherited from `SubagentShared`) has been classified
645
+ // for the shim. When a future maintainer introduces a new field on plugin-side
646
+ // `Subagent` that isn't on `SubagentShared`, the `satisfies` clause on
647
+ // `PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM` below fails at compile time until the
648
+ // new key is listed there — and the destructuring in `pluginSubagentShim`
649
+ // is updated to discard it. Without this guard, the shim's rest-spread
650
+ // would silently leak future plugin-only fields into the internal registry —
651
+ // the opposite-direction drift from the bug this PR fixes for shared fields.
652
+ type PluginOnlySubagentKeys = Exclude<keyof import('@/plugin').Subagent<any>, keyof SubagentShared<any>>
653
+ const PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM = {
654
+ tools: true,
655
+ customTools: true,
656
+ inFlightKey: true,
657
+ } satisfies Record<PluginOnlySubagentKeys, true>
658
+ // Reference the table so it's not dead code. The value is a runtime no-op;
659
+ // the load-bearing work is the `satisfies` clause above which forces
660
+ // exhaustive classification of plugin-only keys at compile time.
661
+ void PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM
662
+
623
663
  function pluginSubagentShim(subagent: import('@/plugin').Subagent<any>): InternalSubagent<any> {
624
- return {
625
- systemPrompt: subagent.systemPrompt,
626
- ...(subagent.payloadSchema ? { payloadSchema: subagent.payloadSchema } : {}),
627
- ...(subagent.handler ? { handler: subagent.handler as InternalSubagent<any>['handler'] } : {}),
628
- }
664
+ // The two diverging fields (`tools` is `BuiltinToolRef[]` plugin-side vs
665
+ // `AgentSessionTools` internal-side; `customTools` similarly differs) are
666
+ // resolved later in `createSessionForSubagent` via the
667
+ // `pluginSubagentByShim` lookup, which recovers the original plugin
668
+ // reference. `inFlightKey` is consumed only by the SubagentConsumer via
669
+ // `pluginSubagentByName`, not through this shim's registry path. Every
670
+ // other plugin-side field lives on `SubagentShared` and is structurally
671
+ // assignable to the internal `Subagent`, so a rest-spread carries them
672
+ // verbatim — including `visibility` and `requiresSpecificPermission`,
673
+ // whose silent drop in the previous shim made every plugin-contributed
674
+ // public subagent (scout, explorer, operator) invisible to the
675
+ // `spawn_subagent` tool. The list of keys removed here is enforced
676
+ // exhaustive at compile time by `PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM` above.
677
+ const { tools: _tools, customTools: _customTools, inFlightKey: _inFlightKey, ...shared } = subagent
678
+ return shared
629
679
  }
@@ -7,8 +7,10 @@ import {
7
7
  type CreateSessionResult,
8
8
  } from '@/agent'
9
9
  import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
10
+ import type { LiveSubagentRegistry } from '@/agent/live-subagents'
10
11
  import { detectProviderError } from '@/agent/provider-error'
11
12
  import type { SessionOrigin } from '@/agent/session-origin'
13
+ import type { CreateSessionForSubagent } from '@/agent/subagents'
12
14
  import type { ChannelRouter } from '@/channels/router'
13
15
  import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
14
16
  import type { HookBus } from '@/plugin'
@@ -79,6 +81,27 @@ export type ServerOptions = {
79
81
  // without it the four `exec_command`-family messages are answered with
80
82
  // `command_error` so the host CLI sees a clean failure.
81
83
  commandRunnerFactory?: (outbound: CommandOutbound) => CommandRunner
84
+ // Subagent orchestration plumbing for TUI sessions. Both fields must be
85
+ // present together for the spawn_subagent / subagent_output / subagent_cancel
86
+ // tools to surface; `createSession` gates registration on all three of
87
+ // (liveSubagentRegistry, subagentRegistry, createSessionForSubagent), and
88
+ // we derive subagentRegistry per WS connection from the same `pluginRuntime`
89
+ // snapshot that already feeds `plugins.registry` — so a reload landing
90
+ // mid-connection keeps using the snapshot the session opened with, matching
91
+ // the existing per-session lifecycle invariant.
92
+ //
93
+ // `createSessionForSubagent` is passed eagerly (not late-bound) because the
94
+ // TUI server is constructed AFTER the channel manager in `startAgent`,
95
+ // breaking the construction cycle that forces the channel session factory's
96
+ // `getCreateSessionForSubagent` late-binding.
97
+ //
98
+ // Channel and cron sessions get the same plumbing through
99
+ // `buildChannelSessionFactory` / `createSessionForCron` (see src/run/). The
100
+ // three top-level callers must stay aligned; otherwise the agent's tool
101
+ // surface diverges across origin kinds — exactly the gap PR #281 flagged
102
+ // as out-of-scope follow-up.
103
+ liveSubagentRegistry?: LiveSubagentRegistry
104
+ createSessionForSubagent?: CreateSessionForSubagent
82
105
  }
83
106
 
84
107
  const consoleLogger: ServerLogger = {
@@ -176,6 +199,8 @@ export function createServer({
176
199
  logger = consoleLogger,
177
200
  claimController,
178
201
  commandRunnerFactory,
202
+ liveSubagentRegistry,
203
+ createSessionForSubagent,
179
204
  }: ServerOptions) {
180
205
  const sessionStates = new WeakMap<Ws, SessionState>()
181
206
  const callIdToWs = new Map<string, AnyOwnerWs>()
@@ -351,6 +376,15 @@ export function createServer({
351
376
  }
352
377
  : undefined
353
378
  const origin: SessionOrigin = { kind: 'tui', sessionId: sessionFileId }
379
+ // Derive subagentRegistry from the same runtimeSnapshot that
380
+ // populates `plugins.registry`. createSession gates the orchestration
381
+ // tools on (liveRegistry, subagentRegistry, createSessionForSubagent,
382
+ // agentDir) being all-present; threading the registry alongside the
383
+ // two server-owned fields gives the gate a complete tuple for TUI
384
+ // sessions whenever the host plumbed in plugin runtime + subagent
385
+ // wiring (production), while keeping every existing test that omits
386
+ // either side at exactly its current tool surface.
387
+ const subagentRegistry = runtimeSnapshot?.subagents
354
388
  const result = await createSession({
355
389
  reloadRegistry,
356
390
  sessionManager,
@@ -360,6 +394,9 @@ export function createServer({
360
394
  ...(pluginsWiring ? { plugins: pluginsWiring } : {}),
361
395
  ...(containerName !== undefined ? { containerName } : {}),
362
396
  ...(runtimeVersion !== undefined ? { runtimeVersion } : {}),
397
+ ...(liveSubagentRegistry !== undefined ? { liveSubagentRegistry } : {}),
398
+ ...(subagentRegistry !== undefined ? { subagentRegistry } : {}),
399
+ ...(createSessionForSubagent !== undefined ? { createSessionForSubagent } : {}),
363
400
  })
364
401
  const session = 'session' in result ? result.session : result
365
402
  const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
@@ -392,6 +429,7 @@ export function createServer({
392
429
  )
393
430
 
394
431
  state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
432
+ routeSubagentCompletionReminder(state, msg, stream)
395
433
  const payload: ServerMessage = {
396
434
  type: 'notification',
397
435
  payload: msg.payload,
@@ -712,6 +750,71 @@ function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, s
712
750
  send(ws, { type: 'error', message: detected.message })
713
751
  }
714
752
 
753
+ function routeSubagentCompletionReminder(state: SessionState, msg: StreamMessage, stream: Stream): void {
754
+ const payload = msg.payload
755
+ if (payload === null || typeof payload !== 'object') return
756
+ const p = payload as {
757
+ kind?: unknown
758
+ taskId?: unknown
759
+ subagent?: unknown
760
+ parentSessionId?: unknown
761
+ ok?: unknown
762
+ durationMs?: unknown
763
+ error?: unknown
764
+ }
765
+ if (p.kind !== 'subagent.completed') return
766
+ if (typeof p.parentSessionId !== 'string' || p.parentSessionId !== state.sessionFileId) return
767
+
768
+ const subagent = typeof p.subagent === 'string' ? p.subagent : 'subagent'
769
+ const taskId = typeof p.taskId === 'string' ? p.taskId : '<unknown>'
770
+ const ok = p.ok === true
771
+ const durationMs = typeof p.durationMs === 'number' ? p.durationMs : 0
772
+ const error = typeof p.error === 'string' ? p.error : undefined
773
+
774
+ const idle = state.drainQueue.length === 0 && !state.draining
775
+ const delivery = idle ? 'interrupt' : 'queue'
776
+ const text = renderCompletionReminder({ subagent, taskId, ok, durationMs, error })
777
+ stream.publish({
778
+ target: { kind: 'session', sessionId: state.sessionFileId },
779
+ payload: { kind: 'prompt', text, delivery },
780
+ meta: { source: 'subagent-completion' },
781
+ })
782
+ }
783
+
784
+ function renderCompletionReminder(args: {
785
+ subagent: string
786
+ taskId: string
787
+ ok: boolean
788
+ durationMs: number
789
+ error?: string
790
+ }): string {
791
+ const durationStr = formatReminderDuration(args.durationMs)
792
+ if (args.ok) {
793
+ return (
794
+ `<system-reminder>\n` +
795
+ `Subagent \`${args.subagent}\` (${args.taskId}) completed in ${durationStr}. ` +
796
+ `Use subagent_output to fetch the result.\n` +
797
+ `</system-reminder>`
798
+ )
799
+ }
800
+ const err = args.error ?? 'unknown error'
801
+ return (
802
+ `<system-reminder>\n` +
803
+ `Subagent \`${args.subagent}\` (${args.taskId}) FAILED after ${durationStr}: ${err}. ` +
804
+ `Use subagent_output to inspect.\n` +
805
+ `</system-reminder>`
806
+ )
807
+ }
808
+
809
+ function formatReminderDuration(ms: number): string {
810
+ if (ms < 1000) return `${ms}ms`
811
+ const totalSec = Math.floor(ms / 1000)
812
+ if (totalSec < 60) return `${totalSec}s`
813
+ const min = Math.floor(totalSec / 60)
814
+ const sec = totalSec % 60
815
+ return `${min}m${sec}s`
816
+ }
817
+
715
818
  function enqueuePrompt(
716
819
  ws: Ws,
717
820
  state: SessionState,