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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/agent/index.ts +80 -8
- package/src/agent/live-subagents.ts +215 -0
- package/src/agent/plugin-tools.ts +60 -20
- package/src/agent/session-origin.ts +15 -0
- package/src/agent/subagents.ts +140 -3
- package/src/agent/system-prompt.ts +40 -0
- package/src/agent/tools/channel-reply.ts +24 -1
- package/src/agent/tools/channel-send.ts +26 -1
- package/src/agent/tools/spawn-subagent.ts +283 -0
- package/src/agent/tools/subagent-cancel.ts +96 -0
- package/src/agent/tools/subagent-output.ts +192 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +26 -0
- package/src/bundled-plugins/explorer/explorer.ts +103 -0
- package/src/bundled-plugins/explorer/index.ts +11 -0
- package/src/bundled-plugins/guard/index.ts +12 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +139 -0
- package/src/bundled-plugins/guard/policy.ts +1 -0
- package/src/bundled-plugins/operator/index.ts +11 -0
- package/src/bundled-plugins/operator/operator.ts +76 -0
- package/src/bundled-plugins/scout/index.ts +11 -0
- package/src/bundled-plugins/scout/scout.ts +94 -0
- package/src/channels/router.ts +32 -0
- package/src/cli/channel.ts +2 -45
- package/src/cli/init.ts +2 -45
- package/src/cli/model.ts +2 -1
- package/src/cli/ui.ts +95 -0
- package/src/config/config.ts +45 -12
- package/src/config/index.ts +3 -0
- package/src/cron/index.ts +3 -0
- package/src/cron/schema.ts +20 -0
- package/src/init/dockerfile.ts +156 -5
- package/src/init/index.ts +33 -0
- package/src/permissions/builtins.ts +23 -2
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/types.ts +15 -22
- package/src/run/bundled-plugins.ts +6 -0
- package/src/run/channel-session-factory.ts +19 -0
- package/src/run/index.ts +56 -6
- package/src/server/index.ts +103 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +273 -0
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +135 -0
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +99 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +157 -0
- package/src/skills/typeclaw-config/SKILL.md +29 -26
- package/typeclaw.schema.json +6 -0
package/src/init/dockerfile.ts
CHANGED
|
@@ -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}${
|
|
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(
|
|
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
|
-
${
|
|
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: [
|
|
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: [
|
|
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: [],
|
package/src/plugin/define.ts
CHANGED
|
@@ -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' }
|
package/src/plugin/index.ts
CHANGED
package/src/plugin/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
}
|