typeclaw 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +35 -2
  3. package/src/agent/plugin-tools.ts +38 -0
  4. package/src/agent/session-meta.ts +6 -2
  5. package/src/agent/session-origin.ts +111 -14
  6. package/src/agent/subagents.ts +6 -1
  7. package/src/agent/system-prompt.ts +41 -32
  8. package/src/agent/tools/channel-reply.ts +3 -2
  9. package/src/agent/tools/grant-role.ts +214 -0
  10. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -6
  11. package/src/bundled-plugins/memory/index.ts +25 -6
  12. package/src/bundled-plugins/security/index.ts +12 -0
  13. package/src/bundled-plugins/security/policies/private-surface-read.ts +215 -0
  14. package/src/channels/adapters/github/inbound.ts +54 -1
  15. package/src/channels/adapters/github/index.ts +1 -0
  16. package/src/channels/router.ts +150 -37
  17. package/src/cli/inspect.ts +20 -9
  18. package/src/cli/role.ts +10 -1
  19. package/src/cli/ui.ts +6 -4
  20. package/src/config/reloadable.ts +10 -3
  21. package/src/init/index.ts +24 -42
  22. package/src/init/paths.ts +1 -0
  23. package/src/init/run-owner-claim.ts +21 -3
  24. package/src/inspect/label.ts +2 -0
  25. package/src/inspect/live.ts +6 -1
  26. package/src/inspect/render.ts +8 -2
  27. package/src/inspect/replay.ts +6 -1
  28. package/src/inspect/types.ts +4 -1
  29. package/src/permissions/builtins.ts +22 -0
  30. package/src/permissions/grant.ts +92 -16
  31. package/src/permissions/index.ts +8 -2
  32. package/src/permissions/permissions.ts +16 -0
  33. package/src/permissions/resolve.ts +10 -0
  34. package/src/plugin/types.ts +12 -0
  35. package/src/role-claim/index.ts +1 -0
  36. package/src/role-claim/reload-after-claim.ts +34 -0
  37. package/src/run/channel-session-factory.ts +6 -1
  38. package/src/run/index.ts +18 -1
  39. package/src/sandbox/build.ts +51 -1
  40. package/src/sandbox/hidden-paths.ts +41 -0
  41. package/src/sandbox/index.ts +2 -1
  42. package/src/sandbox/policy.ts +15 -0
  43. package/src/skills/typeclaw-channel-github/SKILL.md +15 -3
  44. package/src/skills/typeclaw-permissions/SKILL.md +11 -3
  45. package/src/skills/typeclaw-skills/SKILL.md +3 -1
  46. package/src/skills/typeclaw-troubleshooting/SKILL.md +104 -0
  47. package/src/usage/report.ts +4 -0
  48. package/src/usage/scan.ts +1 -1
@@ -1,5 +1,6 @@
1
1
  export { buildSandboxedCommand, type SandboxedCommand } from './build'
2
- export { ensureBwrapAvailable } from './availability'
2
+ export { ensureBwrapAvailable, _resetBwrapAvailabilityCacheForTests } from './availability'
3
+ export { resolveHiddenPaths, type HiddenPaths } from './hidden-paths'
3
4
  export { formatCommand, shellQuote } from './quote'
4
5
  export { SandboxPolicyError, SandboxUnavailableError } from './errors'
5
6
  export {
@@ -23,10 +23,25 @@ export type SandboxProcessPolicy = {
23
23
  dieWithParent?: boolean
24
24
  }
25
25
 
26
+ // Role-derived deny-list overlaid on top of an already-visible tree. dirs are
27
+ // hidden with an empty tmpfs; files are hidden with --ro-bind-data, the only
28
+ // bwrap primitive that masks a single FILE (--tmpfs is dir-only). --ro-bind-data
29
+ // reads its empty content from a file descriptor, and the bash tool spawns with
30
+ // stdio ["ignore","pipe","pipe"] — no inherited extra fds — so the rendered
31
+ // commandString self-opens fd MASK_DATA_FD via a `<fd>< /dev/null` redirection
32
+ // appended after `bash -c <command>`. Masks MUST render after the broad parent
33
+ // mounts: bwrap applies mount ops in command-line order and the last op on a
34
+ // path wins, so a mask emitted before its parent bind would be re-exposed.
35
+ export type SandboxMaskPolicy = {
36
+ dirs?: string[]
37
+ files?: string[]
38
+ }
39
+
26
40
  export type SandboxPolicy = {
27
41
  bwrapPath?: string
28
42
  cwd?: string
29
43
  mounts?: SandboxMount[]
44
+ masks?: SandboxMaskPolicy
30
45
  network?: SandboxNetwork
31
46
  env?: SandboxEnvPolicy
32
47
  commandFilter?: SandboxCommandFilter
@@ -63,7 +63,11 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
63
63
  </review>
64
64
  ```
65
65
 
66
- 4. **Translate findings into a `gh api` review payload.** Each `<finding>` with `severity` of `blocker`, `concern`, or `nit` and a `location="path:line"` becomes one entry in `comments[]`. Compose the inline `body` from the reviewer's `<issue>` + `<evidence>` + `<suggestion>` — preserve the reviewer's wording, do not paraphrase. Findings whose `location` is `general` (no file:line anchor) go into the top-level review `body` instead. **Skip `praise` findings when building `comments[]`** — they are not actionable, and inline praise comments are exactly the noise the reviewer is supposed to filter out at the source; if you want to surface them, weave them into the top-level review `body` alongside the summary. Map the reviewer's `<verdict>` to the GitHub `event`:
66
+ 4. **Translate findings into a `gh api` review payload.** Each `<finding>` with `severity` of `blocker`, `concern`, or `nit` and a `location="path:line"` becomes one entry in `comments[]`. Compose the inline `body` from the reviewer's `<issue>` + `<evidence>` + `<suggestion>` — preserve the reviewer's wording, do not paraphrase. Findings whose `location` is `general` (no file:line anchor) go into the top-level review `body` instead. **Skip `praise` findings when building `comments[]`** — they are not actionable, and inline praise comments are exactly the noise the reviewer is supposed to filter out at the source; if you want to surface them, weave them into the top-level review `body` alongside the summary.
67
+
68
+ **The verdict and the inline comments are independent. The verdict sets only the `event` field; it never decides whether you post `comments[]`.** Whenever there is at least one actionable finding (`blocker`/`concern`/`nit`) with a `location="path:line"`, you MUST submit a formal review via `POST /pulls/<N>/reviews` carrying those findings in `comments[]` — including when the verdict is `approve`. An `approve` with three nits is still a formal `APPROVE` review with three inline comments, **not** a plain approval and **not** a flattened summary posted as a top-level comment. Collapsing inline findings into a single `channel_reply` or issue comment loses the line anchors the reviewer worked to produce — that is the exact failure mode this step exists to prevent.
69
+
70
+ Map the reviewer's `<verdict>` to the GitHub `event`:
67
71
 
68
72
  | Reviewer verdict | GitHub `event` |
69
73
  | ----------------- | ----------------- |
@@ -88,13 +92,21 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
88
92
 
89
93
  **Always use `--input -` with a quoted heredoc (`<<'JSON'`) for review bodies.** Do **not** use `-f body=...` or `-F 'comments[][body]=...'`: those go through shell argument parsing, so backticks (\`) trigger command substitution and have to be backslash-escaped, which leaks the literal `\` into the rendered comment. The quoted heredoc passes the JSON through untouched — backticks, newlines, and `${...}` all survive verbatim. The same applies to any other `gh api` POST whose body contains backticks, embedded newlines, or shell metacharacters.
90
94
 
91
- 5. **Post a one-line summary with `channel_reply`** so the conversation has a human-readable trace pointing at the review (e.g., "Posted review on PR #N: <verdict>, N findings.").
95
+ 5. **Verify the review actually landed before announcing it.** The `gh api` call can fail silently from the model's perspective a permission denial, a bad `line` anchor, or a malformed payload returns an error you must not paper over. After submitting, confirm the review exists:
96
+
97
+ ```sh
98
+ gh api /repos/owner/repo/pulls/<N>/reviews --jq '.[-1] | {id, state, user: .user.login}'
99
+ ```
100
+
101
+ The returned `id`/`state` is your proof the formal review posted. If the call errored or the review is absent, do **not** fall back to a top-level `channel_reply` that _claims_ a review was posted — fix the payload (most often a `line` that isn't part of the diff; re-anchor it or move that finding to the top-level `body`) and resubmit. A trace reply that says "Posted review" when no review exists is worse than silence.
102
+
103
+ 6. **End the turn with `skip_response`, not a trace reply.** The formal review from step 4 already landed _in this PR_ — it carries the summary, the verdict, and the inline comments. A `channel_reply` here does **not** go to a separate operator channel; on GitHub it posts another public comment on the same PR. A one-line "Posted review on PR #N: …" narrated into the PR thread is meta-commentary addressed to a phantom operator, and it reads absurdly next to the review it claims to point at. So once step 5 confirms the review exists, call `skip_response({ reason: "review posted via gh api" })` to close the turn silently. Only fall back to `channel_reply` when there was **no** formal review to post — the zero-actionable-findings branches in Rules below already use `channel_reply`/issue comments _as_ the substantive reply.
92
104
 
93
105
  ### Rules
94
106
 
95
107
  - **Always delegate to the `reviewer` subagent.** Do not perform the review craft yourself. The reviewer is the source of truth for severity, evidence quality, and what counts as a finding. Your job is mechanics: spawn, wait, translate, post.
96
108
  - **Trust the verdict.** Use the GitHub `event` mapped from the reviewer's `<verdict>`. Do not upgrade `comment` → `APPROVE` to seem agreeable, and do not downgrade `request-changes` → `COMMENT` to soften the tone. The reviewer chose deliberately.
97
- - **No actionable findings → no inline review post.** A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. If the reviewer returns zero actionable findings:
109
+ - **No actionable findings → no inline review post.** A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. This branch applies **only when the actionable count is exactly zero** — if there is even one actionable finding with a line anchor, follow step 4 and submit a formal review with `comments[]` regardless of verdict. When the reviewer returns zero actionable findings:
98
110
  - `approve` verdict → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array).
99
111
  - `comment` verdict → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review.
100
112
  - `request-changes` verdict → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern), so if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
@@ -42,11 +42,19 @@ When the runtime knows your permissions, it prepends a block under your `## Sess
42
42
  Role: `member`. Permissions: `channel.respond`.
43
43
  ```
44
44
 
45
- The block renders for cron / channel / subagent sessions. For TUI sessions, the block is omitted because TUI always resolves to `owner` under severity-then-declaration ordering (built-in `owner.match` includes `tui` and is appended-to, never replaced, by user config — and `owner` is walked first). If you don't see the block in a TUI session, treat yourself as `owner`.
45
+ This concrete role/permissions block renders for **cron and subagent** sessions, which have a single fixed actor. For TUI sessions the block is omitted because TUI always resolves to `owner` under severity-then-declaration ordering (built-in `owner.match` includes `tui` and is appended-to, never replaced, by user config — and `owner` is walked first). If you don't see the block in a TUI session, treat yourself as `owner`.
46
46
 
47
- **The role line reflects the session at creation time.** For channel sessions, the speaker on subsequent turns may resolve to a different role; the runtime updates that internally for tool gating (the channel router and the security plugin re-resolve on each turn), but the system prompt is not regenerated mid-session. If the user asks "what role am I right now in this channel", read `typeclaw.json` `roles` and match their author id against `match[]` yourself — do not parrot the system-prompt line as if it always applied.
47
+ **Channel sessions are different.** A channel session is keyed by chat/thread, not by author, so it can see many speakers with different roles. It does NOT print one concrete role; instead the block is a policy reminder:
48
48
 
49
- **The permission list is exhaustive at session-creation time** for the resolved role. If a permission you expect isn't listed there, the role doesn't carry it — adding it requires editing `roles.<role>.permissions[]` and restarting.
49
+ ```
50
+ ## Your role in this session
51
+
52
+ This is a channel conversation that may include multiple speakers...
53
+ ```
54
+
55
+ For each user turn, the current speaker's effective role is delivered in the turn context as a `<your-role authority="current-speaker">…</your-role>` tag (omitted for `owner`, the unconstrained default). **That per-turn tag is authoritative for the current message and overrides any role implied by the system prompt.** If the user asks "what role am I right now in this channel", read the `<your-role>` tag on the current turn (or, if absent, treat them as `owner`); do not consult a session-creation role line — channel sessions no longer carry one.
56
+
57
+ **The permission list (cron/subagent block) is exhaustive at session-creation time** for the resolved role. If a permission you expect isn't listed there, the role doesn't carry it — adding it requires editing `roles.<role>.permissions[]` and restarting.
50
58
 
51
59
  ## The match-rule DSL
52
60
 
@@ -5,7 +5,9 @@ description: Use this skill whenever the user asks you to install, find, list, u
5
5
 
6
6
  # typeclaw-skills
7
7
 
8
- You operate inside an agent folder. Skills — markdown files with YAML frontmatter — are how this folder teaches you new procedures, conventions, and APIs without changing your code. The runtime discovers them on session start, parses each `SKILL.md`'s frontmatter, and surfaces the `name` + `description` to you so you can decide when to read the body. **You do not import or invoke skills; you read them when their description matches the current request.**
8
+ You operate inside an agent folder. Skills — markdown files with YAML frontmatter — are how this folder teaches you new procedures, conventions, and APIs without changing your code. The runtime discovers them on session start, parses each `SKILL.md`'s frontmatter, and surfaces the `name` + `description` (plus an absolute `<location>` path) to you in the `<available_skills>` section so you can decide when to read the body. **You do not import or invoke skills; you load one by `read`-ing the `SKILL.md` at the exact `<location>` path that section gives you.**
9
+
10
+ **Never construct or guess a skill's path.** Use the `<location>` verbatim. Do not assume a layout like `node_modules/typeclaw/src/skills/<name>/SKILL.md` — bundled skills resolve through the installed package, user skills live under `.agents/skills/`, and muscle-memory skills under `memory/skills/`. If a skill you expect is not listed in `<available_skills>`, it is not a file-based skill and has no `SKILL.md` to read — stop looking on disk. (Subagent-only skills loaded via the `load_skill` tool, e.g. the `reviewer` subagent's `code-review`, are never files.)
9
11
 
10
12
  This skill exists so you (a) understand which skills you can edit and which you must not, (b) can install new skills cleanly when the user asks, and (c) can author your own skills without colliding with the rest of the system.
11
13
 
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: typeclaw-troubleshooting
3
+ description: Use this skill when you are stuck in a fix-it loop — you've made roughly three attempts at the same failure and you're still cycling shell commands (kill the process, re-run, `sleep`, `capture-pane`, inspect, retry) without converging. Triggers include a hung or runaway process that won't die, a `C-c` that didn't stop the program, `<defunct>`/zombie processes piling up in `ps`, an interactive program that blocks `bash` waiting for input, a script that "ran" but produced no output and no file, repeated "not found"/timeout/same-error-again loops, and any moment you catch yourself thinking "let me wait a bit more and check again" for the third time. Read it before you spawn `operator` to take over the debugging — it covers the operator hand-off prompt, the tmux session pattern, killing stuck/zombie processes properly, and the edge-triggered capture-pane polling loop that the inline retry-and-sleep approach gets wrong.
4
+ ---
5
+
6
+ # typeclaw-troubleshooting
7
+
8
+ When a problem fights back, the failure mode is not "I can't fix it" — it's "I'm burning my own context and freezing the conversation while I fix it." A debugging loop is inherently noisy: every retry dumps stale shell output, zombie-process listings, and pane captures into your context, and each blocking `bash` call (especially `sleep N` followed by a capture) leaves the user staring at a frozen-looking conversation. The fix is to move the loop out of your session and into `operator`, which has bash-with-side-effects and runs in its own context window.
9
+
10
+ This skill is the runbook for that hand-off. Read it once you've hit the trigger (~3 attempts on the same failure without convergence), **before** you spawn `operator`.
11
+
12
+ ## The trigger, concretely
13
+
14
+ You are in a troubleshooting loop when all of these are true:
15
+
16
+ - You've attempted the **same underlying fix** ~3 times and it still fails.
17
+ - Your recent turns are dominated by `bash` calls whose only purpose is to probe/retry: `kill`, `sleep`, `tmux capture-pane`, `ps aux | grep`, re-running the same script, "let me wait and check again".
18
+ - Each attempt produces more disposable output than signal.
19
+
20
+ If you're still making real progress (each attempt narrows the problem), keep going — this is for the _non-converging_ case. One or two quick probes stay inline; a third lap means delegate.
21
+
22
+ ## Why inline retry-and-sleep is the wrong tool
23
+
24
+ Two failure patterns show up over and over when an agent debugs inline, and both are why this belongs in operator:
25
+
26
+ 1. **`sleep N; capture-pane` blocks you for N seconds at a time.** You can't reply, the typing indicator can't heartbeat, and you still don't know if the work finished — you just guessed at a duration. Operator absorbs all of that latency in its own session.
27
+ 2. **A `C-c` sent to a tmux pane does not always kill the program.** If the foreground process is in a tight loop (e.g. a `while True:` with `pyboy.tick()`), the interrupt may be queued behind work and never processed, so the _next_ command you type lands in the shell while the old process is still running — and you end up reading output from the wrong process. The reliable kill is by PID, not by keystroke (see below).
28
+
29
+ ## The hand-off: spawn operator in background
30
+
31
+ Spawn `operator` with `run_in_background: true` so your session stays free, then keep talking to the user. Give operator everything it needs — it does **not** see this conversation, and it does **not** see this skill. Operator runs on a fixed tool set (`read`, `grep`, `find`, `ls`, `bash`, `write`, `edit`) with no skill loading, so any mechanic below that you want it to follow has to be spelled out in the `[REQUEST]` block — don't assume it knows the tmux/PID/polling patterns:
32
+
33
+ ```
34
+ [CONTEXT]: <what you were doing, the file/process/command involved, the environment>
35
+ [SYMPTOM]: <the exact failure — error text, "process won't die", "script ran but wrote no file", paste the relevant output>
36
+ [ALREADY TRIED]: <each attempt and what happened, so operator doesn't repeat your dead ends>
37
+ [SUCCESS CONDITION]: <something operator can verify with bash alone — "screenshot_now.png exists and is larger than 1KB", "the dev server answers 200 on :3000", "pgrep -f repro.py returns nothing">
38
+ [CONSTRAINTS]: <don't touch X, the relevant tmux session is named Y, the workdir is Z>
39
+ [REQUEST]: Drive the diagnose → fix → verify loop. Use a tmux session for any hung or interactive process so it can't block you (start detached, kill stuck processes by PID not C-c, poll on the success condition not a fixed sleep). Return root cause, what you changed, and whether the success condition is met.
40
+ ```
41
+
42
+ State the success condition as something operator can check with `bash` — file exists and is non-trivially sized, a port answers, a process is gone. Operator has **no vision tools** (`look_at` is yours, not its), so "the screenshot looks right" is **not** a condition operator can verify. If the fix ultimately needs a visual check, have operator confirm the file is written and reasonably sized, then **you** call `look_at` on it after operator reports back — that final eyeball stays in your session.
43
+
44
+ Then stay responsive. When the completion `<system-reminder>` lands, weave operator's report into your next reply (in a channel session, surface it via `channel_reply`/`channel_send` — plain text is invisible there).
45
+
46
+ If the `subagent.spawn.operator` gate denies (you're not owner/trusted tier), you can't delegate — fall back to doing the loop yourself, but apply the mechanics below to do it cleanly.
47
+
48
+ ## Mechanics operator should use (and you, if you can't delegate)
49
+
50
+ ### Run hung/interactive processes in a dedicated tmux session
51
+
52
+ ```sh
53
+ tmux new-session -d -s fix-<short-id> -c /agent/workspace
54
+ tmux send-keys -t fix-<short-id> "python3 repro.py" Enter
55
+ ```
56
+
57
+ A detached session means a process that hangs or waits for input never blocks the driver. Name it for the task (`fix-<id>`) so it's easy to find and tear down.
58
+
59
+ ### Observe without blocking — edge-triggered, not `sleep`-and-guess
60
+
61
+ Don't `sleep N; capture-pane` and hope. Capture the pane, react to what's actually there, and key the next step off a real signal (a file appearing, a process exiting, a prompt string showing up):
62
+
63
+ ```sh
64
+ tmux capture-pane -t fix-<id> -p -S - # -p print, -S - full scrollback
65
+ ls -la /agent/workspace/expected-output.png # the real done-signal
66
+ pgrep -af repro.py # is it still running?
67
+ ```
68
+
69
+ Loop on the **success condition** (output file exists, port answers, process gone), not on a fixed sleep. If you must wait, poll in short intervals and re-check the signal each time rather than sleeping for one long guess.
70
+
71
+ ### Kill stuck processes by PID, not by keystroke
72
+
73
+ `tmux send-keys ... C-c` is unreliable against a tight loop. Find the real PID and signal it:
74
+
75
+ ```sh
76
+ pgrep -af repro.py # find the actual PID(s)
77
+ kill <pid> # SIGTERM first
78
+ sleep 1; pgrep -af repro.py # still there?
79
+ kill -9 <pid> # SIGKILL if it ignored SIGTERM
80
+ ```
81
+
82
+ ### Reap zombies and confirm the field is clear
83
+
84
+ `<defunct>` entries in `ps` are zombies — already dead, waiting for their parent to reap them. They are not your hung process; chasing them wastes turns. Filter them out and confirm the _live_ process is gone before re-running:
85
+
86
+ ```sh
87
+ ps aux | grep -i repro | grep -v grep | grep -v defunct
88
+ ```
89
+
90
+ If the only matches are `<defunct>`, the process is already dead — re-running is safe. If a live PID remains, the previous `C-c` didn't work; kill it by PID (above) before the next attempt.
91
+
92
+ ### Tear down when done
93
+
94
+ ```sh
95
+ tmux kill-session -t fix-<id> 2>/dev/null || true
96
+ ```
97
+
98
+ Don't leave orphaned sessions running between attempts — a stale session is how you end up sending input to the wrong process.
99
+
100
+ ## What operator returns, and what you do with it
101
+
102
+ Operator's final report should give you: **root cause**, **what it changed**, and **whether the success condition is met**. Surface that to the user in your own words — don't paste the raw debugging transcript; the whole point was to keep that noise out of the conversation. If operator couldn't resolve it, relay the outcome plus the partial progress (what's now known, what's still failing) so the user can decide the next move.
103
+
104
+ Bound the loop so it can't spin as badly as the inline version would have. Tell operator in the `[REQUEST]` that if a handful of diagnose-fix-verify rounds (≈5) haven't met the success condition, it should stop and report what it found rather than keep retrying — a non-converging operator loop wastes the same tokens you delegated to avoid, it just wastes them out of sight. When that bounded-failure report comes back, bring the user in: relay the partial progress and ask how to proceed instead of immediately re-spawning operator on the same dead end.
@@ -140,6 +140,8 @@ function renderOriginLabel(kind: OriginKind, ctx: RenderCtx): string {
140
140
  return `${color('green', '#', ctx)} ${'channel'}`
141
141
  case 'subagent':
142
142
  return `${color('yellow', '↳', ctx)} ${'subagent'}`
143
+ case 'system':
144
+ return `${color('blue', '⚙', ctx)} ${'system'}`
143
145
  case 'unknown':
144
146
  return `${dim('?', ctx)} ${dim('unknown', ctx)}`
145
147
  }
@@ -211,6 +213,8 @@ function originGlyphOnly(kind: OriginKind, ctx: RenderCtx): string {
211
213
  return color('green', '#', ctx)
212
214
  case 'subagent':
213
215
  return color('yellow', '↳', ctx)
216
+ case 'system':
217
+ return color('blue', '⚙', ctx)
214
218
  case 'unknown':
215
219
  return dim('?', ctx)
216
220
  }
package/src/usage/scan.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path'
6
6
  // before origin stamping landed AND sessions whose session-meta line is
7
7
  // malformed or missing — surfacing them under one explicit label is more
8
8
  // honest than silently dropping them.
9
- export const ORIGIN_KINDS = ['tui', 'cron', 'channel', 'subagent', 'unknown'] as const
9
+ export const ORIGIN_KINDS = ['tui', 'cron', 'channel', 'subagent', 'system', 'unknown'] as const
10
10
  export type OriginKind = (typeof ORIGIN_KINDS)[number]
11
11
 
12
12
  // Narrow projection: session files can grow into tens of MB on long-lived