@opencode-cockpit/trust 0.0.0 → 0.8.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 (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli/preview.js +251 -0
  3. package/dist/core/adapt/seen.js +24 -0
  4. package/dist/core/adapt/v1.js +109 -0
  5. package/dist/core/adapt/v2.js +109 -0
  6. package/dist/core/config.js +93 -0
  7. package/dist/core/danger.js +366 -0
  8. package/dist/core/engine.js +245 -0
  9. package/dist/core/family.js +512 -0
  10. package/dist/core/history.js +169 -0
  11. package/dist/core/index.js +16 -0
  12. package/dist/core/keys.js +149 -0
  13. package/dist/core/ledger.js +202 -0
  14. package/dist/core/paths.js +38 -0
  15. package/dist/core/policy.js +128 -0
  16. package/dist/core/rules.js +152 -0
  17. package/dist/core/sample.js +311 -0
  18. package/dist/core/shell.js +253 -0
  19. package/dist/core/signature.js +38 -0
  20. package/dist/core/view/actions.js +321 -0
  21. package/dist/core/view/activity.js +557 -0
  22. package/dist/core/view/explorer.js +970 -0
  23. package/dist/core/view/model.js +193 -0
  24. package/dist/core/view/parts.js +290 -0
  25. package/dist/core/view/rows.js +165 -0
  26. package/dist/core/view/sidebar.js +170 -0
  27. package/dist/tui/index.js +920 -0
  28. package/dist/tui/journal.js +80 -0
  29. package/dist/tui/render.js +85 -0
  30. package/dist/tui/source.js +112 -0
  31. package/dist/tui/view/dialog.js +43 -0
  32. package/dist/tui/view/rows.js +69 -0
  33. package/package.json +49 -3
  34. package/tui.js +6 -0
  35. package/types/cli/preview.d.ts +22 -0
  36. package/types/core/adapt/seen.d.ts +46 -0
  37. package/types/core/adapt/v1.d.ts +18 -0
  38. package/types/core/adapt/v2.d.ts +20 -0
  39. package/types/core/config.d.ts +57 -0
  40. package/types/core/danger.d.ts +50 -0
  41. package/types/core/engine.d.ts +117 -0
  42. package/types/core/family.d.ts +97 -0
  43. package/types/core/history.d.ts +81 -0
  44. package/types/core/index.d.ts +16 -0
  45. package/types/core/keys.d.ts +68 -0
  46. package/types/core/ledger.d.ts +158 -0
  47. package/types/core/paths.d.ts +24 -0
  48. package/types/core/policy.d.ts +52 -0
  49. package/types/core/rules.d.ts +55 -0
  50. package/types/core/sample.d.ts +22 -0
  51. package/types/core/shell.d.ts +40 -0
  52. package/types/core/signature.d.ts +18 -0
  53. package/types/core/view/actions.d.ts +76 -0
  54. package/types/core/view/activity.d.ts +101 -0
  55. package/types/core/view/explorer.d.ts +154 -0
  56. package/types/core/view/model.d.ts +106 -0
  57. package/types/core/view/parts.d.ts +76 -0
  58. package/types/core/view/rows.d.ts +60 -0
  59. package/types/core/view/sidebar.d.ts +62 -0
  60. package/types/tui/index.d.ts +15 -0
  61. package/types/tui/journal.d.ts +19 -0
  62. package/types/tui/render.d.ts +14 -0
  63. package/types/tui/source.d.ts +35 -0
  64. package/types/tui/view/dialog.d.ts +24 -0
  65. package/types/tui/view/rows.d.ts +19 -0
package/README.md CHANGED
@@ -1,7 +1,242 @@
1
- # @opencode-cockpit/trust — placeholder
1
+ # @opencode-cockpit/trust
2
2
 
3
- This version (0.0.0) contains no code. It exists only so npm's trusted publishing can be configured
4
- for this package; npm requires a package to exist before a trusted publisher can be added.
3
+ **Permissions that learn.** You approve a command yourself. After you have approved *the same
4
+ command* a few times in a row, Trust approves it for you — and records it, every time. A reject resets
5
+ the count, a dangerous command costs more approvals, and nothing you told OpenCode to always ask
6
+ about is ever answered for you.
5
7
 
6
- Install `@opencode-cockpit/trust@0.8.0` or later, or the `opencode-cockpit` bundle, which includes it.
7
- Documentation: https://codestz.github.io/opencode-cockpit/trust/overview/
8
+ Part of [opencode-cockpit](https://github.com/Codestz/opencode-cockpit). Install it on its own, or
9
+ through the bundle. Works on OpenCode 1.18+ and 2.0.15+.
10
+
11
+ ```sh
12
+ opencode plugin @opencode-cockpit/trust@0.8.0 --global --force # OpenCode 1
13
+ opencode plugin add @opencode-cockpit/trust@0.8.0 # OpenCode 2
14
+ ```
15
+
16
+ It only acts where OpenCode asks you — a `"bash": "ask"` (or `"permission": "ask"`) in
17
+ `opencode.json`. Where config already says `allow` or `deny`, OpenCode never asks, and Trust never
18
+ sees it.
19
+
20
+ ## What counts as "the same command"
21
+
22
+ Exactly the same, and nothing wider — unless you widen it yourself, in the ledger (`w`).
23
+ `docker compose -p cockpit up -d` and
24
+ `docker compose -p prod down -v` are two commands — OpenCode's own "Always" would treat them as one
25
+ (`docker compose -p *`). The same command in another directory (`cd /tmp && rm -rf dist`) is
26
+ another command too. Quoting is the only thing normalised: `echo 'a b'` is `echo "a b"`.
27
+
28
+ A line with several commands (`git add -A && git commit -m wip`) counts for each of them, and is
29
+ answered only when **every** one is trusted — or allowed by your config. Anything that cannot be read
30
+ for certain is always yours to answer: `$(…)`, backticks, `$VAR`, `eval`, `sh -c`, a pipe into a
31
+ shell, a heredoc.
32
+
33
+ Each count is kept per project and **per agent**: `build` earning `git push` is not `general` earning
34
+ it. Other permissions have their own notion of "the same": an edit by its file, a web fetch by its
35
+ host, a subagent by its type. `external_directory` and `doom_loop` are never answered.
36
+
37
+ ## What it will not touch
38
+
39
+ - **A specific `ask` in your config.** `"git push *": "ask"` means *always ask me*; Trust never
40
+ answers a request it matches, however many times you approve it. A catch-all — `"bash": "ask"`,
41
+ `{ "*": "ask" }`, or no rule at all — is a default, and that is the gap Trust fills.
42
+ - **Your config file.** Trust reads OpenCode's merged config through OpenCode; it never writes
43
+ `opencode.json`. The ledger can copy a rule for you to paste.
44
+ - **Answers that were not yours.** A reply under 300ms is not a person's (OpenCode's `--auto` answers
45
+ in about 20ms) and is not counted; neither are Trust's own answers.
46
+
47
+ ## Dangerous commands cost more
48
+
49
+ `rm`, `git push`, `git reset --hard`, `--force`, `docker compose down -v`, `kubectl delete`,
50
+ `terraform apply`, `DROP` in a SQL client, `npm publish`, `sudo`, and the like need the normal
51
+ threshold **plus** `dangerExtra` approvals in a row — eight by default. They still earn trust; slowly.
52
+ `sudo`, `env`, `time`, `nohup`, `timeout` and `xargs` are looked through: `timeout 5 rm x` is `rm`.
53
+
54
+ ## In the sidebar
55
+
56
+ The block is **hidden by default** — the sidebar already carries the statusline, subagents and
57
+ shells, and Trust answers the same without it. `"trust": { "sidebar": true }` in
58
+ `~/.config/opencode-cockpit/config.json` or a project's `.cockpit.json` shows it; the palette's
59
+ "Show or hide Trust in the sidebar" flips it for this session only. Hidden or not, a failure — a
60
+ ledger that could not be saved — always shows there.
61
+
62
+ ```
63
+ Trust 4 auto
64
+ ● git status 7×
65
+ ● edit src/app.ts 3×
66
+ ○ docker compose -p cockpit… 2/3
67
+ ```
68
+
69
+ A filled dot is something Trust just answered, with how many times it has answered it in all; a
70
+ hollow one is a request on screen now and how far it is from being trusted. No answers and nothing
71
+ counting, no block. Each answer is also a line in `~/.cache/opencode-cockpit/cockpit.log`. No toasts.
72
+
73
+ ## What Trust did, and the ledger
74
+
75
+ `/trust`, `ctrl+x p` or the palette opens on **what Trust did for you**: today's answers, newest
76
+ first, each with why it was answered; a sparkline of the week; what is one approval away; and
77
+ OpenCode's own broad "always" approvals in a band of their own.
78
+
79
+ ```
80
+
81
+ Trust · app ● answering
82
+
83
+ TODAY Trust answered 5 prompts for you ▂▁▅▂█▄█ last 7 days
84
+ ▌20:31 ✓ git status --short build trusted since Sep 16, 3 in a row
85
+ 20:30 ✓ ls -la general trusted since Sep 16, 3 in a row
86
+ 20:21 ✓ bun test build trusted since Sep 16, 3 in a row
87
+ 20:13 ✓ git status --short && echo tr… build both commands trusted
88
+ 19:43 ✓ cat src/app.ts general in a family you widened: cat
89
+
90
+ ALMOST THERE closest first
91
+ ○ bun --version build ▰▰▱ 2 of 3
92
+ ○ git status --short -uno general ▰▰▱ 2 of 3
93
+ ○ head -60 general ▰▰▱ 2 of 3
94
+ ○ head -40 general ▰▰▱ 2 of 3
95
+ ○ git push origin feat/trust build ▰▰▰▰▰▱▱▱ 5 of 8 dangerous
96
+
97
+ ! WATCH OUT OpenCode's own "always" approves more than it looks, until it restarts
98
+ ! find . * sort -rn * general [enter] what it covers
99
+
100
+ RULES 8 trusted · 5 learning · 7 seen once l Open the ledger
101
+
102
+ [enter] Why [x] Revoke [w] Trust Family [l] Ledger [p] Pause [?] Keys [esc] Close
103
+ ```
104
+
105
+ - **Today**: every answer Trust gave in this project — from any window — with the time, the agent
106
+ and why in a few words: the approvals that earned it and when, or the family you widened. The same
107
+ line answered again and again is one row with a count (`3×`). With nothing today, the latest from
108
+ earlier days, with their day.
109
+ - **Almost there**: what is still learning, closest first, a dangerous command last. The meter is
110
+ the approvals in a row that count (`▰`) and those still to go (`▱`); a dangerous one is red and
111
+ longer (`threshold + dangerExtra`).
112
+ - **Watch out**: only when you gave OpenCode an "always" — it approves every command that starts
113
+ that way until OpenCode restarts, and Trust cannot take it back.
114
+ - **`enter`** shows why: the card of that rule, in the ledger. **`l`** opens the ledger.
115
+
116
+ **The ledger** (`l`) is every rule, as a tree of **families** — `ls -la`, `ls -x` and `ls -R docs`
117
+ are all `ls` — and a card for the one selected, always on screen:
118
+
119
+ ```
120
+ Trust · app 8 trusted · 5 learning · 7 seen once ● answering
121
+ ────────────────────────────────────────┬───────────────────────────────────────────────────────────
122
+ FAMILIES 1–15 of 18 │ git status --short
123
+ ▾ git status 1 ✓ 1 ○ │ ✓ Trusted for build · answered 8×
124
+ ▌ … --short ✓ trusted 8× │
125
+ … --short -uno ▰▰▱ 2 of 3 │ Exactly git status --short
126
+ ls -la ✓ trusted 2× │ Still asks git status · git status --short > out.txt ·
127
+ bun test ✓ trusted 2× │ any other argument
128
+ echo trust-test ✓ trusted 3× │ History ✓ 7d ✓ 7d ✓ 7d → trusted answered 8×
129
+ ▸ cat any 2 ✓ │ 3 approvals in a row, all yours
130
+ ▸ head 1 ✓ 3 ○ │ Family git status · 2 commands, 1 trusted
131
+ edit src/app.ts ✓ trusted 2× │ [w] trusts any git status … for build, not
132
+ bun --version ▰▰▱ 2 of 3 │ one by one
133
+ git push origin fe… ! ▰▰▰▰▰▱▱▱ 5/8 │ Expires if unused for 30 days
134
+ pwd ▰▱▱ 1 of 3 │
135
+ sed -n 1,40p src/a… ▰▱▱ 1 of 3 │ x Revoke w Trust any git status c Copy rule
136
+ wc -l src/app.ts ▰▱▱ 1 of 3 │
137
+ sort -rn ▰▱▱ 1 of 3 │
138
+ ────────────────────────────────────────┴───────────────────────────────────────────────────────────
139
+ [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [p] Pause [?] Keys [esc] Back
140
+ ```
141
+
142
+ - **Each command is one row**, under its family, whatever its agents say about it; the card lists
143
+ each agent's standing. A family with one command is drawn as that row; others start folded, and
144
+ an open family lists its first three commands and `+ N more`.
145
+ - **The card**: the command whole, on a raised panel; where each agent stands; exactly what it is,
146
+ every argument quoted where a font could merge it (`echo "---"`, never `echo ──`, and said in words
147
+ too: "3 hyphens"); what still asks; the **history** that earned it (`✓ 7d ✓ 7d ✓ 7d → trusted`);
148
+ its family and what `w` would do; when it expires. Its buttons — `x`, `w`, `c` — can be clicked,
149
+ or reached with `tab`.
150
+ - **Below 90 columns** the card moves under the tree, the selection kept in view above it.
151
+ - **A family** is the program, or the program and its subcommand for tools that have them:
152
+ `git status`, `docker compose up` (`-p prod` and other global flags are not part of it),
153
+ `npm run test` (the script is). A wrapper is: `sudo ls` is not `ls`. So is where it runs and the
154
+ environment it is given: `(in web) bun test`, `NODE_ENV=… npm run build`. An edit's family is its
155
+ folder.
156
+ - **`w` trusts the whole family**, for the selected command's agent — on purpose, never by itself.
157
+ Any `ls …` is then answered for that agent, **except** a dangerous command, one that writes a file
158
+ through a redirection (`ls > out.txt`; `2>/dev/null` and `2>&1` write nothing and are fine), one
159
+ that runs another program (`find -exec`, `git -c`), and any line that cannot be read. A specific
160
+ `ask` in your config still wins. A dangerous family (`git push`, `rm`, `sudo …`) can never be
161
+ widened. `w` again — or `x` on the family — goes back to exact rules. Answers through a widened
162
+ family say so, on both screens, the sidebar (`● ls -x · any ls`) and the log.
163
+
164
+ On the activity:
165
+
166
+ | Key | |
167
+ | --- | --- |
168
+ | `j` `k` `↑` `↓`, wheel, click | Move over answers, what is close, and OpenCode's own approvals |
169
+ | `enter` | Why: the rule's card in the ledger |
170
+ | `x` | Revoke what answered (the rule, or the widening that answered it); forget a count still learning |
171
+ | `w` | Trust any command in the family, or undo it |
172
+ | `c` | Copy it as an `opencode.json` rule, to paste yourself |
173
+ | `l`, click on the button | Open the ledger |
174
+ | `/` | Open the ledger and filter it |
175
+ | `p` | Pause Trust in this project (it keeps counting, and answers nothing) — again to resume |
176
+ | `?` | Every key, one line each |
177
+ | `esc` | Close (`q` too) |
178
+
179
+ In the ledger:
180
+
181
+ | Key | |
182
+ | --- | --- |
183
+ | `j` `k` `↑` `↓`, wheel, click | Move over families and commands |
184
+ | `←` `h` / `→` `l` | Fold / open a family — on a command inside one, go to its heading |
185
+ | `space` `enter` | Open or fold a family; on `+ N more`, list the rest |
186
+ | `tab` | Into the card's buttons and back; `←` `→` choose one, `enter` presses it |
187
+ | `x` | Revoke a command for every agent, or a family — every command in it, and its widening. Still learning, it forgets the count |
188
+ | `w` | Trust any command in the family, or undo it |
189
+ | `c` | Copy it as an `opencode.json` rule — a family as `"ls *": "allow"` (config cannot say which agent) |
190
+ | `/` | Filter by text; `enter` keeps it, `esc` clears it |
191
+ | `p` | Pause or resume |
192
+ | `?` | Every key, one line each |
193
+ | `esc` | One step back: the keys, the card's buttons, the filter — then to the activity, which `esc` closes |
194
+
195
+ Trust unused for `expireDays` (30 by default) has to be earned again.
196
+
197
+ ## Settings
198
+
199
+ In the bundle's entry (`"trust": { … }`), this package's own, or the `trust` section of
200
+ `~/.config/opencode-cockpit/config.json` and a project's `.cockpit.json`:
201
+
202
+ | Setting | Default | |
203
+ | --- | --- | --- |
204
+ | `threshold` | `3` | Approvals in a row, by you, before Trust answers |
205
+ | `dangerExtra` | `5` | What a dangerous command costs on top |
206
+ | `expireDays` | `30` | Days unused before trust has to be earned again; `0` never |
207
+ | `enabled` | `true` | `false` turns Trust off (the bundle also has `features.trust: false`) |
208
+ | `sidebar` | `false` | Show the block in the sidebar. The palette's "Show or hide Trust in the sidebar" flips it for the session |
209
+ | `sidebarRows` | `3` | Answers listed in the sidebar |
210
+ | `sidebarOrder` | `160` | Where the block sits in the sidebar; lower draws first |
211
+ | `keybinds` | `{ "cockpit.trust.ledger": "<leader>p" }` | The key that opens the ledger |
212
+
213
+ The ledger lives outside the project, in
214
+ `~/.local/share/opencode-cockpit/trust/<project>-<hash>/events.ndjson` (`$COCKPIT_HOME` or
215
+ `$XDG_DATA_HOME` move it): one line per event, appended, shared by every OpenCode window on the
216
+ project.
217
+
218
+ ## See it without OpenCode
219
+
220
+ ```sh
221
+ bunx @opencode-cockpit/trust preview
222
+ ```
223
+
224
+ Draws the sidebar block, the activity and the ledger from sample projects, in your terminal —
225
+ `--view activity` or `--view ledger` for one screen, `--columns`/`--rows` for another size, `--html`
226
+ to judge the colours in a browser.
227
+
228
+ ## Troubleshooting
229
+
230
+ ```sh
231
+ npx opencode-cockpit@latest doctor
232
+ ```
233
+
234
+ checks OpenCode, its config, Cockpit's logs and the daemon, and prints the fix for anything wrong
235
+ ([what it checks](https://codestz.github.io/opencode-cockpit/help/doctor/)).
236
+
237
+ ```sh
238
+ tail -50 ~/.cache/opencode-cockpit/cockpit.log
239
+ ```
240
+
241
+ shows every answer Trust gave and why; `COCKPIT_DEBUG=1 opencode` adds every request it looked at and
242
+ every reply it did not count.
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The see-it loop: the sidebar block and both Trust screens — the activity `/trust` opens on and the
4
+ * ledger behind `l` — drawn in this terminal from sample worlds, with no OpenCode running. The same
5
+ * rows OpenCode draws; only the colours come from a fixed palette (OpenCode's default theme) instead
6
+ * of the user's.
7
+ *
8
+ * bunx @opencode-cockpit/trust preview every sample, both screens
9
+ * bunx @opencode-cockpit/trust preview --sample busy one of them
10
+ * bunx @opencode-cockpit/trust preview --view activity only the screen /trust opens on
11
+ * bunx @opencode-cockpit/trust preview --view ledger only the ledger (the explorer and its card)
12
+ * bunx @opencode-cockpit/trust preview --columns 80 the dialog at another width
13
+ * bunx @opencode-cockpit/trust preview --rows 20 the dialog in a short window
14
+ * bunx @opencode-cockpit/trust preview --width 30 the sidebar at another width
15
+ * bunx @opencode-cockpit/trust preview --keys the key lists (`?`) too
16
+ * bunx @opencode-cockpit/trust preview --html > a.html the same, as a page, to judge colour in a browser
17
+ *
18
+ * The ledger is drawn more than once a sample: as it opens, then with the cursor on a trusted command
19
+ * in its open family, on a family you widened, on one still counting, on a dangerous command, on
20
+ * OpenCode's own "always", and with `tab` into the card's buttons.
21
+ */
22
+ import { SAMPLE_NOW, SAMPLE_ROOT, SAMPLE_SETTINGS, SAMPLES } from "../core/sample.js";
23
+ import { activityRows } from "../core/view/activity.js";
24
+ import { explorerModel, explorerRows, reveal } from "../core/view/explorer.js";
25
+ import { TINTS } from "../core/view/rows.js";
26
+ import { sidebarRows } from "../core/view/sidebar.js";
27
+
28
+ /** OpenCode's default theme, measured (docs/opencode/v2.md). The dialog is drawn on the panel colour. */
29
+ const HEX = {
30
+ text: "#eeeeee",
31
+ muted: "#808080",
32
+ accent: "#9d7cd8",
33
+ info: "#56b6c2",
34
+ tool: "#fab283",
35
+ success: "#7fd88f",
36
+ error: "#e06c75",
37
+ warning: "#f5a742",
38
+ border: "#484848"
39
+ };
40
+ const PANEL = "#141414";
41
+ const ELEMENT = "#1e1e1e";
42
+ const channels = hex => [1, 3, 5].map(i => Number.parseInt(hex.slice(i, i + 2), 16));
43
+ const toHex = values => `#${values.map(v => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
44
+ /** A tint as `tui/render.ts` mixes it: the tone faded into the panel colour. */
45
+ const mix = (tone, amount) => {
46
+ const a = channels(tone);
47
+ const b = channels(PANEL);
48
+ return toHex(a.map((value, i) => b[i] + (value - b[i]) * amount));
49
+ };
50
+ const FILL = {
51
+ selected: ELEMENT,
52
+ panel: ELEMENT,
53
+ button: ELEMENT,
54
+ buttonOn: HEX.accent,
55
+ chip: mix(HEX[TINTS.chip.tone], TINTS.chip.amount),
56
+ ok: mix(HEX[TINTS.ok.tone], TINTS.ok.amount),
57
+ warn: mix(HEX[TINTS.warn.tone], TINTS.warn.amount),
58
+ err: mix(HEX[TINTS.err.tone], TINTS.err.amount)
59
+ };
60
+ const ink = tone => tone === "ink" ? PANEL : HEX[tone ?? "text"];
61
+ const rgb = hex => channels(hex).join(";");
62
+ const args = process.argv.slice(2);
63
+ const html = args.includes("--html");
64
+ const color = html || process.stdout.isTTY && !process.env.NO_COLOR;
65
+ const entities = text => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
66
+ function paint(row) {
67
+ if (!color) return row.map(run => run.text).join("");
68
+ if (html) return row.map(run => {
69
+ const style = [`color:${ink(run.tone)}`];
70
+ if (run.fill && run.fill !== "none") style.push(`background:${FILL[run.fill]}`);
71
+ if (run.bold) style.push("font-weight:bold");
72
+ if (run.faint) style.push("opacity:.55");
73
+ return `<span style="${style.join(";")}">${entities(run.text)}</span>`;
74
+ }).join("");
75
+ return row.map(run => {
76
+ const codes = [`38;2;${rgb(ink(run.tone))}`];
77
+ if (run.fill && run.fill !== "none") codes.push(`48;2;${rgb(FILL[run.fill])}`);
78
+ if (run.bold) codes.push("1");
79
+ if (run.faint) codes.push("2");
80
+ return `\x1b[${codes.join(";")}m${run.text}\x1b[0m`;
81
+ }).join("");
82
+ }
83
+ if (args.includes("--help") || args.includes("-h")) {
84
+ process.stdout.write(`Usage: trust preview [--sample ${Object.keys(SAMPLES).join("|")}] [--view activity|ledger] [--columns <dialog columns>] [--rows <dialog rows>] [--width <sidebar columns>] [--keys] [--html]\n`);
85
+ process.exit(0);
86
+ }
87
+ const value = flag => {
88
+ const at = args.indexOf(flag);
89
+ return at >= 0 ? args[at + 1] : undefined;
90
+ };
91
+ const sidebarWidth = Number(value("--width")) || 36;
92
+ const columns = Number(value("--columns")) || Math.max(60, Math.min(process.stdout.columns || 100, 116));
93
+ const fixedRows = Number(value("--rows")) || undefined;
94
+ const only = value("--sample");
95
+ const view = value("--view");
96
+ if (view !== undefined && view !== "activity" && view !== "ledger") {
97
+ process.stderr.write(`No view "${view}". Try: activity, ledger\n`);
98
+ process.exit(1);
99
+ }
100
+ const keys = args.includes("--keys");
101
+ const names = only ? [only] : Object.keys(SAMPLES);
102
+ /** The folder name the header shows: the sample project's. */
103
+ const project = SAMPLE_ROOT.split("/").at(-1) ?? "";
104
+ const frame = rows => {
105
+ const edge = html ? `<span style="color:${HEX.border}">│</span>` : color ? `\x1b[38;2;${rgb(HEX.border)}m│\x1b[0m` : "│";
106
+ return rows.map(row => `${edge}${paint(row)}${edge}`);
107
+ };
108
+ const out = [];
109
+ for (const name of names) {
110
+ const make = SAMPLES[name];
111
+ if (!make) {
112
+ process.stderr.write(`No sample "${name}". Try: ${Object.keys(SAMPLES).join(", ")}\n`);
113
+ process.exit(1);
114
+ }
115
+ const {
116
+ engine,
117
+ trouble
118
+ } = make();
119
+ out.push("", `── ${name} ──`);
120
+ if (view === undefined) {
121
+ out.push("", "Sidebar", "");
122
+ const sidebar = sidebarRows({
123
+ width: sidebarWidth,
124
+ recent: engine.recent(),
125
+ count: engine.count(),
126
+ pending: engine.pending(),
127
+ state: engine.state,
128
+ limit: 3,
129
+ ...(trouble ? {
130
+ trouble
131
+ } : {})
132
+ });
133
+ out.push(...(sidebar.length > 0 ? frame(sidebar) : ["(nothing: the block is silent)"]));
134
+ }
135
+ const reading = {
136
+ state: engine.state,
137
+ settings: SAMPLE_SETTINGS,
138
+ now: SAMPLE_NOW,
139
+ history: engine.history
140
+ };
141
+ const height = fixedRows ?? 24;
142
+ if (view !== "ledger") {
143
+ const drawActivity = (title, pick, withKeys = false) => {
144
+ const first = activityRows({
145
+ ...reading,
146
+ width: columns,
147
+ height,
148
+ project
149
+ });
150
+ const selected = pick?.(first.model.items.map(item => item.key));
151
+ const {
152
+ rows
153
+ } = activityRows({
154
+ ...reading,
155
+ width: columns,
156
+ height,
157
+ project,
158
+ ...(selected !== undefined ? {
159
+ selected
160
+ } : {}),
161
+ ...(withKeys ? {
162
+ keys: true
163
+ } : {})
164
+ });
165
+ out.push("", title, "");
166
+ out.push(...frame(rows));
167
+ };
168
+ drawActivity("Activity — as /trust opens");
169
+ if (keys) drawActivity("Activity — ? keys", undefined, true);
170
+ }
171
+ if (view !== "activity") {
172
+ const open = new Set();
173
+ const full = new Set();
174
+ const tree = {
175
+ open,
176
+ full,
177
+ filter: ""
178
+ };
179
+ const families = explorerModel({
180
+ ...reading,
181
+ ...tree
182
+ }).families;
183
+ const drawLedger = (title, pick, extra = {}) => {
184
+ const filter = extra.filter ?? "";
185
+ const model = explorerModel({
186
+ ...reading,
187
+ open,
188
+ full,
189
+ filter
190
+ });
191
+ const selected = pick(model.nodes)?.key;
192
+ const {
193
+ rows
194
+ } = explorerRows({
195
+ ...reading,
196
+ open,
197
+ full,
198
+ filter,
199
+ width: columns,
200
+ height,
201
+ project,
202
+ ...(selected !== undefined ? {
203
+ selected
204
+ } : {}),
205
+ ...(extra.focus ? {
206
+ focus: extra.focus
207
+ } : {}),
208
+ ...(extra.keys ? {
209
+ keys: true
210
+ } : {}),
211
+ ...(extra.typing !== undefined ? {
212
+ typing: extra.typing
213
+ } : {})
214
+ });
215
+ out.push("", title, "");
216
+ out.push(...frame(rows));
217
+ };
218
+ drawLedger("Ledger — as l opens it", nodes => nodes[0]);
219
+ /** A trusted command in a family of several, revealed the way `enter` on the activity does. */
220
+ const trusted = families.flatMap(family => family.commands.length > 1 ? family.commands : []).find(command => command.phase === "answering");
221
+ if (trusted) {
222
+ const key = reveal(tree, families, trusted);
223
+ drawLedger("Ledger — a trusted command, its family open", nodes => nodes.find(node => node.key === key));
224
+ }
225
+ const widened = families.find(family => family.widened.length > 0);
226
+ if (widened) drawLedger("Ledger — a family you widened", nodes => nodes.find(node => node.kind === "family" && node.family === widened) ?? nodes.find(node => node.kind === "family" && node.family.key === widened.key));
227
+ const counting = families.flatMap(family => family.commands).find(command => command.phase === "learning" && !command.danger);
228
+ if (counting) {
229
+ const key = reveal(tree, families, counting);
230
+ drawLedger("Ledger — a command still learning", nodes => nodes.find(node => node.key === key));
231
+ }
232
+ const danger = families.flatMap(family => family.commands).find(command => command.danger && command.phase !== "answering");
233
+ if (danger) {
234
+ const key = reveal(tree, families, danger);
235
+ drawLedger("Ledger — a dangerous command", nodes => nodes.find(node => node.key === key));
236
+ }
237
+ if (engine.state.always.length > 0) drawLedger("Ledger — OpenCode's own always", nodes => nodes.find(node => node.kind === "always"));
238
+ if (trusted) {
239
+ const key = reveal(tree, families, trusted);
240
+ drawLedger("Ledger — tab into the card", nodes => nodes.find(node => node.key === key), {
241
+ focus: {
242
+ button: 1
243
+ }
244
+ });
245
+ }
246
+ if (keys) drawLedger("Ledger — ? keys", nodes => nodes[0], {
247
+ keys: true
248
+ });
249
+ }
250
+ }
251
+ process.stdout.write(html ? `<!doctype html><meta charset="utf-8"><title>Trust preview</title><body style="margin:0;padding:16px;background:${PANEL};color:${HEX.text}"><pre style="font:14px/1.35 Menlo,monospace;margin:0">${out.map(line => line.includes("<span") ? line : entities(line)).join("\n")}</pre>\n` : `${out.join("\n")}\n`);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What Trust takes from the host's events, whichever OpenCode sent them. `v1.ts` and `v2.ts` turn
3
+ * their version's events into these, and nothing past them knows which version it is.
4
+ */
5
+
6
+ export const obj = value => value && typeof value === "object" && !Array.isArray(value) ? value : {};
7
+ export const str = value => typeof value === "string" ? value : undefined;
8
+ export const strings = value => Array.isArray(value) ? value.filter(item => typeof item === "string") : [];
9
+ export const isReply = value => value === "once" || value === "always" || value === "reject";
10
+
11
+ /** The command line and working directory from a shell call's arguments (v1 `workdir`, v2 `cwd`). */
12
+ export function commandOf(input) {
13
+ if (!input) return {};
14
+ const line = str(input.command) ?? str(input.cmd);
15
+ const workdir = str(input.workdir) ?? str(input.cwd);
16
+ return {
17
+ ...(line !== undefined ? {
18
+ line
19
+ } : {}),
20
+ ...(workdir ? {
21
+ workdir
22
+ } : {})
23
+ };
24
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * OpenCode 1's events, as what Trust takes from them.
3
+ *
4
+ * `api.event.on(type)` hands over `{ type, properties }`. Shapes measured on 1.18.32
5
+ * (docs/opencode/permissions.md, and Subagents' recorded run for the message events):
6
+ *
7
+ * permission.asked { id, sessionID, permission: "bash", patterns, always, metadata,
8
+ * tool: { messageID, callID } }
9
+ * permission.replied { sessionID, requestID, reply: "once" | "always" | "reject" }
10
+ * message.part.updated { part: { type: "tool", callID, sessionID, messageID, state: { input } } }
11
+ * message.updated { info: { sessionID, agent } }
12
+ * session.created/updated { info: { id, agent } } — a subagent's session names its agent
13
+ */
14
+
15
+ import { canonical } from "../rules.js";
16
+ import { isReply, obj, str, strings } from "./seen.js";
17
+ export const V1_EVENTS = ["permission.asked", "permission.replied", "message.part.updated", "message.updated", "session.created", "session.updated"];
18
+ export function fromV1Event(raw) {
19
+ const event = obj(raw);
20
+ const props = obj(event.properties);
21
+ switch (event.type) {
22
+ case "permission.asked":
23
+ {
24
+ const id = str(props.id);
25
+ const sessionID = str(props.sessionID);
26
+ const permission = str(props.permission);
27
+ if (!id || !sessionID || !permission) return [];
28
+ const tool = obj(props.tool);
29
+ return [{
30
+ type: "asked",
31
+ request: {
32
+ id,
33
+ sessionID,
34
+ permission: canonical(permission),
35
+ patterns: strings(props.patterns),
36
+ always: strings(props.always),
37
+ ...(str(tool.callID) ? {
38
+ call: str(tool.callID)
39
+ } : {}),
40
+ ...(str(tool.messageID) ? {
41
+ messageID: str(tool.messageID)
42
+ } : {})
43
+ }
44
+ }];
45
+ }
46
+ case "permission.replied":
47
+ {
48
+ const sessionID = str(props.sessionID);
49
+ const requestID = str(props.requestID) ?? str(props.permissionID);
50
+ const reply = props.reply ?? props.response;
51
+ return sessionID && requestID && isReply(reply) ? [{
52
+ type: "replied",
53
+ sessionID,
54
+ requestID,
55
+ reply
56
+ }] : [];
57
+ }
58
+ case "message.part.updated":
59
+ {
60
+ const part = obj(props.part);
61
+ const call = str(part.callID);
62
+ const sessionID = str(part.sessionID);
63
+ const input = obj(obj(part.state).input);
64
+ if (part.type !== "tool" || !call || !sessionID || Object.keys(input).length === 0) return [];
65
+ return [{
66
+ type: "call",
67
+ sessionID,
68
+ call,
69
+ ...(str(part.messageID) ? {
70
+ messageID: str(part.messageID)
71
+ } : {}),
72
+ input
73
+ }];
74
+ }
75
+ case "message.updated":
76
+ {
77
+ const info = obj(props.info);
78
+ const sessionID = str(info.sessionID);
79
+ const agent = str(info.agent) ?? str(info.mode);
80
+ return sessionID && agent ? [{
81
+ type: "agent",
82
+ sessionID,
83
+ agent
84
+ }] : [];
85
+ }
86
+ case "session.created":
87
+ case "session.updated":
88
+ {
89
+ const info = obj(props.info);
90
+ const sessionID = str(info.id);
91
+ const agent = str(info.agent);
92
+ return sessionID && agent ? [{
93
+ type: "agent",
94
+ sessionID,
95
+ agent
96
+ }] : [];
97
+ }
98
+ default:
99
+ return [];
100
+ }
101
+ }
102
+
103
+ /** A pending request as `client.permission.list()` returns it: the same shape as the event. */
104
+ export function fromV1Pending(list) {
105
+ return (Array.isArray(list) ? list : []).flatMap(request => fromV1Event({
106
+ type: "permission.asked",
107
+ properties: request
108
+ }));
109
+ }