typeclaw 0.5.0 → 0.6.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 +4 -0
  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 +40 -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/channel.ts +2 -45
  26. package/src/cli/init.ts +2 -45
  27. package/src/cli/model.ts +2 -1
  28. package/src/cli/ui.ts +95 -0
  29. package/src/config/config.ts +45 -12
  30. package/src/config/index.ts +3 -0
  31. package/src/cron/index.ts +3 -0
  32. package/src/cron/schema.ts +20 -0
  33. package/src/init/dockerfile.ts +156 -5
  34. package/src/init/index.ts +33 -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 +6 -0
@@ -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
@@ -561,9 +599,11 @@ ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
561
599
 
562
600
  ${LAYER_4_AGENT_BROWSER_INSTALL}
563
601
 
602
+ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
603
+
564
604
  ${LAYER_5_CHROME_FOR_TESTING}
565
605
 
566
- ${cloudflaredLayer}${renderEntrypointShimLayer()}
606
+ ${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
567
607
 
568
608
  `
569
609
  }
@@ -638,6 +678,8 @@ ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
638
678
 
639
679
  ${LAYER_4_AGENT_BROWSER_INSTALL}
640
680
 
681
+ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
682
+
641
683
  ${LAYER_5_CHROME_FOR_TESTING}
642
684
 
643
685
  ${renderEntrypointShimLayer()}
@@ -699,6 +741,114 @@ const LAYER_4_AGENT_BROWSER_INSTALL = `# Layer 4 (volatile): install agent-brows
699
741
  RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \\
700
742
  bun install -g agent-browser`
701
743
 
744
+ // Layer 4.5: shim the agent-browser binary with a wrapper that calls
745
+ // \`agent-browser close\` before \`open\`/\`goto\`/\`navigate\` when headed
746
+ // mode is requested. Works around vercel-labs/agent-browser issue #1083
747
+ // ("headed silently ignored on existing session"): when a daemon is
748
+ // already running with a headless browser, subsequent commands with
749
+ // --headed / AGENT_BROWSER_HEADED reuse the existing headless browser
750
+ // regardless of the requested mode. Three upstream fix PRs (#660, #370,
751
+ // #387) have been open and unmerged for months as of 2026-05, so we
752
+ // patch this locally rather than block on upstream.
753
+ //
754
+ // Allowlist, not denylist. The wrapper only pre-closes on the three
755
+ // commands that explicitly start a new browsing session (\`open\`,
756
+ // \`goto\`, \`navigate\`). Every other agent-browser subcommand — \`click\`,
757
+ // \`snapshot\`, \`chat\`, \`connect\`, \`batch\`, \`tab\`, \`record\`, \`trace\`,
758
+ // \`stream\`, \`cookies\`, \`network\`, ... — passes through untouched.
759
+ // Rationale: those subcommands may operate on the live browser/page
760
+ // state (cookies, in-progress recording, attached external CDP, etc.),
761
+ // and a pre-close from us would silently destroy it. The user-reported
762
+ // scenario for #1083 (\"\`agent-browser open <url> --headed\` after a
763
+ // previous headless invocation\") is fully covered because the
764
+ // follow-up commands inherit the now-headed browser the \`open\`
765
+ // pre-close forced. An earlier draft used a deny-list approach that
766
+ // pre-closed on every non-skip subcommand under headed env; oracle
767
+ // self-review flagged the state-destruction risk for stateful commands,
768
+ // and the allowlist fix is the resulting narrower contract.
769
+ //
770
+ // Truthy contract mirrors upstream's \`env_var_is_truthy\`
771
+ // (cli/src/flags.rs:183): any non-empty value EXCEPT case-insensitive
772
+ // "0" / "false" / "no" counts as truthy. So
773
+ // \`AGENT_BROWSER_HEADED=yes\`, \`=y\`, \`=on\`, \`=anything-non-falsy\` all
774
+ // trigger the workaround — matching what upstream's CLI parser would
775
+ // see — instead of the original narrower 1|true match that left the
776
+ // bug present for legitimate truthy values.
777
+ //
778
+ // Re-entrancy is defended at two layers. (1) The pre-close path is
779
+ // \`open\`/\`goto\`/\`navigate\` only, and the close subcommand isn't in the
780
+ // allowlist, so the pre-close never recurses through the wrapper into
781
+ // another pre-close. (2) \`_TYPECLAW_AGENT_BROWSER_HEADED_HANDLED=1\` is
782
+ // set on the env passed to both the pre-close and the final exec; if a
783
+ // future subcommand we don't recognize shells out to \`agent-browser\` as
784
+ // a subprocess while headed env is still set, the child sees the guard
785
+ // and bypasses straight to .real without recursing.
786
+ const LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER = `# Layer 4.5 (cheap): wrap agent-browser to work around upstream issue
787
+ # #1083 (--headed / AGENT_BROWSER_HEADED ignored on existing session).
788
+ # See src/init/dockerfile.ts for the full rationale.
789
+ RUN mv /usr/local/bin/agent-browser /usr/local/bin/agent-browser.real \\
790
+ && cat > /usr/local/bin/agent-browser <<'TYPECLAW_AGENT_BROWSER_WRAPPER_EOF' \\
791
+ && chmod +x /usr/local/bin/agent-browser
792
+ #!/bin/sh
793
+ # typeclaw wrapper for agent-browser — see src/init/dockerfile.ts.
794
+ set -e
795
+ real="\${TYPECLAW_AGENT_BROWSER_REAL:-/usr/local/bin/agent-browser.real}"
796
+ # Re-entrancy guard: if the wrapper invoked us, skip straight to the real
797
+ # binary. Prevents infinite recursion if a subcommand shells out to
798
+ # agent-browser while AGENT_BROWSER_HEADED is still set.
799
+ if [ "\${_TYPECLAW_AGENT_BROWSER_HEADED_HANDLED:-}" = "1" ]; then
800
+ exec "$real" "$@"
801
+ fi
802
+ # Pre-close is only needed when the caller is requesting headed mode.
803
+ # Match upstream's env_var_is_truthy contract (cli/src/flags.rs:183):
804
+ # truthy = any non-empty value except case-insensitive "0", "false", "no".
805
+ # Argv triggers: bare --headed, --headed=true, --headed=1. (A bare
806
+ # --headed followed by a separate "false" argument is upstream-supported
807
+ # to FORCE headless; the wrapper still pre-closes on the --headed match
808
+ # and the real binary launches headless — wasted close, correct end
809
+ # state. The narrower argv match keeps the wrapper from triggering on
810
+ # unrelated --headed-prefixed flags that may exist in future upstream
811
+ # versions.)
812
+ headed=0
813
+ val=\${AGENT_BROWSER_HEADED:-}
814
+ lower=$(printf '%s' "$val" | tr '[:upper:]' '[:lower:]')
815
+ case "$lower" in
816
+ ''|'0'|'false'|'no') ;;
817
+ *) headed=1 ;;
818
+ esac
819
+ for arg in "$@"; do
820
+ case "$arg" in
821
+ --headed|--headed=true|--headed=1) headed=1; break ;;
822
+ esac
823
+ done
824
+ if [ "$headed" != "1" ]; then
825
+ exec "$real" "$@"
826
+ fi
827
+ # Allowlist of commands where pre-close is safe and necessary. Only
828
+ # user-visible "start a new browsing session" verbs go here. Everything
829
+ # else (click, snapshot, chat, connect, batch, tab, record, trace,
830
+ # stream, cookies, ...) may depend on live browser/page state and must
831
+ # not be pre-closed by us.
832
+ first=""
833
+ for arg in "$@"; do
834
+ case "$arg" in
835
+ -*) continue ;;
836
+ *) first="$arg"; break ;;
837
+ esac
838
+ done
839
+ case "$first" in
840
+ open|goto|navigate) ;;
841
+ *) exec "$real" "$@" ;;
842
+ esac
843
+ # Best-effort pre-close. If the daemon is already gone, the real binary
844
+ # prints "No active sessions" and exits 0 — safe to call unconditionally.
845
+ # We discard its output so it never pollutes the caller's stdout/stderr,
846
+ # and we tolerate failures (network blip, stale socket) by falling
847
+ # through to the real command anyway.
848
+ _TYPECLAW_AGENT_BROWSER_HEADED_HANDLED=1 "$real" close >/dev/null 2>&1 || true
849
+ exec env _TYPECLAW_AGENT_BROWSER_HEADED_HANDLED=1 "$real" "$@"
850
+ TYPECLAW_AGENT_BROWSER_WRAPPER_EOF`
851
+
702
852
  // Layer 5: download the pinned Chrome for Testing build into
703
853
  // ~/.agent-browser/browsers/. NO cache mount on that path because the
704
854
  // runtime needs the binary in the image. System shared libraries are
@@ -721,6 +871,7 @@ function defaultConfig(): DockerfileConfig {
721
871
  cjkFonts: true,
722
872
  cloudflared: true,
723
873
  xvfb: true,
874
+ claudeCode: false,
724
875
  append: [],
725
876
  }
726
877
  }
package/src/init/index.ts CHANGED
@@ -37,6 +37,14 @@ const CONFIG_FILE = 'typeclaw.json'
37
37
  const CRON_FILE = 'cron.json'
38
38
  const PACKAGE_FILE = 'package.json'
39
39
 
40
+ // Seeded into `typeclaw.json#roles.member.match[]` whenever a chat adapter
41
+ // (slack-bot, discord-bot, telegram-bot, kakaotalk) is wired. The "*" rule
42
+ // matches every channel session on every platform, so the built-in `member`
43
+ // role (which already carries `channel.respond`) covers any inbound the
44
+ // router sees. Without this, freshly-hatched agents silently drop every
45
+ // chat message — see scaffold() and ensureDefaultChatMemberMatch() below.
46
+ const DEFAULT_CHAT_MEMBER_MATCH_RULE = '*'
47
+
40
48
  const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
41
49
 
42
50
  // `packages/` is a bun workspace root (see `workspaces` in buildPackageJson).
@@ -543,6 +551,11 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
543
551
  if (options.withTelegram) channels['telegram-bot'] = {}
544
552
  if (options.withKakaotalk) channels.kakaotalk = {}
545
553
  if (Object.keys(channels).length > 0) config.channels = channels
554
+ // See DEFAULT_CHAT_MEMBER_MATCH_RULE for why this is here. GitHub is wired
555
+ // separately (writeGithubChannelForInit) and seeds per-repo member.match
556
+ // entries instead of the wildcard, so a github-only init stays scoped to
557
+ // the repos the operator opted in to.
558
+ if (Object.keys(channels).length > 0) config.roles = { member: { match: [DEFAULT_CHAT_MEMBER_MATCH_RULE] } }
546
559
  await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
547
560
 
548
561
  const cron = {
@@ -965,6 +978,8 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
965
978
  if (options.channel === 'github') {
966
979
  await appendGithubMatchRules(options.cwd, options.repos)
967
980
  await maybeInstallGithubWebhooks(options, emit)
981
+ } else {
982
+ await ensureDefaultChatMemberMatch(options.cwd)
968
983
  }
969
984
 
970
985
  // Commit the typeclaw.json change so the agent folder isn't silently
@@ -1209,6 +1224,24 @@ async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Pr
1209
1224
  await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1210
1225
  }
1211
1226
 
1227
+ // Chat-adapter counterpart of appendGithubMatchRules. See
1228
+ // DEFAULT_CHAT_MEMBER_MATCH_RULE for the rationale. Set-union semantics: re-
1229
+ // running `typeclaw channel add` for additional chat adapters is a no-op on
1230
+ // the match list, and any pre-existing rules the operator hand-authored
1231
+ // (e.g. owner-claim's per-author entry on `owner`) are left intact.
1232
+ async function ensureDefaultChatMemberMatch(cwd: string): Promise<void> {
1233
+ const path = join(cwd, CONFIG_FILE)
1234
+ const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
1235
+ const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
1236
+ const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
1237
+ const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
1238
+ if (existing.includes(DEFAULT_CHAT_MEMBER_MATCH_RULE)) return
1239
+ member.match = [...existing, DEFAULT_CHAT_MEMBER_MATCH_RULE]
1240
+ roles.member = member
1241
+ parsed.roles = roles
1242
+ await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1243
+ }
1244
+
1212
1245
  // Writes per-adapter field values into `secrets.json#channels.<adapter>`.
1213
1246
  // Refuses to overwrite existing fields: if the user already has e.g.
1214
1247
  // `botToken` recorded (from a prior `channel add` whose follow-up steps
@@ -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
  }