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
package/README.md CHANGED
@@ -2,51 +2,48 @@
2
2
 
3
3
  > A TypeScript-native, Bun-powered, Docker-friendly general-purpose agent runtime.
4
4
 
5
- ## Why?
6
-
7
- There are great agents out there. None of them were quite the shape I wanted:
5
+ Full docs: **[typeclaw.dev](https://typeclaw.dev)**.
8
6
 
9
- - **OpenClaw** — feature-rich, but heavy
10
- - **NanoClaw** — simple, but no plugin system
11
- - **PicoClaw** — fast, but Go (so plugins live outside the runtime)
12
- - **ZeroClaw** — light, but Rust (same problem, different ecosystem)
13
- - **Hermes Agent** — awesome, but Python
7
+ ## Why?
14
8
 
15
- None of that matters to most people. It matters to me. If you're like me, TypeClaw is the right choice.
9
+ There are great agents out there. None of them were quite the shape I wanted most are written in Go, Rust, or Python, which means plugins live outside the runtime (IPC, FFI, or a separate process). The ones in TypeScript are either too heavy or too bare.
16
10
 
17
11
  TypeClaw is the agent I wanted to use:
18
12
 
19
13
  - **TypeScript end to end** — agent core, plugins, channel adapters, CLI, TUI all in one language
20
14
  - **Bun-native plugins** — plugins are just TS modules; no IPC, no FFI, hot-reloadable config
21
15
  - **Docker-friendly by default** — every agent runs in its own container; the host CLI is purely a launcher
22
- - **Multi-channel out of the box** — Slack, Discord, TUI, websocket — all routed through one in-process stream
23
16
  - **Self-improving** — the agent observes its own work, distills it into long-term memory and reusable skills, and gets sharper over time without you writing prompts for it
24
17
 
25
- ## Features
18
+ If you're like me, TypeClaw is the right choice. If not, that's fine too.
26
19
 
27
- - 🐳 **Sandboxed by default** — every agent runs in its own Docker container, with an `.env` and bind-mounted host folders
28
- - 🔌 **Plugin system** — plain TypeScript modules contribute tools, skills, subagents, channels, and typed config
29
- - 💬 **Multi-channel** — Slack, Discord, and a websocket TUI out of the box; one agent, many inboxes
30
- - 👥 **Group chat awareness** — knows who's in the room, distinguishes humans from bots, and stays engaged after a reply without re-mentioning
20
+ ## What you'd expect
21
+
22
+ - 🐳 **Sandboxed by default** — every agent runs in its own Docker container with `.env` injection and bind-mounted host folders
23
+ - 🔌 **Plugin system** — plain TypeScript modules contribute tools, skills, subagents, channels, commands, and typed config
24
+ - 💬 **Multi-channel** — Slack, Discord, Telegram, KakaoTalk, GitHub webhooks, and a websocket TUI; one agent, many inboxes
31
25
  - ⏰ **Cron** — schedule prompts or shell commands; per-job coalescing so slow jobs don't pile up
32
26
  - 📚 **Skills on demand** — markdown procedures the agent loads only when relevant; zero token cost until used
33
- - 🌱 **Self-improving** — bundled memory plugin observes the agent's work and consolidates it into long-term memory (see below)
34
- - 🧠 **Muscle memory** — repeated procedures get distilled into reusable skills that the agent writes for itself
35
- - 🔄 **Hot reload** — change `typeclaw.json`, `typeclaw reload` no restart for most fields
36
- - 🔁 **Self-restart** — the agent can bounce its own container when it updates itself
37
- - 🌐 **Auto port-forward** — dev servers inside the container appear on `localhost`, even loopback-only ones
38
- - 🌍 **Public tunnels** — Cloudflare Quick (zero signup) or bring-your-own external URL; the agent self-registers GitHub webhooks at the resulting public URL
39
- - 🎼 **Compose** — orchestrate multiple agents across multiple folders
40
-
41
- ### 🌱 Self-improving, in detail
27
+ - 🔎 **Web research** — bundled `scout` subagent plus first-class `websearch` and `webfetch` tools (DuckDuckGo via curl-impersonate, Wikipedia)
28
+ - 🛡 **Security guards** — bundled `tool.before` policies catch secret exfil, SSRF, prompt injection, and tainted git remotes before they fire
29
+ - 📊 **Usage and doctor** — `typeclaw usage` reports token/$ spend per session, model, or day; `typeclaw doctor` diagnoses host, agent folder, and plugin state
42
30
 
43
- The bundled `memory` plugin turns lived experience into reusable knowledge. No manual prompt engineering. No curated example library.
31
+ ## Where it goes further
44
32
 
45
- 1. **Observe.** After every idle turn, a `memory-logger` subagent reads the transcript and appends notable fragments to `memory/yyyy-MM-dd.md`. Cheap, frequent, lossy by design.
46
- 2. **Dream.** On a cron schedule (default 4am), a `dreaming` subagent consolidates daily streams into `MEMORY.md`, and when it spots a procedure worth remembering — writes it as **muscle memory**: a new skill at `memory/skills/<name>/SKILL.md`.
47
- 3. **Apply.** Tomorrow's prompt sees the updated `MEMORY.md`. Muscle-memory skills sit alongside bundled and user-installed ones, loaded on demand. Every dream is committed with a one-line summary — e.g. `dream: 3 fragments + new skill 'pr-review' 🔮` — so growth is auditable.
33
+ - 🌱 **Self-improving** bundled `memory` plugin distills sessions into long-term `MEMORY.md` without you writing prompts for it
34
+ - 🧠 **Muscle memory** repeated procedures get distilled into reusable skills the agent writes for itself and loads on later runs
35
+ - 💾 **Auto-backup** the bundled `backup` plugin commits session logs and memory on every idle window with an LLM-generated commit subject
36
+ - 🪄 **Subagents** — first-class child sessions with their own system prompt, payload schema, and per-payload coalescing; cron and the main agent fire them through one in-process Stream
37
+ - 🪪 **Roles and permissions** — `owner` / `trusted` / `member` / `guest` with first-message match rules per channel; gates `channel.respond`, cron scheduling, and security bypasses, so a Slack stranger can't tell the agent to push to main
38
+ - 👥 **Group chat awareness** — knows who's in the room, distinguishes humans from bots, and stays engaged after a reply without re-mentioning
39
+ - 🧱 **Managed-file guards** — `typeclaw.json`, `cron.json`, `MEMORY.md`, and bundled skills are protected from accidental rewrites; invalid config writes are rejected at the tool boundary
40
+ - 🌐 **Headed browser inside the container** — bundled `agent-browser` plugin ships Chrome under Xvfb so the agent can drive real web pages past bot fingerprinting
41
+ - 🌍 **Tunnels and auto port-forward** — dev servers inside the container appear on `localhost` (even loopback-only ones); public URLs via Cloudflare Quick (zero signup) or your own external URL, with GitHub webhooks self-registered at the resulting URL
42
+ - 🔄 **Hot reload** — change `typeclaw.json`, run `typeclaw reload` — no restart for most fields
43
+ - 🔁 **Self-restart** — the agent can bounce its own container when it updates itself
44
+ - 🎼 **Compose** — orchestrate multiple agents across multiple folders
48
45
 
49
- See [`src/bundled-plugins/memory/README.md`](./src/bundled-plugins/memory/README.md) for the full contract.
46
+ Memory loop and subagent architecture are covered in detail in [AGENTS.md](./AGENTS.md) and [`src/bundled-plugins/memory/README.md`](./src/bundled-plugins/memory/README.md).
50
47
 
51
48
  ## Install
52
49
 
@@ -67,59 +64,7 @@ typeclaw tui # attach a terminal UI to the running agent
67
64
 
68
65
  That's it. The agent is now alive, listening on a websocket, ready to receive prompts from the TUI or any wired channel.
69
66
 
70
- ## CLI
71
-
72
- | Command | Purpose |
73
- | ----------------------------------- | ----------------------------------------------------------------------------------- |
74
- | `typeclaw init` | Scaffold a new agent folder |
75
- | `typeclaw start` | Build and run the container |
76
- | `typeclaw stop` | Stop the container |
77
- | `typeclaw restart` | `stop` then `start` |
78
- | `typeclaw status` | Show container + daemon registration state |
79
- | `typeclaw logs` | Stream container stdout/stderr with local timestamps; `-f` to follow |
80
- | `typeclaw tui` | Attach a terminal UI over the agent's websocket |
81
- | `typeclaw shell` | Open a shell inside the running container |
82
- | `typeclaw reload` | Push a live config reload to the running agent |
83
- | `typeclaw compose` | Orchestrate multiple agents |
84
- | `typeclaw cron list` | List every cron job registered in the running agent (user `cron.json` + plugins) |
85
- | `typeclaw channel add <kind>` | Wire a new channel adapter (Slack, Discord, Telegram, KakaoTalk, GitHub) |
86
- | `typeclaw channel set <kind>` | Rotate the credentials of an already-configured channel (bot/app tokens, PAT, etc.) |
87
- | `typeclaw channel reauth kakaotalk` | Re-authenticate KakaoTalk after a stale-token 401 or to rotate the stored password |
88
- | `typeclaw tunnel ...` | Add/list/status/remove public tunnels and inspect tunnel logs |
89
-
90
- ## Configuration
91
-
92
- Agent folder layout after `init`:
93
-
94
- ```
95
- my-agent/
96
- ├── typeclaw.json # main config (schema-validated)
97
- ├── cron.json # scheduled jobs (optional)
98
- ├── .env # secrets, injected via --env-file
99
- ├── Dockerfile # auto-managed by typeclaw, refreshed every `start`
100
- ├── package.json # `typeclaw` as a dependency
101
- ├── .gitignore # auto-managed
102
- ├── workspace/ # agent's free-write zone (gitignored)
103
- ├── sessions/ # JSONL session logs (gitignored, force-committed by auto-backup)
104
- └── memory/ # MEMORY.md + muscle-memory skills (gitignored, force-committed by dreaming)
105
- ```
106
-
107
- `typeclaw.json` is JSON Schema–validated (see `typeclaw.schema.json`). Highlights:
108
-
109
- - `port` — preferred host port (CLI falls back to ephemeral on conflict)
110
- - `mounts` — host directories to expose inside the container
111
- - `plugins` — list of plugin module specifiers
112
- - `channels` — `slack-bot` / `discord-bot` config
113
- - `portForward` — allow/deny list for auto port forwarding (default: `*`)
114
- - `tunnels` — declare public URLs for inbound webhooks and ad-hoc exposure (`cloudflare-quick` or `external`)
115
- - `dockerfile` — toggles for `gh`, `python`, `tmux`, `ffmpeg`, `cjkFonts`, plus `append` lines
116
- - `memory` — idle window and dreaming schedule for the memory plugin
117
-
118
- `Dockerfile` and `.gitignore` are owned by TypeClaw and rewritten on every `start` — edit `src/init/dockerfile.ts` and re-run `start --build` to ship template changes.
119
-
120
- ### Secrets
121
-
122
- Credentials live in two gitignored files: `.env` (plain `KEY=value` lines, injected into the container via `--env-file`) and `secrets.json` (a structured store managed by TypeClaw). **Env-wins**: when a credential's canonical env var (e.g. `FIREWORKS_API_KEY`, `SLACK_BOT_TOKEN`) is set, that value is used at runtime — `secrets.json` is never auto-mutated to capture it. Every secret-bearing field in `secrets.json` is a `Secret` (`string | { value?, env? }`), so the file can rebind a credential to a custom env-var name on demand. See [AGENTS.md § Secrets](./AGENTS.md#secrets) for the full contract.
67
+ See `typeclaw --help` for the full command surface, or [typeclaw.dev](https://typeclaw.dev) for guides and configuration reference.
123
68
 
124
69
  ## Development
125
70
 
@@ -130,7 +75,7 @@ bun install
130
75
  bun test
131
76
  ```
132
77
 
133
- Pre-commit checks (must all pass — no exceptions):
78
+ Pre-commit checks (all must pass — no exceptions):
134
79
 
135
80
  ```sh
136
81
  bun run typecheck
@@ -138,7 +83,12 @@ bun run lint
138
83
  bun run format
139
84
  ```
140
85
 
141
- See [AGENTS.md](./AGENTS.md) for the long-form architecture notes — stages, hostd internals, message stream, plugin contracts, and the testing philosophy.
86
+ See [AGENTS.md](./AGENTS.md) for the long-form architecture notes — stages, hostd internals, message stream, plugin contracts, and the testing philosophy. The docs site at [typeclaw.dev](https://typeclaw.dev) lives in [`docs/`](./docs/).
87
+
88
+ ## Acknowledgments
89
+
90
+ - **Multi-channel** is powered by [agent-messenger](https://github.com/agent-messenger/agent-messenger) — every non-GitHub adapter (`slack-bot`, `discord-bot`, `telegram-bot`, `kakaotalk`) is built on its SDK. Thanks to the maintainers for the credential extraction, listener protocols, and platform coverage that made multi-channel a feature instead of a year-long project.
91
+ - **Subagent architecture** is inspired by [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent) by [@code-yeongyu](https://github.com/code-yeongyu). Thanks for the shape that made this clean.
142
92
 
143
93
  ## License
144
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -25,12 +25,14 @@ import type { Stream } from '@/stream'
25
25
  import { getAuthFor } from './auth'
26
26
  import { createCompactionSettingsManager } from './compaction'
27
27
  import { renderGitNudge } from './git-nudge'
28
+ import type { LiveSubagentRegistry } from './live-subagents'
28
29
  import { lookAtTool } from './multimodal'
29
30
  import { resolveBuiltinToolRefs, wrapPluginTool, wrapSystemAgentTool, wrapSystemTool } from './plugin-tools'
30
31
  import { createReloadTool } from './reload-tool'
31
32
  import { loadSelf } from './self'
32
33
  import { SESSION_META_CUSTOM_TYPE, sessionMetaPayload } from './session-meta'
33
34
  import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
35
+ import type { CreateSessionForSubagent, SubagentRegistry } from './subagents'
34
36
  import { DEFAULT_SYSTEM_PROMPT, renderRuntimeBlock, SLIM_SYSTEM_PROMPT } from './system-prompt'
35
37
  import {
36
38
  createBudgetState,
@@ -43,7 +45,10 @@ import { createChannelHistoryTool } from './tools/channel-history'
43
45
  import { createChannelReplyTool } from './tools/channel-reply'
44
46
  import { createChannelSendTool } from './tools/channel-send'
45
47
  import { createRestartTool } from './tools/restart'
48
+ import { createSpawnSubagentTool } from './tools/spawn-subagent'
46
49
  import { createStreamSnapshotTool } from './tools/stream-snapshot'
50
+ import { createSubagentCancelTool } from './tools/subagent-cancel'
51
+ import { createSubagentOutputTool } from './tools/subagent-output'
47
52
  import { webfetchTool } from './tools/webfetch'
48
53
  import { websearchTool } from './tools/websearch'
49
54
 
@@ -153,6 +158,16 @@ export type CreateSessionOptions = {
153
158
  // already seen") provide their own here. See `ToolResultBudget` for the
154
159
  // shared shape.
155
160
  toolResultBudgetMessage?: ToolResultBudget['exhaustedMessage']
161
+ // Orchestration wiring. When all three of `liveSubagentRegistry`,
162
+ // `subagentRegistry`, and `createSessionForSubagent` are present (AND
163
+ // `pluginSubagent` is unset), the session exposes the spawn_subagent,
164
+ // subagent_output, and subagent_cancel tools. Subagent-origin sessions
165
+ // get an empty tool set via the `pluginSubagent` branch; the gate here
166
+ // (omitting these for subagent sessions) is what prevents recursive
167
+ // spawning.
168
+ liveSubagentRegistry?: LiveSubagentRegistry
169
+ subagentRegistry?: SubagentRegistry
170
+ createSessionForSubagent?: CreateSessionForSubagent
156
171
  }
157
172
 
158
173
  export type CreateSessionResult = {
@@ -205,9 +220,16 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
205
220
  const getOrigin: () => SessionOrigin | undefined =
206
221
  options.originRef !== undefined ? () => options.originRef!.current : () => options.origin
207
222
 
208
- const subagentBuiltinTools = options.pluginSubagent?.toolRefs
223
+ // Subagent built-in tool refs are dual-routed (see BUILTIN_TOOL_DEFINITION
224
+ // dual-map in plugin-tools.ts): pi-side coding tools go to `tools:` so they
225
+ // become the strict base set, typeclaw-side web tools go to `customTools:`.
226
+ // The two `tools:` fields below (effective `options.tools` and the resolved
227
+ // subagent pi-side builtins) are mutually exclusive — `options.tools` is only
228
+ // passed by non-subagent callers like multimodal look-at; subagent sessions
229
+ // never set both.
230
+ const resolvedSubagentBuiltins = options.pluginSubagent?.toolRefs
209
231
  ? resolveBuiltinToolRefs(options.pluginSubagent.toolRefs)
210
- : undefined
232
+ : { agentTools: [], toolDefinitions: [] }
211
233
  const pluginCustomTools = options.pluginSubagent
212
234
  ? wrapSubagentCustomTools(options.pluginSubagent, options.plugins, getOrigin)
213
235
  : wrapRegistryTools(options.plugins, getOrigin)
@@ -224,11 +246,9 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
224
246
  : undefined
225
247
  const sessionBudgetState = sessionBudget ? createBudgetState() : undefined
226
248
 
227
- const hookWrappedTools = wrapSystemAgentTools(
228
- options.tools ?? (subagentBuiltinTools as AgentSessionTools | undefined),
229
- options.plugins,
230
- getOrigin,
231
- )
249
+ const effectiveTools =
250
+ options.tools ?? (options.pluginSubagent ? (resolvedSubagentBuiltins.agentTools as AgentSessionTools) : undefined)
251
+ const hookWrappedTools = wrapSystemAgentTools(effectiveTools, options.plugins, getOrigin)
232
252
  const tools =
233
253
  sessionBudget && sessionBudgetState && hookWrappedTools
234
254
  ? (hookWrappedTools.map((t) =>
@@ -265,7 +285,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
265
285
  options.customTools !== undefined
266
286
  ? options.customTools
267
287
  : options.pluginSubagent
268
- ? []
288
+ ? resolvedSubagentBuiltins.toolDefinitions
269
289
  : [
270
290
  websearchTool,
271
291
  webfetchTool,
@@ -282,6 +302,16 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
282
302
  }),
283
303
  ]
284
304
  : []),
305
+ ...buildSubagentOrchestrationTools({
306
+ liveRegistry: options.liveSubagentRegistry,
307
+ registry: options.subagentRegistry,
308
+ createSessionForSubagent: options.createSessionForSubagent,
309
+ agentDir: options.plugins?.agentDir,
310
+ parentSessionId: sessionManager.getSessionId(),
311
+ getOrigin,
312
+ permissions: options.permissions,
313
+ stream: options.stream,
314
+ }),
285
315
  ]
286
316
  const customToolsPreBudget = [...wrapSystemTools(customSystemTools, options.plugins, getOrigin), ...pluginCustomTools]
287
317
  const customTools =
@@ -430,6 +460,48 @@ export function buildChannelTools(
430
460
  return tools
431
461
  }
432
462
 
463
+ export function buildSubagentOrchestrationTools(opts: {
464
+ liveRegistry: LiveSubagentRegistry | undefined
465
+ registry: SubagentRegistry | undefined
466
+ createSessionForSubagent: CreateSessionForSubagent | undefined
467
+ agentDir: string | undefined
468
+ parentSessionId: string
469
+ getOrigin: () => SessionOrigin | undefined
470
+ permissions: PermissionService | undefined
471
+ stream: Stream | undefined
472
+ }): ToolDefinition[] {
473
+ if (
474
+ opts.liveRegistry === undefined ||
475
+ opts.registry === undefined ||
476
+ opts.createSessionForSubagent === undefined ||
477
+ opts.agentDir === undefined
478
+ ) {
479
+ return []
480
+ }
481
+ return [
482
+ createSpawnSubagentTool({
483
+ registry: opts.registry,
484
+ liveRegistry: opts.liveRegistry,
485
+ createSessionForSubagent: opts.createSessionForSubagent,
486
+ agentDir: opts.agentDir,
487
+ parentSessionId: opts.parentSessionId,
488
+ getOrigin: opts.getOrigin,
489
+ ...(opts.permissions ? { permissions: opts.permissions } : {}),
490
+ ...(opts.stream ? { stream: opts.stream } : {}),
491
+ }),
492
+ createSubagentOutputTool({
493
+ liveRegistry: opts.liveRegistry,
494
+ getOrigin: opts.getOrigin,
495
+ ...(opts.permissions ? { permissions: opts.permissions } : {}),
496
+ }),
497
+ createSubagentCancelTool({
498
+ liveRegistry: opts.liveRegistry,
499
+ getOrigin: opts.getOrigin,
500
+ ...(opts.permissions ? { permissions: opts.permissions } : {}),
501
+ }),
502
+ ]
503
+ }
504
+
433
505
  function wrapRegistryTools(
434
506
  plugins: PluginSessionWiring | undefined,
435
507
  getOrigin: () => SessionOrigin | undefined,
@@ -0,0 +1,215 @@
1
+ import type { AgentSession } from './index'
2
+
3
+ export type SubagentProgressEvent =
4
+ | { kind: 'started'; ts: number }
5
+ | { kind: 'tool'; name: string; ok: boolean; ts: number }
6
+ | { kind: 'message'; preview: string; ts: number }
7
+
8
+ export type SubagentStatus = 'running' | 'completed' | 'failed'
9
+
10
+ export type SubagentCompletion = {
11
+ ok: boolean
12
+ finalMessage?: string
13
+ error?: string
14
+ durationMs: number
15
+ }
16
+
17
+ export type LiveSubagent = {
18
+ taskId: string
19
+ sessionId: string
20
+ subagentName: string
21
+ parentSessionId?: string
22
+ startedAt: number
23
+ status: SubagentStatus
24
+ completion?: SubagentCompletion
25
+ abort: () => Promise<void>
26
+ awaitCompletion: () => Promise<SubagentCompletion>
27
+ }
28
+
29
+ export const MAX_EVENTS_PER_SUBAGENT = 100
30
+ export const MESSAGE_PREVIEW_CHARS = 200
31
+
32
+ type AgentSessionEvent =
33
+ | { type: 'message_update'; assistantMessageEvent: { type: string; delta?: string } }
34
+ | { type: 'message_end'; message: unknown }
35
+ | { type: 'tool_execution_start'; toolCallId: string; toolName: string; args: unknown }
36
+ | { type: 'tool_execution_end'; toolCallId: string; toolName: string; result: unknown; isError: boolean }
37
+ | { type: string }
38
+
39
+ export function coarsen(event: AgentSessionEvent, now: number): SubagentProgressEvent | null {
40
+ if (event.type === 'tool_execution_end') {
41
+ const ev = event as Extract<AgentSessionEvent, { type: 'tool_execution_end' }>
42
+ return { kind: 'tool', name: ev.toolName, ok: !ev.isError, ts: now }
43
+ }
44
+ if (event.type === 'message_end') {
45
+ const ev = event as Extract<AgentSessionEvent, { type: 'message_end' }>
46
+ const preview = extractMessagePreview(ev.message)
47
+ if (preview === null) return null
48
+ return { kind: 'message', preview, ts: now }
49
+ }
50
+ return null
51
+ }
52
+
53
+ function extractMessagePreview(message: unknown): string | null {
54
+ if (message === null || typeof message !== 'object') return null
55
+ const content = (message as { content?: unknown }).content
56
+ if (typeof content === 'string') {
57
+ const trimmed = content.trim()
58
+ return trimmed ? trimmed.slice(0, MESSAGE_PREVIEW_CHARS) : null
59
+ }
60
+ if (Array.isArray(content)) {
61
+ for (const part of content) {
62
+ if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {
63
+ const text = (part as { text?: unknown }).text
64
+ if (typeof text === 'string') {
65
+ const trimmed = text.trim()
66
+ if (trimmed) return trimmed.slice(0, MESSAGE_PREVIEW_CHARS)
67
+ }
68
+ }
69
+ }
70
+ }
71
+ return null
72
+ }
73
+
74
+ export type StatusSnapshot = {
75
+ taskId: string
76
+ sessionId: string
77
+ subagentName: string
78
+ status: SubagentStatus
79
+ startedAt: number
80
+ elapsedMs: number
81
+ eventsCount: number
82
+ eventsRecent: SubagentProgressEvent[]
83
+ lastActivity: SubagentProgressEvent | null
84
+ statusSummary: string
85
+ completion?: SubagentCompletion
86
+ }
87
+
88
+ export class LiveSubagentRegistry {
89
+ private readonly entries = new Map<string, LiveSubagent>()
90
+ private readonly events = new Map<string, SubagentProgressEvent[]>()
91
+
92
+ register(live: LiveSubagent): void {
93
+ if (this.entries.has(live.taskId)) {
94
+ throw new Error(`task ${live.taskId} already registered`)
95
+ }
96
+ this.entries.set(live.taskId, live)
97
+ this.events.set(live.taskId, [{ kind: 'started', ts: live.startedAt }])
98
+ }
99
+
100
+ unregister(taskId: string): void {
101
+ this.entries.delete(taskId)
102
+ this.events.delete(taskId)
103
+ }
104
+
105
+ get(taskId: string): LiveSubagent | undefined {
106
+ return this.entries.get(taskId)
107
+ }
108
+
109
+ list(filter?: { parentSessionId?: string }): LiveSubagent[] {
110
+ const all = Array.from(this.entries.values())
111
+ if (filter?.parentSessionId === undefined) return all
112
+ return all.filter((e) => e.parentSessionId === filter.parentSessionId)
113
+ }
114
+
115
+ hasLiveForSession(sessionId: string): boolean {
116
+ for (const e of this.entries.values()) {
117
+ if (e.sessionId === sessionId && e.status === 'running') return true
118
+ }
119
+ return false
120
+ }
121
+
122
+ recordEvent(taskId: string, event: SubagentProgressEvent): void {
123
+ const ring = this.events.get(taskId)
124
+ if (ring === undefined) return
125
+ ring.push(event)
126
+ if (ring.length > MAX_EVENTS_PER_SUBAGENT) {
127
+ ring.splice(0, ring.length - MAX_EVENTS_PER_SUBAGENT)
128
+ }
129
+ }
130
+
131
+ recordCompletion(taskId: string, completion: SubagentCompletion): void {
132
+ const entry = this.entries.get(taskId)
133
+ if (entry === undefined) return
134
+ entry.completion = completion
135
+ entry.status = completion.ok ? 'completed' : 'failed'
136
+ }
137
+
138
+ snapshot(taskId: string, now: number = Date.now()): StatusSnapshot | undefined {
139
+ const entry = this.entries.get(taskId)
140
+ if (entry === undefined) return undefined
141
+ const events = this.events.get(taskId) ?? []
142
+ const eventsRecent = events.slice(-10)
143
+ const lastActivity: SubagentProgressEvent | null = events.length > 0 ? (events[events.length - 1] ?? null) : null
144
+ const elapsedMs = (entry.completion ? entry.startedAt + entry.completion.durationMs : now) - entry.startedAt
145
+ return {
146
+ taskId: entry.taskId,
147
+ sessionId: entry.sessionId,
148
+ subagentName: entry.subagentName,
149
+ status: entry.status,
150
+ startedAt: entry.startedAt,
151
+ elapsedMs,
152
+ eventsCount: events.length,
153
+ eventsRecent,
154
+ lastActivity,
155
+ statusSummary: renderStatusSummary(entry, events.length, lastActivity, elapsedMs),
156
+ ...(entry.completion ? { completion: entry.completion } : {}),
157
+ }
158
+ }
159
+
160
+ clear(): void {
161
+ this.entries.clear()
162
+ this.events.clear()
163
+ }
164
+ }
165
+
166
+ function renderStatusSummary(
167
+ entry: LiveSubagent,
168
+ eventsCount: number,
169
+ lastActivity: SubagentProgressEvent | null,
170
+ elapsedMs: number,
171
+ ): string {
172
+ const elapsed = formatElapsed(elapsedMs)
173
+ if (entry.status === 'completed') return `Completed in ${elapsed}.`
174
+ if (entry.status === 'failed') {
175
+ const err = entry.completion?.error ?? 'unknown error'
176
+ return `Failed after ${elapsed}: ${err}`
177
+ }
178
+ const last = describeLastActivity(lastActivity)
179
+ return `Running for ${elapsed}. ${eventsCount} event${eventsCount === 1 ? '' : 's'} so far${last ? `. Last: ${last}` : ''}.`
180
+ }
181
+
182
+ function describeLastActivity(event: SubagentProgressEvent | null): string | null {
183
+ if (event === null) return null
184
+ if (event.kind === 'tool') return `${event.ok ? '' : 'failed '}tool ${event.name}`
185
+ if (event.kind === 'message') {
186
+ const preview = event.preview.length > 60 ? `${event.preview.slice(0, 60)}…` : event.preview
187
+ return `message "${preview}"`
188
+ }
189
+ return null
190
+ }
191
+
192
+ function formatElapsed(ms: number): string {
193
+ if (ms < 1000) return `${ms}ms`
194
+ const totalSec = Math.floor(ms / 1000)
195
+ if (totalSec < 60) return `${totalSec}s`
196
+ const min = Math.floor(totalSec / 60)
197
+ const sec = totalSec % 60
198
+ return `${min}m${sec}s`
199
+ }
200
+
201
+ export function attachProgressCapture(
202
+ registry: LiveSubagentRegistry,
203
+ taskId: string,
204
+ session: Pick<AgentSession, 'subscribe'>,
205
+ ): () => void {
206
+ const unsubscribe = session.subscribe((event: unknown) => {
207
+ const coarsened = coarsen(event as AgentSessionEvent, Date.now())
208
+ if (coarsened !== null) {
209
+ registry.recordEvent(taskId, coarsened)
210
+ }
211
+ })
212
+ return () => {
213
+ if (typeof unsubscribe === 'function') unsubscribe()
214
+ }
215
+ }
@@ -16,6 +16,7 @@ import { z } from 'zod'
16
16
 
17
17
  import {
18
18
  ACKNOWLEDGE_GUARDS,
19
+ checkManagedConfigGuard,
19
20
  checkNonWorkspaceWriteGuard,
20
21
  checkSkillAuthoringGuard,
21
22
  } from '@/bundled-plugins/guard/policy'
@@ -31,15 +32,8 @@ import type {
31
32
  } from '@/plugin'
32
33
 
33
34
  import type { SessionOrigin } from './session-origin'
34
-
35
- type AnyAgentTool =
36
- | typeof piReadTool
37
- | typeof piBashTool
38
- | typeof piEditTool
39
- | typeof piWriteTool
40
- | typeof piGrepTool
41
- | typeof piFindTool
42
- | typeof piLsTool
35
+ import { webfetchTool } from './tools/webfetch'
36
+ import { websearchTool } from './tools/websearch'
43
37
 
44
38
  const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
45
39
  Type.Object(
@@ -50,22 +44,64 @@ const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
50
44
  ),
51
45
  )
52
46
 
53
- const BUILTIN_TOOL_MAP: Record<string, AnyAgentTool> = {
47
+ // `BuiltinToolRef.__builtinTool` strings are dual-routed when a plugin
48
+ // subagent declares them: pi-coding-agent's own coding tools flow through
49
+ // `createAgentSession({ tools: AgentTool[] })` (which pi treats as a strict
50
+ // base-tool override — exactly the declared subset becomes active), and
51
+ // typeclaw's own web tools flow through `customTools: ToolDefinition[]` (the
52
+ // only path pi accepts for non-pi tool definitions). Routing typeclaw tools
53
+ // through `tools:` silently drops them (pi's `tools` validator rejects shapes
54
+ // it doesn't recognize); routing pi tools through `customTools:` would work
55
+ // but ALSO auto-injects pi's default 4 base tools (read/bash/edit/write),
56
+ // widening every plugin subagent's allowlist beyond what it declared. The
57
+ // dual route is the only shape that gives "subagent gets exactly what it
58
+ // asked for, nothing more." See `src/agent/index.ts` `createSessionWithDispose`
59
+ // for the consumer that splits the resolved arrays into the two pi fields.
60
+ type PiAgentToolName = 'read' | 'bash' | 'edit' | 'write' | 'grep' | 'find' | 'ls'
61
+ type TypeclawToolName = 'websearch' | 'webfetch'
62
+
63
+ const PI_AGENT_TOOL_MAP: Record<PiAgentToolName, AgentTool<any, any>> = {
64
+ read: piReadTool,
54
65
  bash: piBashTool,
55
66
  edit: piEditTool,
56
- find: piFindTool,
67
+ write: piWriteTool,
57
68
  grep: piGrepTool,
69
+ find: piFindTool,
58
70
  ls: piLsTool,
59
- read: piReadTool,
60
- write: piWriteTool,
61
71
  }
62
72
 
63
- export function resolveBuiltinToolRefs(refs: BuiltinToolRef[]): AnyAgentTool[] {
64
- return refs.map((ref) => {
65
- const tool = BUILTIN_TOOL_MAP[ref.__builtinTool]
66
- if (!tool) throw new Error(`unknown built-in tool ref: ${ref.__builtinTool}`)
67
- return tool
68
- })
73
+ const TYPECLAW_TOOL_DEFINITION_MAP: Record<TypeclawToolName, ToolDefinition<any, any, any>> = {
74
+ websearch: websearchTool,
75
+ webfetch: webfetchTool,
76
+ }
77
+
78
+ function isPiAgentToolName(name: string): name is PiAgentToolName {
79
+ return name in PI_AGENT_TOOL_MAP
80
+ }
81
+
82
+ function isTypeclawToolName(name: string): name is TypeclawToolName {
83
+ return name in TYPECLAW_TOOL_DEFINITION_MAP
84
+ }
85
+
86
+ export type ResolvedBuiltinTools = {
87
+ agentTools: AgentTool<any, any>[]
88
+ toolDefinitions: ToolDefinition<any, any, any>[]
89
+ }
90
+
91
+ export function resolveBuiltinToolRefs(refs: BuiltinToolRef[]): ResolvedBuiltinTools {
92
+ const agentTools: AgentTool<any, any>[] = []
93
+ const toolDefinitions: ToolDefinition<any, any, any>[] = []
94
+ for (const ref of refs) {
95
+ const name = ref.__builtinTool
96
+ if (isPiAgentToolName(name)) {
97
+ agentTools.push(PI_AGENT_TOOL_MAP[name])
98
+ } else if (isTypeclawToolName(name)) {
99
+ toolDefinitions.push(TYPECLAW_TOOL_DEFINITION_MAP[name])
100
+ } else {
101
+ throw new Error(`unknown built-in tool ref: ${name}`)
102
+ }
103
+ }
104
+ return { agentTools, toolDefinitions }
69
105
  }
70
106
 
71
107
  export type WrapToolOptions = {
@@ -274,7 +310,11 @@ function errorResult(message: string) {
274
310
  }
275
311
 
276
312
  async function runFinalWriteGuards(options: { tool: string; args: Record<string, unknown>; agentDir: string }) {
277
- return (await checkSkillAuthoringGuard(options)) ?? checkNonWorkspaceWriteGuard(options)
313
+ return (
314
+ (await checkManagedConfigGuard(options)) ??
315
+ (await checkSkillAuthoringGuard(options)) ??
316
+ checkNonWorkspaceWriteGuard(options)
317
+ )
278
318
  }
279
319
 
280
320
  function withGuardAcknowledgements<TParams extends TSchema>(toolName: string, parameters: TParams): TParams {