@hwip/cli 0.1.14 → 0.1.16

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.
package/README.md CHANGED
@@ -190,7 +190,8 @@ says it has no live timeline.
190
190
  | Codex CLI | `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl` (`CODEX_HOME`) | `src/codex-transcript.ts` |
191
191
  | Gemini CLI | `~/.gemini/tmp/<project>/chats/session-*.jsonl` (`GEMINI_CLI_HOME`) | `src/gemini-transcript.ts` |
192
192
  | OpenCode 2.x | no file — the `session_message` table of `~/.local/share/opencode/opencode.db` (`OPENCODE_DB`, `XDG_DATA_HOME`), asked again when the store changes, at most once a second per session. The pane's session is the newest one in its directory written to since the pane started | `src/opencode-transcript.ts` |
193
- | Cursor, Hermes, Kimi, Grok, Antigravity, Qwen, Auggie, Kilo, Copilot, Kiro, Droid | — none yet | — |
193
+ | Hermes | no file — the `messages` table of `~/.hermes/state.db`, asked again when the store changes, at most once a second per session. The pane's session is the newest one in its directory with a message since the pane started | `src/hermes-transcript.ts` |
194
+ | Cursor, Kimi, Grok, Antigravity, Qwen, Auggie, Kilo, Copilot, Kiro, Droid | — none yet | — |
194
195
 
195
196
  Adding an agent means a projector file of its own, not another branch in an
196
197
  existing one: the formats share no vocabulary. Each projector is pinned to the
@@ -705,12 +706,17 @@ When a session waits for your approval, the listener also sends what it is waiti
705
706
  for — the tool, the command or the diff, and the answers — so the app can show it
706
707
  and answer. That covers Claude Code (a Bash command, a file edit or a new file),
707
708
  Gemini CLI (its tool approvals), OpenCode (its permission request; the listener reads
708
- the screen with its colours, since only a colour marks the chosen button) and pi
709
- approval extensions that ask through `ctx.ui.select`. Codex's approval dialog is not
710
- read yet. It is sealed the same
709
+ the screen with its colours, since only a colour marks the chosen button), pi
710
+ approval extensions that ask through `ctx.ui.select` and Codex (a shell command or a file edit; the app answers with
711
+ the arrow keys and Enter). It is sealed the same
711
712
  way as a chunk, for the same pinned devices, and turned off by the same
712
- `--no-turn-history`; the server stores ciphertext only. Other dialogs (a plan, a
713
- question, a trust prompt) send nothing beyond the state. An answer the app sends is
713
+ `--no-turn-history`; the server stores ciphertext only. For Claude Code that
714
+ includes a question it asks (one question, a multi-select with its Submit row,
715
+ the review of several answers) and the plan it asks you to approve; a plan longer
716
+ than the screen shows is cut where the screen cuts it, with the plan file's path
717
+ as the note. Rows that need typed text (`Type something`, `Chat about this`,
718
+ `Tell Claude what to change`) are not offered, and a dialog you are typing into is
719
+ not read. Other dialogs (a trust prompt) send nothing beyond the state. An answer the app sends is
714
720
  typed only while that same prompt is still on screen: if you have moved the
715
721
  highlight or answered at the keyboard, the command is refused (`prompt_changed`)
716
722
  instead of landing on whatever is there now.
@@ -18,7 +18,7 @@
18
18
  * still be working underneath, so it must not change the state.
19
19
  *
20
20
  * Every agent here carries the same rules the server manifest publishes
21
- * (hwip server/src/agent-rules/manifest.json, version 2026.10.21.1 — hwip#616, merged and deployed before this cli ships),
21
+ * (hwip server/src/agent-rules/manifest.json, version 2026.11.01.2 — the hwip mirror of this rule merges and deploys before this cli ships),
22
22
  * so a listener that never reaches the endpoint classifies them too. A fetched
23
23
  * manifest replaces this bundle whole for state reports; schedules read only
24
24
  * this bundle. An agent without rules here
@@ -21,7 +21,7 @@ exports.DEFAULT_MANIFEST = void 0;
21
21
  * still be working underneath, so it must not change the state.
22
22
  *
23
23
  * Every agent here carries the same rules the server manifest publishes
24
- * (hwip server/src/agent-rules/manifest.json, version 2026.10.21.1 — hwip#616, merged and deployed before this cli ships),
24
+ * (hwip server/src/agent-rules/manifest.json, version 2026.11.01.2 — the hwip mirror of this rule merges and deploys before this cli ships),
25
25
  * so a listener that never reaches the endpoint classifies them too. A fetched
26
26
  * manifest replaces this bundle whole for state reports; schedules read only
27
27
  * this bundle. An agent without rules here
@@ -31,7 +31,7 @@ exports.DEFAULT_MANIFEST = void 0;
31
31
  const agent_state_js_1 = require("./agent-state.js");
32
32
  exports.DEFAULT_MANIFEST = {
33
33
  engineVersion: agent_state_js_1.ENGINE_VERSION,
34
- version: '2026.10.21.1',
34
+ version: '2026.11.01.2',
35
35
  agents: {
36
36
  claude: [
37
37
  {
@@ -61,6 +61,20 @@ exports.DEFAULT_MANIFEST = {
61
61
  ],
62
62
  not: [{ contains: ['esc to interrupt'] }],
63
63
  },
64
+ {
65
+ // Plan approval (ExitPlanMode) and the review of several AskUserQuestion answers: their option lists
66
+ // carry no "esc to cancel" / "enter to select" line, so the dialog rule above misses them and the
67
+ // `❯ 1.` row reads as an idle prompt. Anchored to the dialogs' own question lines, near the bottom.
68
+ id: 'claude-blocked-plan-or-review',
69
+ state: 'blocked',
70
+ priority: 900,
71
+ tailLines: 14,
72
+ any: [
73
+ { regex: ['^\\s*Claude has written up a plan and is ready to execute'] },
74
+ { regex: ['^\\s*Ready to submit your answers\\?\\s*$'] },
75
+ ],
76
+ not: [{ contains: ['esc to interrupt'] }],
77
+ },
64
78
  {
65
79
  id: 'claude-working-interrupt-hint',
66
80
  state: 'working',
@@ -125,15 +139,11 @@ exports.DEFAULT_MANIFEST = {
125
139
  // while a turn runs both stay drawn under a `• Working (… • esc to
126
140
  // interrupt)` line, so working must outrank idle.
127
141
  //
128
- // KNOWN GAP — no tool ever ran (the workspace was out of credits),
129
- // so the tool-approval dialog was never drawn and has no rule. The
130
- // idle rule refuses a tail holding the markers those dialogs and
131
- // older Codex approval prompts draw — a `› 1.` option list, a
132
- // `(y)`/`(n)` key hint, a `· esc …` footer, `enter to confirm` or
133
- // `esc to cancel` — so an approval dialog that kept the footer
134
- // reads `unknown` as long as it draws one of them. One that draws
135
- // none would read `idle`; capture it before trusting this rule
136
- // further.
142
+ // The tool-approval dialog (captured on 0.166.0) has its own blocked
143
+ // rule. The idle rule still refuses a tail holding the markers
144
+ // approval prompts draw — a `› 1.` option list, a `(y)`/`(n)` key
145
+ // hint, a `· esc …` footer, `enter to confirm` or `esc to cancel` —
146
+ // so a variant the blocked rule misses reads `unknown`, not `idle`.
137
147
  {
138
148
  id: 'codex-blocked-hooks-review',
139
149
  state: 'blocked',
@@ -158,6 +168,20 @@ exports.DEFAULT_MANIFEST = {
158
168
  priority: 900,
159
169
  contains: ['trust this folder', 'enter continue'],
160
170
  },
171
+ {
172
+ // Tool approval (codex-cli 0.166.0, shell command or file edit): the question and the
173
+ // confirm footer. Both must be in view, so a transcript that
174
+ // only quotes one of them does not read blocked. The composer is hidden while it is drawn.
175
+ id: 'codex-blocked-tool-approval',
176
+ state: 'blocked',
177
+ priority: 900,
178
+ tailLines: 30,
179
+ regex: ['^\\s*Press enter to confirm or esc to cancel\\s*$'],
180
+ any: [
181
+ { regex: ['^\\s*Would you like to run the following command\\?\\s*$'] },
182
+ { regex: ['^\\s*Would you like to make the following edits\\?\\s*$'] },
183
+ ],
184
+ },
161
185
  {
162
186
  id: 'codex-working-interrupt-hint',
163
187
  state: 'working',
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Claude Code's other numbered dialogs: AskUserQuestion (a question, a multi-select, the review of several answers)
3
+ * and ExitPlanMode (the plan to approve). They come out as the same `permission`-shaped prompt the approval dialogs
4
+ * do, so a phone that reads those reads these too; only the title tells them apart.
5
+ *
6
+ * Rows that need typed text (`Type something`, `Chat about this`, `Tell Claude what to change`) are not options: an
7
+ * option has to be one answer a single press gives (in a multi-select a press toggles a box, and the screen it
8
+ * draws next is a new prompt). A dialog whose highlight sits on such a row is not read at all, as
9
+ * is one the user is typing into: the keys of the other options would land in the text.
10
+ */
11
+ import { type ParsedPrompt } from './blocked-prompt.js';
12
+ /** An AskUserQuestion dialog at the bottom of `pane`, or undefined. */
13
+ export declare const parseClaudeQuestion: (pane: string) => ParsedPrompt | undefined;
14
+ /** An ExitPlanMode dialog at the bottom of `pane`, or undefined. */
15
+ export declare const parseClaudePlan: (pane: string) => ParsedPrompt | undefined;
16
+ //# sourceMappingURL=blocked-prompt-claude-dialogs.d.ts.map
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseClaudePlan = exports.parseClaudeQuestion = void 0;
4
+ /**
5
+ * Claude Code's other numbered dialogs: AskUserQuestion (a question, a multi-select, the review of several answers)
6
+ * and ExitPlanMode (the plan to approve). They come out as the same `permission`-shaped prompt the approval dialogs
7
+ * do, so a phone that reads those reads these too; only the title tells them apart.
8
+ *
9
+ * Rows that need typed text (`Type something`, `Chat about this`, `Tell Claude what to change`) are not options: an
10
+ * option has to be one answer a single press gives (in a multi-select a press toggles a box, and the screen it
11
+ * draws next is a new prompt). A dialog whose highlight sits on such a row is not read at all, as
12
+ * is one the user is typing into: the keys of the other options would land in the text.
13
+ */
14
+ const blocked_prompt_js_1 = require("./blocked-prompt.js");
15
+ const FULL_RULE = /^\s*─{20,}$/;
16
+ const DASHED_RULE = /^\s*╌{20,}$/;
17
+ const OPTION = /^\s*([❯>])?\s*(\d{1,2})\.\s+(\S.*)$/;
18
+ const TAB_LINE = /^\s*(?:←\s+)?[☐☒]\s/;
19
+ const TAB_CHIP = /([☐☒✔])\s+(\S+)/g;
20
+ const SUBMIT_ROW = /^\s*(❯)?\s+Submit\s*$/;
21
+ const ASK_FOOTER = /Enter to select/;
22
+ const PLAN_FOOTER = /^\s*ctrl\+g to edit/;
23
+ const MULTI_LABEL = /^\[[ ✔]\]\s/;
24
+ const OTHER_LABEL = /^(?:\[[ ✔]\]\s+)?Type something\.?$/;
25
+ const CHAT_LABEL = /^Chat about this$/;
26
+ const PLAN_TITLE = 'Ready to code?';
27
+ const OWN_ANSWER = /^Tell Claude what to change$/;
28
+ /** The hint under a plan option: it belongs to the row above, not to its label. */
29
+ const PLAN_HINT = /^shift\+tab to /;
30
+ /** The marker the viewport draws at a plan's last visible row when more is below (or above). */
31
+ const SCROLL_MARK = /\s{2,}[↓↑]$/;
32
+ const blank = (l) => l.trim() === '';
33
+ const lastIndex = (lines, test) => {
34
+ for (let i = lines.length - 1; i >= 0; i--)
35
+ if (test(lines[i], i))
36
+ return i;
37
+ return -1;
38
+ };
39
+ const dedent = (lines) => {
40
+ const indent = Math.min(...lines.filter((l) => !blank(l)).map((l) => l.length - l.trimStart().length));
41
+ return Number.isFinite(indent) ? lines.map((l) => l.slice(indent)) : [...lines];
42
+ };
43
+ /** The numbered rows of `lines`; a more indented line under a row describes it. Undefined for any other line. */
44
+ const readRows = (lines) => {
45
+ const rows = [];
46
+ for (const line of lines) {
47
+ if (blank(line))
48
+ continue;
49
+ const m = OPTION.exec(line);
50
+ if (m) {
51
+ rows.push({ n: Number(m[2]), label: m[3].trim(), selected: m[1] === '❯', note: [] });
52
+ }
53
+ else if (rows.length > 0 && /^\s{3,}\S/.test(line)) {
54
+ rows[rows.length - 1].note.push(line.trim());
55
+ }
56
+ else {
57
+ return undefined;
58
+ }
59
+ }
60
+ return rows.length > 0 ? rows : undefined;
61
+ };
62
+ /** `label — description`, or the label alone when the description only repeats it. */
63
+ const withNote = (row) => {
64
+ const text = row.note.join(' ');
65
+ const bare = row.label.replace(/^\[[ ✔]\]\s+/, '');
66
+ return text === '' || text === bare ? row.label : `${row.label} — ${text}`;
67
+ };
68
+ /** An AskUserQuestion dialog at the bottom of `pane`, or undefined. */
69
+ const parseClaudeQuestion = (pane) => {
70
+ const lines = pane.split('\n').map((l) => l.trimEnd());
71
+ let end = lines.length;
72
+ while (end > 0 && blank(lines[end - 1]))
73
+ end--;
74
+ const live = lines.slice(0, end);
75
+ const tabAt = lastIndex(live, (l, i) => i > 0 && TAB_LINE.test(l) && FULL_RULE.test(live[i - 1]));
76
+ if (tabAt < 0)
77
+ return undefined;
78
+ const tabs = live[tabAt];
79
+ // Below the options the dialog may draw its own rule, the "Chat about this" row and a footer; nothing else.
80
+ const rest = live.slice(tabAt + 1);
81
+ const ruleAt = rest.findIndex((l) => FULL_RULE.test(l));
82
+ const body = ruleAt < 0 ? rest : rest.slice(0, ruleAt);
83
+ if (ruleAt >= 0) {
84
+ for (const l of rest.slice(ruleAt + 1)) {
85
+ if (blank(l) || ASK_FOOTER.test(l))
86
+ continue;
87
+ if (CHAT_LABEL.test(OPTION.exec(l)?.[3]?.trim() ?? ''))
88
+ continue;
89
+ return undefined;
90
+ }
91
+ if (!rest.slice(ruleAt + 1).some((l) => ASK_FOOTER.test(l)))
92
+ return undefined;
93
+ }
94
+ // Text above the first row: paragraphs, the last one the question.
95
+ const firstRow = body.findIndex((l) => OPTION.test(l));
96
+ if (firstRow < 0)
97
+ return undefined;
98
+ const paragraphs = [];
99
+ for (const l of body.slice(0, firstRow)) {
100
+ if (blank(l))
101
+ paragraphs.push([]);
102
+ else
103
+ (paragraphs.length === 0 || paragraphs[paragraphs.length - 1].length === 0 ? paragraphs.push([l]) : paragraphs[paragraphs.length - 1].push(l));
104
+ }
105
+ const texts = paragraphs.filter((p) => p.length > 0);
106
+ const questionLines = texts.pop();
107
+ if (!questionLines)
108
+ return undefined;
109
+ // AskUserQuestion takes any text as the question; a "?" is not guaranteed.
110
+ const question = questionLines.map((l) => l.trim()).join(' ');
111
+ // Under the question: the numbered rows, then in a multi-select an unnumbered Submit row.
112
+ let rowLines = body.slice(firstRow);
113
+ let submit;
114
+ const submitAt = rowLines.findIndex((l) => SUBMIT_ROW.test(l));
115
+ if (submitAt >= 0) {
116
+ submit = { selected: SUBMIT_ROW.exec(rowLines[submitAt])[1] === '❯' };
117
+ if (rowLines.slice(submitAt + 1).some((l) => !blank(l)))
118
+ return undefined;
119
+ rowLines = rowLines.slice(0, submitAt);
120
+ }
121
+ const rows = readRows(rowLines);
122
+ if (!rows)
123
+ return undefined;
124
+ // A question with a free-text row ends in it; the review of the answers has none. On that row the user either has
125
+ // not typed yet or is typing: either way the other rows' keys would hit the text.
126
+ if (ruleAt >= 0) {
127
+ const other = rows[rows.length - 1];
128
+ if (other.selected || !OTHER_LABEL.test(other.label))
129
+ return undefined;
130
+ rows.pop();
131
+ }
132
+ if (rows.some((r) => OTHER_LABEL.test(r.label) || CHAT_LABEL.test(r.label)))
133
+ return undefined;
134
+ const multi = rows.length > 0 && rows.every((r) => MULTI_LABEL.test(r.label));
135
+ // Submit sits after the free-text row, which the key positions count: no such row, no known layout.
136
+ if (submit && (!multi || ruleAt < 0))
137
+ return undefined;
138
+ const options = rows.map((r) => ({ n: r.n, label: withNote(r), selected: r.selected }));
139
+ if (submit)
140
+ options.push({ n: rows.length + 1, label: 'Submit', selected: submit.selected });
141
+ const chips = [...tabs.matchAll(TAB_CHIP)].filter((m) => m[1] !== '✔').map((m) => `${m[1]} ${m[2]}`);
142
+ const heading = texts.length > 0 ? texts[0].map((l) => l.trim()).join(' ') : undefined;
143
+ const summary = texts.length > 1 ? texts.slice(1).flat() : [];
144
+ // The review screen heads itself; a question is known by the header chips of its tab line.
145
+ const review = heading !== undefined && /^Review your answers$/i.test(heading);
146
+ return (0, blocked_prompt_js_1.assemble)({
147
+ region: live.slice(tabAt),
148
+ title: review ? heading : 'Claude is asking',
149
+ subtitle: chips.join(' · '),
150
+ detail: review ? summary.map((l) => l.replace(/^ /, '')) : [],
151
+ question,
152
+ options,
153
+ // The numbered rows, then a free-text row the options skip, then Submit.
154
+ keys: (from, to) => {
155
+ const pos = (i) => (submit && i === options.length - 1 && multi ? i + 1 : i);
156
+ const steps = pos(to) - pos(from);
157
+ return [...Array(Math.abs(steps)).fill(steps > 0 ? 'down' : 'up'), 'enter'];
158
+ },
159
+ });
160
+ };
161
+ exports.parseClaudeQuestion = parseClaudeQuestion;
162
+ /** An ExitPlanMode dialog at the bottom of `pane`, or undefined. */
163
+ const parseClaudePlan = (pane) => {
164
+ const lines = pane.split('\n').map((l) => l.trimEnd());
165
+ let end = lines.length;
166
+ while (end > 0 && blank(lines[end - 1]))
167
+ end--;
168
+ const live = lines.slice(0, end);
169
+ // The footer names the plan file, and wraps when the pane is narrow: `ctrl+g to edit in Nvim · ~/.claude/…md`.
170
+ const footerAt = lastIndex(live, (l) => PLAN_FOOTER.test(l));
171
+ if (footerAt < 0)
172
+ return undefined;
173
+ const footerText = live.slice(footerAt).map((l) => l.trim());
174
+ const afterDot = footerText[0].split('·').slice(1).join('·').trim();
175
+ const path = [afterDot, ...footerText.slice(1)].join('').trim();
176
+ const note = path.startsWith('~/') || path.startsWith('/') ? path : undefined;
177
+ // The question sits under the last full rule above the footer; a numbered plan above it is not the options.
178
+ const lastQ = lastIndex(live.slice(0, footerAt), (l) => FULL_RULE.test(l));
179
+ if (lastQ < 0)
180
+ return undefined;
181
+ const optionAt = live.findIndex((l, i) => i > lastQ && i < footerAt && OPTION.exec(l)?.[2] === '1');
182
+ if (optionAt < 0)
183
+ return undefined;
184
+ const question = live.slice(lastQ + 1, optionAt).filter((l) => !blank(l)).map((l) => l.trim()).join(' ');
185
+ if (!question.endsWith('?'))
186
+ return undefined;
187
+ const rows = [];
188
+ for (const line of live.slice(optionAt, footerAt)) {
189
+ if (blank(line) || PLAN_HINT.test(line.trim()))
190
+ continue;
191
+ const m = OPTION.exec(line);
192
+ if (m)
193
+ rows.push({ n: Number(m[2]), label: m[3].trim(), selected: m[1] === '❯', note: [] });
194
+ else if (rows.length > 0 && /^\s{3,}\S/.test(line))
195
+ rows[rows.length - 1].label += ` ${line.trim()}`;
196
+ else
197
+ return undefined;
198
+ }
199
+ const options = rows.filter((r) => !OWN_ANSWER.test(r.label)).map((r) => ({ n: r.n, label: r.label, selected: r.selected }));
200
+ // Above the question: rule, plan between two dashed rules, and above them the title and subtitle.
201
+ // The title opens the dialog, under a rule; the plan sits between two dashed rules. The closing one is missing
202
+ // when the viewport cut the plan: the rule above the question ends it then.
203
+ const titleAt = lastIndex(live.slice(0, lastQ), (l) => l.trim() === PLAN_TITLE);
204
+ if (titleAt < 1 || !FULL_RULE.test(live[titleAt - 1]))
205
+ return undefined;
206
+ const topRule = titleAt - 1;
207
+ const dashedStart = live.findIndex((l, i) => i > titleAt && i < lastQ && DASHED_RULE.test(l));
208
+ if (dashedStart < 0)
209
+ return undefined;
210
+ const closing = live.findIndex((l, i) => i > dashedStart && i < lastQ && DASHED_RULE.test(l));
211
+ const dashedEnd = closing < 0 ? lastQ : closing;
212
+ const head = live.slice(titleAt + 1, dashedStart).filter((l) => !blank(l)).map((l) => l.trim());
213
+ let plan = live.slice(dashedStart + 1, dashedEnd);
214
+ while (plan.length > 0 && blank(plan[plan.length - 1]))
215
+ plan.pop();
216
+ let more = false;
217
+ plan = plan.map((l) => {
218
+ if (SCROLL_MARK.test(l))
219
+ more = true;
220
+ return l.replace(SCROLL_MARK, '');
221
+ });
222
+ const prompt = (0, blocked_prompt_js_1.assemble)({
223
+ region: live.slice(topRule + 1),
224
+ title: PLAN_TITLE,
225
+ subtitle: head.join(' '),
226
+ detail: dedent(plan),
227
+ ...(note ? { note } : {}),
228
+ question,
229
+ options,
230
+ });
231
+ return prompt && more ? { ...prompt, detailTruncated: true } : prompt;
232
+ };
233
+ exports.parseClaudePlan = parseClaudePlan;
@@ -30,6 +30,24 @@ export interface ParsedPrompt {
30
30
  export declare const isPromptId: (value: unknown) => value is string;
31
31
  /** `pane` without terminal escapes: what `capture-pane` without `-e` gives. */
32
32
  export declare const stripEscapes: (pane: string) => string;
33
+ /** The checks and caps every reader shares: numbering 1..n, one highlight, label and detail limits. */
34
+ export declare const assemble: (d: {
35
+ region: readonly string[];
36
+ title: string;
37
+ subtitle?: string;
38
+ detail: readonly string[];
39
+ note?: string;
40
+ question: string;
41
+ options: readonly {
42
+ n: number;
43
+ label: string;
44
+ selected: boolean;
45
+ }[];
46
+ /** Keys from the highlighted option to option `to`; up/down by default. */
47
+ keys?: (from: number, to: number) => string[];
48
+ /** Folded into the id when the highlight is not in the region's text. */
49
+ idSalt?: string;
50
+ }) => ParsedPrompt | undefined;
33
51
  /** The approval dialog at the bottom of `pane`, read the way `kind` draws it; undefined when it is not one of those. */
34
52
  export declare const parseBlockedDialog: (pane: string, kind: AgentKind) => ParsedPrompt | undefined;
35
53
  /** True when some reader finds the prompt `id` on `pane`: an id hashes the exact dialog, so the kind need not be known. */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.promptPlaintext = exports.showsPrompt = exports.parseBlockedDialog = exports.stripEscapes = exports.isPromptId = exports.MAX_PROMPT_BYTES = exports.MAX_OPTIONS = exports.MAX_LABEL_CHARS = exports.MAX_DETAIL_LINE_CHARS = exports.MAX_DETAIL_LINES = exports.KNOWN_TITLES = void 0;
3
+ exports.promptPlaintext = exports.showsPrompt = exports.parseBlockedDialog = exports.assemble = exports.stripEscapes = exports.isPromptId = exports.MAX_PROMPT_BYTES = exports.MAX_OPTIONS = exports.MAX_LABEL_CHARS = exports.MAX_DETAIL_LINE_CHARS = exports.MAX_DETAIL_LINES = exports.KNOWN_TITLES = void 0;
4
4
  /**
5
5
  * What a blocked agent session is waiting for, read off its screen.
6
6
  *
@@ -49,8 +49,13 @@ exports.promptPlaintext = exports.showsPrompt = exports.parseBlockedDialog = exp
49
49
  * row of buttons whose chosen one is told apart by background colour only (`parseOpencodePermission`). Its question
50
50
  * tool (v2.1.2) draws a `Questions` panel whose highlighted answer differs by the colour of its number
51
51
  * (`parseOpencodeQuestion`).
52
+ *
53
+ * Codex (codex-cli 0.166.0, `blocked-prompt-codex.test.ts`): a question, the reason or the destination and the
54
+ * `$ command`, numbered answers with `›` on the highlighted one and a `Press enter to confirm` footer
55
+ * (`parseCodexDialog`). Answered with up/down and enter like the rest: that lands on "No" the way esc does.
52
56
  */
53
57
  const node_crypto_1 = require("node:crypto");
58
+ const blocked_prompt_claude_dialogs_js_1 = require("./blocked-prompt-claude-dialogs.js");
54
59
  /** Dialog titles whose layout was captured and is read. */
55
60
  exports.KNOWN_TITLES = ['Bash command', 'Edit file', 'Create file'];
56
61
  exports.MAX_DETAIL_LINES = 12;
@@ -69,7 +74,7 @@ const OPTION = /^\s*([❯>])?\s*(\d{1,2})\.\s+(\S.*)$/;
69
74
  const FOOTER = /^\s*(esc to cancel|enter to select)/i;
70
75
  // A choice that grants more than this one request: "don't ask again", "always allow", "allow all edits during this
71
76
  // session", "… for this session". Wider on purpose — a miss lets the app skip its extra confirmation.
72
- const PERSISTENT = /don.?t ask again|never ask|always|allow all|this session|from now on/i;
77
+ const PERSISTENT = /don.?t ask again|never ask|always|allow all|this session|from now on|auto mode/i;
73
78
  /** The character the highlight is drawn with. A pane without it is not a dialog this module can answer. */
74
79
  const HIGHLIGHT = '❯';
75
80
  const cut = (text, max) => {
@@ -151,7 +156,7 @@ const parseClaudeDialog = (pane) => {
151
156
  while (detailLines.length > 0 && detailLines[detailLines.length - 1].trim() === '')
152
157
  detailLines.pop();
153
158
  const after = secondDash < 0 ? [] : rest.slice(secondDash + 1).map((l) => l.trim()).filter(Boolean);
154
- return assemble({
159
+ return (0, exports.assemble)({
155
160
  region,
156
161
  title,
157
162
  subtitle: before.join(' '),
@@ -161,6 +166,77 @@ const parseClaudeDialog = (pane) => {
161
166
  options,
162
167
  });
163
168
  };
169
+ const CODEX_FOOTER = /^\s*Press enter to confirm or esc to cancel$/;
170
+ const CODEX_QUESTION = /^\s*(Would you like to (?:run the following command|make the following edits)\?)$/;
171
+ const CODEX_OPTION = /^\s*(›)?\s*(\d{1,2})\.\s+(\S.*)$/;
172
+ const CODEX_META = /^\s*(Environment|Reason|Description|Destination):\s*(.*)$/;
173
+ /**
174
+ * Codex's tool approval (codex-cli 0.166.0): the question, `Reason:`/`Description:`/`Destination:` lines and the
175
+ * `$ command`, numbered answers with `›` on the highlighted one, then `Press enter to confirm or esc to cancel`. The
176
+ * dialog replaces the composer; the turn's history stays above it. Only the two questions captured are read.
177
+ */
178
+ const parseCodexDialog = (pane) => {
179
+ const lines = pane.split('\n').map((l) => l.trimEnd());
180
+ let footer = lines.length - 1;
181
+ while (footer >= 0 && lines[footer].trim() === '')
182
+ footer--;
183
+ if (footer < 0 || !CODEX_FOOTER.test(lines[footer]))
184
+ return undefined;
185
+ let first = -1;
186
+ for (let i = footer - 1; i >= 0; i--) {
187
+ const m = CODEX_OPTION.exec(lines[i]);
188
+ if (m && m[2] === '1') {
189
+ first = i;
190
+ break;
191
+ }
192
+ }
193
+ if (first < 0)
194
+ return undefined;
195
+ const options = [];
196
+ for (const line of lines.slice(first, footer)) {
197
+ if (line.trim() === '')
198
+ continue;
199
+ const m = CODEX_OPTION.exec(line);
200
+ if (m) {
201
+ options.push({ n: Number(m[2]), label: m[3].trim(), selected: m[1] === '›' });
202
+ }
203
+ else if (/^\s{4,}\S/.test(line)) {
204
+ options[options.length - 1].label += ` ${line.trim()}`;
205
+ }
206
+ else {
207
+ return undefined;
208
+ }
209
+ }
210
+ let q = -1;
211
+ for (let i = first - 1; i >= Math.max(0, first - exports.MAX_DETAIL_LINES - 6); i--) {
212
+ if (CODEX_QUESTION.test(lines[i])) {
213
+ q = i;
214
+ break;
215
+ }
216
+ }
217
+ if (q < 0)
218
+ return undefined;
219
+ const question = CODEX_QUESTION.exec(lines[q])[1];
220
+ const detail = [];
221
+ let note = '';
222
+ for (const line of lines.slice(q + 1, first)) {
223
+ const meta = CODEX_META.exec(line);
224
+ if (meta?.[1] === 'Reason')
225
+ note = meta[2].trim();
226
+ else if (meta?.[1] === 'Environment')
227
+ continue;
228
+ else if (line.trim() !== '')
229
+ detail.push(line.trim());
230
+ }
231
+ return (0, exports.assemble)({
232
+ region: lines.slice(q, footer + 1),
233
+ title: question.includes('command') ? 'Run command' : 'Edit files',
234
+ detail,
235
+ note,
236
+ question,
237
+ options,
238
+ });
239
+ };
164
240
  // CSI sequences (colours and the rest) and OSC ones (OSC 8 hyperlinks, ended by BEL or ST): what `-e` adds.
165
241
  const ESCAPES = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
166
242
  /** `pane` without terminal escapes: what `capture-pane` without `-e` gives. */
@@ -267,7 +343,7 @@ const parseGeminiDialog = (pane) => {
267
343
  noteLines.push(text.trim());
268
344
  }
269
345
  const detail = dedent(trimBlank(detailLines));
270
- return assemble({
346
+ return (0, exports.assemble)({
271
347
  region: lines.slice(top, end + 1),
272
348
  title: title.trim(),
273
349
  subtitle,
@@ -329,7 +405,7 @@ const parsePiSelect = (pane) => {
329
405
  const text = trimBlank(lines.slice(top + 1, first).map((l) => l.replace(/^ /, '')));
330
406
  if (text.length === 0)
331
407
  return undefined;
332
- return assemble({
408
+ return (0, exports.assemble)({
333
409
  region: lines.slice(top, footer + 1),
334
410
  title: text[0].trim(),
335
411
  detail: dedent(trimBlank(text.slice(1, -1))),
@@ -400,7 +476,7 @@ const parsePiQuestion = (pane) => {
400
476
  if (asked === '')
401
477
  return undefined;
402
478
  const context = gap < 0 ? [] : trimBlank(text.slice(gap + 1)).map((l) => l.trim()).filter((l) => l !== '');
403
- const prompt = assemble({
479
+ const prompt = (0, exports.assemble)({
404
480
  region: lines.slice(top, footer + 1),
405
481
  title: asked,
406
482
  detail: [...context, ...options.filter((o) => o.note !== undefined && o.n !== own).map((o) => `${o.label}: ${o.note}`)],
@@ -529,7 +605,7 @@ const parseOpencodePermission = (rows) => {
529
605
  const first = body.findIndex((l) => l.trim() !== '');
530
606
  const sub = first < 0 ? null : OPENCODE_SUBTITLE.exec(body[first].trim());
531
607
  const detail = dedent(trimBlank(sub ? body.slice(first + 1) : body));
532
- return assemble({
608
+ return (0, exports.assemble)({
533
609
  region: rows.slice(head, bar + 1).map((r) => r.text.trimEnd()),
534
610
  title: 'Permission required',
535
611
  ...(sub ? { subtitle: sub[1].trim() } : {}),
@@ -595,7 +671,7 @@ const parseOpencodeQuestion = (rows) => {
595
671
  const asked = trimBlank(question).join(' ').trim();
596
672
  if (asked === '')
597
673
  return undefined;
598
- const prompt = assemble({
674
+ const prompt = (0, exports.assemble)({
599
675
  region: rows.slice(head, footer + 1).map((r) => r.text.trimEnd()),
600
676
  title: 'Question',
601
677
  detail: options.filter((o) => o.note !== undefined && o.label !== OPENCODE_OWN_ANSWER).map((o) => `${o.label}: ${o.note}`),
@@ -637,14 +713,19 @@ const assemble = (d) => {
637
713
  }
638
714
  return prompt;
639
715
  };
716
+ exports.assemble = assemble;
640
717
  /** One reader per agent kind whose approval dialog was captured. Each takes the pane as captured, escapes and all. */
641
718
  const READERS = {
642
- claude: (pane) => parseClaudeDialog((0, exports.stripEscapes)(pane)),
719
+ claude: (pane) => {
720
+ const plain = (0, exports.stripEscapes)(pane);
721
+ return parseClaudeDialog(plain) ?? (0, blocked_prompt_claude_dialogs_js_1.parseClaudeQuestion)(plain) ?? (0, blocked_prompt_claude_dialogs_js_1.parseClaudePlan)(plain);
722
+ },
643
723
  gemini: (pane) => parseGeminiDialog((0, exports.stripEscapes)(pane)),
644
724
  pi: (pane) => {
645
725
  const plain = (0, exports.stripEscapes)(pane);
646
726
  return parsePiSelect(plain) ?? parsePiQuestion(plain);
647
727
  },
728
+ codex: (pane) => parseCodexDialog((0, exports.stripEscapes)(pane)),
648
729
  opencode: (pane) => {
649
730
  const rows = pane.split('\n').map(withColours);
650
731
  return parseOpencodePermission(rows) ?? parseOpencodeQuestion(rows);
package/dist/cli.js CHANGED
@@ -487,7 +487,8 @@ const handleNotify = async (args) => {
487
487
  // dial now works for every hook-driven agent. Gated-out → silent success.
488
488
  // With no dial the mode falls back to --pushmode-default, then to quiet.
489
489
  // Quiet still pushes when the user has left the terminal (presence.ts).
490
- if ((0, args_js_1.flagOn)(args, 'auto')) {
490
+ const isAuto = (0, args_js_1.flagOn)(args, 'auto');
491
+ if (isAuto) {
491
492
  const marker = (0, gate_js_1.normalizeMarker)(typeof args.marker === 'string' ? args.marker : undefined);
492
493
  const pushMode = (0, gate_js_1.autoPushMode)(projectDir, args[gate_js_1.PUSHMODE_DEFAULT_FLAG]);
493
494
  const verdict = (0, gate_js_1.decidePush)({
@@ -505,7 +506,7 @@ const handleNotify = async (args) => {
505
506
  }
506
507
  // Past the gates, this IS a push attempt — its outcome is recorded for
507
508
  // `hwip verify` and the plugin (notify-failure.ts), since hooks discard stderr.
508
- const hook = createHook(args, (0, args_js_1.flagOn)(args, 'auto') ? HOOK_REQUEST_TIMEOUT_MS : undefined);
509
+ const hook = createHook(args, isAuto ? HOOK_REQUEST_TIMEOUT_MS : undefined);
509
510
  if (!hook) {
510
511
  (0, notify_failure_js_1.recordNotifyFailure)({ kind: 'no-key', status: null, code: null });
511
512
  return 3;
@@ -532,10 +533,11 @@ const handleNotify = async (args) => {
532
533
  targetDeviceId: args.device,
533
534
  sessionId,
534
535
  }));
536
+ // Hook mode: stdout is the hook's response (Codex parses it as JSON), so stay silent.
535
537
  if (isJson) {
536
538
  printJson({ pushId: result.pushId, status: 'ok' });
537
539
  }
538
- else {
540
+ else if (!isAuto) {
539
541
  console.log(`Push sent: ${result.pushId}`);
540
542
  }
541
543
  return 0;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Hermes' session store, read into the events the timeline draws.
3
+ *
4
+ * Hermes keeps no transcript file: every conversation is rows of `messages` in
5
+ * `~/.hermes/state.db` (SQLite). Like opencode's, the row behind this agent
6
+ * carries a reader of its own (`readHermesTranscript`, a `TranscriptReader`)
7
+ * and a projector for what it hands back (`projectHermesEntries`).
8
+ *
9
+ * Shape measured 2026-11-01 against hermes 0.20.0 (schema_version 25), with
10
+ * `.schema` and the writer, `hermes_state.py`:
11
+ *
12
+ * - `messages(id INTEGER AUTOINCREMENT, session_id, role, content,
13
+ * tool_call_id, tool_calls, tool_name, timestamp REAL, active, compacted, …)`.
14
+ * `timestamp` is **seconds** since the epoch, as a REAL.
15
+ * - Rows are inserted once and never rewritten (the only UPDATEs touch
16
+ * `display_*` and flip `active`/`compacted` when a conversation is rewound or
17
+ * compressed), so `id` is a safe cursor: a row read is final.
18
+ * - `role` is `user` · `assistant` · `tool`. An assistant row that calls tools
19
+ * has `tool_calls`, a JSON array of `{ id, type: "function", function: {
20
+ * name, arguments } }` where `arguments` is itself a JSON **string**; each
21
+ * call is answered by a `tool` row whose `tool_call_id` is that `id`.
22
+ *
23
+ * Only rows with `active = 1` are drawn — a rewound turn is not part of the
24
+ * conversation any more.
25
+ */
26
+ import { type TailRead, type TailState, type TranscriptProjector } from './transcript-tail.js';
27
+ /**
28
+ * A Hermes session id (`<YYYYMMDD>_<HHMMSS>_<hash>`, measured). The query has
29
+ * nowhere to bind a parameter (`sqliteJson` runs the `sqlite3` binary), so an
30
+ * id goes into the SQL text only after it passes this.
31
+ */
32
+ export declare const HERMES_SESSION_ID: RegExp;
33
+ /** The transcript "path" a Hermes pane resolves to: the store and the session in one string. */
34
+ export declare const hermesSourcePath: (db: string, sessionId: string) => string;
35
+ /** One tool call of an assistant row, reduced in SQL to the fields drawn. */
36
+ export interface HermesTool {
37
+ id?: string | null;
38
+ name?: string | null;
39
+ target?: string | null;
40
+ }
41
+ /** One `messages` row as the query below returns it. */
42
+ export interface HermesRow {
43
+ id: number;
44
+ role: string;
45
+ /** Seconds since the epoch. */
46
+ ts?: number | null;
47
+ text?: string | null;
48
+ /** Assistant rows: the calls, as a JSON array (string) or already parsed. */
49
+ tools?: HermesTool[] | string | null;
50
+ /** `tool` rows: the id of the call answered. */
51
+ callId?: string | null;
52
+ /** `tool` rows: line count of the output. */
53
+ lines?: number | null;
54
+ }
55
+ /** Rows read on the first look at a session. */
56
+ export declare const HERMES_BACKFILL_ROWS = 200;
57
+ /** Rows read per look after that. */
58
+ export declare const HERMES_ROWS_PER_READ = 500;
59
+ /** The fewest milliseconds between two queries for one session. */
60
+ export declare const HERMES_MIN_QUERY_MS = 1000;
61
+ /**
62
+ * The rows of one session from message id `from` on, or its last
63
+ * `HERMES_BACKFILL_ROWS` when `from` is null. Read-only; exported for the test
64
+ * that pins the id check.
65
+ */
66
+ export declare const hermesRowsSql: (sessionId: string, from: number | null) => string;
67
+ /** Rows → one JSON event per line (what the projector reads back), in row order. */
68
+ export declare const hermesLinesOf: (rows: readonly HermesRow[]) => string[];
69
+ export interface HermesReadDeps {
70
+ /** Rows of a SQLite file, null when the read failed (`sqliteJson`). */
71
+ query: (db: string, sql: string) => unknown[] | null;
72
+ /** The store's files, size and mtime — any write shows up in one of them. */
73
+ stamp?: (db: string) => string | null;
74
+ now?: () => number;
75
+ }
76
+ /**
77
+ * What was added to one Hermes session since `prev`, or null when nothing was —
78
+ * the store's files have not moved, the last query was under
79
+ * `HERMES_MIN_QUERY_MS` ago, or the store cannot be read. The WAL moves on every
80
+ * write by every session on the machine, so the time floor is what keeps a busy
81
+ * neighbour from turning a 500 ms poll into a `sqlite3` spawn per tick.
82
+ */
83
+ export declare const readHermesTranscript: (path: string, prev: TailState | null, deps: HermesReadDeps) => TailRead | null;
84
+ /** The reader's lines back into events; names what the reader marked unknown. */
85
+ export declare const projectHermesEntries: TranscriptProjector;
86
+ //# sourceMappingURL=hermes-transcript.d.ts.map
@@ -0,0 +1,262 @@
1
+ "use strict";
2
+ /**
3
+ * Hermes' session store, read into the events the timeline draws.
4
+ *
5
+ * Hermes keeps no transcript file: every conversation is rows of `messages` in
6
+ * `~/.hermes/state.db` (SQLite). Like opencode's, the row behind this agent
7
+ * carries a reader of its own (`readHermesTranscript`, a `TranscriptReader`)
8
+ * and a projector for what it hands back (`projectHermesEntries`).
9
+ *
10
+ * Shape measured 2026-11-01 against hermes 0.20.0 (schema_version 25), with
11
+ * `.schema` and the writer, `hermes_state.py`:
12
+ *
13
+ * - `messages(id INTEGER AUTOINCREMENT, session_id, role, content,
14
+ * tool_call_id, tool_calls, tool_name, timestamp REAL, active, compacted, …)`.
15
+ * `timestamp` is **seconds** since the epoch, as a REAL.
16
+ * - Rows are inserted once and never rewritten (the only UPDATEs touch
17
+ * `display_*` and flip `active`/`compacted` when a conversation is rewound or
18
+ * compressed), so `id` is a safe cursor: a row read is final.
19
+ * - `role` is `user` · `assistant` · `tool`. An assistant row that calls tools
20
+ * has `tool_calls`, a JSON array of `{ id, type: "function", function: {
21
+ * name, arguments } }` where `arguments` is itself a JSON **string**; each
22
+ * call is answered by a `tool` row whose `tool_call_id` is that `id`.
23
+ *
24
+ * Only rows with `active = 1` are drawn — a rewound turn is not part of the
25
+ * conversation any more.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.projectHermesEntries = exports.readHermesTranscript = exports.hermesLinesOf = exports.hermesRowsSql = exports.HERMES_MIN_QUERY_MS = exports.HERMES_ROWS_PER_READ = exports.HERMES_BACKFILL_ROWS = exports.hermesSourcePath = exports.HERMES_SESSION_ID = void 0;
29
+ const node_fs_1 = require("node:fs");
30
+ const node_path_1 = require("node:path");
31
+ const transcript_tail_js_1 = require("./transcript-tail.js");
32
+ /**
33
+ * A Hermes session id (`<YYYYMMDD>_<HHMMSS>_<hash>`, measured). The query has
34
+ * nowhere to bind a parameter (`sqliteJson` runs the `sqlite3` binary), so an
35
+ * id goes into the SQL text only after it passes this.
36
+ */
37
+ exports.HERMES_SESSION_ID = /^[A-Za-z0-9_-]{1,96}$/;
38
+ /** The transcript "path" a Hermes pane resolves to: the store and the session in one string. */
39
+ const hermesSourcePath = (db, sessionId) => `${db}#${sessionId}`;
40
+ exports.hermesSourcePath = hermesSourcePath;
41
+ const parseSourcePath = (path) => {
42
+ const at = path.lastIndexOf('#');
43
+ if (at <= 0)
44
+ return null;
45
+ const sessionId = path.slice(at + 1);
46
+ const db = path.slice(0, at);
47
+ // The path also comes back from the recorder's state file and goes to `sqlite3` as an argument:
48
+ // a relative one could start with `-` and read as an option.
49
+ return exports.HERMES_SESSION_ID.test(sessionId) && (0, node_path_1.isAbsolute)(db) ? { db, sessionId } : null;
50
+ };
51
+ const isRow = (value) => {
52
+ if (!value || typeof value !== 'object')
53
+ return false;
54
+ const row = value;
55
+ return typeof row.id === 'number' && Number.isSafeInteger(row.id) && typeof row.role === 'string';
56
+ };
57
+ /** Rows read on the first look at a session. */
58
+ exports.HERMES_BACKFILL_ROWS = 200;
59
+ /** Rows read per look after that. */
60
+ exports.HERMES_ROWS_PER_READ = 500;
61
+ /** The fewest milliseconds between two queries for one session. */
62
+ exports.HERMES_MIN_QUERY_MS = 1_000;
63
+ /**
64
+ * Which tool argument reads as "what this call is about", first match wins.
65
+ * `command` and `cmd` are absent on purpose: a command line is the argument
66
+ * most likely to carry a secret.
67
+ */
68
+ const TARGET_KEYS = ['path', 'file_path', 'pattern', 'query', 'url', 'image_url', 'name'];
69
+ const SQL_TEXT_CHARS = transcript_tail_js_1.MAX_EVENT_TEXT_CHARS + 1;
70
+ /**
71
+ * The rows of one session from message id `from` on, or its last
72
+ * `HERMES_BACKFILL_ROWS` when `from` is null. Read-only; exported for the test
73
+ * that pins the id check.
74
+ */
75
+ const hermesRowsSql = (sessionId, from) => {
76
+ if (!exports.HERMES_SESSION_ID.test(sessionId))
77
+ throw new Error('not a hermes session id');
78
+ if (from !== null && !Number.isSafeInteger(from))
79
+ throw new Error('not a row position');
80
+ const args = `json_extract(c.value, '$.function.arguments')`;
81
+ const arg = (key) => `CASE WHEN json_valid(${args}) THEN json_extract(${args}, '$.${key}') END`;
82
+ const target = `substr(coalesce(${TARGET_KEYS.map(arg).join(', ')}), 1, ${transcript_tail_js_1.MAX_EVENT_FIELD_CHARS + 1})`;
83
+ const tools = `CASE WHEN m.role = 'assistant' AND m.tool_calls IS NOT NULL AND json_valid(m.tool_calls)
84
+ THEN (SELECT json_group_array(json_object('id', coalesce(json_extract(c.value, '$.id'), json_extract(c.value, '$.call_id')),
85
+ 'name', json_extract(c.value, '$.function.name'), 'target', ${target}))
86
+ FROM json_each(m.tool_calls) c) END`;
87
+ const select = `SELECT m.id AS id, m.role AS role, m.timestamp AS ts,
88
+ CASE WHEN m.role IN ('user', 'assistant') THEN substr(m.content, 1, ${SQL_TEXT_CHARS}) END AS text,
89
+ ${tools} AS tools,
90
+ CASE WHEN m.role = 'tool' THEN m.tool_call_id END AS callId,
91
+ CASE WHEN m.role = 'tool' THEN length(m.content) - length(replace(m.content, char(10), '')) + 1 END AS lines
92
+ FROM messages m
93
+ WHERE m.session_id = '${sessionId}' AND m.role IN ('user', 'assistant', 'tool') AND coalesce(m.active, 1) = 1`;
94
+ return from === null
95
+ ? `SELECT * FROM (${select} ORDER BY m.id DESC LIMIT ${exports.HERMES_BACKFILL_ROWS}) ORDER BY id`
96
+ : `${select} AND m.id >= ${from} ORDER BY m.id LIMIT ${exports.HERMES_ROWS_PER_READ}`;
97
+ };
98
+ exports.hermesRowsSql = hermesRowsSql;
99
+ const isoOfSeconds = (seconds) => {
100
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds))
101
+ return {};
102
+ const date = new Date(seconds * 1000);
103
+ return Number.isNaN(date.getTime()) ? {} : { at: date.toISOString() };
104
+ };
105
+ const toolsOf = (row) => {
106
+ if (row.tools === undefined || row.tools === null)
107
+ return [];
108
+ let parsed = row.tools;
109
+ if (typeof parsed === 'string') {
110
+ try {
111
+ parsed = JSON.parse(parsed);
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ return Array.isArray(parsed) ? parsed : null;
118
+ };
119
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
120
+ const unknownLine = (what) => JSON.stringify({ unknown: what });
121
+ /** Rows → one JSON event per line (what the projector reads back), in row order. */
122
+ const hermesLinesOf = (rows) => {
123
+ const lines = [];
124
+ const warned = new Set();
125
+ const once = (key, what) => {
126
+ if (warned.has(key))
127
+ return;
128
+ warned.add(key);
129
+ lines.push(unknownLine(what));
130
+ };
131
+ for (const row of rows) {
132
+ const at = isoOfSeconds(row.ts);
133
+ if (row.role === 'user') {
134
+ const text = (typeof row.text === 'string' ? row.text : '').trim();
135
+ if (text)
136
+ lines.push(JSON.stringify({ kind: 'prompt', text: (0, transcript_tail_js_1.clamp)(text, transcript_tail_js_1.MAX_EVENT_TEXT_CHARS), ...at }));
137
+ continue;
138
+ }
139
+ if (row.role === 'assistant') {
140
+ const text = (typeof row.text === 'string' ? row.text : '').trim();
141
+ if (text)
142
+ lines.push(JSON.stringify({ kind: 'text', text: (0, transcript_tail_js_1.clamp)(text, transcript_tail_js_1.MAX_EVENT_TEXT_CHARS), ...at }));
143
+ const tools = toolsOf(row);
144
+ if (tools === null)
145
+ once(`${row.id}:x`, 'hermes tool_calls that are not a list');
146
+ for (const tool of tools ?? []) {
147
+ if (!tool || typeof tool !== 'object' || typeof tool.id !== 'string' || typeof tool.name !== 'string') {
148
+ once(`${row.id}:x`, 'hermes tool call without an id or name');
149
+ continue;
150
+ }
151
+ const line = (0, transcript_tail_js_1.oneLine)(typeof tool.target === 'string' ? tool.target : undefined);
152
+ const target = line ? (0, transcript_tail_js_1.clamp)(line) : undefined;
153
+ lines.push(JSON.stringify({ kind: 'tool', id: (0, transcript_tail_js_1.clamp)(tool.id), name: (0, transcript_tail_js_1.clamp)(tool.name), ...(target ? { target } : {}), ...at }));
154
+ }
155
+ continue;
156
+ }
157
+ if (row.role === 'tool') {
158
+ if (typeof row.callId !== 'string' || !row.callId) {
159
+ once(`${row.id}:x`, 'hermes tool result without a call id');
160
+ continue;
161
+ }
162
+ const count = num(row.lines);
163
+ lines.push(JSON.stringify({ kind: 'tool_result', id: (0, transcript_tail_js_1.clamp)(row.callId), ok: true, ...(count ? { lines: count } : {}), ...at }));
164
+ }
165
+ }
166
+ return lines;
167
+ };
168
+ exports.hermesLinesOf = hermesLinesOf;
169
+ const cursorOf = (prev, session) => {
170
+ const c = prev?.cursor;
171
+ if (!c || typeof c !== 'object')
172
+ return null;
173
+ const { session: s, from, stamp, queriedAt } = c;
174
+ if (s !== session || typeof from !== 'number' || !Number.isSafeInteger(from))
175
+ return null;
176
+ if (typeof stamp !== 'string' || typeof queriedAt !== 'number')
177
+ return null;
178
+ return { session, from, stamp, queriedAt };
179
+ };
180
+ /** When a query last failed, per source path — so an unreadable store is not asked on every tick. */
181
+ const failedAt = new Map();
182
+ const fileStamp = (db) => {
183
+ const parts = [];
184
+ for (const path of [db, `${db}-wal`]) {
185
+ try {
186
+ const s = (0, node_fs_1.statSync)(path);
187
+ parts.push(`${s.size}:${s.mtimeMs}`);
188
+ }
189
+ catch {
190
+ parts.push('-');
191
+ }
192
+ }
193
+ return parts[0] === '-' ? null : parts.join('|');
194
+ };
195
+ /**
196
+ * What was added to one Hermes session since `prev`, or null when nothing was —
197
+ * the store's files have not moved, the last query was under
198
+ * `HERMES_MIN_QUERY_MS` ago, or the store cannot be read. The WAL moves on every
199
+ * write by every session on the machine, so the time floor is what keeps a busy
200
+ * neighbour from turning a 500 ms poll into a `sqlite3` spawn per tick.
201
+ */
202
+ const readHermesTranscript = (path, prev, deps) => {
203
+ const source = parseSourcePath(path);
204
+ if (!source)
205
+ return null;
206
+ const now = deps.now ?? Date.now;
207
+ const stamp = (deps.stamp ?? fileStamp)(source.db);
208
+ if (stamp === null)
209
+ return null;
210
+ const cursor = cursorOf(prev, source.sessionId);
211
+ if (cursor && cursor.stamp === stamp)
212
+ return null;
213
+ const last = Math.max(cursor?.queriedAt ?? -Infinity, failedAt.get(path) ?? -Infinity);
214
+ if (now() - last < exports.HERMES_MIN_QUERY_MS)
215
+ return null;
216
+ const raw = deps.query(source.db, (0, exports.hermesRowsSql)(source.sessionId, cursor ? cursor.from : null));
217
+ if (raw === null) {
218
+ failedAt.set(path, now());
219
+ return null;
220
+ }
221
+ failedAt.delete(path);
222
+ const rows = raw.filter(isRow);
223
+ const newest = rows.reduce((max, r) => Math.max(max, r.id), cursor ? cursor.from - 1 : 0);
224
+ const state = {
225
+ offset: newest,
226
+ // `agent read` says there is more above when the read was a window.
227
+ size: !cursor && rows.length >= exports.HERMES_BACKFILL_ROWS ? transcript_tail_js_1.TRANSCRIPT_BACKFILL_BYTES + 1 : 0,
228
+ mtimeMs: 0,
229
+ ino: 0,
230
+ carry: '',
231
+ resyncing: false,
232
+ salvage: [],
233
+ cursor: { session: source.sessionId, from: newest + 1, stamp, queriedAt: now() },
234
+ };
235
+ return { lines: (0, exports.hermesLinesOf)(rows), state, bytesRead: 0, droppedLines: 0 };
236
+ };
237
+ exports.readHermesTranscript = readHermesTranscript;
238
+ const EVENT_KINDS = new Set(['prompt', 'text', 'tool', 'tool_result']);
239
+ /** The reader's lines back into events; names what the reader marked unknown. */
240
+ const projectHermesEntries = (lines, opts = {}) => {
241
+ const events = [];
242
+ for (const raw of lines) {
243
+ let entry;
244
+ try {
245
+ const parsed = JSON.parse(raw);
246
+ if (!parsed || typeof parsed !== 'object')
247
+ continue;
248
+ entry = parsed;
249
+ }
250
+ catch {
251
+ continue;
252
+ }
253
+ if (typeof entry.unknown === 'string') {
254
+ opts.onUnknown?.(entry.unknown);
255
+ continue;
256
+ }
257
+ if (typeof entry.kind === 'string' && EVENT_KINDS.has(entry.kind))
258
+ events.push(entry);
259
+ }
260
+ return (0, transcript_tail_js_1.sliceSinceLastPrompt)(events, opts);
261
+ };
262
+ exports.projectHermesEntries = projectHermesEntries;
@@ -323,6 +323,8 @@ export declare const detectHermesSessionName: (paneCwd: string, panePid?: number
323
323
  * A `-c` picks back up a session started long before the pane, which the start-time match above cannot see.
324
324
  */
325
325
  export declare const detectHermesResumeId: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
326
+ /** The store and the pane's session in one string, as opencode's. Null until the session has a message. */
327
+ export declare const hermesTranscriptPath: (paneCwd: string | null, panePid?: number, deps?: StoreResolveDeps) => string | null;
326
328
  /**
327
329
  * The highest-numbered match, numerically. Codex versions its state file
328
330
  * (`state_5.sqlite` today) and moves to the next number on a schema migration,
@@ -413,6 +415,9 @@ declare const REMOTE_AGENT_TABLE: readonly [{
413
415
  readonly subcommands: readonly ["hermes"];
414
416
  readonly resolveSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => ProviderName | null;
415
417
  readonly resolveResumeId: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
418
+ readonly resolveTranscript: (paneCwd: string, panePid: number | undefined) => string | null;
419
+ readonly projectTranscript: TranscriptProjector;
420
+ readonly readTranscript: (path: string, prev: import("./transcript-tail.js").TailState | null) => import("./transcript-tail.js").TailRead | null;
416
421
  readonly resumeArgs: (id: string) => string[];
417
422
  }, {
418
423
  readonly kind: "pi";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.matchAgentByPaneCommand = exports.findAgentBySubcommand = exports.REMOTE_AGENTS = exports.opencodeTranscriptPath = exports.detectOpencodeSession = exports.geminiTranscriptPath = exports.codexTranscriptPath = exports.detectCodexSessionName = exports.newestVersionedDb = exports.detectHermesResumeId = exports.detectHermesSessionName = exports.sqliteJson = exports.transcriptIdSince = exports.paneSince = exports.pickRowByProcStart = exports.detectPiSessionName = exports.piTranscriptPath = exports.claudeTranscriptPath = exports.detectClaudeSessionHandle = exports.detectClaudeProviderName = exports.detectClaudeSessionIdByPid = exports.detectClaudeSessionByPid = exports.pidSessionRecordsExist = exports.collectDescendantPids = exports.psStartTimes = exports.foregroundAgentFor = exports.readProcTable = exports.parseProcTable = exports.detectClaudeSessionId = exports.claudeProjectDirName = void 0;
3
+ exports.matchAgentByPaneCommand = exports.findAgentBySubcommand = exports.REMOTE_AGENTS = exports.opencodeTranscriptPath = exports.detectOpencodeSession = exports.geminiTranscriptPath = exports.codexTranscriptPath = exports.detectCodexSessionName = exports.newestVersionedDb = exports.hermesTranscriptPath = exports.detectHermesResumeId = exports.detectHermesSessionName = exports.sqliteJson = exports.transcriptIdSince = exports.paneSince = exports.pickRowByProcStart = exports.detectPiSessionName = exports.piTranscriptPath = exports.claudeTranscriptPath = exports.detectClaudeSessionHandle = exports.detectClaudeProviderName = exports.detectClaudeSessionIdByPid = exports.detectClaudeSessionByPid = exports.pidSessionRecordsExist = exports.collectDescendantPids = exports.psStartTimes = exports.foregroundAgentFor = exports.readProcTable = exports.parseProcTable = exports.detectClaudeSessionId = exports.claudeProjectDirName = void 0;
4
4
  /**
5
5
  * Remote-control agent registry — the single table behind every
6
6
  * `hwip <agent>` subcommand, the listener's pane matching, and the
@@ -25,6 +25,7 @@ const pi_transcript_js_1 = require("./pi-transcript.js");
25
25
  const session_title_js_1 = require("./session-title.js");
26
26
  const codex_transcript_js_1 = require("./codex-transcript.js");
27
27
  const gemini_transcript_js_1 = require("./gemini-transcript.js");
28
+ const hermes_transcript_js_1 = require("./hermes-transcript.js");
28
29
  const opencode_transcript_js_1 = require("./opencode-transcript.js");
29
30
  /**
30
31
  * A per-key TTL cache, bounded so a long-lived listener that has seen many keys
@@ -782,6 +783,21 @@ const detectHermesResumeId = (paneCwd, panePid, deps = {}) => {
782
783
  return best?.id ?? null;
783
784
  };
784
785
  exports.detectHermesResumeId = detectHermesResumeId;
786
+ /** `sqliteJson` with its own warning: a failed transcript read costs the timeline, not the session list. */
787
+ const queryHermesTranscript = (db, sql) => {
788
+ const rows = (0, exports.sqliteJson)(db, sql);
789
+ if (rows === null)
790
+ warnReaderOnce('hermes transcript', `sqlite3 ${(0, path_1.basename)(db)}`, 'hermes panes show no live timeline');
791
+ return rows;
792
+ };
793
+ /** The store and the pane's session in one string, as opencode's. Null until the session has a message. */
794
+ const hermesTranscriptPath = (paneCwd, panePid, deps = {}) => {
795
+ if (!paneCwd)
796
+ return null;
797
+ const id = (0, exports.detectHermesResumeId)(paneCwd, panePid, deps);
798
+ return id ? (0, hermes_transcript_js_1.hermesSourcePath)(HERMES_STATE_DB, id) : null;
799
+ };
800
+ exports.hermesTranscriptPath = hermesTranscriptPath;
785
801
  // ── Codex ────────────────────────────────────────────────────────
786
802
  const CODEX_DIR = (0, path_1.join)((0, os_1.homedir)(), '.codex');
787
803
  const CODEX_STATE_DB = /^state_(\d+)\.sqlite$/;
@@ -1282,6 +1298,10 @@ const REMOTE_AGENT_TABLE = [
1282
1298
  // `<timestamp>_<hash>` string, and nothing on the wire consumes it yet.
1283
1299
  resolveSessionName: exports.detectHermesSessionName,
1284
1300
  resolveResumeId: exports.detectHermesResumeId,
1301
+ // No file: the conversation is rows of `messages` in the state store.
1302
+ resolveTranscript: (paneCwd, panePid) => (0, exports.hermesTranscriptPath)(paneCwd, panePid),
1303
+ projectTranscript: hermes_transcript_js_1.projectHermesEntries,
1304
+ readTranscript: (path, prev) => (0, hermes_transcript_js_1.readHermesTranscript)(path, prev, { query: queryHermesTranscript }),
1285
1305
  // Measured: `--resume <id>` carries the conversation on, and an id the
1286
1306
  // store lost starts a new session rather than exit — so, like opencode,
1287
1307
  // not checked against the store.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",