@hwip/cli 0.1.5 → 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 +11 -5
- 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 +9 -0
- package/dist/listener.js +51 -8
- package/dist/remote-agents.d.ts +3 -0
- package/dist/remote-agents.js +40 -7
- package/dist/session-registry.d.ts +8 -3
- package/dist/session-registry.js +10 -3
- package/dist/wrapper.d.ts +2 -0
- package/dist/wrapper.js +4 -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
|
|
|
@@ -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
|
@@ -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
|
@@ -100,6 +100,7 @@ const tmux_liveness_js_1 = require("./tmux-liveness.js");
|
|
|
100
100
|
Object.defineProperty(exports, "liveSessionNames", { enumerable: true, get: function () { return tmux_liveness_js_1.liveSessionNames; } });
|
|
101
101
|
Object.defineProperty(exports, "sessionExists", { enumerable: true, get: function () { return tmux_liveness_js_1.sessionExists; } });
|
|
102
102
|
Object.defineProperty(exports, "sessionLiveness", { enumerable: true, get: function () { return tmux_liveness_js_1.sessionLiveness; } });
|
|
103
|
+
const wrapper_js_1 = require("./wrapper.js");
|
|
103
104
|
const run_seqs_js_1 = require("./run-seqs.js");
|
|
104
105
|
const args_js_1 = require("./args.js");
|
|
105
106
|
const schedule_js_1 = require("./schedule.js");
|
|
@@ -321,11 +322,12 @@ const knownSessionsToReport = (live, now = Date.now(), seqs = (0, run_seqs_js_1.
|
|
|
321
322
|
...(e.tmuxRunIds?.length ? { tmuxRunIds: e.tmuxRunIds } : {}),
|
|
322
323
|
...(e.providerSessionName ? { providerSessionName: e.providerSessionName } : {}),
|
|
323
324
|
...(e.providerSessionName && e.providerSessionNameSource ? { providerSessionNameSource: e.providerSessionNameSource } : {}),
|
|
325
|
+
resumable: isResumable(e),
|
|
324
326
|
lastSeenAt: e.lastSeenAt,
|
|
325
327
|
}));
|
|
326
328
|
};
|
|
327
329
|
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('|');
|
|
330
|
+
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
331
|
exports.knownSessionsFingerprint = knownSessionsFingerprint;
|
|
330
332
|
const sessionsFingerprint = (sessions) => sessions
|
|
331
333
|
.map((s) => JSON.stringify([
|
|
@@ -1365,6 +1367,13 @@ const handleScheduleListRequest = (req, send) => {
|
|
|
1365
1367
|
return true;
|
|
1366
1368
|
};
|
|
1367
1369
|
exports.handleScheduleListRequest = handleScheduleListRequest;
|
|
1370
|
+
/** A run id as the registry writes it: tmux `session_created`, epoch seconds. */
|
|
1371
|
+
const RUN_ID = /^\d{1,12}$/;
|
|
1372
|
+
/** The name a wrapper family counts from: `hwip-x-3` → `hwip-x`. */
|
|
1373
|
+
const familyBase = (name) => {
|
|
1374
|
+
const base = /^(.+)-\d+$/.exec(name)?.[1];
|
|
1375
|
+
return base !== undefined && (0, wrapper_js_1.familyNames)(base).includes(name) ? base : name;
|
|
1376
|
+
};
|
|
1368
1377
|
/** What an agent conversation id looks like — a value, never an option. */
|
|
1369
1378
|
const CONVERSATION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
1370
1379
|
/**
|
|
@@ -1380,6 +1389,26 @@ const resumableConversation = (agent, known) => {
|
|
|
1380
1389
|
const args = agent.resumeArgs?.(id, known.cwd);
|
|
1381
1390
|
return args ? { id, args } : null;
|
|
1382
1391
|
};
|
|
1392
|
+
/**
|
|
1393
|
+
* A live session is on this run's conversation already: the run started beside
|
|
1394
|
+
* its name before, or the live run under it picked the conversation up. The
|
|
1395
|
+
* ended entry stays in the registry, so without this every tap on it would
|
|
1396
|
+
* start one more agent on one transcript.
|
|
1397
|
+
*/
|
|
1398
|
+
const conversationRunning = (known, live) => {
|
|
1399
|
+
const id = known.agentSessionId;
|
|
1400
|
+
if (!id)
|
|
1401
|
+
return false;
|
|
1402
|
+
return [...live].some((name) => {
|
|
1403
|
+
const current = (0, session_registry_js_1.recallSession)(name);
|
|
1404
|
+
return current?.agentKind === known.agentKind && current.agentSessionId === id;
|
|
1405
|
+
});
|
|
1406
|
+
};
|
|
1407
|
+
/** What the report says about a run: the same answer a resume of it would act on. */
|
|
1408
|
+
const isResumable = (known) => {
|
|
1409
|
+
const agent = remote_agents_js_1.REMOTE_AGENTS.find((a) => a.kind === known.agentKind);
|
|
1410
|
+
return agent !== undefined && resumableConversation(agent, known) !== null;
|
|
1411
|
+
};
|
|
1383
1412
|
/**
|
|
1384
1413
|
* Start a remembered session again, detached, and tell the phone what happened.
|
|
1385
1414
|
* Returns true when the message was ours to answer.
|
|
@@ -1423,14 +1452,27 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1423
1452
|
// newest run would start claude for a phone that asked for pi — silently,
|
|
1424
1453
|
// which is the exact failure this path exists to remove. A phone that names
|
|
1425
1454
|
// nothing still means the newest run.
|
|
1455
|
+
const runId = typeof req.runId === 'string' && RUN_ID.test(req.runId) ? req.runId : undefined;
|
|
1426
1456
|
const known = typeof req.agentKind === 'string'
|
|
1427
|
-
? (0, session_registry_js_1.recallRun)(sessionName, req.agentKind)
|
|
1428
|
-
: (0, session_registry_js_1.recallSession)(sessionName);
|
|
1457
|
+
? (0, session_registry_js_1.recallRun)(sessionName, req.agentKind, Date.now(), runId)
|
|
1458
|
+
: (0, session_registry_js_1.recallSession)(sessionName, Date.now(), runId);
|
|
1429
1459
|
if (!known) {
|
|
1430
1460
|
(0, listener_log_js_1.log)(`✗ resume ${sessionName}: never seen on this machine`);
|
|
1431
1461
|
return reply({ error: 'unknown_session' });
|
|
1432
1462
|
}
|
|
1433
|
-
|
|
1463
|
+
// A live name is refused unless the request names a run: then the phone
|
|
1464
|
+
// asked for one earlier conversation, and the session holding the name is
|
|
1465
|
+
// a different one — so it starts beside it, under the next free name of
|
|
1466
|
+
// the family `hwip cc` would pick (Q41), counted from the family base so a
|
|
1467
|
+
// busy `<base>-2` leads to `<base>-3`. A request without a run id is an
|
|
1468
|
+
// older app that only knows the name, and still gets the refusal it renders.
|
|
1469
|
+
const live = new Set((0, tmux_liveness_js_1.liveSessionNames)() ?? []);
|
|
1470
|
+
const target = !live.has(sessionName)
|
|
1471
|
+
? sessionName
|
|
1472
|
+
: runId !== undefined
|
|
1473
|
+
? (0, wrapper_js_1.familyNames)(familyBase(sessionName)).find((name) => !live.has(name))
|
|
1474
|
+
: undefined;
|
|
1475
|
+
if (!target || conversationRunning(known, live))
|
|
1434
1476
|
return reply({ error: 'already_running' });
|
|
1435
1477
|
if (!(0, session_registry_js_1.sessionDirectoryExists)(known)) {
|
|
1436
1478
|
(0, listener_log_js_1.log)(`✗ resume ${sessionName}: ${known.cwd} is gone`);
|
|
@@ -1451,14 +1493,15 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1451
1493
|
? [';', 'set-option', config_js_1.SESSION_PROJECT_OPTION, known.project, ';', 'set-option', config_js_1.SESSION_LABEL_OPTION, known.label]
|
|
1452
1494
|
: [];
|
|
1453
1495
|
const conversation = resumableConversation(agent, known);
|
|
1454
|
-
const started = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['new-session', '-d', '-s',
|
|
1496
|
+
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'] });
|
|
1455
1497
|
if (started.status !== 0) {
|
|
1456
1498
|
(0, listener_log_js_1.log)(`✗ resume ${sessionName}: tmux refused (${(started.stderr ?? '').trim() || 'no detail'})`);
|
|
1457
1499
|
return reply({ error: 'start_failed' });
|
|
1458
1500
|
}
|
|
1459
1501
|
const resumedFrom = conversation ? ` (conversation ${conversation.id.slice(0, 8)})` : '';
|
|
1460
|
-
|
|
1461
|
-
|
|
1502
|
+
const beside = target === sessionName ? '' : ` as ${target}`;
|
|
1503
|
+
(0, listener_log_js_1.log)(`⟲ resume ${sessionName}${beside}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
|
|
1504
|
+
return reply({ resumed: true, sessionName: target });
|
|
1462
1505
|
};
|
|
1463
1506
|
exports.handleSessionResumeRequest = handleSessionResumeRequest;
|
|
1464
1507
|
/**
|
|
@@ -1618,7 +1661,7 @@ const handleSessionForgetRequest = (req, send) => {
|
|
|
1618
1661
|
// alternative is an irreversible delete (registry AND scrollback) justified
|
|
1619
1662
|
// by a question that failed to be asked. It says so in the log, because
|
|
1620
1663
|
// from the phone the two refusals are the same sentence.
|
|
1621
|
-
const runId = typeof req.runId === 'string' &&
|
|
1664
|
+
const runId = typeof req.runId === 'string' && RUN_ID.test(req.runId) ? req.runId : undefined;
|
|
1622
1665
|
const liveness = (0, tmux_liveness_js_1.sessionLiveness)(sessionName);
|
|
1623
1666
|
if (liveness === 'unknown') {
|
|
1624
1667
|
(0, listener_log_js_1.log)(`✗ forget ${sessionName}: no tmux answered, so nothing was deleted`);
|
package/dist/remote-agents.d.ts
CHANGED
|
@@ -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";
|
package/dist/remote-agents.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
/**
|
package/dist/session-registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|