@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 +12 -6
- package/dist/agent-rules.default.d.ts +1 -1
- package/dist/agent-rules.default.js +35 -11
- package/dist/blocked-prompt-claude-dialogs.d.ts +16 -0
- package/dist/blocked-prompt-claude-dialogs.js +233 -0
- package/dist/blocked-prompt.d.ts +18 -0
- package/dist/blocked-prompt.js +90 -9
- package/dist/cli.js +5 -3
- package/dist/hermes-transcript.d.ts +86 -0
- package/dist/hermes-transcript.js +262 -0
- package/dist/remote-agents.d.ts +5 -0
- package/dist/remote-agents.js +21 -1
- package/package.json +1 -1
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
|
-
|
|
|
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)
|
|
709
|
-
approval extensions that ask through `ctx.ui.select
|
|
710
|
-
|
|
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.
|
|
713
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
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;
|
package/dist/blocked-prompt.d.ts
CHANGED
|
@@ -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. */
|
package/dist/blocked-prompt.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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,
|
|
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;
|
package/dist/remote-agents.d.ts
CHANGED
|
@@ -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";
|
package/dist/remote-agents.js
CHANGED
|
@@ -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.
|