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.
Files changed (125) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +1 -1
  4. package/secrets.schema.json +113 -0
  5. package/src/agent/auth.ts +4 -2
  6. package/src/agent/index.ts +16 -28
  7. package/src/agent/model-fallback.ts +127 -0
  8. package/src/agent/session-meta.ts +1 -1
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/tools/curl-impersonate.ts +300 -0
  11. package/src/agent/tools/ddg.ts +13 -88
  12. package/src/agent/tools/webfetch/fetch.ts +105 -2
  13. package/src/agent/tools/webfetch/tool.ts +4 -0
  14. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  15. package/src/bundled-plugins/backup/subagents.ts +2 -0
  16. package/src/bundled-plugins/memory/README.md +49 -12
  17. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  18. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  19. package/src/bundled-plugins/memory/index.ts +2 -2
  20. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  21. package/src/bundled-plugins/memory/strength.ts +127 -0
  22. package/src/bundled-plugins/memory/topics.ts +75 -0
  23. package/src/bundled-plugins/security/index.ts +88 -43
  24. package/src/bundled-plugins/security/permissions.ts +36 -0
  25. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  26. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  27. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  28. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  29. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  30. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  31. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  32. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  33. package/src/channels/adapters/github/auth-app.ts +120 -0
  34. package/src/channels/adapters/github/auth-pat.ts +50 -0
  35. package/src/channels/adapters/github/auth.ts +33 -0
  36. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  37. package/src/channels/adapters/github/dedup.ts +26 -0
  38. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  39. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  40. package/src/channels/adapters/github/history.ts +63 -0
  41. package/src/channels/adapters/github/inbound.ts +286 -0
  42. package/src/channels/adapters/github/index.ts +370 -0
  43. package/src/channels/adapters/github/managed-path.ts +54 -0
  44. package/src/channels/adapters/github/membership.ts +35 -0
  45. package/src/channels/adapters/github/outbound.ts +145 -0
  46. package/src/channels/adapters/github/webhook-register.ts +349 -0
  47. package/src/channels/manager.ts +94 -9
  48. package/src/channels/router.ts +194 -28
  49. package/src/channels/schema.ts +31 -1
  50. package/src/channels/tunnel-bridge.ts +51 -0
  51. package/src/channels/types.ts +3 -1
  52. package/src/cli/builtins.ts +28 -0
  53. package/src/cli/channel.ts +511 -25
  54. package/src/cli/container-command-client.ts +244 -0
  55. package/src/cli/cron.ts +173 -0
  56. package/src/cli/host-command-runner.ts +150 -0
  57. package/src/cli/index.ts +42 -1
  58. package/src/cli/init.ts +400 -67
  59. package/src/cli/model.ts +14 -4
  60. package/src/cli/oauth-callbacks.ts +49 -0
  61. package/src/cli/plugin-command-help.ts +49 -0
  62. package/src/cli/plugin-commands-dispatch.ts +112 -0
  63. package/src/cli/plugin-commands.ts +118 -0
  64. package/src/cli/provider.ts +3 -20
  65. package/src/cli/tui.ts +10 -2
  66. package/src/cli/tunnel.ts +533 -0
  67. package/src/cli/ui.ts +8 -3
  68. package/src/config/config.ts +134 -24
  69. package/src/config/models-mutation.ts +42 -8
  70. package/src/config/providers-mutation.ts +12 -8
  71. package/src/container/start.ts +48 -4
  72. package/src/cron/bridge.ts +136 -0
  73. package/src/cron/consumer.ts +174 -48
  74. package/src/cron/index.ts +19 -2
  75. package/src/cron/list.ts +105 -0
  76. package/src/cron/scheduler.ts +12 -3
  77. package/src/cron/schema.ts +11 -3
  78. package/src/doctor/checks.ts +0 -50
  79. package/src/init/dockerfile.ts +165 -13
  80. package/src/init/ensure-deps.ts +15 -4
  81. package/src/init/github-webhook-install.ts +109 -0
  82. package/src/init/hatching.ts +2 -2
  83. package/src/init/index.ts +519 -12
  84. package/src/init/oauth-login.ts +17 -3
  85. package/src/init/run-bun-install.ts +17 -3
  86. package/src/init/run-owner-claim.ts +11 -2
  87. package/src/permissions/builtins.ts +29 -2
  88. package/src/permissions/match-rule.ts +24 -2
  89. package/src/permissions/permissions.ts +24 -7
  90. package/src/permissions/resolve.ts +1 -0
  91. package/src/plugin/define.ts +44 -1
  92. package/src/plugin/index.ts +18 -3
  93. package/src/plugin/manager.ts +16 -0
  94. package/src/plugin/registry.ts +85 -3
  95. package/src/plugin/types.ts +144 -1
  96. package/src/plugin/zod-introspect.ts +100 -0
  97. package/src/role-claim/match-rule.ts +2 -1
  98. package/src/run/index.ts +112 -4
  99. package/src/secrets/index.ts +1 -1
  100. package/src/secrets/schema.ts +21 -0
  101. package/src/server/command-runner.ts +476 -0
  102. package/src/server/index.ts +388 -5
  103. package/src/shared/index.ts +8 -0
  104. package/src/shared/protocol.ts +80 -1
  105. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  106. package/src/skills/typeclaw-config/SKILL.md +27 -26
  107. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  108. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  109. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  110. package/src/skills/typeclaw-permissions/SKILL.md +35 -16
  111. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  112. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  113. package/src/test-helpers/wait-for.ts +50 -0
  114. package/src/tui/index.ts +70 -7
  115. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  116. package/src/tunnels/events.ts +14 -0
  117. package/src/tunnels/index.ts +12 -0
  118. package/src/tunnels/log-ring.ts +54 -0
  119. package/src/tunnels/manager.ts +139 -0
  120. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  121. package/src/tunnels/providers/external.ts +53 -0
  122. package/src/tunnels/quick-url-parser.ts +5 -0
  123. package/src/tunnels/types.ts +43 -0
  124. package/src/usage/report.ts +15 -12
  125. 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`, **all `security.bypass.*` contributed by plugins** (wildcard sentinel) |
27
- | `trusted` | none | `channel.respond`, `cron.schedule`, `security.bypass.secretExfilBash` |
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
- `owner` carries every `security.bypass.*` from sources 2 and 3 by default (via a wildcard sentinel expanded at boot). `trusted` carries only `security.bypass.secretExfilBash` by default. `member` and `guest` carry no `security.bypass.*` strings.
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>` (owner has all
97
- security.bypass.*; trusted has security.bypass.secretExfilBash).
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
- Three escape hatches, ordered from least to most invasive:
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.<X>` string the block message named. This is the right answer for "I'm `trusted` in this Slack channel and I want to be able to run dangerous bash without confirming each time" — give `trusted` the relevant `security.bypass.*` permission.
104
- 3. **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.
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 it) and **which built-in roles have that permission**. Do not just relay the guard reason — that loses the access-control framing entirely.
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 and bypass the bash secret guard. Only use `owner` if they should have full bypass on every security guard — typically the agent's primary operator.
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
- - Only two kinds: `prompt` and `exec`. Plugins do not extend the schema.
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** beyond `prompt` and `exec`. (Subagent invocation is a `prompt` variant, not a separate kind.)
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, // all optional
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
+ }