@hwip/cli 0.1.4 → 0.1.6
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 +27 -12
- package/dist/agent-dirs.d.ts +15 -0
- package/dist/agent-dirs.js +76 -0
- package/dist/agent-rules.default.d.ts +1 -1
- package/dist/agent-rules.default.js +15 -4
- package/dist/blocked-prompt.js +172 -14
- package/dist/listener.d.ts +33 -0
- package/dist/listener.js +76 -19
- package/dist/mcp-command.d.ts +1 -1
- package/dist/remote-agents.d.ts +3 -0
- package/dist/remote-agents.js +40 -7
- package/dist/schedule-command.d.ts +11 -1
- package/dist/schedule-command.js +113 -4
- package/dist/schedule-launch.d.ts +3 -1
- package/dist/schedule-launch.js +4 -1
- package/dist/schedule-remote.d.ts +7 -1
- package/dist/schedule-remote.js +51 -4
- package/dist/schedule-runner.js +5 -2
- package/dist/schedule.d.ts +31 -0
- package/dist/schedule.js +45 -7
- package/dist/session-registry.d.ts +20 -3
- package/dist/session-registry.js +27 -4
- package/dist/usage.js +4 -0
- package/dist/wrapper.d.ts +2 -0
- package/dist/wrapper.js +14 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -557,11 +557,17 @@ offers it during setup and `hwip uninstall` takes it away again.
|
|
|
557
557
|
What you get back after a reboot is your **past sessions**, ready to
|
|
558
558
|
resume from the phone — not live agents. tmux does not survive a reboot
|
|
559
559
|
either, and this doesn't try to bring those sessions back to life.
|
|
560
|
-
A resume starts the agent in the directory it last ran in; for Claude Code
|
|
561
|
-
and
|
|
562
|
-
(
|
|
563
|
-
|
|
564
|
-
|
|
560
|
+
A resume starts the agent in the directory it last ran in; for Claude Code,
|
|
561
|
+
OpenCode, Codex, Pi and Hermes it also picks up the conversation that session
|
|
562
|
+
last had (`claude --resume <id>`, `opencode --session <id>`,
|
|
563
|
+
`codex resume <thread>`, `pi --session <file>`, `hermes --resume <id>` — the
|
|
564
|
+
id this machine read off the live pane's own process; an id two panes both
|
|
565
|
+
answered to is not kept). Claude Code, Codex and Pi start fresh when that
|
|
566
|
+
conversation is no longer on disk, and the other agents always start a new one.
|
|
567
|
+
Each ended run of a name is its own row on the phone, and starting one
|
|
568
|
+
again picks up that run's conversation, not the newest one. If the name
|
|
569
|
+
is live again by then, that run starts beside it under the next free name
|
|
570
|
+
of the family (`<name>-2`, `<name>-3`, …) instead of being refused.
|
|
565
571
|
|
|
566
572
|
Two details worth knowing:
|
|
567
573
|
|
|
@@ -645,8 +651,9 @@ went.
|
|
|
645
651
|
- **The prompt** (`--prompt`) is typed once, and only when the agent is the foreground program of the pane and its detection rules read the screen as `idle` — not working, not a dialog. If that does not happen within 90 seconds the prompt is not typed, the session stays, and the phone is told. **Only agents that have an `idle` rule can take a prompt** (today Claude Code, Codex, Gemini CLI and pi): for any other agent nothing separates "ready" from a trust or permission dialog, and the Enter that submits a prompt would answer the dialog's default with nobody there to read it. `hwip schedule add … --prompt` refuses those agents and says which ones work; a schedule without `--prompt` starts any agent. The rules that decide this are the ones shipped in the `hwip` package, not the detection rules the listener updates over the air: a phone's input is typed by someone who has seen the screen, a schedule by nobody, and a rule set that arrives over the air is not signed — it must not be able to call a dialog `idle` and have your prompt and Enter typed into it.
|
|
646
652
|
- **The cap**: a session that runs longer than `--max-runtime` is asked to end the way the phone's *end session* does — the agent's own quit command, then signals. It is never killed; one that stays keeps the schedule from starting its next run (`overlap`).
|
|
647
653
|
- **The phone is told** with one push per listener tick, listing each schedule with how many times and why (`<label> · 놓침 31회` — the push is in Korean). A run skipped because the last one is still going counts once until the result changes; a push that does not go out is tried again after 30 seconds, then 1, 2, 4 … minutes up to 30 (only a refusal by the server, such as the daily limit, is not tried again; a timeout, a proxy or a captive portal in the way is), and is dropped after 24 hours. The push has one title per kind of news — skipped or missed, over its runtime, or a prompt not typed — and a general one when a push holds several kinds. The prompt is never in it. A free account sends at most 100 pushes a day (`pushPerDay`), which is why these are batched.
|
|
648
|
-
- **
|
|
649
|
-
- **
|
|
654
|
+
- **Editing**: `hwip schedule edit <id>` changes the time (`--at`, `--days`, `--every`), `--prompt` and `--max-runtime` of a schedule in place; the id, the session, the directory and the agent stay. What a flag does not name is kept — `--at` keeps the days, `--days` the time. A changed time counts from the edit: the times before it are neither started nor counted as missed, and an `--every` schedule runs one period after the edit; changing only the prompt or the run time keeps the schedule on its times. A session already running keeps going. A listener of an older build is restarted first, because it would skip the edited file as unreadable; when it cannot be replaced, a new time is not written (exit 1) and the schedule keeps running as it was.
|
|
655
|
+
- **State** is files under `~/.local/state/hwip`: one `schedules/<id>.json` per schedule (limit 20) and `schedule-state.json`, written only by the listener, with where each schedule has got to. `agent-dirs.json` holds the directories `hwip <agent>` typed in a shell found its agent in: a listener run as a login service has only the PATH its service file baked, so a scheduled launch adds these to it — run an agent from a shell once (`hwip cc`) before scheduling it, or the run is skipped with `'claude' not found on PATH` in `~/.hwip/listener.log`.
|
|
656
|
+
- **From the phone**: the app lists the schedules of a computer, makes one for a session this computer has run, edits one, and removes one. It names the session, a time and what to do; the directory, the agent's command and its arguments are never the phone's (see *Defense*). A schedule made there needs an agent that can take a prompt, and shows in `hwip schedule` like any other.
|
|
650
657
|
|
|
651
658
|
### Session history
|
|
652
659
|
|
|
@@ -832,7 +839,7 @@ The phone also reads this machine's schedules (see [Scheduled sessions](#schedul
|
|
|
832
839
|
{ subtype: 'agent.schedule.list.result', requestId, at, schedules | encrypted | error }
|
|
833
840
|
```
|
|
834
841
|
|
|
835
|
-
`schedules` is the list as a JSON string; `encrypted` is the list sealed for the asking device (purpose `schedule.list`, bound to this machine's device id) as `{ v: 1, requestId, at, schedules: [...], unreadable? }` — the app checks that the `requestId` inside is the one it asked, so an older sealed list is not taken for a newer answer. `unreadable` is how many schedule files could not be read; a directory that cannot be listed at all, or that holds more than 20 files, is the error `read_failed`, never an empty list (and a remove then fails as `schedule_failed`; `hwip schedule rm` still works on the computer). A list too long for one frame has its prompts shortened, step by step (`too_large` only when it does not fit even without them). Each entry holds `id`, `source`, `agentKind`, `project` (the directory's name, not its path), `session`, `when`, `prompt` (cut to 500 characters, shorter when the list would not fit), `maxRuntimeMin`, `nextAt` and the `last` result — never a directory path or the agent's arguments.
|
|
842
|
+
`schedules` is the list as a JSON string; `encrypted` is the list sealed for the asking device (purpose `schedule.list`, bound to this machine's device id) as `{ v: 1, requestId, at, schedules: [...], unreadable? }` — the app checks that the `requestId` inside is the one it asked, so an older sealed list is not taken for a newer answer. `unreadable` is how many schedule files could not be read; a directory that cannot be listed at all, or that holds more than 20 files, is the error `read_failed`, never an empty list (and a remove then fails as `schedule_failed`; `hwip schedule rm` still works on the computer). A list too long for one frame has its prompts shortened, step by step (`too_large` only when it does not fit even without them). Each entry holds `id`, `source`, `agentKind`, `project` (the directory's name, not its path), `session`, `when`, `prompt` (cut to 500 characters, shorter when the list would not fit; `promptLocked: true` says the phone cannot change it — it is not the whole text, or the schedule has agent arguments — so the app leaves it to `hwip schedule edit`), `maxRuntimeMin`, `nextAt` and the `last` result — never a directory path or the agent's arguments.
|
|
836
843
|
|
|
837
844
|
### Your API key and redirects
|
|
838
845
|
|
|
@@ -972,7 +979,12 @@ handing the listener a key:
|
|
|
972
979
|
session name, its `seq`/`epoch` and that nonce, so a relay that cannot
|
|
973
980
|
swap in a key of its own (below) can't move one to another stream,
|
|
974
981
|
replay one from an earlier subscription, or hand one back as input —
|
|
975
|
-
or as anything else this listener sealed for the phone.
|
|
982
|
+
or as anything else this listener sealed for the phone. A history page
|
|
983
|
+
(scrolling up past the live window) holds the same nonce and `epoch`,
|
|
984
|
+
the request id it answers and where it sits in the scrollback, so a
|
|
985
|
+
relay can't hand an earlier page back under a new request or move
|
|
986
|
+
where the next one is asked for; only the request id rides beside it
|
|
987
|
+
in the clear. Your
|
|
976
988
|
keystrokes are sealed for this listener as `stream.input` with their
|
|
977
989
|
`seq`/`epoch` stamps, the stream's start epoch and the phone's device
|
|
978
990
|
id inside, so a forged restart can't replay them. The start answer (`agent.stream.start.ok`)
|
|
@@ -1188,7 +1200,7 @@ device, takes plain commands as before; an unreadable
|
|
|
1188
1200
|
forgets the pins, and with them goes back to taking plain commands until a
|
|
1189
1201
|
device is pinned again.
|
|
1190
1202
|
|
|
1191
|
-
**A schedule request** — the phone making or removing a scheduled session — rides
|
|
1203
|
+
**A schedule request** — the phone making, changing or removing a scheduled session — rides
|
|
1192
1204
|
the same `agent.command` push, with a `schedule` field and no `body`. It carries
|
|
1193
1205
|
a time, a prompt and, if the phone says so, an agent and a run time — and nothing
|
|
1194
1206
|
else. The directory and the agent come from what this machine recorded for the
|
|
@@ -1197,8 +1209,11 @@ schedule at another directory; it cannot send agent arguments, a worktree or a
|
|
|
1197
1209
|
label either, and a request that carries one is refused rather than ignored
|
|
1198
1210
|
(`schedule_invalid`; `schedule_limit` at 20 schedules; `unknown_session` for a
|
|
1199
1211
|
session this machine never recorded; `schedule_failed` when this machine's own
|
|
1200
|
-
files would not read or write). A
|
|
1201
|
-
|
|
1212
|
+
files would not read or write). A change (`op: 'update'`) carries the schedule's id
|
|
1213
|
+
and any of the time, the prompt and the run time; what it leaves out stays as it was,
|
|
1214
|
+
and a change with none of them is refused. A schedule made on the computer with agent arguments keeps its prompt: the phone may change its time and run time, not what is typed under those arguments. A change and a remove name the schedule's own
|
|
1215
|
+
session, so they cannot reach another. A listener of an older build refuses a change as
|
|
1216
|
+
`schedule_invalid`. Applying a request never types into a
|
|
1202
1217
|
session. The request is sealed like any
|
|
1203
1218
|
command — the whole of it inside the body, only `op` beside it, and the sender,
|
|
1204
1219
|
label and counter checks above apply, so a replay or a swapped `op` changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the person's own shell finds their agents (F8). A schedule is started by the listener, and the listener run by
|
|
3
|
+
* launchd or systemd has only the PATH its service file baked: tmux's and node's directories and the system ones. An
|
|
4
|
+
* agent installed anywhere else — `~/.local/bin/claude` — is not on it, and the scheduled session's pane dies at once
|
|
5
|
+
* with "command not found". So every `hwip <agent>` typed in a shell writes down the directory its agent was found in,
|
|
6
|
+
* and a schedule launch adds those directories to its PATH.
|
|
7
|
+
*
|
|
8
|
+
* Builtins only: wrapper.ts writes this on the `hwip cc` hot path.
|
|
9
|
+
*/
|
|
10
|
+
export declare const agentDirsPath: () => string;
|
|
11
|
+
/** Writes down the directory of `binary`, an absolute path the shell resolved. Best-effort: a launch never fails on it. */
|
|
12
|
+
export declare const rememberAgentDir: (binary: string) => void;
|
|
13
|
+
/** `path` with every written-down directory it lacks appended: what the listener has first, so nothing it runs changes. */
|
|
14
|
+
export declare const withAgentDirs: (path: string | undefined) => string;
|
|
15
|
+
//# sourceMappingURL=agent-dirs.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Where the person's own shell finds their agents (F8). A schedule is started by the listener, and the listener run by
|
|
4
|
+
* launchd or systemd has only the PATH its service file baked: tmux's and node's directories and the system ones. An
|
|
5
|
+
* agent installed anywhere else — `~/.local/bin/claude` — is not on it, and the scheduled session's pane dies at once
|
|
6
|
+
* with "command not found". So every `hwip <agent>` typed in a shell writes down the directory its agent was found in,
|
|
7
|
+
* and a schedule launch adds those directories to its PATH.
|
|
8
|
+
*
|
|
9
|
+
* Builtins only: wrapper.ts writes this on the `hwip cc` hot path.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.withAgentDirs = exports.rememberAgentDir = exports.agentDirsPath = void 0;
|
|
13
|
+
const node_fs_1 = require("node:fs");
|
|
14
|
+
const node_path_1 = require("node:path");
|
|
15
|
+
const gate_js_1 = require("./gate.js");
|
|
16
|
+
const private_files_js_1 = require("./private-files.js");
|
|
17
|
+
/** More than anyone has agents in; bounds what a schedule launch puts on PATH. */
|
|
18
|
+
const MAX_DIRS = 16;
|
|
19
|
+
const agentDirsPath = () => (0, node_path_1.join)((0, gate_js_1.stateDir)(), 'agent-dirs.json');
|
|
20
|
+
exports.agentDirsPath = agentDirsPath;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the file is the user's own regular file that only they can write: a directory put on PATH by someone else runs
|
|
23
|
+
* their programs as the agent. 'missing' when there is none yet.
|
|
24
|
+
*/
|
|
25
|
+
const fileState = (path) => {
|
|
26
|
+
try {
|
|
27
|
+
const info = (0, node_fs_1.lstatSync)(path);
|
|
28
|
+
const mine = typeof process.getuid !== 'function' || info.uid === process.getuid();
|
|
29
|
+
return info.isFile() && mine && (info.mode & 0o022) === 0 ? 'ok' : 'refused';
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return 'missing';
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const readDirs = () => {
|
|
36
|
+
try {
|
|
37
|
+
const path = (0, exports.agentDirsPath)();
|
|
38
|
+
if (fileState(path) !== 'ok')
|
|
39
|
+
return [];
|
|
40
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf-8'));
|
|
41
|
+
if (!Array.isArray(parsed))
|
|
42
|
+
return [];
|
|
43
|
+
return parsed.filter((dir) => typeof dir === 'string' && (0, node_path_1.isAbsolute)(dir)).slice(0, MAX_DIRS);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/** Writes down the directory of `binary`, an absolute path the shell resolved. Best-effort: a launch never fails on it. */
|
|
50
|
+
const rememberAgentDir = (binary) => {
|
|
51
|
+
if (!(0, node_path_1.isAbsolute)(binary))
|
|
52
|
+
return;
|
|
53
|
+
const dir = (0, node_path_1.dirname)(binary);
|
|
54
|
+
const path = (0, exports.agentDirsPath)();
|
|
55
|
+
// Never written through a link, nor into a file that is not read back: either would be writing for nothing, or elsewhere.
|
|
56
|
+
if (fileState(path) === 'refused')
|
|
57
|
+
return;
|
|
58
|
+
const dirs = readDirs();
|
|
59
|
+
if (dirs.includes(dir))
|
|
60
|
+
return;
|
|
61
|
+
try {
|
|
62
|
+
(0, private_files_js_1.mkdirHandedBack)((0, gate_js_1.stateDir)(), 0o700);
|
|
63
|
+
(0, node_fs_1.writeFileSync)(path, JSON.stringify([...dirs, dir].slice(-MAX_DIRS)), { mode: 0o600 });
|
|
64
|
+
(0, private_files_js_1.handBackToSudoUser)(path);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// The schedule launch then says the agent is not on PATH, which is the truth.
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
exports.rememberAgentDir = rememberAgentDir;
|
|
71
|
+
/** `path` with every written-down directory it lacks appended: what the listener has first, so nothing it runs changes. */
|
|
72
|
+
const withAgentDirs = (path) => {
|
|
73
|
+
const have = (path ?? '').split(node_path_1.delimiter).filter(Boolean);
|
|
74
|
+
return [...have, ...readDirs().filter((dir) => !have.includes(dir))].join(node_path_1.delimiter);
|
|
75
|
+
};
|
|
76
|
+
exports.withAgentDirs = withAgentDirs;
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* still be working underneath, so it must not change the state.
|
|
19
19
|
*
|
|
20
20
|
* Every agent here carries the same rules the server manifest publishes
|
|
21
|
-
* (hwip server/src/agent-rules/manifest.json, version 2026.10.
|
|
21
|
+
* (hwip server/src/agent-rules/manifest.json, version 2026.10.21.1 — hwip#616, merged and deployed before this cli ships),
|
|
22
22
|
* so a listener that never reaches the endpoint classifies them too. A fetched
|
|
23
23
|
* manifest replaces this bundle whole for state reports; schedules read only
|
|
24
24
|
* this bundle. An agent without rules here
|
|
@@ -21,7 +21,7 @@ exports.DEFAULT_MANIFEST = void 0;
|
|
|
21
21
|
* still be working underneath, so it must not change the state.
|
|
22
22
|
*
|
|
23
23
|
* Every agent here carries the same rules the server manifest publishes
|
|
24
|
-
* (hwip server/src/agent-rules/manifest.json, version 2026.10.
|
|
24
|
+
* (hwip server/src/agent-rules/manifest.json, version 2026.10.21.1 — hwip#616, merged and deployed before this cli ships),
|
|
25
25
|
* so a listener that never reaches the endpoint classifies them too. A fetched
|
|
26
26
|
* manifest replaces this bundle whole for state reports; schedules read only
|
|
27
27
|
* this bundle. An agent without rules here
|
|
@@ -31,7 +31,7 @@ exports.DEFAULT_MANIFEST = void 0;
|
|
|
31
31
|
const agent_state_js_1 = require("./agent-state.js");
|
|
32
32
|
exports.DEFAULT_MANIFEST = {
|
|
33
33
|
engineVersion: agent_state_js_1.ENGINE_VERSION,
|
|
34
|
-
version: '2026.10.
|
|
34
|
+
version: '2026.10.21.1',
|
|
35
35
|
agents: {
|
|
36
36
|
claude: [
|
|
37
37
|
{
|
|
@@ -237,8 +237,8 @@ exports.DEFAULT_MANIFEST = {
|
|
|
237
237
|
// guarded by any button or footer string so the phrase in tool
|
|
238
238
|
// output or agent text alone is not a dialog. tailLines 30
|
|
239
239
|
// because an edit request draws its diff between that line and
|
|
240
|
-
// the buttons. Other prompts (
|
|
241
|
-
// not
|
|
240
|
+
// the buttons. Other prompts (anything but a permission request or
|
|
241
|
+
// the question tool, below) are not read and report `unknown`.
|
|
242
242
|
// No idle rule: at rest the footer is only `ctrl+p commands`,
|
|
243
243
|
// which says nothing the absence of `esc interrupt` does not.
|
|
244
244
|
{
|
|
@@ -253,6 +253,17 @@ exports.DEFAULT_MANIFEST = {
|
|
|
253
253
|
{ contains: ['enter confirm'] },
|
|
254
254
|
],
|
|
255
255
|
},
|
|
256
|
+
{
|
|
257
|
+
// The question tool's panel (captured on opencode 2.1.2).
|
|
258
|
+
// tailLines 40: answers carry notes. `esc interrupt` means a
|
|
259
|
+
// turn still running, so these words in agent text do not count.
|
|
260
|
+
id: 'opencode-blocked-question',
|
|
261
|
+
state: 'blocked',
|
|
262
|
+
priority: 900,
|
|
263
|
+
tailLines: 40,
|
|
264
|
+
contains: ['questions', 'enter submit', 'esc dismiss'],
|
|
265
|
+
not: [{ contains: ['esc interrupt'] }],
|
|
266
|
+
},
|
|
256
267
|
{
|
|
257
268
|
id: 'opencode-working-interrupt-hint',
|
|
258
269
|
state: 'working',
|
package/dist/blocked-prompt.js
CHANGED
|
@@ -42,10 +42,13 @@ exports.promptPlaintext = exports.showsPrompt = exports.parseBlockedDialog = exp
|
|
|
42
42
|
* numbered answers. No footer.
|
|
43
43
|
*
|
|
44
44
|
* pi has no approval dialog of its own; extensions that gate tools ask through
|
|
45
|
-
* `ctx.ui.select` (`parsePiSelect`, `blocked-prompt-pi.test.ts`).
|
|
45
|
+
* `ctx.ui.select` (`parsePiSelect`, `blocked-prompt-pi.test.ts`). The `ask_user_question` extension draws its own
|
|
46
|
+
* picker (`parsePiQuestion`).
|
|
46
47
|
*
|
|
47
48
|
* OpenCode (v2.0.29, `blocked-prompt-opencode.test.ts`): a `┃` panel, `△ Permission required`, the request, and a
|
|
48
|
-
* row of buttons whose chosen one is told apart by background colour only (`parseOpencodePermission`).
|
|
49
|
+
* row of buttons whose chosen one is told apart by background colour only (`parseOpencodePermission`). Its question
|
|
50
|
+
* tool (v2.1.2) draws a `Questions` panel whose highlighted answer differs by the colour of its number
|
|
51
|
+
* (`parseOpencodeQuestion`).
|
|
49
52
|
*/
|
|
50
53
|
const node_crypto_1 = require("node:crypto");
|
|
51
54
|
/** Dialog titles whose layout was captured and is read. */
|
|
@@ -334,14 +337,91 @@ const parsePiSelect = (pane) => {
|
|
|
334
337
|
options,
|
|
335
338
|
});
|
|
336
339
|
};
|
|
340
|
+
/** A line indented under an answer, as both question pickers draw a note. */
|
|
341
|
+
const QUESTION_NOTE = /^ {5}(\S.*)$/;
|
|
342
|
+
const appendNote = (option, text) => {
|
|
343
|
+
option.note = option.note === undefined ? text.trim() : `${option.note} ${text.trim()}`;
|
|
344
|
+
};
|
|
345
|
+
const PI_QUESTION_FOOTER = /^ ↑↓ navigate • Enter select • Esc cancel$/;
|
|
346
|
+
const PI_QUESTION_OPTION = /^(> | {2})(?:(\d{1,2})\. )?(\S.*)$/;
|
|
347
|
+
/**
|
|
348
|
+
* The `ask_user_question` extension's single-answer picker (pi 0.85.1, `pi-blocked.txt`): a full-width rule, the
|
|
349
|
+
* question, an optional context, numbered answers with `> ` on the highlighted one and an indented note under any,
|
|
350
|
+
* an unnumbered last answer that opens a text field (left out: keys cannot fill it), then the
|
|
351
|
+
* `↑↓ navigate • Enter select • Esc cancel` footer and a closing rule.
|
|
352
|
+
*/
|
|
353
|
+
const parsePiQuestion = (pane) => {
|
|
354
|
+
const lines = pane.split('\n').map((l) => l.trimEnd());
|
|
355
|
+
let footer = -1;
|
|
356
|
+
for (let i = lines.length - 1; i >= 0 && footer < 0; i--)
|
|
357
|
+
if (PI_QUESTION_FOOTER.test(lines[i]))
|
|
358
|
+
footer = i;
|
|
359
|
+
if (footer < 0 || !FULL_RULE.test(lines[footer + 1] ?? ''))
|
|
360
|
+
return undefined;
|
|
361
|
+
let top = -1;
|
|
362
|
+
for (let i = footer - 1; i >= 0 && top < 0; i--)
|
|
363
|
+
if (FULL_RULE.test(lines[i]))
|
|
364
|
+
top = i;
|
|
365
|
+
if (top < 0)
|
|
366
|
+
return undefined;
|
|
367
|
+
// The picker takes the editor's place, so one rule follows it; the same text in the agent's output sits above
|
|
368
|
+
// the editor and its two rules.
|
|
369
|
+
if (lines.slice(footer + 1).filter((l) => FULL_RULE.test(l)).length > 1)
|
|
370
|
+
return undefined;
|
|
371
|
+
// Answers run up to the footer's gap; the question and context sit above the first one's gap.
|
|
372
|
+
let first = top + 1;
|
|
373
|
+
while (first < footer && !/^(> | {2})1\. /.test(lines[first]))
|
|
374
|
+
first++;
|
|
375
|
+
if (first >= footer || lines[first - 1] !== '')
|
|
376
|
+
return undefined;
|
|
377
|
+
const options = [];
|
|
378
|
+
let own;
|
|
379
|
+
for (const line of lines.slice(first, footer)) {
|
|
380
|
+
if (line === '')
|
|
381
|
+
continue;
|
|
382
|
+
const note = QUESTION_NOTE.exec(line);
|
|
383
|
+
if (note && options.length > 0) {
|
|
384
|
+
appendNote(options[options.length - 1], note[1]);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const m = PI_QUESTION_OPTION.exec(line);
|
|
388
|
+
if (!m || own !== undefined)
|
|
389
|
+
return undefined;
|
|
390
|
+
const n = m[2] === undefined ? options.length + 1 : Number(m[2]);
|
|
391
|
+
if (m[2] === undefined)
|
|
392
|
+
own = n;
|
|
393
|
+
options.push({ n, label: m[3].trim(), selected: m[1] === '> ' });
|
|
394
|
+
}
|
|
395
|
+
if (own === undefined)
|
|
396
|
+
return undefined;
|
|
397
|
+
const text = lines.slice(top + 1, first).map((l) => l.replace(/^ /, ''));
|
|
398
|
+
const gap = text.indexOf('');
|
|
399
|
+
const asked = (gap < 0 ? text : text.slice(0, gap)).join(' ').trim();
|
|
400
|
+
if (asked === '')
|
|
401
|
+
return undefined;
|
|
402
|
+
const context = gap < 0 ? [] : trimBlank(text.slice(gap + 1)).map((l) => l.trim()).filter((l) => l !== '');
|
|
403
|
+
const prompt = assemble({
|
|
404
|
+
region: lines.slice(top, footer + 1),
|
|
405
|
+
title: asked,
|
|
406
|
+
detail: [...context, ...options.filter((o) => o.note !== undefined && o.n !== own).map((o) => `${o.label}: ${o.note}`)],
|
|
407
|
+
question: asked,
|
|
408
|
+
options,
|
|
409
|
+
});
|
|
410
|
+
return prompt && { ...prompt, options: prompt.options.filter((o) => o.n !== own) };
|
|
411
|
+
};
|
|
337
412
|
// One SGR sequence and its parameters; any other escape is dropped without changing the background.
|
|
338
413
|
const SGR = /^\u001b\[([0-9;:]*)m/;
|
|
339
414
|
const ANY_ESCAPE = new RegExp(`^(?:${ESCAPES.source})`);
|
|
340
|
-
/**
|
|
341
|
-
|
|
415
|
+
/**
|
|
416
|
+
* `line` without escapes, and the background ('7' = reverse video) and foreground each of its UTF-16 units is drawn
|
|
417
|
+
* in ('' = the default).
|
|
418
|
+
*/
|
|
419
|
+
const withColours = (line) => {
|
|
342
420
|
let text = '';
|
|
343
421
|
const bg = [];
|
|
422
|
+
const fg = [];
|
|
344
423
|
let current = '';
|
|
424
|
+
let ink = '';
|
|
345
425
|
let reverse = false;
|
|
346
426
|
for (let i = 0; i < line.length;) {
|
|
347
427
|
const sgr = SGR.exec(line.slice(i));
|
|
@@ -351,6 +431,7 @@ const withBackground = (line) => {
|
|
|
351
431
|
const code = Number(p[j]);
|
|
352
432
|
if (code === 0) {
|
|
353
433
|
current = '';
|
|
434
|
+
ink = '';
|
|
354
435
|
reverse = false;
|
|
355
436
|
}
|
|
356
437
|
else if (code === 7)
|
|
@@ -359,12 +440,18 @@ const withBackground = (line) => {
|
|
|
359
440
|
reverse = false;
|
|
360
441
|
else if (code === 49)
|
|
361
442
|
current = '';
|
|
443
|
+
else if (code === 39)
|
|
444
|
+
ink = '';
|
|
362
445
|
else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107))
|
|
363
446
|
current = String(code);
|
|
447
|
+
else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97))
|
|
448
|
+
ink = String(code);
|
|
364
449
|
else if (code === 38 || code === 48 || code === 58) {
|
|
365
450
|
const span = p[j + 1] === '2' ? 3 : p[j + 1] === '5' ? 1 : 0;
|
|
366
451
|
if (code === 48)
|
|
367
452
|
current = p.slice(j, j + 2 + span).join(';');
|
|
453
|
+
if (code === 38)
|
|
454
|
+
ink = p.slice(j, j + 2 + span).join(';');
|
|
368
455
|
j += 1 + span;
|
|
369
456
|
}
|
|
370
457
|
}
|
|
@@ -378,27 +465,27 @@ const withBackground = (line) => {
|
|
|
378
465
|
}
|
|
379
466
|
text += line[i];
|
|
380
467
|
bg.push(reverse ? '7' : current);
|
|
468
|
+
fg.push(ink);
|
|
381
469
|
i++;
|
|
382
470
|
}
|
|
383
|
-
return { text, bg };
|
|
471
|
+
return { text, bg, fg };
|
|
384
472
|
};
|
|
385
473
|
const OPENCODE_BAR = '┃';
|
|
386
474
|
const OPENCODE_HEADER = /^\s*△ Permission required$/;
|
|
387
475
|
const OPENCODE_HINTS = /ctrl\+|⇆|enter confirm/;
|
|
388
476
|
const OPENCODE_SUBTITLE = /^→ (\S.*)$/;
|
|
477
|
+
/** The column after the panel bar on a row drawn in the panel; -1 for any other row. */
|
|
478
|
+
const inPanel = (row) => {
|
|
479
|
+
const at = row.text.indexOf(OPENCODE_BAR);
|
|
480
|
+
return at >= 0 && row.text.slice(0, at).trim() === '' ? at + 1 : -1;
|
|
481
|
+
};
|
|
389
482
|
/**
|
|
390
483
|
* OpenCode's permission request: `△ Permission required` in a `┃` panel, the request (a `$ command`, or `→ Edit file`
|
|
391
484
|
* over its diff), then a row of buttons `Allow once Always allow Reject` beside `… enter confirm`. The chosen
|
|
392
485
|
* button differs from the others only by its background, so this reads the pane with its escapes: a plain capture
|
|
393
486
|
* yields nothing. Left/right move the choice; the dialog draws no question, so the question is a fixed one.
|
|
394
487
|
*/
|
|
395
|
-
const parseOpencodePermission = (
|
|
396
|
-
const rows = pane.split('\n').map(withBackground);
|
|
397
|
-
// Text after the panel bar, with the bar's column, for every row drawn in the panel.
|
|
398
|
-
const inPanel = (row) => {
|
|
399
|
-
const at = row.text.indexOf(OPENCODE_BAR);
|
|
400
|
-
return at >= 0 && row.text.slice(0, at).trim() === '' ? at + 1 : -1;
|
|
401
|
-
};
|
|
488
|
+
const parseOpencodePermission = (rows) => {
|
|
402
489
|
let head = -1;
|
|
403
490
|
for (let i = rows.length - 1; i >= 0 && head < 0; i--) {
|
|
404
491
|
const at = inPanel(rows[i]);
|
|
@@ -453,6 +540,71 @@ const parseOpencodePermission = (pane) => {
|
|
|
453
540
|
idSalt: `opencode:selected=${selected}`,
|
|
454
541
|
});
|
|
455
542
|
};
|
|
543
|
+
const OPENCODE_QUESTION_HEADER = /^\s*Questions$/;
|
|
544
|
+
/** The one footer captured: a single question, single answer. Several questions add `tab`, several answers a toggle. */
|
|
545
|
+
const OPENCODE_QUESTION_FOOTER = /^\s*↑↓ select {2}enter submit {2}esc dismiss$/;
|
|
546
|
+
const OPENCODE_QUESTION_OPTION = /^ {2}(\d{1,2})\. (\S.*)$/;
|
|
547
|
+
/** The answer OpenCode adds to every question: it opens a text field, which keys cannot fill. */
|
|
548
|
+
const OPENCODE_OWN_ANSWER = 'Type your own answer';
|
|
549
|
+
/**
|
|
550
|
+
* OpenCode's question tool (v2.1.2): `Questions` in a `┃` panel, the question, numbered answers each over an
|
|
551
|
+
* optional indented note, and a `↑↓ select enter submit esc dismiss` footer. The highlighted answer differs only by
|
|
552
|
+
* the colour of its number, so this too reads the pane with its escapes. Up/down move the highlight.
|
|
553
|
+
*/
|
|
554
|
+
const parseOpencodeQuestion = (rows) => {
|
|
555
|
+
const textOf = (i) => rows[i].text.slice(inPanel(rows[i])).trimEnd();
|
|
556
|
+
let footer = -1;
|
|
557
|
+
for (let i = rows.length - 1; i >= 0 && footer < 0; i--) {
|
|
558
|
+
if (inPanel(rows[i]) >= 0 && OPENCODE_QUESTION_FOOTER.test(textOf(i)))
|
|
559
|
+
footer = i;
|
|
560
|
+
}
|
|
561
|
+
if (footer < 0)
|
|
562
|
+
return undefined;
|
|
563
|
+
let head = footer - 1;
|
|
564
|
+
while (head >= 0 && inPanel(rows[head]) >= 0 && !OPENCODE_QUESTION_HEADER.test(textOf(head)))
|
|
565
|
+
head--;
|
|
566
|
+
if (head < 0 || inPanel(rows[head]) < 0)
|
|
567
|
+
return undefined;
|
|
568
|
+
const question = [];
|
|
569
|
+
const options = [];
|
|
570
|
+
const numberInk = [];
|
|
571
|
+
for (let i = head + 1; i < footer; i++) {
|
|
572
|
+
const text = textOf(i);
|
|
573
|
+
const option = OPENCODE_QUESTION_OPTION.exec(text);
|
|
574
|
+
if (option) {
|
|
575
|
+
options.push({ n: Number(option[1]), label: option[2].trim(), selected: false });
|
|
576
|
+
numberInk.push(rows[i].fg[inPanel(rows[i]) + 2]);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const note = QUESTION_NOTE.exec(text);
|
|
580
|
+
if (options.length > 0 && note) {
|
|
581
|
+
appendNote(options[options.length - 1], note[1]);
|
|
582
|
+
}
|
|
583
|
+
else if (options.length > 0 && text.trim() !== '') {
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
else if (options.length === 0) {
|
|
587
|
+
question.push(text.trim());
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// The highlighted answer: the one number ink no other answer shares, the rest all alike.
|
|
591
|
+
const odd = numberInk.filter((ink) => numberInk.filter((other) => other === ink).length === 1);
|
|
592
|
+
if (odd.length !== 1 || new Set(numberInk).size !== 2)
|
|
593
|
+
return undefined;
|
|
594
|
+
options[numberInk.indexOf(odd[0])].selected = true;
|
|
595
|
+
const asked = trimBlank(question).join(' ').trim();
|
|
596
|
+
if (asked === '')
|
|
597
|
+
return undefined;
|
|
598
|
+
const prompt = assemble({
|
|
599
|
+
region: rows.slice(head, footer + 1).map((r) => r.text.trimEnd()),
|
|
600
|
+
title: 'Question',
|
|
601
|
+
detail: options.filter((o) => o.note !== undefined && o.label !== OPENCODE_OWN_ANSWER).map((o) => `${o.label}: ${o.note}`),
|
|
602
|
+
question: asked,
|
|
603
|
+
options,
|
|
604
|
+
idSalt: `opencode-question:selected=${options.findIndex((o) => o.selected)}`,
|
|
605
|
+
});
|
|
606
|
+
return prompt && { ...prompt, options: prompt.options.filter((o) => o.label !== OPENCODE_OWN_ANSWER) };
|
|
607
|
+
};
|
|
456
608
|
/** The checks and caps every reader shares: numbering 1..n, one highlight, label and detail limits. */
|
|
457
609
|
const assemble = (d) => {
|
|
458
610
|
const { options } = d;
|
|
@@ -489,8 +641,14 @@ const assemble = (d) => {
|
|
|
489
641
|
const READERS = {
|
|
490
642
|
claude: (pane) => parseClaudeDialog((0, exports.stripEscapes)(pane)),
|
|
491
643
|
gemini: (pane) => parseGeminiDialog((0, exports.stripEscapes)(pane)),
|
|
492
|
-
pi: (pane) =>
|
|
493
|
-
|
|
644
|
+
pi: (pane) => {
|
|
645
|
+
const plain = (0, exports.stripEscapes)(pane);
|
|
646
|
+
return parsePiSelect(plain) ?? parsePiQuestion(plain);
|
|
647
|
+
},
|
|
648
|
+
opencode: (pane) => {
|
|
649
|
+
const rows = pane.split('\n').map(withColours);
|
|
650
|
+
return parseOpencodePermission(rows) ?? parseOpencodeQuestion(rows);
|
|
651
|
+
},
|
|
494
652
|
};
|
|
495
653
|
/** The approval dialog at the bottom of `pane`, read the way `kind` draws it; undefined when it is not one of those. */
|
|
496
654
|
const parseBlockedDialog = (pane, kind) => READERS[kind]?.(pane);
|
package/dist/listener.d.ts
CHANGED
|
@@ -229,6 +229,11 @@ export interface ReportedKnownSession {
|
|
|
229
229
|
lastSeq?: number;
|
|
230
230
|
/** The later tmux sessions the run went on in (Q35): the server ends another run's push window at their start. */
|
|
231
231
|
tmuxRunIds?: string[];
|
|
232
|
+
/** The last name the agent gave the run, and who gave it (hwip docs/session-runs.md Q39): what the phone calls it once ended. */
|
|
233
|
+
providerSessionName?: string;
|
|
234
|
+
providerSessionNameSource?: ProviderNameSource;
|
|
235
|
+
/** Whether starting the run again picks its conversation back up (Q43) — never the conversation id itself. */
|
|
236
|
+
resumable: boolean;
|
|
232
237
|
lastSeenAt: string;
|
|
233
238
|
}
|
|
234
239
|
/** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
|
|
@@ -502,6 +507,13 @@ export interface SessionResumeRequest {
|
|
|
502
507
|
* the same answer a phone too old to send it gets.
|
|
503
508
|
*/
|
|
504
509
|
agentKind?: string;
|
|
510
|
+
/**
|
|
511
|
+
* Which run of that name and agent (docs/session-runs.md Q41–Q46): the
|
|
512
|
+
* earlier-runs fold starts one conversation, not the newest. Selects among
|
|
513
|
+
* runs this machine recorded; one it never had is refused. Read only when
|
|
514
|
+
* it reads as a run id.
|
|
515
|
+
*/
|
|
516
|
+
runId?: string;
|
|
505
517
|
requestId?: string;
|
|
506
518
|
}
|
|
507
519
|
export interface SessionResumeResult {
|
|
@@ -640,6 +652,27 @@ export interface ScreenHistorySnapshot {
|
|
|
640
652
|
error?: string;
|
|
641
653
|
capturedAt: string;
|
|
642
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* What a sealed history page's envelope holds (as JSON): the page and every
|
|
657
|
+
* number a viewer pages by, bound to the request it answers and to the
|
|
658
|
+
* subscription it was sealed for. On a sealed stream the snapshot carries
|
|
659
|
+
* only `requestId` (for routing) beside this — a relay that hands an earlier
|
|
660
|
+
* page back under a new request, or rewrites where it sits, is caught on open.
|
|
661
|
+
*/
|
|
662
|
+
export type SealedHistoryPage = {
|
|
663
|
+
sessionName: string;
|
|
664
|
+
requestId: string;
|
|
665
|
+
nonce?: string;
|
|
666
|
+
epoch: number;
|
|
667
|
+
before: number;
|
|
668
|
+
lines: number;
|
|
669
|
+
hasMore: boolean;
|
|
670
|
+
truncated: boolean;
|
|
671
|
+
/** The pane's history size when this page was captured, so the viewer can
|
|
672
|
+
* tell how much the history grew between its request and the capture. */
|
|
673
|
+
historySize: number;
|
|
674
|
+
content: string;
|
|
675
|
+
};
|
|
643
676
|
/**
|
|
644
677
|
* Answer a phone's request for one page of scrollback above the live window.
|
|
645
678
|
* Returns true when the message was ours to answer, so the caller stops routing
|