@hwip/cli 0.1.5 → 0.1.7

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
 
@@ -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);
@@ -232,6 +232,8 @@ export interface ReportedKnownSession {
232
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
233
  providerSessionName?: string;
234
234
  providerSessionNameSource?: ProviderNameSource;
235
+ /** Whether starting the run again picks its conversation back up (Q43) — never the conversation id itself. */
236
+ resumable: boolean;
235
237
  lastSeenAt: string;
236
238
  }
237
239
  /** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
@@ -505,6 +507,13 @@ export interface SessionResumeRequest {
505
507
  * the same answer a phone too old to send it gets.
506
508
  */
507
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;
508
517
  requestId?: string;
509
518
  }
510
519
  export interface SessionResumeResult {
package/dist/listener.js CHANGED
@@ -36,6 +36,7 @@ const os_1 = require("os");
36
36
  const path_1 = require("path");
37
37
  const ws_1 = __importDefault(require("ws"));
38
38
  const config_js_1 = require("./config.js");
39
+ const agent_dirs_js_1 = require("./agent-dirs.js");
39
40
  const api_js_1 = require("./api.js");
40
41
  const shell_audit_js_1 = require("./shell-audit.js");
41
42
  const insecure_url_js_1 = require("./insecure-url.js");
@@ -100,6 +101,7 @@ const tmux_liveness_js_1 = require("./tmux-liveness.js");
100
101
  Object.defineProperty(exports, "liveSessionNames", { enumerable: true, get: function () { return tmux_liveness_js_1.liveSessionNames; } });
101
102
  Object.defineProperty(exports, "sessionExists", { enumerable: true, get: function () { return tmux_liveness_js_1.sessionExists; } });
102
103
  Object.defineProperty(exports, "sessionLiveness", { enumerable: true, get: function () { return tmux_liveness_js_1.sessionLiveness; } });
104
+ const wrapper_js_1 = require("./wrapper.js");
103
105
  const run_seqs_js_1 = require("./run-seqs.js");
104
106
  const args_js_1 = require("./args.js");
105
107
  const schedule_js_1 = require("./schedule.js");
@@ -321,11 +323,12 @@ const knownSessionsToReport = (live, now = Date.now(), seqs = (0, run_seqs_js_1.
321
323
  ...(e.tmuxRunIds?.length ? { tmuxRunIds: e.tmuxRunIds } : {}),
322
324
  ...(e.providerSessionName ? { providerSessionName: e.providerSessionName } : {}),
323
325
  ...(e.providerSessionName && e.providerSessionNameSource ? { providerSessionNameSource: e.providerSessionNameSource } : {}),
326
+ resumable: isResumable(e),
324
327
  lastSeenAt: e.lastSeenAt,
325
328
  }));
326
329
  };
327
330
  exports.knownSessionsToReport = knownSessionsToReport;
328
- const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''} ${k.providerSessionNameSource ?? ''} ${k.providerSessionName ?? ''}`).sort().join('|');
331
+ const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''} ${k.providerSessionNameSource ?? ''} ${k.providerSessionName ?? ''} ${k.resumable}`).sort().join('|');
329
332
  exports.knownSessionsFingerprint = knownSessionsFingerprint;
330
333
  const sessionsFingerprint = (sessions) => sessions
331
334
  .map((s) => JSON.stringify([
@@ -1365,6 +1368,13 @@ const handleScheduleListRequest = (req, send) => {
1365
1368
  return true;
1366
1369
  };
1367
1370
  exports.handleScheduleListRequest = handleScheduleListRequest;
1371
+ /** A run id as the registry writes it: tmux `session_created`, epoch seconds. */
1372
+ const RUN_ID = /^\d{1,12}$/;
1373
+ /** The name a wrapper family counts from: `hwip-x-3` → `hwip-x`. */
1374
+ const familyBase = (name) => {
1375
+ const base = /^(.+)-\d+$/.exec(name)?.[1];
1376
+ return base !== undefined && (0, wrapper_js_1.familyNames)(base).includes(name) ? base : name;
1377
+ };
1368
1378
  /** What an agent conversation id looks like — a value, never an option. */
1369
1379
  const CONVERSATION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
1370
1380
  /**
@@ -1380,6 +1390,26 @@ const resumableConversation = (agent, known) => {
1380
1390
  const args = agent.resumeArgs?.(id, known.cwd);
1381
1391
  return args ? { id, args } : null;
1382
1392
  };
1393
+ /**
1394
+ * A live session is on this run's conversation already: the run started beside
1395
+ * its name before, or the live run under it picked the conversation up. The
1396
+ * ended entry stays in the registry, so without this every tap on it would
1397
+ * start one more agent on one transcript.
1398
+ */
1399
+ const conversationRunning = (known, live) => {
1400
+ const id = known.agentSessionId;
1401
+ if (!id)
1402
+ return false;
1403
+ return [...live].some((name) => {
1404
+ const current = (0, session_registry_js_1.recallSession)(name);
1405
+ return current?.agentKind === known.agentKind && current.agentSessionId === id;
1406
+ });
1407
+ };
1408
+ /** What the report says about a run: the same answer a resume of it would act on. */
1409
+ const isResumable = (known) => {
1410
+ const agent = remote_agents_js_1.REMOTE_AGENTS.find((a) => a.kind === known.agentKind);
1411
+ return agent !== undefined && resumableConversation(agent, known) !== null;
1412
+ };
1383
1413
  /**
1384
1414
  * Start a remembered session again, detached, and tell the phone what happened.
1385
1415
  * Returns true when the message was ours to answer.
@@ -1423,14 +1453,27 @@ const handleSessionResumeRequest = (req, send) => {
1423
1453
  // newest run would start claude for a phone that asked for pi — silently,
1424
1454
  // which is the exact failure this path exists to remove. A phone that names
1425
1455
  // nothing still means the newest run.
1456
+ const runId = typeof req.runId === 'string' && RUN_ID.test(req.runId) ? req.runId : undefined;
1426
1457
  const known = typeof req.agentKind === 'string'
1427
- ? (0, session_registry_js_1.recallRun)(sessionName, req.agentKind)
1428
- : (0, session_registry_js_1.recallSession)(sessionName);
1458
+ ? (0, session_registry_js_1.recallRun)(sessionName, req.agentKind, Date.now(), runId)
1459
+ : (0, session_registry_js_1.recallSession)(sessionName, Date.now(), runId);
1429
1460
  if (!known) {
1430
1461
  (0, listener_log_js_1.log)(`✗ resume ${sessionName}: never seen on this machine`);
1431
1462
  return reply({ error: 'unknown_session' });
1432
1463
  }
1433
- if ((0, tmux_liveness_js_1.sessionExists)(sessionName))
1464
+ // A live name is refused unless the request names a run: then the phone
1465
+ // asked for one earlier conversation, and the session holding the name is
1466
+ // a different one — so it starts beside it, under the next free name of
1467
+ // the family `hwip cc` would pick (Q41), counted from the family base so a
1468
+ // busy `<base>-2` leads to `<base>-3`. A request without a run id is an
1469
+ // older app that only knows the name, and still gets the refusal it renders.
1470
+ const live = new Set((0, tmux_liveness_js_1.liveSessionNames)() ?? []);
1471
+ const target = !live.has(sessionName)
1472
+ ? sessionName
1473
+ : runId !== undefined
1474
+ ? (0, wrapper_js_1.familyNames)(familyBase(sessionName)).find((name) => !live.has(name))
1475
+ : undefined;
1476
+ if (!target || conversationRunning(known, live))
1434
1477
  return reply({ error: 'already_running' });
1435
1478
  if (!(0, session_registry_js_1.sessionDirectoryExists)(known)) {
1436
1479
  (0, listener_log_js_1.log)(`✗ resume ${sessionName}: ${known.cwd} is gone`);
@@ -1451,14 +1494,18 @@ const handleSessionResumeRequest = (req, send) => {
1451
1494
  ? [';', 'set-option', config_js_1.SESSION_PROJECT_OPTION, known.project, ';', 'set-option', config_js_1.SESSION_LABEL_OPTION, known.label]
1452
1495
  : [];
1453
1496
  const conversation = resumableConversation(agent, known);
1454
- const started = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['new-session', '-d', '-s', sessionName, '-c', known.cwd, agent.binary, ...(conversation?.args ?? []), ...sidecar]), { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
1497
+ // tmux gives the new pane this client's PATH. A login service's PATH has none
1498
+ // of the agent directories, so they go on as schedule launches add them —
1499
+ // otherwise the agent is not found and the session ends as it starts.
1500
+ const started = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['new-session', '-d', '-s', target, '-c', known.cwd, agent.binary, ...(conversation?.args ?? []), ...sidecar]), { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PATH: (0, agent_dirs_js_1.withAgentDirs)(process.env.PATH) } });
1455
1501
  if (started.status !== 0) {
1456
1502
  (0, listener_log_js_1.log)(`✗ resume ${sessionName}: tmux refused (${(started.stderr ?? '').trim() || 'no detail'})`);
1457
1503
  return reply({ error: 'start_failed' });
1458
1504
  }
1459
1505
  const resumedFrom = conversation ? ` (conversation ${conversation.id.slice(0, 8)})` : '';
1460
- (0, listener_log_js_1.log)(`⟲ resume ${sessionName}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
1461
- return reply({ resumed: true });
1506
+ const beside = target === sessionName ? '' : ` as ${target}`;
1507
+ (0, listener_log_js_1.log)(`⟲ resume ${sessionName}${beside}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
1508
+ return reply({ resumed: true, sessionName: target });
1462
1509
  };
1463
1510
  exports.handleSessionResumeRequest = handleSessionResumeRequest;
1464
1511
  /**
@@ -1618,7 +1665,7 @@ const handleSessionForgetRequest = (req, send) => {
1618
1665
  // alternative is an irreversible delete (registry AND scrollback) justified
1619
1666
  // by a question that failed to be asked. It says so in the log, because
1620
1667
  // from the phone the two refusals are the same sentence.
1621
- const runId = typeof req.runId === 'string' && /^\d{1,12}$/.test(req.runId) ? req.runId : undefined;
1668
+ const runId = typeof req.runId === 'string' && RUN_ID.test(req.runId) ? req.runId : undefined;
1622
1669
  const liveness = (0, tmux_liveness_js_1.sessionLiveness)(sessionName);
1623
1670
  if (liveness === 'unknown') {
1624
1671
  (0, listener_log_js_1.log)(`✗ forget ${sessionName}: no tmux answered, so nothing was deleted`);
@@ -380,6 +380,7 @@ declare const REMOTE_AGENT_TABLE: readonly [{
380
380
  readonly resolveTranscript: (paneCwd: string, panePid: number | undefined) => string | null;
381
381
  readonly projectTranscript: TranscriptProjector;
382
382
  readonly resolveResumeId: (paneCwd: string, panePid: number | undefined) => string | null;
383
+ readonly resumeArgs: (id: string) => string[] | null;
383
384
  }, {
384
385
  readonly kind: "cursor";
385
386
  readonly displayName: "Cursor CLI";
@@ -400,6 +401,7 @@ declare const REMOTE_AGENT_TABLE: readonly [{
400
401
  readonly subcommands: readonly ["hermes"];
401
402
  readonly resolveSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => ProviderName | null;
402
403
  readonly resolveResumeId: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
404
+ readonly resumeArgs: (id: string) => string[];
403
405
  }, {
404
406
  readonly kind: "pi";
405
407
  readonly displayName: "Pi";
@@ -409,6 +411,7 @@ declare const REMOTE_AGENT_TABLE: readonly [{
409
411
  readonly projectTranscript: TranscriptProjector;
410
412
  readonly resolveResumeId: (paneCwd: string, panePid: number | undefined) => string | null;
411
413
  readonly resolveSessionName: (paneCwd: string, panePid: number | undefined) => ProviderName | null;
414
+ readonly resumeArgs: (id: string, cwd: string) => string[] | null;
412
415
  }, {
413
416
  readonly kind: "opencode";
414
417
  readonly displayName: "OpenCode";
@@ -462,6 +462,15 @@ const claudeTranscriptPath = (paneCwd, panePid) => {
462
462
  return claudeConversationFile(paneCwd, sessionId);
463
463
  };
464
464
  exports.claudeTranscriptPath = claudeTranscriptPath;
465
+ /** A regular file is there: what a resume flag needs before it names one. */
466
+ const isFileAt = (path) => {
467
+ try {
468
+ return (0, fs_1.statSync)(path).isFile();
469
+ }
470
+ catch {
471
+ return false;
472
+ }
473
+ };
465
474
  /**
466
475
  * Where Claude Code keeps conversation `sessionId` begun in `cwd`, or null
467
476
  * when no such file is there. Proven, not computed: the pid path never touches
@@ -470,12 +479,7 @@ exports.claudeTranscriptPath = claudeTranscriptPath;
470
479
  */
471
480
  const claudeConversationFile = (cwd, sessionId) => {
472
481
  const path = (0, path_1.join)(CLAUDE_PROJECTS_DIR, (0, exports.claudeProjectDirName)(cwd), `${sessionId}.jsonl`);
473
- try {
474
- return (0, fs_1.statSync)(path).isFile() ? path : null;
475
- }
476
- catch {
477
- return null;
478
- }
482
+ return isFileAt(path) ? path : null;
479
483
  };
480
484
  // ── pi ───────────────────────────────────────────────────────────
481
485
  /**
@@ -524,6 +528,11 @@ const piSessionDirName = (cwd) => {
524
528
  const resolved = (0, path_1.resolve)(expanded);
525
529
  return `--${resolved.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
526
530
  };
531
+ /** `--session <file>` for a pi session still on disk, or null. */
532
+ const piSessionFile = (cwd, id) => {
533
+ const path = (0, path_1.join)(piSessionsDir(), piSessionDirName(cwd), `${id}.jsonl`);
534
+ return isFileAt(path) ? ['--session', path] : null;
535
+ };
527
536
  /**
528
537
  * Newest session file pi wrote for this directory.
529
538
  *
@@ -849,6 +858,15 @@ const codexSessionsDir = () => {
849
858
  const home = override ? override.replace(/^~(?=\/|$)/, (0, os_1.homedir)()) : CODEX_DIR;
850
859
  return (0, path_1.join)(home, 'sessions');
851
860
  };
861
+ /** `rollout-YYYY-MM-DDThh-mm-ss-<uuid>`: the day it shards under, and the thread it carries. */
862
+ const CODEX_ROLLOUT = /^rollout-(\d{4})-(\d{2})-(\d{2})T[\d-]+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
863
+ /** The thread id of a rollout that is still on disk, or null. */
864
+ const codexRolloutThread = (rolloutId) => {
865
+ const m = CODEX_ROLLOUT.exec(rolloutId);
866
+ if (!m)
867
+ return null;
868
+ return isFileAt((0, path_1.join)(codexSessionsDir(), m[1], m[2], m[3], `${rolloutId}.jsonl`)) ? m[4] : null;
869
+ };
852
870
  /**
853
871
  * The two newest `sessions/<YYYY>/<MM>/<DD>` shards.
854
872
  *
@@ -1211,8 +1229,15 @@ const REMOTE_AGENT_TABLE = [
1211
1229
  resolveSessionName: exports.detectCodexSessionName,
1212
1230
  resolveTranscript: (paneCwd, panePid) => (0, exports.codexTranscriptPath)(paneCwd, panePid),
1213
1231
  projectTranscript: codex_transcript_js_1.projectCodexEntries,
1214
- // Read only to tell a resumed conversation's run (no `resumeArgs`): the rollout a resume appends to.
1232
+ // The rollout a resume appends to, by file name.
1215
1233
  resolveResumeId: (paneCwd, panePid) => paneTranscriptId((0, exports.codexTranscriptPath)(paneCwd, panePid), panePid),
1234
+ // `codex resume` takes the thread's UUID; the rollout's file name reads as
1235
+ // a thread NAME and starts a new conversation (measured on 0.163.0). An
1236
+ // unknown UUID exits at once, so only a rollout still on disk.
1237
+ resumeArgs: (id) => {
1238
+ const thread = codexRolloutThread(id);
1239
+ return thread ? ['resume', thread] : null;
1240
+ },
1216
1241
  },
1217
1242
  {
1218
1243
  // The pane binary is `cursor-agent`, Cursor's terminal TUI — NOT the
@@ -1250,6 +1275,10 @@ const REMOTE_AGENT_TABLE = [
1250
1275
  // `<timestamp>_<hash>` string, and nothing on the wire consumes it yet.
1251
1276
  resolveSessionName: exports.detectHermesSessionName,
1252
1277
  resolveResumeId: exports.detectHermesResumeId,
1278
+ // Measured: `--resume <id>` carries the conversation on, and an id the
1279
+ // store lost starts a new session rather than exit — so, like opencode,
1280
+ // not checked against the store.
1281
+ resumeArgs: (id) => ['--resume', id],
1253
1282
  },
1254
1283
  {
1255
1284
  kind: 'pi',
@@ -1260,6 +1289,10 @@ const REMOTE_AGENT_TABLE = [
1260
1289
  projectTranscript: pi_transcript_js_1.projectPiEntries,
1261
1290
  resolveResumeId: (paneCwd, panePid) => paneTranscriptId((0, exports.piTranscriptPath)(paneCwd, panePid), panePid),
1262
1291
  resolveSessionName: (paneCwd, panePid) => (0, exports.detectPiSessionName)(paneCwd, panePid),
1292
+ // The session file itself: `--session` takes a path or a UUID, and the
1293
+ // recorded id is the file name, which it does not match. A session pi
1294
+ // cannot find exits at once (measured), so only a file still on disk.
1295
+ resumeArgs: (id, cwd) => piSessionFile(cwd, id),
1263
1296
  },
1264
1297
  {
1265
1298
  kind: 'opencode',
@@ -89,8 +89,9 @@ export declare const runKey: (name: string, agentKind: string, runId?: string) =
89
89
  /** Sessions this machine has seen, newest first, expired ones dropped. */
90
90
  export declare const knownSessions: (now?: number) => KnownSession[];
91
91
  /** One remembered session, or null when this machine never saw that name.
92
- * The newest run under that name — which is the one a bare resume means. */
93
- export declare const recallSession: (name: string, now?: number) => KnownSession | null;
92
+ * The newest run under that name — which is the one a bare resume means.
93
+ * With `runId`, that run of the name and nothing else (Q46). */
94
+ export declare const recallSession: (name: string, now?: number, runId?: string) => KnownSession | null;
94
95
  /**
95
96
  * One specific run: the newest under this name that ran THIS agent.
96
97
  *
@@ -99,8 +100,12 @@ export declare const recallSession: (name: string, now?: number) => KnownSession
99
100
  * which row was tapped and says so; this is how that answer is honoured.
100
101
  * Null when this machine never saw that agent under that name — the caller
101
102
  * decides whether to fall back to the newest run or refuse.
103
+ *
104
+ * With `runId`, that run of the name and nothing else (docs/session-runs.md
105
+ * Q46): the earlier-runs fold asks for one conversation, and the newest run is
106
+ * a different one.
102
107
  */
103
- export declare const recallRun: (name: string, agentKind: string, now?: number) => KnownSession | null;
108
+ export declare const recallRun: (name: string, agentKind: string, now?: number, runId?: string) => KnownSession | null;
104
109
  /** tmux sessions remembered per run: an `exit` and `-c` every few minutes all day stays a short list. */
105
110
  export declare const MAX_TMUX_RUNS_PER_RUN = 10;
106
111
  /**
@@ -104,9 +104,12 @@ const knownSessions = (now = Date.now()) => readAll()
104
104
  .filter((e) => now - Date.parse(e.lastSeenAt) < exports.KNOWN_SESSION_TTL_MS)
105
105
  .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
106
106
  exports.knownSessions = knownSessions;
107
+ /** No run asked for, or that run. */
108
+ const ofRun = (e, runId) => runId === undefined || e.runId === runId;
107
109
  /** One remembered session, or null when this machine never saw that name.
108
- * The newest run under that name — which is the one a bare resume means. */
109
- const recallSession = (name, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name) ?? null;
110
+ * The newest run under that name — which is the one a bare resume means.
111
+ * With `runId`, that run of the name and nothing else (Q46). */
112
+ const recallSession = (name, now = Date.now(), runId) => (0, exports.knownSessions)(now).find((e) => e.name === name && ofRun(e, runId)) ?? null;
110
113
  exports.recallSession = recallSession;
111
114
  /**
112
115
  * One specific run: the newest under this name that ran THIS agent.
@@ -116,8 +119,12 @@ exports.recallSession = recallSession;
116
119
  * which row was tapped and says so; this is how that answer is honoured.
117
120
  * Null when this machine never saw that agent under that name — the caller
118
121
  * decides whether to fall back to the newest run or refuse.
122
+ *
123
+ * With `runId`, that run of the name and nothing else (docs/session-runs.md
124
+ * Q46): the earlier-runs fold asks for one conversation, and the newest run is
125
+ * a different one.
119
126
  */
120
- const recallRun = (name, agentKind, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.agentKind === agentKind) ?? null;
127
+ const recallRun = (name, agentKind, now = Date.now(), runId) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.agentKind === agentKind && ofRun(e, runId)) ?? null;
121
128
  exports.recallRun = recallRun;
122
129
  /** tmux sessions remembered per run: an `exit` and `-c` every few minutes all day stays a short list. */
123
130
  exports.MAX_TMUX_RUNS_PER_RUN = 10;
package/dist/wrapper.d.ts CHANGED
@@ -44,6 +44,8 @@ export declare const installedTmuxVersion: () => string;
44
44
  export declare const tmuxSessionName: (project: string) => string;
45
45
  /** The first name in the family no live session holds — for a launch that can only create. */
46
46
  export declare const firstFreeSession: (base: string) => string;
47
+ /** `<base>`, `<base>-2`, `<base>-3`, … — the session family for one project. */
48
+ export declare const familyNames: (base: string) => string[];
47
49
  /**
48
50
  * Pick a tmux session name that won't steal focus from another live
49
51
  * `hwip cc`. Strategy:
package/dist/wrapper.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleShellSession = exports.shellTargets = exports.readShellSessionState = exports.planShellSession = exports.handleAgentSession = exports.tmuxMissingMessage = exports.inPaneNote = exports.handOffExec = exports.ensureListenerRunning = exports.takeListenerLaunchMarker = exports.listenerVersionDrifted = exports.targetForAgent = exports.splitAgentOptions = exports.sanitizeLabel = exports.findAvailableSession = exports.firstFreeSession = exports.tmuxSessionName = exports.installedTmuxVersion = exports.herdrEnvArgs = exports.herdrSessionArgs = exports.sessionSidecarArgs = exports.detectProjectName = exports.detectProjectDir = exports.safeBasename = void 0;
3
+ exports.handleShellSession = exports.shellTargets = exports.readShellSessionState = exports.planShellSession = exports.handleAgentSession = exports.tmuxMissingMessage = exports.inPaneNote = exports.handOffExec = exports.ensureListenerRunning = exports.takeListenerLaunchMarker = exports.listenerVersionDrifted = exports.targetForAgent = exports.splitAgentOptions = exports.sanitizeLabel = exports.findAvailableSession = exports.familyNames = exports.firstFreeSession = exports.tmuxSessionName = exports.installedTmuxVersion = exports.herdrEnvArgs = exports.herdrSessionArgs = exports.sessionSidecarArgs = exports.detectProjectName = exports.detectProjectDir = exports.safeBasename = void 0;
4
4
  /**
5
5
  * `hwip cc` / `hwip codex` / `hwip gemini` — spawn an agent inside a named
6
6
  * tmux session so the resident listener (`hwip listener`) can address it
@@ -172,11 +172,12 @@ const MAX_SUFFIX_ATTEMPTS = 20;
172
172
  /** The first name in the family no live session holds — for a launch that can only create. */
173
173
  const firstFreeSession = (base) => {
174
174
  const live = liveSessions();
175
- return familyNames(base).find((name) => !live.has(name)) ?? `${base}-${MAX_SUFFIX_ATTEMPTS + 1}`;
175
+ return (0, exports.familyNames)(base).find((name) => !live.has(name)) ?? `${base}-${MAX_SUFFIX_ATTEMPTS + 1}`;
176
176
  };
177
177
  exports.firstFreeSession = firstFreeSession;
178
178
  /** `<base>`, `<base>-2`, `<base>-3`, … — the session family for one project. */
179
179
  const familyNames = (base) => Array.from({ length: MAX_SUFFIX_ATTEMPTS }, (_, i) => (i === 0 ? base : `${base}-${i + 1}`));
180
+ exports.familyNames = familyNames;
180
181
  /**
181
182
  * Pick a tmux session name that won't steal focus from another live
182
183
  * `hwip cc`. Strategy:
@@ -208,7 +209,7 @@ agentKind,
208
209
  /** Test seam: what this machine recorded about a session name. */
209
210
  recall = session_registry_js_1.recallSession) => {
210
211
  const live = liveSessions();
211
- const family = familyNames(base);
212
+ const family = (0, exports.familyNames)(base);
212
213
  /**
213
214
  * Whether a detached session can host this agent.
214
215
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",