@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 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 OpenCode it also picks up the conversation that session last had
562
- (`--resume <id>` / `--session <id>`, the id this machine read off the live
563
- pane's own process — an id two panes both answered to is not kept). Claude Code starts fresh when that conversation is no longer on disk,
564
- and the other agents always start a new one.
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
- - **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.
649
- - **From the phone**: the app lists the schedules of a computer, makes one for a session this computer has run, 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.
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. Your
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 remove names the schedule's own
1201
- session, so it cannot reach another. Applying a request never types into a
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.18.1 — hwip#532, merged and deployed before this cli ships),
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.18.1 — hwip#532, merged and deployed before this cli ships),
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.18.1',
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 (the agent asking a question) are
241
- // not captured and report `unknown`.
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',
@@ -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
- /** `line` without escapes, and the background each of its UTF-16 units is drawn on ('7' = reverse video). */
341
- const withBackground = (line) => {
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 = (pane) => {
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) => parsePiSelect((0, exports.stripEscapes)(pane)),
493
- opencode: parseOpencodePermission,
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);
@@ -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