typeclaw 0.4.0 → 0.5.1

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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/agent/auth.ts +4 -2
  3. package/src/agent/index.ts +16 -28
  4. package/src/agent/model-fallback.ts +127 -0
  5. package/src/agent/tools/curl-impersonate.ts +300 -0
  6. package/src/agent/tools/ddg.ts +13 -88
  7. package/src/agent/tools/webfetch/fetch.ts +105 -2
  8. package/src/agent/tools/webfetch/tool.ts +4 -0
  9. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  10. package/src/bundled-plugins/backup/subagents.ts +2 -0
  11. package/src/bundled-plugins/memory/README.md +49 -12
  12. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  13. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  14. package/src/bundled-plugins/memory/index.ts +2 -2
  15. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  16. package/src/bundled-plugins/memory/strength.ts +127 -0
  17. package/src/bundled-plugins/memory/topics.ts +75 -0
  18. package/src/bundled-plugins/security/index.ts +87 -43
  19. package/src/bundled-plugins/security/permissions.ts +36 -0
  20. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  21. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  22. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  23. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  24. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  25. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  26. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  27. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  28. package/src/channels/adapters/github/index.ts +87 -3
  29. package/src/channels/router.ts +194 -28
  30. package/src/channels/types.ts +3 -1
  31. package/src/cli/channel.ts +2 -45
  32. package/src/cli/init.ts +148 -87
  33. package/src/cli/model.ts +12 -3
  34. package/src/cli/oauth-callbacks.ts +49 -0
  35. package/src/cli/provider.ts +3 -20
  36. package/src/cli/ui.ts +95 -0
  37. package/src/config/config.ts +59 -24
  38. package/src/config/models-mutation.ts +42 -8
  39. package/src/config/providers-mutation.ts +12 -8
  40. package/src/container/start.ts +18 -1
  41. package/src/cron/consumer.ts +129 -43
  42. package/src/init/dockerfile.ts +221 -3
  43. package/src/init/hatching.ts +2 -2
  44. package/src/init/index.ts +47 -3
  45. package/src/init/oauth-login.ts +17 -3
  46. package/src/permissions/builtins.ts +29 -7
  47. package/src/permissions/permissions.ts +24 -7
  48. package/src/plugin/define.ts +2 -0
  49. package/src/plugin/manager.ts +14 -0
  50. package/src/plugin/types.ts +6 -0
  51. package/src/run/index.ts +2 -1
  52. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  53. package/src/skills/typeclaw-permissions/SKILL.md +35 -17
  54. package/src/tui/index.ts +35 -3
  55. package/src/usage/report.ts +15 -12
  56. package/typeclaw.schema.json +57 -25
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: typeclaw-memory
3
- description: Use this skill whenever the user asks what you remember, what you forgot, what you dreamed, why a fact is or isn't in your memory, when memory consolidation happens, or whenever you are about to read or write `MEMORY.md`, anything under `memory/`, or `memory/skills/`. Triggers include "what do you remember", "do you remember X", "forget that", "what did you dream", "when do you dream next", "why did you forget X", "edit MEMORY.md", "add to memory", "your daily streams", "memory-logger", "dreaming", "muscle memory", or any mention of `memory.idleMs` / `memory.dreaming.schedule` in `typeclaw.json`. Read it before you touch any memory file — `MEMORY.md` and `memory/yyyy-MM-dd.jsonl` are runtime-owned, hand-edits are easy to do wrong, and the user almost always means something more specific than "edit memory" when they say it.
3
+ description: Use this skill whenever the user asks what you remember, what you forgot, what you dreamed, why a fact is or isn't in your memory, when memory consolidation happens, or whenever you are about to read or write `MEMORY.md`, anything under `memory/`, or `memory/skills/`. Triggers include "what do you remember", "do you remember X", "forget that", "what did you dream", "when do you dream next", "why did you forget X", "edit MEMORY.md", "add to memory", "your daily streams", "memory-logger", "dreaming", "muscle memory", or any mention of `memory.idleMs` / `memory.bufferBytes` / `memory.dreaming.schedule` in `typeclaw.json`. Read it before you touch any memory file — `MEMORY.md` and `memory/yyyy-MM-dd.jsonl` are runtime-owned, hand-edits are easy to do wrong, and the user almost always means something more specific than "edit memory" when they say it.
4
4
  ---
5
5
 
6
6
  # typeclaw-memory
@@ -13,7 +13,7 @@ This skill exists so you can answer the user's questions about your own memory h
13
13
 
14
14
  ### Stage 1: memory-logger (online, per-session)
15
15
 
16
- After every prompt completes, the runtime fires the `session.idle` hook. The memory plugin starts a debounce timer (`memory.idleMs`, default `10_000` ms; minimum `1000`). Every subsequent prompt completion resets the timer. When the user has been quiet for `idleMs`, the plugin spawns the **memory-logger** subagent for the current session. It also fires immediately on `session.end` (websocket close) so the final transcript never gets lost.
16
+ After every prompt completes, the runtime fires the `session.idle` hook. The memory plugin starts a debounce timer (`memory.idleMs`, default `60_000` ms; minimum `1000`). Every subsequent prompt completion resets the timer. When the user has been quiet for `idleMs`, the plugin spawns the **memory-logger** subagent for the current session. It also fires immediately on `session.end` (websocket close) so the final transcript never gets lost.
17
17
 
18
18
  The memory-logger reads:
19
19
 
@@ -45,32 +45,40 @@ When dreaming fires, it reads:
45
45
  1. `MEMORY.md`
46
46
  2. The **undreamed fragments** of every `memory/yyyy-MM-dd.jsonl` (the runtime tells it which fragment ids are new — fragments whose ids are already in `memory/.dreaming-state.json#dreamedThrough[date].dreamedIds` have been consolidated and must NOT be re-cited)
47
47
 
48
- It rewrites `MEMORY.md` with the merged result, advances the per-day dreamed-id set in `memory/.dreaming-state.json`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, **compacts the touched daily streams** (drops superseded watermarks per source and fragments that are in `dreamedIds` but not cited from `MEMORY.md`), then commits the snapshot with a message shaped like `dream: <summary> <emoji>` — e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`. The summary is derived from the staged diff (line additions in daily streams, newly-added skills, etc.), and the emoji is a random pick from a small thematic pool. After the commit, the runtime sets the `skip-worktree` index flag on the tracked memory artifacts so the user's `git status` and `git diff` stay clean. The flag is cleared and re-applied around every commit.
48
+ It rewrites `MEMORY.md` with the merged result (treating it as a **saturated surface** that gets rebalanced every run, not an append-only log), advances the per-day dreamed-id set in `memory/.dreaming-state.json`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, **compacts the touched daily streams** (drops superseded watermarks per source and fragments that are in `dreamedIds` but not cited from `MEMORY.md`), then commits the snapshot with a message shaped like `dream: <summary> <emoji>` — e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`. The summary is derived from the staged diff (line additions in daily streams, newly-added skills, etc.), and the emoji is a random pick from a small thematic pool. After the commit, the runtime sets the `skip-worktree` index flag on the tracked memory artifacts so the user's `git status` and `git diff` stay clean. The flag is cleared and re-applied around every commit.
49
49
 
50
50
  The dreaming subagent has only three tools: `read`, `write`, `ls`. No `bash`. No `edit`. It cannot run shell commands.
51
51
 
52
+ **Strength-driven rebalancing.** On every run, the runtime computes per-topic strength signals from `MEMORY.md`'s existing citations (`cites`, `days` = distinct calendar days, `last reinforced` date, `age` in days) and injects them as a table at the top of the dreaming user prompt. Dreaming uses them to promote reinforced topics (`days >= 3` → "consistently", `days >= 7` → "always"), merge near-duplicates while preserving the **union** of their fragment ids, and demote decayed single-day topics into a `## Historical observations` bucket as one-line bullets that still cite the underlying fragment. Strong topics (`days >= 3`) are never demoted regardless of age. The bucket grows monotonically — there is no hard-deletion path today; every demoted citation stays alive forever via its bullet.
53
+
54
+ **Citation-superset safety net.** The runtime cross-checks every MEMORY.md rewrite against the prior file's citation set. If dreaming's rewrite drops any previously-cited fragment id, the runtime reverts MEMORY.md to its pre-run bytes, skips fragment GC, but **advances dreamed-ids** anyway (so the same input cannot infinite-loop). The conscious tradeoff: a violation orphans this run's new undreamed fragments — they survive in the daily JSONL (force-committed, recoverable via `git log memory/`) but will never be re-shown to a future dreaming run. If the revert write itself fails, the runtime additionally skips the dreamed-id advance, skips compaction, and skips the commit, leaving recovery to the operator (`git checkout -- MEMORY.md && typeclaw restart`). Look for `[dreaming] citation-superset violation` log lines if `MEMORY.md` ever seems to stop updating.
55
+
52
56
  `MEMORY.md` after dreaming looks like:
53
57
 
54
58
  ```
55
59
  # Memory
56
60
 
57
- ## <topic>
61
+ ## <strong topic — wording from days >= 3>
58
62
  <conclusion paragraph in dreaming's own words>
59
63
 
60
64
  fragments:
61
65
  - memory/yyyy-MM-dd#<fragment-id>
62
66
  - memory/yyyy-MM-dd#<fragment-id>
63
67
 
64
- ## <topic>
68
+ ## <weaker topic>
65
69
  <conclusion paragraph>
66
70
 
67
71
  fragments:
68
72
  - memory/yyyy-MM-dd#<fragment-id>
73
+
74
+ ## Historical observations
75
+ - yyyy-MM-dd: one-line summary of a demoted fact — memory/yyyy-MM-dd#<fragment-id>
76
+ - yyyy-MM-dd: one-line summary of another demoted fact — memory/yyyy-MM-dd#<fragment-id>
69
77
  ```
70
78
 
71
- The first line is always `# Memory`. Topics are level-2 headings. Every topic cites the source fragments by `memory/yyyy-MM-dd#<uuidv7>` (the full id from the fragment event's `id` field) so any claim is traceable back to the daily stream entry that justified it. Citations are id-based, not line-based, so daily streams can be compacted between dreaming runs without invalidating prior references.
79
+ The first line is always `# Memory`. Topics are level-2 headings. Every topic cites the source fragments by `memory/yyyy-MM-dd#<uuidv7>` (the full id from the fragment event's `id` field) so any claim is traceable back to the daily stream entry that justified it. Citations are id-based, not line-based, so daily streams can be compacted between dreaming runs without invalidating prior references. The `## Historical observations` bucket is always last when present.
72
80
 
73
- If the undreamed tails contain only watermarks, or every new fragment is already represented in `MEMORY.md`, dreaming **does nothing** and exits without writing. The watermark advances either way. "No-op dreaming" is a normal outcome, not a failure.
81
+ Dreaming does NOT no-op just because there are no new fragments. Even with only watermarks past the tail, if the strength table shows obvious merge or demotion candidates (e.g. a stale single-day topic that has aged past the demotion threshold), the run is productive and rebalances. The truly-no-op case ("only watermarks AND every topic looks well-shaped at its current strength AND no procedure clears the muscle-memory bar") still exits without writing; the watermark advances either way.
74
82
 
75
83
  ### What gets injected into your prompt every turn
76
84
 
@@ -131,24 +139,26 @@ Stay concrete. Use this map:
131
139
 
132
140
  `typeclaw init` does **not** scaffold any of these. They appear when needed — `MEMORY.md` and `memory/` are created by the first dreaming run; daily streams appear when the first memory-logger fires.
133
141
 
134
- ## When the user asks about `memory.idleMs` or `memory.dreaming.schedule`
142
+ ## When the user asks about `memory.idleMs`, `memory.bufferBytes`, or `memory.dreaming.schedule`
135
143
 
136
- These are the only two configurable knobs. They live in the `memory` block of `typeclaw.json`:
144
+ These are the configurable knobs. They live in the `memory` block of `typeclaw.json`:
137
145
 
138
146
  ```json
139
147
  {
140
148
  "memory": {
141
- "idleMs": 10000,
149
+ "idleMs": 60000,
150
+ "bufferBytes": 500000,
142
151
  "dreaming": { "schedule": "*/30 * * * *" }
143
152
  }
144
153
  }
145
154
  ```
146
155
 
147
- | Field | Default | Effect | Reload class |
148
- | -------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
149
- | `memory.idleMs` | `10000` (min `1000`) | Debounce window before `memory-logger` spawns after a prompt completes. | Restart-required. |
150
- | `memory.dreaming` | `{}` (cron job on) | Dreaming cron job is always registered. Override `schedule` to change when it fires. | Restart-required. |
151
- | `memory.dreaming.schedule` | `"*/30 * * * *"` | Cron expression. Parsed via `cron-parser`; an invalid expression fails config load. Fires with nothing past the watermark short-circuit before any LLM call, so frequent no-op fires are intentionally cheap. | Restart-required. |
156
+ | Field | Default | Effect | Reload class |
157
+ | -------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
158
+ | `memory.idleMs` | `60000` (min `1000`) | Debounce window before `memory-logger` spawns after a prompt completes. | Restart-required. |
159
+ | `memory.bufferBytes` | `500000` (0 disables) | Size-based ceiling. Spawns `memory-logger` immediately when the transcript has grown by this many bytes since the last run, regardless of `idleMs`. Lets busy channel sessions still produce memory updates without waiting for a full quiet window. Minimum `10000` when non-zero. | Restart-required. |
160
+ | `memory.dreaming` | `{}` (cron job on) | Dreaming cron job is always registered. Override `schedule` to change when it fires. | Restart-required. |
161
+ | `memory.dreaming.schedule` | `"*/30 * * * *"` | Cron expression. Parsed via `cron-parser`; an invalid expression fails config load. Fires with nothing past the watermark short-circuit before any LLM call, so frequent no-op fires are intentionally cheap. | Restart-required. |
152
162
 
153
163
  Both fields are restart-required because plugin config is read once at boot. After editing them, tell the user: "Edited `memory.<field>` — restart-required. Run `typeclaw restart` (host stage) to pick up the change." The bundled plugin's config schema is merged into `typeclaw.schema.json`, so editor autocomplete will validate these fields, but a `reload` will not re-instantiate the plugin.
154
164
 
@@ -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`, `security.bypass.gitExfil` |
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 `security.bypass.secretExfilBash` and `security.bypass.gitExfil` by default (so a trusted actor can run dangerous bash and `git push` without per-call acks) but **deliberately not** `security.bypass.gitRemoteTainted` — the two-step social-attack defense (re-point remote, then push to it) still fires for trusted, so a prompt-injection mid-session that swaps the remote URL still blocks the eventual push. `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,18 +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 and
98
- security.bypass.gitExfil — but not gitRemoteTainted).
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.
99
113
  ```
100
114
 
101
- Three escape hatches, ordered from least to most invasive:
115
+ Four escape hatches, ordered from least to most invasive:
102
116
 
103
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.
104
- 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.
105
- 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.
106
121
 
107
- 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.
108
123
 
109
124
  ## When the user asks "why aren't you replying in #channel?"
110
125
 
@@ -121,7 +136,7 @@ To distinguish cause 1/2 from cause 3: if `typeclaw logs <container> -f` (host s
121
136
  This is a `roles` edit. The full procedure:
122
137
 
123
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.
124
- 2. **Pick a role.** Default to `member` for "give them normal channel access". Use `trusted` if they should also be able to schedule cron, bypass the bash secret guard, and run `git push` / `git remote add` / `git add -f` without per-call acks (the two-step taint defense still fires for trusted so a mid-session remote re-point still blocks the eventual push). Only use `owner` if they should have full bypass on every security guard, including the taint defense 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.
125
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.
126
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)."
127
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").
@@ -151,7 +166,10 @@ If you see a cron job mysteriously failing every fire with `denied by permission
151
166
  ## Things you must not do
152
167
 
153
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).
154
- - **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.
155
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.
156
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.
157
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.
package/src/tui/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Editor, Key, Markdown, matchesKey, ProcessTerminal, type Terminal, Text, TUI } from '@mariozechner/pi-tui'
2
2
 
3
+ import { parseCommand } from '@/commands'
4
+
3
5
  import { createClient as createClientDefault, type Client } from './client'
4
6
  import { formatQueuePanel, formatToolEnd, formatToolStart, formatUserPromptHistory } from './format'
5
7
  import { colors, editorTheme, markdownTheme } from './theme'
@@ -9,6 +11,21 @@ export type TerminalFactory = () => Terminal
9
11
 
10
12
  const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000
11
13
 
14
+ // Bare slash-command names (no leading `/`) the TUI intercepts client-side and
15
+ // turns into a clean process exit. The hatching ritual tells the agent to point
16
+ // users at `/quit` (see src/init/hatching.ts); without an intercept the literal
17
+ // text would be shipped to the LLM as a chat message. Grammar (case-insensitive,
18
+ // whitespace-tolerant, `//foo` escapes to a literal prompt) comes from
19
+ // `parseCommand` in src/commands so channel and TUI slash commands stay
20
+ // consistent. Arguments after the name disqualify the match: `/quit me a story`
21
+ // is a real prompt, not a command.
22
+ const QUIT_COMMAND_NAMES: ReadonlySet<string> = new Set(['quit', 'exit'])
23
+
24
+ function isQuitCommand(text: string): boolean {
25
+ const parsed = parseCommand(text)
26
+ return parsed !== null && parsed.args.length === 0 && QUIT_COMMAND_NAMES.has(parsed.name)
27
+ }
28
+
12
29
  export type VersionMismatch = { expected: string; actual: string }
13
30
 
14
31
  export type TuiOptions = {
@@ -205,14 +222,18 @@ export function createTui({
205
222
  return undefined
206
223
  })
207
224
 
225
+ const shutdown = (code: number) => {
226
+ tui.stop()
227
+ client.close()
228
+ exit(code)
229
+ }
230
+
208
231
  // Ctrl+C exits cleanly. In raw mode the kernel does NOT generate SIGINT,
209
232
  // so we must intercept the \x03 byte ourselves. The Editor would otherwise
210
233
  // swallow it. tui.stop() restores raw-mode/cursor/echo before we exit.
211
234
  tui.addInputListener((data) => {
212
235
  if (matchesKey(data, Key.ctrl('c'))) {
213
- tui.stop()
214
- client.close()
215
- exit(0)
236
+ shutdown(0)
216
237
  return { consume: true }
217
238
  }
218
239
  return undefined
@@ -220,6 +241,10 @@ export function createTui({
220
241
 
221
242
  editor.onSubmit = (text) => {
222
243
  if (text.trim().length === 0) return
244
+ if (isQuitCommand(text)) {
245
+ shutdown(0)
246
+ return
247
+ }
223
248
  editor.setText('')
224
249
  editor.addToHistory(text)
225
250
  tui.requestRender()
@@ -238,6 +263,13 @@ export function createTui({
238
263
  }
239
264
 
240
265
  if (initialPrompt) {
266
+ // initialPrompt bypasses editor.onSubmit, so the quit intercept above
267
+ // would never run. Guard the same way so `typeclaw tui /quit` exits
268
+ // instead of leaking the command into the agent's chat context.
269
+ if (isQuitCommand(initialPrompt)) {
270
+ shutdown(0)
271
+ return
272
+ }
241
273
  await send(initialPrompt)
242
274
  }
243
275
 
@@ -145,27 +145,30 @@ function renderOriginLabel(kind: OriginKind, ctx: RenderCtx): string {
145
145
  }
146
146
  }
147
147
 
148
- // Sparkline trend across the full byDay range, scaled to the row's max cost.
149
- // Returns null when there are fewer than 2 days (a single point conveys no
150
- // trend information). The 8-level Unicode block scale `▁▂▃▄▅▆▇█` lets us
151
- // pack ~80 days into a one-line glance wider than any table-based view
152
- // could fit at terminal widths under ~160 columns.
148
+ // Sparkline trend across the full byDay range, scaled to the row's max token
149
+ // count. Tokens (not cost) drive the chart because they're the load-bearing
150
+ // usage signal model price changes and free-tier credits shouldn't flatten
151
+ // or distort the visible workload pattern. Returns null when there are fewer
152
+ // than 2 days (a single point conveys no trend information). The 8-level
153
+ // Unicode block scale `▁▂▃▄▅▆▇█` lets us pack ~80 days into a one-line glance
154
+ // — wider than any table-based view could fit at terminal widths under ~160
155
+ // columns.
153
156
  const SPARK_GLYPHS = '▁▂▃▄▅▆▇█'
154
157
 
155
- function renderDailyTrend(byDay: readonly { date: string; cost: number }[], ctx: RenderCtx): string | null {
158
+ function renderDailyTrend(byDay: readonly { date: string; totalTokens: number }[], ctx: RenderCtx): string | null {
156
159
  if (byDay.length < 2) return null
157
- const costs = byDay.map((d) => d.cost)
158
- const max = costs.reduce((m, c) => Math.max(m, c), 0)
160
+ const tokens = byDay.map((d) => d.totalTokens)
161
+ const max = tokens.reduce((m, t) => Math.max(m, t), 0)
159
162
  if (max <= 0) return null
160
- const spark = costs
161
- .map((c) => {
162
- const idx = Math.min(SPARK_GLYPHS.length - 1, Math.max(0, Math.round((c / max) * (SPARK_GLYPHS.length - 1))))
163
+ const spark = tokens
164
+ .map((t) => {
165
+ const idx = Math.min(SPARK_GLYPHS.length - 1, Math.max(0, Math.round((t / max) * (SPARK_GLYPHS.length - 1))))
163
166
  return SPARK_GLYPHS[idx]!
164
167
  })
165
168
  .join('')
166
169
  const first = byDay[0]!.date
167
170
  const last = byDay[byDay.length - 1]!.date
168
- return `${dim('Trend (cost):', ctx)} ${color('cyan', spark, ctx)} ${dim(`${first} → ${last}`, ctx)}`
171
+ return `${dim('Trend (tokens):', ctx)} ${color('cyan', spark, ctx)} ${dim(`${first} → ${last}`, ctx)}`
169
172
  }
170
173
 
171
174
  function renderDaily(report: UsageReport, ctx: RenderCtx, limit: number | undefined): string {
@@ -12,33 +12,59 @@
12
12
  "maximum": 65535
13
13
  },
14
14
  "models": {
15
- "default": {
16
- "default": "openai/gpt-5.4-nano"
17
- },
18
15
  "type": "object",
19
16
  "propertyNames": {
20
17
  "type": "string",
21
18
  "minLength": 1
22
19
  },
23
20
  "additionalProperties": {
24
- "type": "string",
25
- "enum": [
26
- "openai/gpt-5.4-nano",
27
- "openai/gpt-5.4-mini",
28
- "openai/gpt-5.4",
29
- "openai/gpt-5.5",
30
- "openai-codex/gpt-5.4-mini",
31
- "openai-codex/gpt-5.4",
32
- "openai-codex/gpt-5.5",
33
- "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
34
- "zai/glm-4.5-air",
35
- "zai/glm-4.6",
36
- "zai/glm-4.7",
37
- "zai-coding/glm-4.5-air",
38
- "zai-coding/glm-4.7",
39
- "zai-coding/glm-5",
40
- "zai-coding/glm-5-turbo",
41
- "zai-coding/glm-5.1"
21
+ "anyOf": [
22
+ {
23
+ "type": "string",
24
+ "enum": [
25
+ "openai/gpt-5.4-nano",
26
+ "openai/gpt-5.4-mini",
27
+ "openai/gpt-5.4",
28
+ "openai/gpt-5.5",
29
+ "openai-codex/gpt-5.4-mini",
30
+ "openai-codex/gpt-5.4",
31
+ "openai-codex/gpt-5.5",
32
+ "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
33
+ "zai/glm-4.5-air",
34
+ "zai/glm-4.6",
35
+ "zai/glm-4.7",
36
+ "zai-coding/glm-4.5-air",
37
+ "zai-coding/glm-4.7",
38
+ "zai-coding/glm-5",
39
+ "zai-coding/glm-5-turbo",
40
+ "zai-coding/glm-5.1"
41
+ ]
42
+ },
43
+ {
44
+ "minItems": 1,
45
+ "type": "array",
46
+ "items": {
47
+ "type": "string",
48
+ "enum": [
49
+ "openai/gpt-5.4-nano",
50
+ "openai/gpt-5.4-mini",
51
+ "openai/gpt-5.4",
52
+ "openai/gpt-5.5",
53
+ "openai-codex/gpt-5.4-mini",
54
+ "openai-codex/gpt-5.4",
55
+ "openai-codex/gpt-5.5",
56
+ "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
57
+ "zai/glm-4.5-air",
58
+ "zai/glm-4.6",
59
+ "zai/glm-4.7",
60
+ "zai-coding/glm-4.5-air",
61
+ "zai-coding/glm-4.7",
62
+ "zai-coding/glm-5",
63
+ "zai-coding/glm-5-turbo",
64
+ "zai-coding/glm-5.1"
65
+ ]
66
+ }
67
+ }
42
68
  ]
43
69
  }
44
70
  },
@@ -898,6 +924,7 @@
898
924
  "tmux": true,
899
925
  "cjkFonts": true,
900
926
  "cloudflared": true,
927
+ "xvfb": true,
901
928
  "append": []
902
929
  }
903
930
  },
@@ -911,6 +938,7 @@
911
938
  "tmux": true,
912
939
  "cjkFonts": true,
913
940
  "cloudflared": true,
941
+ "xvfb": true,
914
942
  "append": []
915
943
  },
916
944
  "type": "object",
@@ -963,6 +991,10 @@
963
991
  "default": true,
964
992
  "type": "boolean"
965
993
  },
994
+ "xvfb": {
995
+ "default": true,
996
+ "type": "boolean"
997
+ },
966
998
  "append": {
967
999
  "default": [],
968
1000
  "type": "array",
@@ -1130,20 +1162,20 @@
1130
1162
  },
1131
1163
  "memory": {
1132
1164
  "default": {
1133
- "idleMs": 10000,
1134
- "bufferBytes": 100000,
1165
+ "idleMs": 60000,
1166
+ "bufferBytes": 500000,
1135
1167
  "spawnTimeoutMs": 50000
1136
1168
  },
1137
1169
  "type": "object",
1138
1170
  "properties": {
1139
1171
  "idleMs": {
1140
- "default": 10000,
1172
+ "default": 60000,
1141
1173
  "type": "integer",
1142
1174
  "minimum": 1000,
1143
1175
  "maximum": 9007199254740991
1144
1176
  },
1145
1177
  "bufferBytes": {
1146
- "default": 100000,
1178
+ "default": 500000,
1147
1179
  "type": "integer",
1148
1180
  "minimum": 0,
1149
1181
  "maximum": 9007199254740991