@hwip/cli 0.1.15 → 0.1.17
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 +23 -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/device-presence.d.ts +6 -0
- package/dist/device-presence.js +10 -3
- package/dist/hermes-transcript.d.ts +86 -0
- package/dist/hermes-transcript.js +262 -0
- package/dist/listener.d.ts +6 -0
- package/dist/listener.js +18 -4
- 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
|
|
@@ -572,6 +573,17 @@ again picks up that run's conversation, not the newest one. If the name
|
|
|
572
573
|
is live again by then, that run starts beside it under the next free name
|
|
573
574
|
of the family (`<name>-2`, `<name>-3`, …) instead of being refused.
|
|
574
575
|
|
|
576
|
+
The phone can also start a **new** session in a project this machine has
|
|
577
|
+
run before: the same request with `fresh: true` starts the agent recorded
|
|
578
|
+
for that session in its directory, with no conversation picked up, under
|
|
579
|
+
the session's name or — when that runs — the next free name of its family.
|
|
580
|
+
The directory and the agent still come only from this machine's record, so
|
|
581
|
+
the phone cannot name either. The listener advertises `session-start-v1`
|
|
582
|
+
so the apps offer it only to a build that knows it. Ending a session from
|
|
583
|
+
the phone is `agent.session.exit.request`: `C-c`, the agent's own quit
|
|
584
|
+
command, then `C-d` — never a forced kill, so an agent that will not quit is
|
|
585
|
+
reported as still running.
|
|
586
|
+
|
|
575
587
|
Two details worth knowing:
|
|
576
588
|
|
|
577
589
|
- It fires at **user login**, not at boot. Without automatic login, the
|
|
@@ -705,12 +717,17 @@ When a session waits for your approval, the listener also sends what it is waiti
|
|
|
705
717
|
for — the tool, the command or the diff, and the answers — so the app can show it
|
|
706
718
|
and answer. That covers Claude Code (a Bash command, a file edit or a new file),
|
|
707
719
|
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
|
-
|
|
720
|
+
the screen with its colours, since only a colour marks the chosen button), pi
|
|
721
|
+
approval extensions that ask through `ctx.ui.select` and Codex (a shell command or a file edit; the app answers with
|
|
722
|
+
the arrow keys and Enter). It is sealed the same
|
|
711
723
|
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
|
-
|
|
724
|
+
`--no-turn-history`; the server stores ciphertext only. For Claude Code that
|
|
725
|
+
includes a question it asks (one question, a multi-select with its Submit row,
|
|
726
|
+
the review of several answers) and the plan it asks you to approve; a plan longer
|
|
727
|
+
than the screen shows is cut where the screen cuts it, with the plan file's path
|
|
728
|
+
as the note. Rows that need typed text (`Type something`, `Chat about this`,
|
|
729
|
+
`Tell Claude what to change`) are not offered, and a dialog you are typing into is
|
|
730
|
+
not read. Other dialogs (a trust prompt) send nothing beyond the state. An answer the app sends is
|
|
714
731
|
typed only while that same prompt is still on screen: if you have moved the
|
|
715
732
|
highlight or answered at the keyboard, the command is refused (`prompt_changed`)
|
|
716
733
|
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);
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This build reads `fresh` on a resume request (`listener.ts` `handleSessionResumeRequest`): the phone may start a new
|
|
3
|
+
* session in a recorded session's project. An older listener would resume the recorded conversation instead, so the apps
|
|
4
|
+
* offer it only to a computer that lists this.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SESSION_START_CAPABILITY = "session-start-v1";
|
|
1
7
|
/** The one message both halves of presence ride; the server answers
|
|
2
8
|
* `listener.presence.ack` or `listener.presence.error`. */
|
|
3
9
|
export declare const LISTENER_PRESENCE = "listener.presence";
|
package/dist/device-presence.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = void 0;
|
|
3
|
+
exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = exports.SESSION_START_CAPABILITY = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Per-device E2E (ADR-0007) — put this machine's public key on its device
|
|
6
6
|
* record, so other devices can wrap pushes and files for it.
|
|
@@ -28,13 +28,20 @@ exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = void 0;
|
|
|
28
28
|
* a file in chunks only for a device that lists `file-chunk-v1`, and one over
|
|
29
29
|
* the relay's ceiling over LAN only to one that lists `lan-large-v1`; an app
|
|
30
30
|
* offers `once` schedules and schedules into a session only to one that lists
|
|
31
|
-
* `schedule-session-v1
|
|
31
|
+
* `schedule-session-v1`, and a new session in a project only to one that
|
|
32
|
+
* lists `session-start-v1`. In one frame
|
|
32
33
|
* with the key, because a server from before `capabilities` answers a frame
|
|
33
34
|
* that carries nothing else it knows with an error.
|
|
34
35
|
*/
|
|
35
36
|
const file_crypto_js_1 = require("./file-crypto.js");
|
|
36
37
|
const lan_receiver_js_1 = require("./lan-receiver.js");
|
|
37
38
|
const schedule_js_1 = require("./schedule.js");
|
|
39
|
+
/**
|
|
40
|
+
* This build reads `fresh` on a resume request (`listener.ts` `handleSessionResumeRequest`): the phone may start a new
|
|
41
|
+
* session in a recorded session's project. An older listener would resume the recorded conversation instead, so the apps
|
|
42
|
+
* offer it only to a computer that lists this.
|
|
43
|
+
*/
|
|
44
|
+
exports.SESSION_START_CAPABILITY = 'session-start-v1';
|
|
38
45
|
/** The one message both halves of presence ride; the server answers
|
|
39
46
|
* `listener.presence.ack` or `listener.presence.error`. */
|
|
40
47
|
exports.LISTENER_PRESENCE = 'listener.presence';
|
|
@@ -45,7 +52,7 @@ const createDeviceKeyRegistration = (deps) => {
|
|
|
45
52
|
const register = async () => {
|
|
46
53
|
try {
|
|
47
54
|
const publicKey = await deps.publicKey();
|
|
48
|
-
const sent = deps.send({ type: exports.LISTENER_PRESENCE, data: { publicKey, capabilities: [file_crypto_js_1.FILE_CHUNK_CAPABILITY, lan_receiver_js_1.LAN_LARGE_CAPABILITY, schedule_js_1.SCHEDULE_SESSION_CAPABILITY] } });
|
|
55
|
+
const sent = deps.send({ type: exports.LISTENER_PRESENCE, data: { publicKey, capabilities: [file_crypto_js_1.FILE_CHUNK_CAPABILITY, lan_receiver_js_1.LAN_LARGE_CAPABILITY, schedule_js_1.SCHEDULE_SESSION_CAPABILITY, exports.SESSION_START_CAPABILITY] } });
|
|
49
56
|
if (!sent)
|
|
50
57
|
deps.log('device key: not registered — no connection right now, retried at the next open');
|
|
51
58
|
return sent;
|
|
@@ -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/listener.d.ts
CHANGED
|
@@ -551,6 +551,12 @@ export interface SessionResumeRequest {
|
|
|
551
551
|
* it reads as a run id.
|
|
552
552
|
*/
|
|
553
553
|
runId?: string;
|
|
554
|
+
/**
|
|
555
|
+
* A new session in that session's project, not the session again (hwip docs/session-runs.md Q47): the recorded
|
|
556
|
+
* directory and agent, no conversation picked up, and beside the name when it runs. Read only when it is `true`.
|
|
557
|
+
* Advertised as `SESSION_START_CAPABILITY` (`device-presence.ts`) — an older listener would resume the recorded conversation instead.
|
|
558
|
+
*/
|
|
559
|
+
fresh?: boolean;
|
|
554
560
|
requestId?: string;
|
|
555
561
|
}
|
|
556
562
|
export interface SessionResumeResult {
|
package/dist/listener.js
CHANGED
|
@@ -369,6 +369,12 @@ const RATE_LIMIT_TOKENS = 120;
|
|
|
369
369
|
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
370
370
|
/** What one command submit costs, keeping its ceiling at the old 30/min. */
|
|
371
371
|
const SUBMIT_COST = 4;
|
|
372
|
+
/**
|
|
373
|
+
* What a fresh start costs on its family's own bucket (`start:<family base>`): three new agents a minute. Each one is a
|
|
374
|
+
* process left running, and every name of the family leads to the same bucket, so a session budget of 30 a minute
|
|
375
|
+
* cannot fill the family's 20 names in one burst.
|
|
376
|
+
*/
|
|
377
|
+
const FRESH_START_COST = RATE_LIMIT_TOKENS / 3;
|
|
372
378
|
// Shells are refused: a shell prompt + send-keys = arbitrary command exec.
|
|
373
379
|
const SHELL_COMMANDS = new Set(['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'pwsh']);
|
|
374
380
|
// Auth-failure close codes: retrying with the same bad credentials hammers
|
|
@@ -1546,13 +1552,16 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1546
1552
|
// the family `hwip cc` would pick (Q41), counted from the family base so a
|
|
1547
1553
|
// busy `<base>-2` leads to `<base>-3`. A request without a run id is an
|
|
1548
1554
|
// older app that only knows the name, and still gets the refusal it renders.
|
|
1555
|
+
// A fresh start asks for a new session, so a live name is no refusal either, and no conversation is picked up —
|
|
1556
|
+
// the live one holding the recorded conversation included.
|
|
1557
|
+
const fresh = req.fresh === true;
|
|
1549
1558
|
const live = new Set((0, tmux_liveness_js_1.liveSessionNames)() ?? []);
|
|
1550
1559
|
const target = !live.has(sessionName)
|
|
1551
1560
|
? sessionName
|
|
1552
|
-
: runId !== undefined
|
|
1561
|
+
: runId !== undefined || fresh
|
|
1553
1562
|
? (0, wrapper_js_1.familyNames)(familyBase(sessionName)).find((name) => !live.has(name))
|
|
1554
1563
|
: undefined;
|
|
1555
|
-
if (!target || conversationRunning(known, live))
|
|
1564
|
+
if (!target || (!fresh && conversationRunning(known, live)))
|
|
1556
1565
|
return reply({ error: 'already_running' });
|
|
1557
1566
|
if (!(0, session_registry_js_1.sessionDirectoryExists)(known)) {
|
|
1558
1567
|
(0, listener_log_js_1.log)(`✗ resume ${sessionName}: ${known.cwd} is gone`);
|
|
@@ -1563,6 +1572,11 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1563
1572
|
(0, listener_log_js_1.log)(`✗ resume ${sessionName}: no binary for agent kind ${known.agentKind}`);
|
|
1564
1573
|
return reply({ error: 'unknown_agent' });
|
|
1565
1574
|
}
|
|
1575
|
+
// A fresh start leaves one more process running each time, so the family pays on a bucket of its own as well —
|
|
1576
|
+
// charged only here, where it starts one: a refusal above says why, not that the budget ran out.
|
|
1577
|
+
if (fresh && !(0, exports.checkRateLimit)(`start:${familyBase(sessionName)}`, undefined, FRESH_START_COST)) {
|
|
1578
|
+
return reply({ error: 'rate_limited' });
|
|
1579
|
+
}
|
|
1566
1580
|
// argv, never a shell string: the binary comes from the agent table and the
|
|
1567
1581
|
// directory from our own record, and neither is concatenated into anything
|
|
1568
1582
|
// a shell would re-parse. A recorded label means the session carried
|
|
@@ -1572,7 +1586,7 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1572
1586
|
const sidecar = known.label && known.project
|
|
1573
1587
|
? [';', 'set-option', config_js_1.SESSION_PROJECT_OPTION, known.project, ';', 'set-option', config_js_1.SESSION_LABEL_OPTION, known.label]
|
|
1574
1588
|
: [];
|
|
1575
|
-
const conversation = resumableConversation(agent, known);
|
|
1589
|
+
const conversation = fresh ? null : resumableConversation(agent, known);
|
|
1576
1590
|
// tmux gives the new pane this client's PATH. A login service's PATH has none
|
|
1577
1591
|
// of the agent directories, so they go on as schedule launches add them —
|
|
1578
1592
|
// otherwise the agent is not found and the session ends as it starts.
|
|
@@ -1583,7 +1597,7 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1583
1597
|
}
|
|
1584
1598
|
const resumedFrom = conversation ? ` (conversation ${conversation.id.slice(0, 8)})` : '';
|
|
1585
1599
|
const beside = target === sessionName ? '' : ` as ${target}`;
|
|
1586
|
-
(0, listener_log_js_1.log)(
|
|
1600
|
+
(0, listener_log_js_1.log)(`${fresh ? '+ start' : '⟲ resume'} ${sessionName}${beside}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
|
|
1587
1601
|
return reply({ resumed: true, sessionName: target });
|
|
1588
1602
|
};
|
|
1589
1603
|
exports.handleSessionResumeRequest = handleSessionResumeRequest;
|
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.
|