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.
- package/README.md +34 -84
- 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 +42 -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/init.ts +8 -1
- package/src/cli/oauth-callbacks.ts +64 -34
- package/src/cli/provider.ts +9 -4
- package/src/config/config.ts +73 -16
- package/src/config/index.ts +3 -0
- package/src/config/providers.ts +106 -0
- package/src/cron/index.ts +3 -0
- package/src/cron/schema.ts +20 -0
- package/src/init/dockerfile.ts +44 -5
- package/src/init/models-dev.ts +1 -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 +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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
18
|
+
If you're like me, TypeClaw is the right choice. If not, that's fine too.
|
|
26
19
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
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
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
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
|
-
|
|
31
|
+
## Where it goes further
|
|
44
32
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
package/src/agent/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
:
|
|
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
|
|
228
|
-
options.tools ?? (
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 (
|
|
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 {
|