@hwip/cli 0.0.1 → 0.1.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 (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1979 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +292 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +35 -0
  39. package/dist/blocked-prompt-seal.js +89 -0
  40. package/dist/blocked-prompt.d.ts +57 -0
  41. package/dist/blocked-prompt.js +181 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +272 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5680 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
@@ -0,0 +1,390 @@
1
+ "use strict";
2
+ /**
3
+ * opencode's session store, read into the events the timeline draws.
4
+ *
5
+ * opencode keeps no transcript file. 2.x writes every session to SQLite
6
+ * (`opencode.db`, WAL), one `session_message` row per user message and per
7
+ * assistant step, and rewrites an assistant row in place while it streams. So
8
+ * this file is both halves the other agents split: a reader that asks the
9
+ * database what changed (`readOpencodeTranscript`, the `TranscriptReader`
10
+ * behind the opencode row) and a projector for what it hands back
11
+ * (`projectOpencodeEntries`).
12
+ *
13
+ * Shape measured 2026-10-10 against opencode 2.0.24 — the schema with
14
+ * `.schema`, the JSON with `json_each` key and type counts over 3,965 rows on
15
+ * this machine, and a scratch store written by sessions run for the purpose.
16
+ * No conversation text was opened.
17
+ *
18
+ * - `session_message(id, session_id, type, seq, time_created, time_updated,
19
+ * data)`, `seq` increasing per session. `type` is `user` · `assistant` ·
20
+ * `system` · `synthetic` · `idle` · `compaction` · `*-switched`; only the
21
+ * first two are drawn.
22
+ * - user `data`: `{ text, time: { created }, files, agents, skills }`.
23
+ * - assistant `data`: `{ model: { id, providerID }, content: [...], tokens:
24
+ * { input, output, reasoning, cache: { read, write } }, time: { created,
25
+ * completed?, streamed? }, finish, … }` — `time.completed` is set once the
26
+ * step is over. `content` items are `{ type: "text", text }`,
27
+ * `{ type: "reasoning", … }` and `{ type: "tool", id, name, state: { status,
28
+ * input, content: [{ type: "text", text }], error }, time }`; a finished
29
+ * call is `completed` or `error`.
30
+ * - 1.x wrote `session` · `message` · `part` instead. 2.x leaves those alone
31
+ * and this file does not read them.
32
+ *
33
+ * The lines the reader hands over are `TurnEvent`s already, one JSON object
34
+ * each, so the projector only checks them. The work of deciding what is new
35
+ * lives in `opencodeLinesOf`, which is pure and is where the tests point.
36
+ */
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.projectOpencodeEntries = exports.readOpencodeTranscript = exports.opencodeLinesOf = exports.opencodeRowsSql = exports.OPENCODE_MIN_QUERY_MS = exports.OPENCODE_OPEN_WINDOW = exports.OPENCODE_ROWS_PER_READ = exports.OPENCODE_BACKFILL_ROWS = exports.opencodeSourcePath = exports.OPENCODE_SESSION_ID = exports.opencodeDbPath = void 0;
39
+ const fs_1 = require("fs");
40
+ const os_1 = require("os");
41
+ const path_1 = require("path");
42
+ const transcript_tail_js_1 = require("./transcript-tail.js");
43
+ /** Where opencode 2.x keeps its store, by the rules opencode itself follows (`opencode debug paths db`, 2.0.24). */
44
+ const opencodeDbPath = (env = process.env) => {
45
+ if (env.OPENCODE_DB && (0, path_1.isAbsolute)(env.OPENCODE_DB))
46
+ return env.OPENCODE_DB;
47
+ const data = env.XDG_DATA_HOME && (0, path_1.isAbsolute)(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : (0, path_1.join)((0, os_1.homedir)(), '.local', 'share');
48
+ return (0, path_1.join)(data, 'opencode', 'opencode.db');
49
+ };
50
+ exports.opencodeDbPath = opencodeDbPath;
51
+ /**
52
+ * A session id as opencode mints them (`ses_` + base62, measured). The query
53
+ * below has nowhere to bind a parameter (`sqliteJson` runs the `sqlite3`
54
+ * binary), so an id goes into the SQL text only after it passes this.
55
+ */
56
+ exports.OPENCODE_SESSION_ID = /^ses_[A-Za-z0-9]{1,64}$/;
57
+ /**
58
+ * The transcript "path" an opencode pane resolves to: the store and the session
59
+ * in one string. A string because every consumer compares and logs paths, and
60
+ * because the session changing — a new one in the same pane — has to read as
61
+ * the path changing, which is what makes `turn-watch` start over on it.
62
+ */
63
+ const opencodeSourcePath = (db, sessionId) => `${db}#${sessionId}`;
64
+ exports.opencodeSourcePath = opencodeSourcePath;
65
+ const parseSourcePath = (path) => {
66
+ const at = path.lastIndexOf('#');
67
+ if (at <= 0)
68
+ return null;
69
+ const sessionId = path.slice(at + 1);
70
+ const db = path.slice(0, at);
71
+ // The path also comes back from the recorder's state file, and goes to `sqlite3` as an argument:
72
+ // a relative one could start with `-` and read as an option.
73
+ return exports.OPENCODE_SESSION_ID.test(sessionId) && (0, path_1.isAbsolute)(db) ? { db, sessionId } : null;
74
+ };
75
+ /** A row the query could have returned — anything else is not drawn. */
76
+ const isRow = (value) => {
77
+ if (!value || typeof value !== 'object')
78
+ return false;
79
+ const row = value;
80
+ return typeof row.id === 'string' && typeof row.type === 'string' && typeof row.seq === 'number' && Number.isSafeInteger(row.seq);
81
+ };
82
+ /** Rows read on the first look at a session — enough for the turn in flight and a screen above it. */
83
+ exports.OPENCODE_BACKFILL_ROWS = 200;
84
+ /** Rows read per look after that. */
85
+ exports.OPENCODE_ROWS_PER_READ = 500;
86
+ /** How far behind the newest row an unfinished one may hold the read position. */
87
+ exports.OPENCODE_OPEN_WINDOW = 200;
88
+ /** The fewest milliseconds between two queries for one session. */
89
+ exports.OPENCODE_MIN_QUERY_MS = 1_000;
90
+ /**
91
+ * Which `state.input` key reads as "what this call is about", first match
92
+ * wins. The keys are the ones 2.0.24's tools were seen to take (read, edit,
93
+ * write: `filePath`/`path`; glob, grep: `pattern`; webfetch: `url`; subagent,
94
+ * task: `description`). `command` is absent for the reason it is absent from
95
+ * every other projector — a command line is the argument most likely to carry
96
+ * a secret.
97
+ */
98
+ const TARGET_KEYS = ['filePath', 'path', 'pattern', 'url', 'description'];
99
+ /** Prose is cut in SQL a little above the event cap, so `clamp` still marks the cut. */
100
+ const SQL_TEXT_CHARS = transcript_tail_js_1.MAX_EVENT_TEXT_CHARS + 1;
101
+ /**
102
+ * The rows of one session from `from` on, or its last `OPENCODE_BACKFILL_ROWS`
103
+ * when `from` is null. Exported for the test that pins the id check.
104
+ */
105
+ const opencodeRowsSql = (sessionId, from) => {
106
+ if (!exports.OPENCODE_SESSION_ID.test(sessionId))
107
+ throw new Error('not an opencode session id');
108
+ if (from !== null && !Number.isSafeInteger(from))
109
+ throw new Error('not a row position');
110
+ const item = (key) => `json_extract(c.value, '$.${key}')`;
111
+ const target = `substr(coalesce(${TARGET_KEYS.map((k) => item(`state.input.${k}`)).join(', ')}), 1, ${transcript_tail_js_1.MAX_EVENT_FIELD_CHARS + 1})`;
112
+ const lines = `(SELECT sum(length(json_extract(o.value, '$.text')) - length(replace(json_extract(o.value, '$.text'), char(10), '')) + 1)
113
+ FROM json_each(c.value, '$.state.content') o WHERE json_extract(o.value, '$.type') = 'text')`;
114
+ const items = `(SELECT json_group_array(json_object(
115
+ 'type', ${item('type')}, 'id', ${item('id')}, 'name', ${item('name')},
116
+ 'text', CASE WHEN ${item('type')} = 'text' THEN substr(${item('text')}, 1, ${SQL_TEXT_CHARS}) END,
117
+ 'status', ${item('state.status')}, 'target', ${target}, 'lines', ${lines}, 'at', ${item('time.created')}))
118
+ FROM json_each(m.data, '$.content') c)`;
119
+ const select = `SELECT m.id AS id, m.seq AS seq, m.type AS type, m.time_created AS created,
120
+ json_extract(m.data, '$.time.completed') AS completed,
121
+ CASE WHEN m.type = 'user' THEN substr(json_extract(m.data, '$.text'), 1, ${SQL_TEXT_CHARS}) END AS text,
122
+ json_extract(m.data, '$.model.id') AS model,
123
+ json_extract(m.data, '$.tokens.output') AS out,
124
+ json_extract(m.data, '$.tokens.input') AS input,
125
+ json_extract(m.data, '$.tokens.cache.read') AS cacheRead,
126
+ json_extract(m.data, '$.tokens.cache.write') AS cacheWrite,
127
+ json_extract(m.data, '$.error.type') AS errorType,
128
+ substr(json_extract(m.data, '$.error.message'), 1, ${SQL_TEXT_CHARS}) AS errorText,
129
+ CASE WHEN m.type = 'assistant' THEN ${items} END AS items
130
+ FROM session_message m
131
+ WHERE m.session_id = '${sessionId}' AND m.type IN ('user', 'assistant')`;
132
+ return from === null
133
+ ? `SELECT * FROM (${select} ORDER BY m.seq DESC LIMIT ${exports.OPENCODE_BACKFILL_ROWS}) ORDER BY seq`
134
+ : `${select} AND m.seq >= ${from} ORDER BY m.seq LIMIT ${exports.OPENCODE_ROWS_PER_READ}`;
135
+ };
136
+ exports.opencodeRowsSql = opencodeRowsSql;
137
+ const isoOf = (ms) => {
138
+ if (typeof ms !== 'number' || !Number.isFinite(ms))
139
+ return {};
140
+ const date = new Date(ms);
141
+ return Number.isNaN(date.getTime()) ? {} : { at: date.toISOString() };
142
+ };
143
+ /** The row's `content`, or null when the query handed back something that is not a list. */
144
+ const itemsOf = (row) => {
145
+ if (row.items === undefined || row.items === null)
146
+ return [];
147
+ let parsed = row.items;
148
+ if (typeof parsed === 'string') {
149
+ try {
150
+ parsed = JSON.parse(parsed);
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ // Each item is checked field by field where it is drawn.
157
+ return Array.isArray(parsed) ? parsed : null;
158
+ };
159
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
160
+ /** A line the projector reads as "this kind of thing is not drawn". */
161
+ const unknownLine = (what) => JSON.stringify({ unknown: what });
162
+ /**
163
+ * What these rows add to what was already sent, and where the next read starts.
164
+ *
165
+ * Every piece of a row gets a key (`<seq>:<part><index>`), and a key sent once
166
+ * is never sent again. A user row is final when written. An assistant row is
167
+ * not: its text is sent once something follows it in the row or the row is
168
+ * closed, a tool call once it runs, its result once the call has one, and
169
+ * the step's token count and any error when the step is finished. A row is
170
+ * closed when it is finished, when a later assistant step exists (opencode
171
+ * starts one only after the last is over, so an unfinished one behind it died
172
+ * mid-stream), or when it falls out of the `OPENCODE_OPEN_WINDOW` rows behind
173
+ * the newest — closing is what sends a dead step's last words, which nothing
174
+ * would otherwise send. `from` stays on the oldest row still open, so the next
175
+ * read sees it again. `emitted` keeps only the keys at or after `from`;
176
+ * nothing before it is read again.
177
+ */
178
+ const opencodeLinesOf = (rows, sent) => {
179
+ const seen = new Set(sent.emitted);
180
+ const lines = [];
181
+ const out = (key, event) => {
182
+ if (seen.has(key))
183
+ return;
184
+ seen.add(key);
185
+ lines.push(JSON.stringify(event));
186
+ };
187
+ const once = (key, what) => {
188
+ if (seen.has(key))
189
+ return;
190
+ seen.add(key);
191
+ lines.push(unknownLine(what));
192
+ };
193
+ let newest = sent.from - 1;
194
+ let lastStep = -1;
195
+ for (const row of rows) {
196
+ newest = Math.max(newest, row.seq);
197
+ if (row.type === 'assistant')
198
+ lastStep = Math.max(lastStep, row.seq);
199
+ }
200
+ let oldestOpen = null;
201
+ for (const row of rows) {
202
+ const stamp = isoOf(row.created);
203
+ if (row.type === 'user') {
204
+ // A prompt of attachments alone has no text, and still starts a turn.
205
+ const text = (typeof row.text === 'string' ? row.text : '').trim() || '(attachment)';
206
+ out(`${row.seq}:p`, { kind: 'prompt', text: (0, transcript_tail_js_1.clamp)(text, transcript_tail_js_1.MAX_EVENT_TEXT_CHARS), ...stamp });
207
+ continue;
208
+ }
209
+ if (row.type !== 'assistant')
210
+ continue;
211
+ const done = typeof row.completed === 'number';
212
+ const closed = done || row.seq < lastStep || row.seq < newest - exports.OPENCODE_OPEN_WINDOW;
213
+ if (!closed && oldestOpen === null)
214
+ oldestOpen = row.seq;
215
+ const items = itemsOf(row);
216
+ if (items === null)
217
+ once(`${row.seq}:x`, 'opencode content that is not a list');
218
+ let thinking = 0;
219
+ (items ?? []).forEach((item, i) => {
220
+ if (!item || typeof item !== 'object') {
221
+ once(`${row.seq}:x${i}`, 'opencode content item that is not an object');
222
+ return;
223
+ }
224
+ const at = item.at !== undefined && item.at !== null ? isoOf(item.at) : stamp;
225
+ if (item.type === 'reasoning') {
226
+ thinking += 1;
227
+ return;
228
+ }
229
+ if (item.type === 'text') {
230
+ const text = (item.text ?? '').trim();
231
+ const final = closed || i < (items ?? []).length - 1;
232
+ if (text && final)
233
+ out(`${row.seq}:t${i}`, { kind: 'text', text: (0, transcript_tail_js_1.clamp)(text, transcript_tail_js_1.MAX_EVENT_TEXT_CHARS), ...at });
234
+ return;
235
+ }
236
+ if (item.type === 'tool') {
237
+ if (typeof item.id !== 'string' || typeof item.name !== 'string') {
238
+ once(`${row.seq}:x${i}`, 'opencode tool call without an id or name');
239
+ return;
240
+ }
241
+ // A call is first written while its input still streams in
242
+ // (measured on 2.0.24: a write seen then had no path yet), and a
243
+ // call sent once is never sent again — so wait until it runs.
244
+ const settled = item.status === 'running' || item.status === 'completed' || item.status === 'error';
245
+ if (!settled && !closed)
246
+ return;
247
+ const line = (0, transcript_tail_js_1.oneLine)(typeof item.target === 'string' ? item.target : undefined);
248
+ const target = line ? (0, transcript_tail_js_1.clamp)(line) : undefined;
249
+ out(`${row.seq}:u${i}`, { kind: 'tool', id: (0, transcript_tail_js_1.clamp)(item.id), name: (0, transcript_tail_js_1.clamp)(item.name), ...(target ? { target } : {}), ...at });
250
+ if (item.status === 'completed' || item.status === 'error') {
251
+ const count = num(item.lines);
252
+ out(`${row.seq}:r${i}`, { kind: 'tool_result', id: (0, transcript_tail_js_1.clamp)(item.id), ok: item.status === 'completed', ...(count ? { lines: count } : {}), ...at });
253
+ }
254
+ return;
255
+ }
256
+ once(`${row.seq}:x${i}`, `opencode content type ${typeof item.type === 'string' ? item.type : '(none)'}`);
257
+ });
258
+ if (done && typeof row.errorType === 'string') {
259
+ // A failed step is finished like any other (measured: `time.completed`
260
+ // set beside `finish: "error"`), and its error is all it has to say.
261
+ const why = (0, transcript_tail_js_1.oneLine)(typeof row.errorText === 'string' ? row.errorText : undefined);
262
+ out(`${row.seq}:e`, { kind: 'text', text: (0, transcript_tail_js_1.clamp)(`opencode stopped: ${row.errorType}${why ? ` — ${why}` : ''}`, transcript_tail_js_1.MAX_EVENT_TEXT_CHARS), ...stamp });
263
+ }
264
+ if (done) {
265
+ out(`${row.seq}:m`, {
266
+ kind: 'msg',
267
+ mid: (0, transcript_tail_js_1.clamp)(row.id),
268
+ ...(typeof row.model === 'string' ? { model: (0, transcript_tail_js_1.clamp)(row.model) } : {}),
269
+ out: num(row.out),
270
+ // `input` excludes the cached prompt (measured: input 279 beside
271
+ // cache.read 140,145), so the context is the three together.
272
+ ctx: num(row.input) + num(row.cacheRead) + num(row.cacheWrite),
273
+ ...(thinking ? { thinking } : {}),
274
+ ...stamp,
275
+ });
276
+ }
277
+ }
278
+ let from = oldestOpen ?? newest + 1;
279
+ from = Math.max(from, newest - exports.OPENCODE_OPEN_WINDOW, sent.from);
280
+ // A full page of finished rows behind one open row would be read again
281
+ // forever; past the page is where the next read has to start.
282
+ if (rows.length >= exports.OPENCODE_ROWS_PER_READ && from <= sent.from)
283
+ from = newest + 1;
284
+ const emitted = [...seen].filter((key) => Number(key.slice(0, key.indexOf(':'))) >= from);
285
+ return { lines, from, emitted };
286
+ };
287
+ exports.opencodeLinesOf = opencodeLinesOf;
288
+ const cursorOf = (prev, session) => {
289
+ const c = prev?.cursor;
290
+ if (!c || typeof c !== 'object')
291
+ return null;
292
+ const { session: s, from, emitted, stamp, queriedAt } = c;
293
+ if (s !== session || typeof from !== 'number' || !Number.isSafeInteger(from))
294
+ return null;
295
+ if (!Array.isArray(emitted) || typeof stamp !== 'string' || typeof queriedAt !== 'number')
296
+ return null;
297
+ return { session, from, emitted: emitted.filter((key) => typeof key === 'string'), stamp, queriedAt };
298
+ };
299
+ /**
300
+ * When a query last failed, per source path. A failure leaves no state to
301
+ * carry the time on (the reader returns null), and without it a store that
302
+ * cannot be read would be asked again on every 500 ms tick.
303
+ */
304
+ const failedAt = new Map();
305
+ const fileStamp = (db) => {
306
+ const parts = [];
307
+ for (const path of [db, `${db}-wal`]) {
308
+ try {
309
+ const s = (0, fs_1.statSync)(path);
310
+ parts.push(`${s.size}:${s.mtimeMs}`);
311
+ }
312
+ catch {
313
+ // No WAL file is a normal state (checkpointed, or opencode not running).
314
+ parts.push('-');
315
+ }
316
+ }
317
+ return parts[0] === '-' ? null : parts.join('|');
318
+ };
319
+ /**
320
+ * What changed in one opencode session since `prev`, or null when nothing did
321
+ * — the store's files have not moved, the last query was under
322
+ * `OPENCODE_MIN_QUERY_MS` ago, or the store cannot be read. The WAL moves on
323
+ * every write by every opencode session on this machine, so the time floor is
324
+ * what keeps a busy neighbour from turning a 500 ms poll into a `sqlite3`
325
+ * spawn per tick.
326
+ */
327
+ const readOpencodeTranscript = (path, prev, deps) => {
328
+ const source = parseSourcePath(path);
329
+ if (!source)
330
+ return null;
331
+ const now = deps.now ?? Date.now;
332
+ const stamp = (deps.stamp ?? fileStamp)(source.db);
333
+ if (stamp === null)
334
+ return null;
335
+ const cursor = cursorOf(prev, source.sessionId);
336
+ if (cursor && cursor.stamp === stamp)
337
+ return null;
338
+ const last = Math.max(cursor?.queriedAt ?? -Infinity, failedAt.get(path) ?? -Infinity);
339
+ if (now() - last < exports.OPENCODE_MIN_QUERY_MS)
340
+ return null;
341
+ const rows = deps.query(source.db, (0, exports.opencodeRowsSql)(source.sessionId, cursor ? cursor.from : null));
342
+ if (rows === null) {
343
+ failedAt.set(path, now());
344
+ return null;
345
+ }
346
+ failedAt.delete(path);
347
+ const next = (0, exports.opencodeLinesOf)(rows.filter(isRow), cursor ?? { from: 0, emitted: [] });
348
+ const state = {
349
+ offset: next.from,
350
+ // `agent read` says there is more above when the read was a window:
351
+ // a backfill that came back full stands for a session longer than it.
352
+ size: !cursor && rows.length >= exports.OPENCODE_BACKFILL_ROWS ? transcript_tail_js_1.TRANSCRIPT_BACKFILL_BYTES + 1 : 0,
353
+ mtimeMs: 0,
354
+ ino: 0,
355
+ carry: '',
356
+ resyncing: false,
357
+ salvage: [],
358
+ cursor: { session: source.sessionId, from: next.from, emitted: next.emitted, stamp, queriedAt: now() },
359
+ };
360
+ return { lines: next.lines, state, bytesRead: 0, droppedLines: 0 };
361
+ };
362
+ exports.readOpencodeTranscript = readOpencodeTranscript;
363
+ const EVENT_KINDS = new Set(['prompt', 'text', 'tool', 'tool_result', 'msg']);
364
+ /**
365
+ * The reader's lines back into events. They were built as events, so this
366
+ * only refuses what is not one and names what the reader marked unknown.
367
+ */
368
+ const projectOpencodeEntries = (lines, opts = {}) => {
369
+ const events = [];
370
+ for (const raw of lines) {
371
+ let entry;
372
+ try {
373
+ const parsed = JSON.parse(raw);
374
+ if (!parsed || typeof parsed !== 'object')
375
+ continue;
376
+ entry = parsed;
377
+ }
378
+ catch {
379
+ continue;
380
+ }
381
+ if (typeof entry.unknown === 'string') {
382
+ opts.onUnknown?.(entry.unknown);
383
+ continue;
384
+ }
385
+ if (typeof entry.kind === 'string' && EVENT_KINDS.has(entry.kind))
386
+ events.push(entry);
387
+ }
388
+ return (0, transcript_tail_js_1.sliceSinceLastPrompt)(events, opts);
389
+ };
390
+ exports.projectOpencodeEntries = projectOpencodeEntries;
@@ -0,0 +1,6 @@
1
+ import type { HwipConfig } from './config.js';
2
+ export declare const optionalFeatures: (args: Record<string, string | boolean>, config: Pick<HwipConfig, "turnHistory" | "keepAwake">) => {
3
+ turnHistory: boolean;
4
+ keepAwake: boolean;
5
+ };
6
+ //# sourceMappingURL=optional-features.d.ts.map
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.optionalFeatures = void 0;
4
+ const args_js_1 = require("./args.js");
5
+ /**
6
+ * The listener's opt-out features: on unless a flag or the config says no. Only an absent value or
7
+ * `true` keeps one on in the config: `"turnHistory": "false"`, `0` or `null` is someone's attempt to
8
+ * turn it off, and reading it as "not set" uploaded the conversations anyway. `loadConfig` reads a
9
+ * config.json it cannot use as every option off (`unreadableConfigDefaults`), so what this
10
+ * returns is the privacy-safe set then, with the flags unable to turn anything back on. A flag is
11
+ * read by `flagOn`: `--no-turn-history=true` and `=1` are as much a no as the bare form, and a
12
+ * value that is neither yes nor no throws `FlagValueError`.
13
+ */
14
+ const configKeepsOn = (value) => value === undefined || value === true;
15
+ const optionalFeatures = (args, config) => ({
16
+ turnHistory: !(0, args_js_1.flagOn)(args, 'no-turn-history') && configKeepsOn(config.turnHistory),
17
+ keepAwake: !(0, args_js_1.flagOn)(args, 'no-keep-awake') && configKeepsOn(config.keepAwake),
18
+ });
19
+ exports.optionalFeatures = optionalFeatures;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Fit a detached tmux window to the phone that is watching it.
3
+ *
4
+ * A pane is as wide as the terminal that last drew it, usually a desktop's
5
+ * 120-odd columns. The phone mirror shows about 45, so every full-width row a
6
+ * prompt or TUI draws (a rule, a right-aligned clock, a status line) folds into
7
+ * two. Nobody else sees a DETACHED window, so while a phone watches one it is
8
+ * resized to the phone's width and the program redraws to fit; when the phone
9
+ * leaves, the old width comes back.
10
+ *
11
+ * An attached window is left alone: shrinking it would shrink the desktop
12
+ * terminal of whoever is attached. A window split into panes is left alone
13
+ * too, since the window width is not any one pane's width. And when a client
14
+ * attaches while the phone is watching, the next check hands the window back
15
+ * to tmux, which sizes it to that client.
16
+ *
17
+ * A fitted window carries its old width in `@hwip_fit_width`. A listener that
18
+ * died without releasing (a crash, a SIGKILL) leaves that mark behind, and
19
+ * the next one to start gives those windows back ({@link releaseStaleFits}).
20
+ */
21
+ /** Runs one tmux command (socket-aware) and returns what it printed. */
22
+ export type TmuxRun = (args: string[]) => {
23
+ status: number | null;
24
+ stdout: string;
25
+ };
26
+ export declare const PANE_FIT_MIN_COLS = 20;
27
+ export declare const PANE_FIT_MAX_COLS = 400;
28
+ /** A viewer's column count, or null when it did not send a usable one. */
29
+ export declare const clampViewerCols: (cols: unknown) => number | null;
30
+ /**
31
+ * Hand back every window a previous listener fitted and never released:
32
+ * restore its width when detached, then drop the manual size and the mark.
33
+ */
34
+ export declare const releaseStaleFits: (run: TmuxRun) => number;
35
+ export declare const createPaneFitter: (run: TmuxRun) => {
36
+ fit: (session: string, target: string, cols: unknown) => void;
37
+ release: (session: string) => void;
38
+ fitted: (session: string) => boolean;
39
+ };
40
+ //# sourceMappingURL=pane-fit.d.ts.map
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ /**
3
+ * Fit a detached tmux window to the phone that is watching it.
4
+ *
5
+ * A pane is as wide as the terminal that last drew it, usually a desktop's
6
+ * 120-odd columns. The phone mirror shows about 45, so every full-width row a
7
+ * prompt or TUI draws (a rule, a right-aligned clock, a status line) folds into
8
+ * two. Nobody else sees a DETACHED window, so while a phone watches one it is
9
+ * resized to the phone's width and the program redraws to fit; when the phone
10
+ * leaves, the old width comes back.
11
+ *
12
+ * An attached window is left alone: shrinking it would shrink the desktop
13
+ * terminal of whoever is attached. A window split into panes is left alone
14
+ * too, since the window width is not any one pane's width. And when a client
15
+ * attaches while the phone is watching, the next check hands the window back
16
+ * to tmux, which sizes it to that client.
17
+ *
18
+ * A fitted window carries its old width in `@hwip_fit_width`. A listener that
19
+ * died without releasing (a crash, a SIGKILL) leaves that mark behind, and
20
+ * the next one to start gives those windows back ({@link releaseStaleFits}).
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.createPaneFitter = exports.releaseStaleFits = exports.clampViewerCols = exports.PANE_FIT_MAX_COLS = exports.PANE_FIT_MIN_COLS = void 0;
24
+ exports.PANE_FIT_MIN_COLS = 20;
25
+ exports.PANE_FIT_MAX_COLS = 400;
26
+ /** A viewer's column count, or null when it did not send a usable one. */
27
+ const clampViewerCols = (cols) => {
28
+ if (typeof cols !== 'number' || !Number.isFinite(cols))
29
+ return null;
30
+ return Math.min(exports.PANE_FIT_MAX_COLS, Math.max(exports.PANE_FIT_MIN_COLS, Math.floor(cols)));
31
+ };
32
+ exports.clampViewerCols = clampViewerCols;
33
+ const SEP = '\u241f';
34
+ const FIT_MARK = '@hwip_fit_width';
35
+ /**
36
+ * Hand back every window a previous listener fitted and never released:
37
+ * restore its width when detached, then drop the manual size and the mark.
38
+ */
39
+ const releaseStaleFits = (run) => {
40
+ const r = run(['list-windows', '-a', '-F', `#{window_id}${SEP}#{${FIT_MARK}}${SEP}#{session_attached}`]);
41
+ if (r.status !== 0)
42
+ return 0;
43
+ let released = 0;
44
+ for (const line of r.stdout.split('\n')) {
45
+ const [id, mark, attached] = line.split(SEP);
46
+ const width = Number(mark);
47
+ if (!id || !mark || !Number.isFinite(width))
48
+ continue;
49
+ if (attached === '0')
50
+ run(['resize-window', '-t', id, '-x', String(width)]);
51
+ run(['set-option', '-w', '-u', '-t', id, 'window-size']);
52
+ run(['set-option', '-w', '-u', '-t', id, FIT_MARK]);
53
+ released++;
54
+ }
55
+ return released;
56
+ };
57
+ exports.releaseStaleFits = releaseStaleFits;
58
+ const readWindow = (run, target) => {
59
+ const r = run(['display-message', '-p', '-t', target,
60
+ `#{session_attached}${SEP}#{window_panes}${SEP}#{window_width}`]);
61
+ if (r.status !== 0)
62
+ return null;
63
+ const [attached, panes, width] = r.stdout.trim().split(SEP).map(Number);
64
+ if (![attached, panes, width].every(Number.isFinite))
65
+ return null;
66
+ return { attached, panes, width };
67
+ };
68
+ const createPaneFitter = (run) => {
69
+ const fits = new Map();
70
+ /**
71
+ * Give the window back: restore its width if nobody is attached, then drop
72
+ * the manual size `resize-window` set, so the next client to attach sizes
73
+ * it again. With a client attached, dropping the option alone resizes it to
74
+ * that client.
75
+ */
76
+ const release = (session) => {
77
+ const fit = fits.get(session);
78
+ if (!fit)
79
+ return;
80
+ fits.delete(session);
81
+ const win = readWindow(run, fit.target);
82
+ if (win && win.attached === 0)
83
+ run(['resize-window', '-t', fit.target, '-x', String(fit.width)]);
84
+ run(['set-option', '-w', '-u', '-t', fit.target, 'window-size']);
85
+ run(['set-option', '-w', '-u', '-t', fit.target, FIT_MARK]);
86
+ };
87
+ /**
88
+ * Size `session`'s window to `cols` if it is detached and unsplit. Safe to
89
+ * call on every renew: an unchanged width costs one display-message, and a
90
+ * window that gained a client since is released.
91
+ */
92
+ const fit = (session, target, cols) => {
93
+ const want = (0, exports.clampViewerCols)(cols);
94
+ if (want === null)
95
+ return;
96
+ const win = readWindow(run, target);
97
+ if (!win)
98
+ return;
99
+ const held = fits.get(session);
100
+ if (win.attached > 0 || win.panes !== 1) {
101
+ if (held)
102
+ release(session);
103
+ return;
104
+ }
105
+ if (held && held.cols === want && win.width === want)
106
+ return;
107
+ const width = held?.width ?? win.width;
108
+ // Mark first: a crash between the two must still be recoverable.
109
+ run(['set-option', '-w', '-t', target, FIT_MARK, String(width)]);
110
+ if (run(['resize-window', '-t', target, '-x', String(want)]).status !== 0) {
111
+ if (!held)
112
+ run(['set-option', '-w', '-u', '-t', target, FIT_MARK]);
113
+ return;
114
+ }
115
+ fits.set(session, { target, width, cols: want });
116
+ };
117
+ return { fit, release, fitted: (session) => fits.has(session) };
118
+ };
119
+ exports.createPaneFitter = createPaneFitter;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Which key to believe for a device that signs something with its own key.
3
+ *
4
+ * An envelope that opens only shows its sender held *a* key (push v1 carries
5
+ * the sender's public key unsigned), so the reader must say whose key it
6
+ * expects. This answers that for one device id: the key the account's device
7
+ * list registers for it. The device is pinned on the way (`observeDevices`) — a first
8
+ * sight pins it, a key that changed replaces the pin with one warning and a record
9
+ * (`key-changes.ts`). The pin is a record for a person (`hwip trust`) and for the
10
+ * listener; it does not decide here, the list does.
11
+ */
12
+ export type PeerKeyRefusal =
13
+ /** Not a device id, or not in the account's device list. */
14
+ 'unlisted'
15
+ /** The device list could not be fetched. */
16
+ | 'devices_unavailable';
17
+ export type PeerKey = {
18
+ key: string;
19
+ } | {
20
+ refused: PeerKeyRefusal;
21
+ };
22
+ export interface PeerKeyDeps {
23
+ /** The key the account's device list registers for `deviceId`: null when none is listed, a rejection when the list cannot be had. */
24
+ registeredKey: (deviceId: string) => Promise<string | null>;
25
+ /** The listener's state file (`~/.hwip/trusted-devices.json`). */
26
+ trustPath: string;
27
+ /** This computer's own id: it never pins itself. */
28
+ selfId?: string;
29
+ /** One line for a person, as `observeDevices` words it. */
30
+ warn?: (line: string) => void;
31
+ }
32
+ export declare const peerKeyOf: (deviceId: string, deps: PeerKeyDeps) => Promise<PeerKey>;
33
+ //# sourceMappingURL=peer-key.d.ts.map
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ /**
3
+ * Which key to believe for a device that signs something with its own key.
4
+ *
5
+ * An envelope that opens only shows its sender held *a* key (push v1 carries
6
+ * the sender's public key unsigned), so the reader must say whose key it
7
+ * expects. This answers that for one device id: the key the account's device
8
+ * list registers for it. The device is pinned on the way (`observeDevices`) — a first
9
+ * sight pins it, a key that changed replaces the pin with one warning and a record
10
+ * (`key-changes.ts`). The pin is a record for a person (`hwip trust`) and for the
11
+ * listener; it does not decide here, the list does.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.peerKeyOf = void 0;
15
+ const crypto_js_1 = require("./crypto.js");
16
+ const subscriber_trust_js_1 = require("./subscriber-trust.js");
17
+ const trust_pins_js_1 = require("./trust-pins.js");
18
+ const peerKeyOf = async (deviceId, deps) => {
19
+ if (!subscriber_trust_js_1.DEVICE_ID.test(deviceId))
20
+ return { refused: 'unlisted' };
21
+ let registered;
22
+ try {
23
+ registered = await deps.registeredKey(deviceId);
24
+ }
25
+ catch {
26
+ return { refused: 'devices_unavailable' };
27
+ }
28
+ // A listed key that is no device key in the one spelling is as good as none.
29
+ if (!registered || !(0, crypto_js_1.samePublicKey)(registered, registered))
30
+ return { refused: 'unlisted' };
31
+ (0, trust_pins_js_1.observeDevices)(deps.trustPath, [{ deviceId, publicKey: registered }], { selfId: deps.selfId, warn: deps.warn ?? (() => undefined) });
32
+ return { key: registered };
33
+ };
34
+ exports.peerKeyOf = peerKeyOf;