typeclaw 0.3.1 → 0.5.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 +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +88 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +370 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +194 -28
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/channels/types.ts +3 -1
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +400 -67
- package/src/cli/model.ts +14 -4
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +134 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +48 -4
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +174 -48
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +165 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +519 -12
- package/src/init/oauth-login.ts +17 -3
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +29 -2
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/permissions.ts +24 -7
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +44 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +16 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +144 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +112 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +35 -16
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +70 -7
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +311 -26
|
@@ -21,12 +21,12 @@ Each role carries a set of **permissions** — opaque dotted strings like `chann
|
|
|
21
21
|
|
|
22
22
|
You always have these four, even if `typeclaw.json` declares zero `roles`. User-declared roles **append** match rules to the built-ins but **replace** the permission list entirely (so `"permissions": []` on a built-in role means "no permissions" — be careful).
|
|
23
23
|
|
|
24
|
-
| Role | Built-in `match[]` | Default `permissions[]`
|
|
25
|
-
| --------- | ----------------------------------------------------------------- |
|
|
26
|
-
| `owner` | `["tui"]` (always prepended) | `channel.respond`, `cron.schedule`, `cron.modify`, **
|
|
27
|
-
| `trusted` | none | `channel.respond`, `cron.schedule`, `security.bypass.
|
|
28
|
-
| `member` | none | `channel.respond`
|
|
29
|
-
| `guest` | none (fallback when nothing else matches, or stamped role is bad) | none
|
|
24
|
+
| Role | Built-in `match[]` | Default `permissions[]` |
|
|
25
|
+
| --------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
26
|
+
| `owner` | `["tui"]` (always prepended) | `channel.respond`, `cron.schedule`, `cron.modify`, `security.bypass.low`, `security.bypass.medium`, **plugin-contributed `security.bypass.*` MINUS high-tier strings** (the wildcard sentinel expands to plugin bypasses but excludes the security plugin's `ownerWildcardExclusions` — today: `gitExfil`, `gitRemoteTainted`, `outboundSecret`, `systemPromptLeak`, plus `bypass.high`) |
|
|
27
|
+
| `trusted` | none | `channel.respond`, `cron.schedule`, `security.bypass.low` |
|
|
28
|
+
| `member` | none | `channel.respond` |
|
|
29
|
+
| `guest` | none (fallback when nothing else matches, or stamped role is bad) | none |
|
|
30
30
|
|
|
31
31
|
A session that doesn't match anything resolves to `guest`. `guest` has no `channel.respond`, so the router silently drops inbound messages whose author resolves to `guest`. **This is the most common cause of "the agent stopped responding"**: the user added a channel but did not add a match rule, so every speaker in that channel is `guest` and every inbound is dropped before you ever see it. There is no message in your session log when this happens — only a host-side line `[channels] <key>: denied by permissions (channel.respond) author=<id>`.
|
|
32
32
|
|
|
@@ -79,10 +79,24 @@ Things the DSL rejects (the parser emits actionable errors at boot, but you shou
|
|
|
79
79
|
Three sources contribute permission strings:
|
|
80
80
|
|
|
81
81
|
1. **Core** (always present): `channel.respond`, `cron.schedule`, `cron.modify`.
|
|
82
|
-
2. **Bundled security plugin** (always loaded): `security.bypass.secretExfilBash`, `security.bypass.gitExfil`, `security.bypass.gitRemoteTainted`, `security.bypass.secretExfilRead`, `security.bypass.ssrf`, `security.bypass.sessionSearchSecrets`, `security.bypass.systemPromptLeak`, `security.bypass.outboundSecret
|
|
82
|
+
2. **Bundled security plugin** (always loaded): the eight per-guard strings (`security.bypass.secretExfilBash`, `security.bypass.gitExfil`, `security.bypass.gitRemoteTainted`, `security.bypass.secretExfilRead`, `security.bypass.ssrf`, `security.bypass.sessionSearchSecrets`, `security.bypass.systemPromptLeak`, `security.bypass.outboundSecret`) AND three severity-tier strings (`security.bypass.low`, `security.bypass.medium`, `security.bypass.high`).
|
|
83
83
|
3. **User-declared plugins** (variable): each plugin can contribute its own strings via `definePlugin({ permissions: [...] })`.
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
The security plugin classifies each guard on a two-axis policy:
|
|
86
|
+
|
|
87
|
+
- **high — audience-leak.** Bypass sends data to a third-party audience outside the operator's control loop (channel readers, remote git hosts). Inhabitants: `outboundSecret`, `systemPromptLeak`, `gitExfil`, `gitRemoteTainted`. **No role auto-bypasses high.** Per-call ack required from every role, including `owner`. The canonical case is **owner-in-public-channel**: even an owner asking "post deploy status to #general" must not silently include a `Bearer ghp_…` line; even `git push` from TUI must be ack'd. Operators who knowingly want one role to skip a high-tier guard add the per-guard string explicitly to `roles.<role>.permissions[]`.
|
|
88
|
+
- **medium — silent-attack.** Bypass returns secrets / IAM creds into model context with no immediate operator visibility. Inhabitants: `secretExfilBash`, `secretExfilRead`, `ssrf`, `sessionSearchSecrets`. `owner` bypasses (operator already has host access); `trusted` does NOT.
|
|
89
|
+
- **low — noisy, immediately recoverable.** No inhabitants today. Forward-compat for future guards. `trusted` carries `bypass.low` so a future low-tier guard auto-bypasses for trusted without a config edit.
|
|
90
|
+
|
|
91
|
+
At `tool.before` time, an actor bypasses a guard if they hold **either** the tier permission **or** the per-guard permission (OR-check, both axes work forever).
|
|
92
|
+
|
|
93
|
+
`owner` carries `security.bypass.low + security.bypass.medium` AND the wildcard sentinel; the sentinel expands to plugin-contributed `security.bypass.*` strings minus the security plugin's `ownerWildcardExclusions` (today: the four high-tier per-guard strings plus `bypass.high`). Net: `owner` auto-bypasses every low- and medium-tier guard; high-tier guards require ack from owner too. `trusted` carries only `bypass.low` — no per-guard medium/high grants by default. `member` and `guest` carry no `security.bypass.*` strings.
|
|
94
|
+
|
|
95
|
+
**Trusted lost two per-guard grants in PR #255 compared to pre-PR behavior.** Before: trusted carried `bypassSecretExfilBash` and `bypassGitExfil` so they could `bash env` and `git push` without acks. After: those guards (medium and high respectively) need acks for trusted too. Operators who relied on the old ergonomics can restore them by adding the per-guard strings explicitly to `roles.trusted.permissions[]` in `typeclaw.json` — the per-guard path is supported forever via the OR-check. When the user complains "trusted can't push anymore," this is the explanation and the workaround.
|
|
96
|
+
|
|
97
|
+
Note on the two-step `gitRemoteTainted` defense: even when an operator explicitly grants `security.bypass.gitExfil` to a role (re-opening the high-tier bypass for `git push`), the second-step `gitRemoteTainted` check still fires on the eventual push if origin was re-pointed mid-session. The recorder runs on the first step (the `set-url`) gated by "would the command actually run" — so the second-step checker has taint state to consult. Granting `bypassGitExfil` does NOT silently grant `bypassGitRemoteTainted`; those are independent per-guard strings AND independent high-tier guards.
|
|
98
|
+
|
|
99
|
+
**Two-layer defense for channel-side git operations**: the runtime `tool.before` guards are not the only layer that gates `git push` from channel messages. The security plugin's `session.prompt` hook also pattern-matches inbound text for `git push` / `git remote add` / `gh repo create --push` and injects a refusal rule into the system prompt. **The prompt-side `git_exfil` defense is gated to non-subagent origins** — it fires for `channel` and `tui` prompts but skips `subagent` prompts. The reason: bundled subagents like `backup-diagnose` legitimately embed git stderr in their payloads (which contains literal "git push --help" hint strings on failures), and triggering the defense there would inject a "do NOT run git push" rule that contradicts the subagent's own system-prompt instructions to retry with an ack. The runtime `tool.before` is the universal backstop for subagents (under the audience-leak policy, even owner-spawned subagents need an ack for `git push`), so the prompt-side check is redundant for them and harmful to bundled-plugin recovery flows. For channel and TUI prompts the two layers agree: nobody auto-bypasses gitExfil at the runtime layer, so the prompt-injection layer's text-match refusal is the same answer the runtime would give. The only case where the two layers disagree is when an operator has explicitly granted `security.bypass.gitExfil` to a channel speaker's role in `typeclaw.json` — then the runtime would allow the push but the prompt-injection text-match would still refuse. That's a known narrow-scope gap (operator opted into the bypass already); if the user is confused why the agent refused a channel-side push despite the per-guard grant they added, this is why.
|
|
86
100
|
|
|
87
101
|
User-declared `permissions[]` strings that don't appear in any of the three sources are **logged as warnings at boot** (`[permissions] role "X" declares unknown permission "Y" — did you mean 'Z'?`) but the role still resolves with the unknown string in its list. This is intentional — the runtime is forward-compatible with strings from plugins that aren't loaded yet — but it also means typos silently fail to bypass guards. If you wrote `security.bypass.secretExfilBach` instead of `Bash`, no guard will be skipped and you will only notice when you read the boot logs.
|
|
88
102
|
|
|
@@ -93,17 +107,19 @@ The security plugin's `tool.before` hook produces block messages of the form:
|
|
|
93
107
|
```
|
|
94
108
|
Guard `<guardName>` blocked <what>. If this is genuinely intentional and the user
|
|
95
109
|
explicitly asked for it, retry with `acknowledgeGuards.<guardName>: true` in the
|
|
96
|
-
<tool> arguments. Or run as a role carrying `<permission>` (
|
|
97
|
-
|
|
110
|
+
<tool> arguments. Or run as a role carrying `<per-guard-permission>` (...role hint...)
|
|
111
|
+
or the tier permission `security.bypass.<low|medium|high>`; see the
|
|
112
|
+
`typeclaw-permissions` skill.
|
|
98
113
|
```
|
|
99
114
|
|
|
100
|
-
|
|
115
|
+
Four escape hatches, ordered from least to most invasive:
|
|
101
116
|
|
|
102
117
|
1. **`acknowledgeGuards.<guardName>: true`** in the tool args. This is a per-call, in-session bypass. Use it when the user has just explicitly told you to run the dangerous thing (e.g. "yes, push the secret to a private gist on purpose"). Never use it without explicit user confirmation — the guard exists for a reason.
|
|
103
|
-
2. **Run as a role with the bypass permission**. If the user wants this pattern to keep working without an ack every time, they edit `roles.<role>.permissions[]` to include the `security.bypass.<
|
|
104
|
-
3. **Run
|
|
118
|
+
2. **Run as a role with the per-guard bypass permission**. If the user wants this pattern to keep working without an ack every time, they edit `roles.<role>.permissions[]` to include the specific `security.bypass.<guardName>` string the block message named. This is the most granular grant — it only opens up that one guard. Use this when the user wants exactly one capability and nothing else.
|
|
119
|
+
3. **Run as a role with the tier bypass permission**. The block message also names the tier permission (`security.bypass.low` / `.medium` / `.high`). Granting the tier opens up every guard of that tier at once — broader than option 2, narrower than full owner. Use this when "let trusted users post credentials to chat AND view system prompt fingerprints AND search session history" is the user's actual intent rather than three separate per-guard grants.
|
|
120
|
+
4. **Run from a session that already resolves to a role with the bypass**. The TUI is always `owner`, so a guard that blocks in channel sessions for a `member` author will not block at all from the TUI. This is why "the agent can do X in TUI but not in Slack" is normal, not a bug.
|
|
105
121
|
|
|
106
|
-
When you see a block, tell the user **which permission would skip it** (the block message now names
|
|
122
|
+
When you see a block, tell the user **which permission would skip it** (the block message now names both the per-guard and the tier options) and **which built-in roles have those permissions**. Do not just relay the guard reason — that loses the access-control framing entirely.
|
|
107
123
|
|
|
108
124
|
## When the user asks "why aren't you replying in #channel?"
|
|
109
125
|
|
|
@@ -120,7 +136,7 @@ To distinguish cause 1/2 from cause 3: if `typeclaw logs <container> -f` (host s
|
|
|
120
136
|
This is a `roles` edit. The full procedure:
|
|
121
137
|
|
|
122
138
|
1. **Resolve the coordinates.** Get the platform name (`slack | discord | telegram | kakao`), the workspace ID, the chat ID. If the user gave you names, ask them or look them up in the participants list of a previous inbound from that channel.
|
|
123
|
-
2. **Pick a role.** Default to `member` for "give them normal channel access". Use `trusted` if they should also be able to schedule cron
|
|
139
|
+
2. **Pick a role.** Default to `member` for "give them normal channel access". Use `trusted` if they should also be able to schedule cron — by default trusted gets ONLY `bypass.low` (no inhabitants today), so trusted on its own does NOT skip any security guard. If the user wants the old pre-PR-#255 trusted ergonomics (bypass bash secret guard, push without ack), add per-guard strings explicitly: `roles.trusted.permissions: ["channel.respond", "cron.schedule", "security.bypass.low", "security.bypass.secretExfilBash", "security.bypass.gitExfil"]`. Use `owner` only for the primary operator — owner auto-bypasses every medium-tier guard (`secretExfilBash`, `secretExfilRead`, `ssrf`, `sessionSearchSecrets`) but **still must ack every high-tier guard** (`gitExfil`, `gitRemoteTainted`, `outboundSecret`, `systemPromptLeak`) because audience-leak guards have no role auto-bypass — that's the owner-in-public-channel rule. If the user explicitly wants `git push` from TUI without acks, that's a per-guard explicit grant on `roles.owner.permissions[]` (re-add `security.bypass.gitExfil`), and the user should understand they are re-opening the audience-leak path for that guard.
|
|
124
140
|
3. **Edit `typeclaw.json` `roles.<role>.match[]`.** Append the canonical DSL string. Example: `roles.member.match` adds `"slack:T0123/C0ABCDE"`. If the user wants only a specific person in that channel, append `slack:T0123/C0ABCDE author:U_ME` instead.
|
|
125
141
|
4. **Restart.** `roles` is **restart-required** — `typeclaw reload` does not re-evaluate role config. Tell the user: "edited `roles.<role>.match` — restart-required. Run `typeclaw restart` (host stage)."
|
|
126
142
|
5. **Commit the change.** See the `typeclaw-git` skill. The decision context in the commit message should name the role, the channel, and the author/scope ("let @X talk to me as `member` in #foo in workspace bar").
|
|
@@ -150,7 +166,10 @@ If you see a cron job mysteriously failing every fire with `denied by permission
|
|
|
150
166
|
## Things you must not do
|
|
151
167
|
|
|
152
168
|
- **Do not write `*` in user-declared `permissions[]`.** The owner wildcard is a runtime sentinel, not part of the user-facing string format. The schema rejects `*` (it's not a valid dotted permission string anyway).
|
|
153
|
-
- **Do not invent permission strings.** Only the three sources above (core, security plugin, declared plugins) contribute valid strings. A string like `bash.execute` looks plausible but is not gated by anything and will only earn a boot warning. If the user asks for a permission the model doesn't have, tell them — don't invent one.
|
|
169
|
+
- **Do not invent permission strings.** Only the three sources above (core, security plugin including the eight per-guard + three tier strings, declared plugins) contribute valid strings. A string like `bash.execute` looks plausible but is not gated by anything and will only earn a boot warning. If the user asks for a permission the model doesn't have, tell them — don't invent one.
|
|
170
|
+
|
|
171
|
+
- **Do not grant `security.bypass.high` casually.** High-tier guards (`gitExfil`, `gitRemoteTainted`, `outboundSecret`, `systemPromptLeak`) all defend the audience-leak axis — bypassing them means data leaves the operator's perimeter without per-call confirmation. Even `owner` doesn't have `bypass.high` by default for this exact reason. Granting `security.bypass.high` to any role opens audience-leak bypass on every current high-tier guard PLUS every future high-tier guard added by a security plugin update. If the user wants one specific high-tier bypass (e.g. "let me push from TUI without acks"), grant the **per-guard** string explicitly (`security.bypass.gitExfil`) on the specific role, not the tier — that's narrower and won't widen on plugin updates.
|
|
172
|
+
- **Do not grant `security.bypass.medium` to roles wider than the operator.** Medium-tier bypasses (`secretExfilBash`, `secretExfilRead`, `ssrf`, `sessionSearchSecrets`) silently dump secrets into model context. Granting `bypass.medium` to a `trusted` role that matches a broad Slack workspace means any trusted speaker can ask the agent to dump environment variables and the bypass succeeds — the operator only sees on session review. If the user wants this anyway, name the specific guard with a per-guard grant rather than the whole tier.
|
|
154
173
|
- **Do not promise that `typeclaw reload` applied a `roles` edit.** `roles` is restart-required. The reload tool will return success on the config file change, but the live `PermissionService` was built at boot and is not swapped on reload.
|
|
155
174
|
- **Do not silently change a built-in role's permission list.** Setting `"permissions": []` on `member` is a wholesale replace, not a merge — you just took `channel.respond` away from every speaker who resolves to `member`. If the user said "give member just `channel.respond` and nothing else", that's fine (it's the same as the default), but say so explicitly: "this matches the default for `member`, no behavior change". If the user said "remove cron from `trusted`", make the change but warn that `trusted` no longer carries `cron.schedule` either.
|
|
156
175
|
- **Do not write match rules using display names** (`#general`, `@user`, channel/user names). Match rules are platform IDs. Display names change; IDs don't. Always look up the ID before writing the rule.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-plugins
|
|
3
|
-
description: TypeClaw plugin authoring and operation guide. Use when writing, editing, configuring, debugging, or installing a TypeClaw plugin — including any work with definePlugin, defineTool, defineSubagent, plugin hooks (session.start/end/idle/prompt, tool.before/after), plugin cron jobs, plugin skills, the typeclaw/plugin import path, or per-plugin config blocks in typeclaw.json. Triggers on mentions of 'TypeClaw plugin', 'definePlugin', 'plugin hook', 'plugin cron', 'plugins[]', 'typeclaw-plugin-', or any file under src/plugin/ or plugins/.
|
|
3
|
+
description: TypeClaw plugin authoring and operation guide. Use when writing, editing, configuring, debugging, or installing a TypeClaw plugin — including any work with definePlugin, defineTool, defineSubagent, plugin hooks (session.start/end/idle/prompt, tool.before/after), plugin cron jobs, plugin commands (host/container/either CLI subcommands callable as `typeclaw <name>`), plugin skills, the typeclaw/plugin import path, or per-plugin config blocks in typeclaw.json. Also use when you need to bridge a cron `exec` job to LLM-driven work — the canonical pattern is a `surface: 'container'` plugin command whose `run` calls `ctx.prompt(...)`, invoked as `typeclaw <command>` from cron's `command` array. Triggers on mentions of 'TypeClaw plugin', 'definePlugin', 'plugin hook', 'plugin cron', 'plugin command', 'PluginCommand', 'ContainerCommand', 'HostCommand', 'EitherCommand', 'ctx.prompt', 'ctx.subagent', 'ctx.exec', 'plugins[]', 'typeclaw-plugin-', or any file under src/plugin/ or plugins/.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# TypeClaw Plugins
|
|
@@ -252,13 +252,59 @@ cronJobs: {
|
|
|
252
252
|
kind: 'exec',
|
|
253
253
|
command: ['bun', 'run', 'scripts/rotate.ts'],
|
|
254
254
|
},
|
|
255
|
+
// The canonical shape for scheduled imperative LLM work: a handler
|
|
256
|
+
// function the cron consumer invokes directly. No shell-out, no WS
|
|
257
|
+
// round-trip, no Bun.spawn — the handler runs in-process with the same
|
|
258
|
+
// ctx.prompt / ctx.exec surface a container command sees.
|
|
259
|
+
'inbox-watch': {
|
|
260
|
+
schedule: '*/15 * * * *',
|
|
261
|
+
kind: 'handler',
|
|
262
|
+
handler: async (ctx) => {
|
|
263
|
+
const { stdout } = await ctx.exec`gmail unread --count`
|
|
264
|
+
if (Number(stdout.trim()) === 0) return
|
|
265
|
+
await ctx.prompt(`Triage ${stdout.trim()} new emails…`)
|
|
266
|
+
},
|
|
267
|
+
},
|
|
255
268
|
}
|
|
256
269
|
```
|
|
257
270
|
|
|
258
271
|
- The map key is a **suffix**. The runtime constructs the global cron id as `__plugin_<plugin-name>_<key>` (e.g., `__plugin_standup-log_weekly-digest`).
|
|
259
272
|
- `cron.json` user job ids cannot start with underscore, so collision is impossible by construction.
|
|
260
273
|
- A `prompt` job's `subagent` and `payload` are **validated against the registry at boot** — bad references fail loudly on disk, not 6 hours later when the job fires.
|
|
261
|
-
-
|
|
274
|
+
- Three kinds: `prompt`, `exec`, `handler`. **`handler` is plugin-only** — it cannot appear in `cron.json` because the handler is a TypeScript function reference (not JSON-serializable). User-authored cron files are validated by `parseCronFile` which rejects anything outside `prompt | exec`.
|
|
275
|
+
|
|
276
|
+
#### `kind: 'handler'` — direct function dispatch
|
|
277
|
+
|
|
278
|
+
When the cron job needs imperative control flow (probe → maybe prompt → write file) and the logic lives in the same plugin as the schedule, declare it as a `handler`. The consumer invokes the function directly with a `CronHandlerContext`:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
type CronHandlerContext = {
|
|
282
|
+
readonly jobId: string // __plugin_<name>_<key>
|
|
283
|
+
readonly name: string // plugin name that registered the job
|
|
284
|
+
readonly agentDir: string // /agent in container
|
|
285
|
+
readonly logger: PluginLogger
|
|
286
|
+
readonly signal: AbortSignal // reserved for future cancellation; currently inert
|
|
287
|
+
readonly permissions: PermissionService
|
|
288
|
+
readonly origin: SessionOrigin // { kind: 'cron', jobKind: 'handler', ... }
|
|
289
|
+
readonly prompt: (text: string) => Promise<string> // full agent session, slim system prompt mode
|
|
290
|
+
readonly subagent: (name, payload?) => Promise<void>
|
|
291
|
+
readonly exec: (cmd, ...vals) => Promise<CommandExecResult> // tagged template
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The `prompt` / `subagent` / `exec` surface is identical to `ContainerCommandContext` (§5.7) and reuses the same underlying implementation — abort semantics, process-group kill, slim system prompt mode are all shared. Differences from `ContainerCommandContext`: no `stdin` / `stdout` / `stderr` (cron has no caller piping bytes), no `args` (handlers are scheduled, not invoked with flags), no return value (throw to signal failure, the consumer logs).
|
|
296
|
+
|
|
297
|
+
#### When to use which `kind`
|
|
298
|
+
|
|
299
|
+
| `kind` | Use for |
|
|
300
|
+
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
301
|
+
| `'prompt'` | One-shot natural-language prompts. Stable instruction, no shell pre-work, no conditional logic. |
|
|
302
|
+
| `'exec'` | Pure shell work — `git commit`, log rotation, calling a script. Can also point at a plugin's `surface: 'host'` command via `['typeclaw', '<cmd>']`. |
|
|
303
|
+
| **`'handler'`** | **The default for plugin-internal scheduled imperative work.** Probe + maybe prompt, multi-step orchestration, anything mixing shell and LLM calls. |
|
|
304
|
+
|
|
305
|
+
A plugin that exposes a `surface: 'container'` command (§5.7) often does NOT need a corresponding cron handler — if the command's whole `run` body is the scheduled work, just factor the body into a shared private function and have BOTH the command and the cron handler call it. The command stays callable from the TUI / manual shell; the cron handler stays callable from the scheduler without shelling out.
|
|
306
|
+
|
|
307
|
+
The pre-handler workaround (cron `kind: 'exec'` with `command: ['typeclaw', '<plugin-cmd>']` shelling out to its own container) is still valid but no longer the default — reach for it only when the user owns the cadence (`cron.json` scheduling someone else's command) or when the scheduled work is genuinely a host-side command. See `typeclaw-cron` for the full decision tree.
|
|
262
308
|
|
|
263
309
|
### 5.4 `skills` — string-form (per-session tmpdir)
|
|
264
310
|
|
|
@@ -326,6 +372,198 @@ Provider prompt caching makes the **prefix** of the system prompt 5–10× cheap
|
|
|
326
372
|
|
|
327
373
|
If your content varies per session, **append**. If it's stable across sessions, prepending is fine but understand the cost.
|
|
328
374
|
|
|
375
|
+
### 5.7 `commands` — typeclaw CLI subcommands
|
|
376
|
+
|
|
377
|
+
A plugin can register top-level CLI commands invocable as `typeclaw <name>` from any shell sitting in the agent folder. **Unlike every other contribution in §5, `commands` is declared by-value on `definePlugin(...)`, NOT inside the factory return.** This is so the host-stage CLI can dispatch commands without booting the plugin runtime (no `bun install`, no factory, no engine spin-up just to print `--help`).
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
import { z } from 'zod'
|
|
381
|
+
import { definePlugin } from 'typeclaw/plugin'
|
|
382
|
+
|
|
383
|
+
export default definePlugin({
|
|
384
|
+
commands: {
|
|
385
|
+
'standup-now': {
|
|
386
|
+
surface: 'container',
|
|
387
|
+
description: 'Generate a standup write-up for today from sessions/.',
|
|
388
|
+
args: z.object({
|
|
389
|
+
date: z.string().optional().describe('YYYY-MM-DD; defaults to today'),
|
|
390
|
+
}),
|
|
391
|
+
async run(ctx, args) {
|
|
392
|
+
const text = await ctx.prompt(
|
|
393
|
+
`Read sessions/ for ${args.date ?? 'today'} and write a 3-bullet standup to standup/${args.date ?? 'today'}.md.`,
|
|
394
|
+
)
|
|
395
|
+
const writer = ctx.stdout.getWriter()
|
|
396
|
+
await writer.write(new TextEncoder().encode(text + '\n'))
|
|
397
|
+
writer.releaseLock()
|
|
398
|
+
return 0
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
plugin: async (ctx) => ({
|
|
403
|
+
/* tools, hooks, cron, ... */
|
|
404
|
+
}),
|
|
405
|
+
})
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Once installed, the user (or a cron `exec` job) runs `typeclaw standup-now --date=2026-05-18`. `typeclaw --help` lists all discovered plugin commands automatically — no separate registration.
|
|
409
|
+
|
|
410
|
+
#### The three surfaces
|
|
411
|
+
|
|
412
|
+
| `surface` | Runs where | `ctx` has | Use when |
|
|
413
|
+
| ------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
414
|
+
| `'container'` | Inside the running agent container, proxied over WS by the host CLI | `prompt`, `subagent`, `exec`, `permissions`, `origin`, `signal` + streams | The command needs the agent runtime — LLM calls (`ctx.prompt`), subagent invocation, permission checks. Reusable from TUI, manual shell, `compose`, and (as a narrower fallback when reusability is required) cron `exec`. For plugin-internal scheduled `exec → LLM` work, prefer `kind: 'handler'` (§5.3) — see "Cron usage" below. |
|
|
415
|
+
| `'host'` | On the user's machine, no container required | `streams`, `signal`, `logger`, `agentDir` (host path) | The command only touches host-side state (files, host binaries, prompts the user). Container does NOT need to be running. |
|
|
416
|
+
| `'either'` | Whichever stage invoked it — same author code runs in both | The intersection (`streams`, `signal`, `logger`, `agentDir`) | The command's logic is stage-agnostic. `agentDir` resolves to `/agent` in the container and the host path on the host, automatically. |
|
|
417
|
+
|
|
418
|
+
The `surface: 'container'` command requires the container to be running. The host CLI opens a WebSocket to `/commands` on the agent's port, sends `exec_command`, and streams stdout/stderr back. Ctrl-C on the host propagates as `AbortSignal` to `ctx.signal` inside the container.
|
|
419
|
+
|
|
420
|
+
#### `ContainerCommandContext` — what you get inside `run`
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
type ContainerCommandContext = {
|
|
424
|
+
readonly name: string // plugin name (e.g. 'standup-log'), NOT command name
|
|
425
|
+
readonly version: string | undefined
|
|
426
|
+
readonly agentDir: string // /agent inside the container
|
|
427
|
+
readonly logger: PluginLogger
|
|
428
|
+
readonly permissions: PermissionService
|
|
429
|
+
readonly origin: SessionOrigin // caller's origin — cron job, TUI op, etc.
|
|
430
|
+
readonly signal: AbortSignal // aborts on ws close or host Ctrl-C
|
|
431
|
+
readonly stdin: ReadableStream<Uint8Array>
|
|
432
|
+
readonly stdout: WritableStream<Uint8Array>
|
|
433
|
+
readonly stderr: WritableStream<Uint8Array>
|
|
434
|
+
readonly prompt: (text: string) => Promise<string> // full LLM session, full toolset, returns last assistant text
|
|
435
|
+
readonly subagent: (name: string, payload?: unknown) => Promise<void>
|
|
436
|
+
readonly exec: (cmd: TemplateStringsArray, ...values: unknown[]) => Promise<CommandExecResult>
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Key facts about each capability:
|
|
441
|
+
|
|
442
|
+
- **`ctx.prompt(text)`** opens a brand-new `AgentSession` with the full agent toolset (read/bash/edit/write/grep/find/ls + plugin tools), sends `text` as if a user typed it, and returns the last assistant message. The session is created and disposed inside the call. The session uses **slim system prompt mode** (subagent-shaped origin) so you save ~2000 tokens per LLM call versus a normal TUI session.
|
|
443
|
+
- **`ctx.subagent(name, payload)`** invokes a registered subagent (yours or another plugin's). Returns when the subagent's `runSession` resolves.
|
|
444
|
+
- **`ctx.exec` is a tagged template** — `await ctx.exec\`git log --oneline -10\``runs the command in the agent folder with`ctx.signal` threaded through. Aborts kill the entire process group (SIGTERM → 5s grace → SIGKILL) so daemonized grandchildren don't outlive the abort.
|
|
445
|
+
- **`ctx.origin`** carries the caller's `SessionOrigin`. For host-invoked (TUI op) calls it's `{ kind: 'tui', ... }`; for cron-invoked calls it's the cron job's origin including `scheduledByRole`. **No silent role elevation** — a cron job running as `scheduledByRole: 'member'` invokes the command with that same role, and permission checks inside the command resolve accordingly.
|
|
446
|
+
|
|
447
|
+
#### Cron usage: prefer `kind: 'handler'` over shelling out to your own command
|
|
448
|
+
|
|
449
|
+
Plugin cron jobs support `kind: 'handler'` (§5.3) which invokes a TypeScript function directly with a `CronHandlerContext`. The handler ctx exposes the SAME `ctx.prompt` / `ctx.subagent` / `ctx.exec` surface a container command sees — same slim-mode session, same process-group abort semantics — but without the shell-out, the WS round-trip, or the args-parse round-trip.
|
|
450
|
+
|
|
451
|
+
**If the cron job and the command both live in the same plugin, prefer a handler.** Factor any shared logic into a private function and have BOTH the command's `run` body and the cron handler call it. The command stays callable from the TUI / manual `typeclaw` invocations; the cron handler stays callable from the scheduler with zero shell-out cost.
|
|
452
|
+
|
|
453
|
+
The shell-out pattern below (cron `exec` → `typeclaw <plugin-cmd>`) is still supported and stays valid in three narrow cases, all rooted in **the same logic needing a callable surface beyond cron**:
|
|
454
|
+
|
|
455
|
+
1. **The logic is also a reusable CLI command.** The user wants to run it manually as `typeclaw <cmd> --flag=...` from the TUI / shell / `compose`, or another caller needs the same args contract. Write the logic once inside a `surface: 'container'` command's `run`; reuse it from cron by pointing an `exec` job at the same command. "Scheduled work that needs LLM judgement" alone is NOT this case — without an external caller, prefer `kind: 'handler'` and avoid the shell-out overhead.
|
|
456
|
+
2. **The user owns the cadence.** `cron.json` schedules someone else's plugin command at a custom cadence the plugin author didn't anticipate. The user doesn't fork the plugin to change the schedule.
|
|
457
|
+
3. **The scheduled work needs a `surface: 'host'` command.** Host commands run outside the container with no agent runtime, so `ctx.prompt` is unavailable; the shell-out via `typeclaw <host-cmd>` is the only path.
|
|
458
|
+
|
|
459
|
+
#### The cron-exec → typeclaw shell-out (narrower use case)
|
|
460
|
+
|
|
461
|
+
For plugin-internal scheduled `exec → LLM` work, `kind: 'handler'` (§5.3) is the best practice — see "Cron usage" above. The pattern below — write a `surface: 'container'` plugin command whose `run` calls `ctx.prompt(...)`, then point a `cron.json` `exec` job at it — is the fallback when **reusability is the actual requirement**: the same logic must also be invocable as a CLI command from TUI / manual shell / `compose`, or the user owns the cadence for a command they didn't write, or the work needs `surface: 'host'` (where `ctx.prompt` doesn't exist).
|
|
462
|
+
|
|
463
|
+
User-authored `cron.json` itself supports only `prompt` and `exec` — that's by design. `kind: 'handler'` is plugin-only because the handler is a function reference, not JSON-serializable.
|
|
464
|
+
|
|
465
|
+
```json
|
|
466
|
+
// cron.json
|
|
467
|
+
{
|
|
468
|
+
"jobs": [
|
|
469
|
+
{
|
|
470
|
+
"id": "daily-standup",
|
|
471
|
+
"schedule": "30 9 * * 1-5",
|
|
472
|
+
"timezone": "Asia/Seoul",
|
|
473
|
+
"kind": "exec",
|
|
474
|
+
"command": ["typeclaw", "standup-now"]
|
|
475
|
+
}
|
|
476
|
+
]
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
// packages/standup-log/index.ts
|
|
482
|
+
export default definePlugin({
|
|
483
|
+
commands: {
|
|
484
|
+
'standup-now': {
|
|
485
|
+
surface: 'container',
|
|
486
|
+
description: 'Generate today’s standup.',
|
|
487
|
+
async run(ctx) {
|
|
488
|
+
await ctx.prompt(
|
|
489
|
+
`Read sessions/$(date +%F)*.jsonl and append a 3-bullet standup to memory/standups/$(date +%F).md.`,
|
|
490
|
+
)
|
|
491
|
+
return 0
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
plugin: async () => ({}),
|
|
496
|
+
})
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
This `cron.json → typeclaw <cmd>` shape is the right choice in the three narrow cases listed above. For plugin-internal scheduled work where the cadence belongs to the plugin author and nothing outside cron needs to invoke the logic, write a `kind: 'handler'` job (§5.3) instead — same `ctx.prompt` / `ctx.exec` shape, none of the shell-out overhead.
|
|
500
|
+
|
|
501
|
+
Why a CLI command is worth defining (and exposing via cron `exec`) when one of the three cases applies:
|
|
502
|
+
|
|
503
|
+
- The command is reusable from the TUI, from `compose` orchestration, or from a manual `typeclaw standup-now` invocation by the user.
|
|
504
|
+
- Args (`--date`, `--dry-run`, etc.) are declared once via `args: z.object({...})` and parsed/validated by the runtime — both at the host CLI and as defense-in-depth in the container.
|
|
505
|
+
- A user who wants a different cadence than the plugin's default can drop a `cron.json` entry pointing at the command without forking the plugin.
|
|
506
|
+
|
|
507
|
+
If none of those benefits actually apply to your case, the CLI command shape is overhead — write a handler.
|
|
508
|
+
|
|
509
|
+
For the cron-side decision rules (when to pick `handler` vs `prompt` vs `exec → typeclaw <cmd>`, and how to gate `ctx.prompt` behind a cheap `ctx.exec` probe) read `typeclaw-cron`.
|
|
510
|
+
|
|
511
|
+
#### `args` — Zod object schema with primitive leaves
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
args: z.object({
|
|
515
|
+
date: z.string().optional().describe('YYYY-MM-DD; defaults to today'),
|
|
516
|
+
dryRun: z.boolean().default(false),
|
|
517
|
+
count: z.number().int().min(1).max(100).default(10),
|
|
518
|
+
})
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
- The top level **MUST** be `z.object({...})`. Leaves should be primitives (`string`, `number`, `boolean`, `literal`, `enum`) so `--help` can render `--<name>=<type>`.
|
|
522
|
+
- Args are validated locally by the host CLI **before** any WS round-trip, so bad args fail fast with a clean error and exit code 2. The container re-validates as defense-in-depth.
|
|
523
|
+
- `.describe(...)` populates `--help` output. Use it.
|
|
524
|
+
- Omit `args` entirely if the command takes no flags.
|
|
525
|
+
|
|
526
|
+
#### `permissions: [...]` on the command
|
|
527
|
+
|
|
528
|
+
```ts
|
|
529
|
+
{
|
|
530
|
+
surface: 'container',
|
|
531
|
+
permissions: ['standup-log.write.standup'],
|
|
532
|
+
async run(ctx, args) {
|
|
533
|
+
ctx.permissions.assert(ctx.origin, 'standup-log.write.standup')
|
|
534
|
+
// ...
|
|
535
|
+
},
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Declared permissions are surfaced in `--help` and (for container commands) checked against the caller's origin. Same `<plugin>.<verb>.<noun>` shape as the rest of the permission system; see `typeclaw-permissions`.
|
|
540
|
+
|
|
541
|
+
#### `isolated: true` (container surface only)
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
{
|
|
545
|
+
surface: 'container',
|
|
546
|
+
isolated: true, // currently degrades to in-process with a warning on stderr
|
|
547
|
+
async run(ctx, args) { /* ... */ },
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Reserved for a future subprocess sandbox. Today the runtime accepts the flag and emits a warning on the per-command stderr (visible to the invoking CLI) but executes in-process anyway. Set it now if you genuinely want the isolation when it lands; otherwise omit.
|
|
552
|
+
|
|
553
|
+
#### Discovery and naming
|
|
554
|
+
|
|
555
|
+
- Command names are **global across all plugins**. Two plugins registering `standup-now` is a discovery error — the second one is dropped and logged on `--help`.
|
|
556
|
+
- Command names are NOT auto-prefixed with the plugin name. Pick discriminating names (`standup-now`, not `run`).
|
|
557
|
+
- `typeclaw --help` (in any agent folder) lists every discovered plugin command with description, surface, and which plugin owns it.
|
|
558
|
+
- `typeclaw <name> --help` renders args, surface, plugin name + version. Free.
|
|
559
|
+
|
|
560
|
+
#### What's NOT supported
|
|
561
|
+
|
|
562
|
+
- **No host-stage CLI commands that mutate the live container without going through `restart` / `reload`.** A host command can `Bun.spawn('typeclaw', ['reload'])` if it needs to push a config change, but there's no privileged backdoor.
|
|
563
|
+
- **No tool-style `content: ContentPart[]` return.** Commands write to `ctx.stdout` and return an exit code. They are CLI processes, not LLM tool calls.
|
|
564
|
+
- **No streaming token output from `ctx.prompt`** yet — the full LLM response arrives as one stdout burst. Chunked streaming is on the roadmap.
|
|
565
|
+
- **No nested command dispatch.** A command cannot invoke `typeclaw <other-cmd>` and expect to share state; spawn a subprocess or share a subagent instead.
|
|
566
|
+
|
|
329
567
|
---
|
|
330
568
|
|
|
331
569
|
## 6. PluginContext
|
|
@@ -530,9 +768,8 @@ If you find yourself wanting any of these, the design has gone wrong somewhere
|
|
|
530
768
|
- **Stream subscriptions**. Plugins observe through the typed `hooks` surface; they cannot subscribe to the in-process pub/sub directly.
|
|
531
769
|
- **Server-side TUI push notifications** from plugin code. Tool calls reach the TUI via existing `tool_start`/`tool_end` events.
|
|
532
770
|
- **Dockerfile fragments** contributed by plugins. The Dockerfile is core-managed.
|
|
533
|
-
- **New cron job kinds
|
|
771
|
+
- **New cron job kinds for user-authored `cron.json`** beyond `prompt` and `exec`. (Subagent invocation is a `prompt` variant, not a separate kind. Plugin cron jobs additionally support `kind: 'handler'` — see §5.3 — but that's plugin-only because the handler is a TypeScript function reference, not JSON-serializable.)
|
|
534
772
|
- **Reload-registry scopes** for plugin-owned state.
|
|
535
|
-
- **Host-stage CLI commands** registered by plugins. Plugins are container-stage only.
|
|
536
773
|
- **`extendConfig`** for arbitrary top-level fields outside the plugin's own config block.
|
|
537
774
|
- **Per-LLM-call hooks** (`llm.params` / `llm.headers`). Wait until a real plugin needs them.
|
|
538
775
|
|
|
@@ -575,6 +812,10 @@ export default definePlugin({
|
|
|
575
812
|
configSchema: z.object({
|
|
576
813
|
/* ... */
|
|
577
814
|
}), // optional
|
|
815
|
+
commands: {
|
|
816
|
+
/* name: { surface, run, args?, ... } */
|
|
817
|
+
}, // optional, declared BY-VALUE (not inside factory)
|
|
818
|
+
permissions: ['my-plugin.write.x'], // optional
|
|
578
819
|
plugin: async (ctx) => ({
|
|
579
820
|
// required
|
|
580
821
|
tools,
|
|
@@ -582,7 +823,8 @@ export default definePlugin({
|
|
|
582
823
|
cronJobs,
|
|
583
824
|
skills,
|
|
584
825
|
skillsDirs,
|
|
585
|
-
hooks,
|
|
826
|
+
hooks,
|
|
827
|
+
doctorChecks, // all optional
|
|
586
828
|
}),
|
|
587
829
|
})
|
|
588
830
|
```
|
|
@@ -591,4 +833,8 @@ export default definePlugin({
|
|
|
591
833
|
|
|
592
834
|
**Plugin name = derived**: scope-stripped, `typeclaw-plugin-` prefix stripped (npm), or basename minus extension (local).
|
|
593
835
|
|
|
836
|
+
**Command name = global**: NOT prefixed with plugin name. Two plugins registering the same command name is a discovery error (second is dropped, logged on `--help`).
|
|
837
|
+
|
|
838
|
+
**`exec → LLM` from cron** (best practice): plugin `cronJobs` entry with `kind: 'handler'` — a TypeScript function the cron consumer invokes directly with `ctx.prompt` / `ctx.subagent` / `ctx.exec`. No shell-out, no WS round-trip. Fall back to `surface: 'container'` command + cron `exec` pointing at `["typeclaw", "<cmd>"]` ONLY when the same logic must also be invocable as a reusable CLI command, the user owns the cadence for someone else's command, or the work needs `surface: 'host'`.
|
|
839
|
+
|
|
594
840
|
**Boundary**: `src/plugin/**` MUST NOT import `@mariozechner/*`.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: typeclaw-tunnels
|
|
3
|
+
description: Use when the user mentions tunnel, ngrok, webhook URL, cloudflared, expose to internet, show my friend, public URL, GitHub webhook, port forward to public, reverse proxy, trycloudflare, or making a container-local service reachable from the internet. Read it before suggesting tunnel add/remove/status/logs or editing typeclaw.json tunnels[].
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# typeclaw-tunnels
|
|
7
|
+
|
|
8
|
+
TypeClaw tunnels expose a container-private HTTP/TCP service to the public internet. Use them for inbound webhooks (especially GitHub) or short-lived demos where the user wants a public URL for a local port.
|
|
9
|
+
|
|
10
|
+
## When to suggest `typeclaw tunnel add`
|
|
11
|
+
|
|
12
|
+
Suggest a tunnel when the user asks for any of these:
|
|
13
|
+
|
|
14
|
+
- "give GitHub a webhook URL", "make GitHub webhooks work", or similar webhook delivery work.
|
|
15
|
+
- "expose this to the internet", "show my friend", "public URL", or "share this dashboard".
|
|
16
|
+
- "use ngrok" or "cloudflared" for a port running inside the agent container.
|
|
17
|
+
|
|
18
|
+
Do **not** suggest a tunnel for host-local browsing only. If the user just needs to open a dev server on their own machine, TypeClaw's port-forwarder usually already maps container LISTEN ports to `127.0.0.1:<port>` on the host. Tunnels are for public internet ingress.
|
|
19
|
+
|
|
20
|
+
## Provider choices
|
|
21
|
+
|
|
22
|
+
### Cloudflare Quick Tunnel
|
|
23
|
+
|
|
24
|
+
Choose Cloudflare Quick when the user wants the easiest path:
|
|
25
|
+
|
|
26
|
+
- No Cloudflare account or signup.
|
|
27
|
+
- No host-side binary install; `cloudflared` runs inside the container.
|
|
28
|
+
- The URL looks like `https://<random>.trycloudflare.com`.
|
|
29
|
+
- The URL rotates on container restart or tunnel restart. That is expected.
|
|
30
|
+
|
|
31
|
+
For GitHub channel setup, `typeclaw channel add github` can write a channel-owned Cloudflare Quick tunnel named `github-webhook` and set `docker.file.cloudflared: true`. The first `typeclaw start` or `restart` after that rebuilds the image with `cloudflared` installed.
|
|
32
|
+
|
|
33
|
+
### External URL
|
|
34
|
+
|
|
35
|
+
Choose External when the user already has their own reverse proxy or tunnel:
|
|
36
|
+
|
|
37
|
+
- ngrok, a Cloudflare named tunnel managed outside TypeClaw, Caddy on a VPS, Tailscale Funnel, or any HTTPS reverse proxy.
|
|
38
|
+
- The URL is stable because the user owns it.
|
|
39
|
+
- TypeClaw does not spawn a subprocess for this provider; it records the URL and broadcasts it to channel consumers.
|
|
40
|
+
|
|
41
|
+
Use `provider: "external"` with `externalUrl: "https://..."`. External URLs must be HTTPS.
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
- `typeclaw tunnel add <name>` — add a manual tunnel to `typeclaw.json`.
|
|
46
|
+
- `typeclaw tunnel list` — show all configured tunnels and their current URL/health.
|
|
47
|
+
- `typeclaw tunnel status <name>` — inspect one tunnel in detail.
|
|
48
|
+
- `typeclaw tunnel logs <name>` — print the tunnel's recent log ring.
|
|
49
|
+
- `typeclaw tunnel logs <name> -f` — follow live tunnel logs.
|
|
50
|
+
- `typeclaw tunnel remove <name>` — remove a manual tunnel. Channel-owned tunnels should be removed through the owning channel flow, not by hand.
|
|
51
|
+
|
|
52
|
+
Tunnel config is **restart-required**. After adding/removing/changing `tunnels[]` or `docker.file.cloudflared`, the user must run `typeclaw restart` from the host stage.
|
|
53
|
+
|
|
54
|
+
## Reading `tunnel status`
|
|
55
|
+
|
|
56
|
+
Healthy Cloudflare Quick tunnels should show:
|
|
57
|
+
|
|
58
|
+
- provider `cloudflare-quick`.
|
|
59
|
+
- a current `https://...trycloudflare.com` URL after cloudflared has emitted one.
|
|
60
|
+
- health like `healthy` or equivalent live/running state.
|
|
61
|
+
- restart count near zero for a stable tunnel.
|
|
62
|
+
|
|
63
|
+
Unhealthy signs:
|
|
64
|
+
|
|
65
|
+
- no URL after startup.
|
|
66
|
+
- repeated restarts or increasing restart count.
|
|
67
|
+
- `unhealthy` / `permanently-failed` state.
|
|
68
|
+
- last error mentioning spawn failure, missing binary, or cloudflared exit.
|
|
69
|
+
|
|
70
|
+
For External tunnels, the URL should be the configured `externalUrl`; there may be no subprocess health to inspect.
|
|
71
|
+
|
|
72
|
+
## Reading `tunnel logs`
|
|
73
|
+
|
|
74
|
+
Healthy Cloudflare Quick logs usually include:
|
|
75
|
+
|
|
76
|
+
- cloudflared startup lines.
|
|
77
|
+
- a line containing the public `https://...trycloudflare.com` URL.
|
|
78
|
+
- no rapid repeated exit/restart sequence.
|
|
79
|
+
|
|
80
|
+
Unhealthy logs often show:
|
|
81
|
+
|
|
82
|
+
- `cloudflared` not found or spawn failure.
|
|
83
|
+
- repeated process exits followed by backoff/restart lines.
|
|
84
|
+
- Cloudflare connection errors or network failures.
|
|
85
|
+
- no URL emission before the process exits.
|
|
86
|
+
|
|
87
|
+
Use `typeclaw tunnel logs <name> -f` while restarting the agent if you need to watch URL discovery live.
|
|
88
|
+
|
|
89
|
+
## Common failure modes
|
|
90
|
+
|
|
91
|
+
### `cloudflared` is not installed
|
|
92
|
+
|
|
93
|
+
The Cloudflare Quick provider requires `docker.file.cloudflared: true`. If it is missing, add it to `typeclaw.json` or re-run the GitHub channel setup choosing Cloudflare Quick, then run `typeclaw restart` so the Dockerfile is regenerated and the image rebuilds.
|
|
94
|
+
|
|
95
|
+
### Quick tunnel URL changed
|
|
96
|
+
|
|
97
|
+
This is normal. Quick Tunnel URLs rotate on restart. GitHub channel-owned tunnels handle this by flowing the resolved URL through the channel manager's `tunnelUrl()` callback and restarting the adapter; do not persist the rotating URL into `typeclaw.json`.
|
|
98
|
+
|
|
99
|
+
### Repeated restarts
|
|
100
|
+
|
|
101
|
+
Inspect `typeclaw tunnel status <name>` and `typeclaw tunnel logs <name>`. Look for spawn errors, network restrictions, or cloudflared exiting before URL discovery. The tunnel manager backs off and eventually stops retrying after repeated failures without a URL.
|
|
102
|
+
|
|
103
|
+
### GitHub webhooks are not delivered
|
|
104
|
+
|
|
105
|
+
Check in this order:
|
|
106
|
+
|
|
107
|
+
1. `typeclaw tunnel status github-webhook` has a current URL.
|
|
108
|
+
2. `typeclaw tunnel logs github-webhook` shows a URL and no crash loop.
|
|
109
|
+
3. The GitHub channel config has repos listed under `channels.github.repos`.
|
|
110
|
+
4. The channel adapter was restarted after the URL arrived. Channel-owned tunnel URLs should flow through `tunnelUrl()` into adapter `start()`, not through config mutation.
|
|
111
|
+
5. GitHub repo webhook settings point at the current URL if using External; for Cloudflare Quick, expect TypeClaw to re-register on URL changes.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type WaitForOptions = {
|
|
2
|
+
timeoutMs?: number
|
|
3
|
+
intervalMs?: number
|
|
4
|
+
description?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 1_000
|
|
8
|
+
const DEFAULT_INTERVAL_MS = 1
|
|
9
|
+
|
|
10
|
+
export async function waitFor<T>(
|
|
11
|
+
predicate: () => T | Promise<T>,
|
|
12
|
+
options: WaitForOptions = {},
|
|
13
|
+
): Promise<NonNullable<T>> {
|
|
14
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
15
|
+
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS
|
|
16
|
+
const deadline = Date.now() + timeoutMs
|
|
17
|
+
|
|
18
|
+
const initial = await predicate()
|
|
19
|
+
if (initial) return initial as NonNullable<T>
|
|
20
|
+
|
|
21
|
+
while (Date.now() < deadline) {
|
|
22
|
+
await new Promise<void>((resolve) => setTimeout(resolve, intervalMs))
|
|
23
|
+
const result = await predicate()
|
|
24
|
+
if (result) return result as NonNullable<T>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const label = options.description ?? 'condition'
|
|
28
|
+
throw new Error(`waitFor: ${label} did not become truthy within ${timeoutMs}ms`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Asserts that `predicate` STAYS falsy for the full `durationMs`. Unlike
|
|
32
|
+
// `waitFor`, this MUST pay the full duration — you cannot observe the absence
|
|
33
|
+
// of an event faster than waiting for it. Use sparingly, and keep durations
|
|
34
|
+
// tight.
|
|
35
|
+
export async function expectStable<T>(
|
|
36
|
+
predicate: () => T | Promise<T>,
|
|
37
|
+
options: { durationMs: number; intervalMs?: number; description?: string },
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
const intervalMs = options.intervalMs ?? 5
|
|
40
|
+
const deadline = Date.now() + options.durationMs
|
|
41
|
+
|
|
42
|
+
while (Date.now() < deadline) {
|
|
43
|
+
const result = await predicate()
|
|
44
|
+
if (result) {
|
|
45
|
+
const label = options.description ?? 'condition'
|
|
46
|
+
throw new Error(`expectStable: ${label} became truthy before ${options.durationMs}ms elapsed`)
|
|
47
|
+
}
|
|
48
|
+
await new Promise<void>((resolve) => setTimeout(resolve, intervalMs))
|
|
49
|
+
}
|
|
50
|
+
}
|