@hwip/cli 0.1.22 → 0.1.24
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 +33 -2
- package/dist/args.js +1 -0
- package/dist/background-tasks-output.d.ts +28 -0
- package/dist/background-tasks-output.js +107 -0
- package/dist/background-tasks-requests.d.ts +22 -0
- package/dist/background-tasks-requests.js +26 -0
- package/dist/background-tasks-stop.d.ts +43 -0
- package/dist/background-tasks-stop.js +128 -0
- package/dist/background-tasks.d.ts +65 -0
- package/dist/background-tasks.js +225 -0
- package/dist/cli.js +2 -0
- package/dist/crypto.d.ts +1 -1
- package/dist/listener.d.ts +65 -0
- package/dist/listener.js +258 -8
- package/dist/resume-hint.d.ts +27 -0
- package/dist/resume-hint.js +59 -0
- package/dist/subagent-transcripts.d.ts +5 -0
- package/dist/subagent-transcripts.js +5 -5
- package/dist/wrapper.d.ts +11 -0
- package/dist/wrapper.js +37 -4
- package/package.json +1 -1
package/dist/listener.js
CHANGED
|
@@ -26,8 +26,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
26
26
|
};
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
28
|
exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.withSettledPrompts = exports.PROMPT_SEAL_WAIT_MS = exports.withBlockedPrompts = exports.setPromptSealer = exports.deriveSessionState = exports.resetSessionStates = exports.continueRuns = exports.runIdOfEpoch = exports.__checkoutCacheSize = exports.parseSessionName = exports.resetPaneFitClaims = exports.tmuxTargetFor = exports.subagentTranscriptFor = exports.recordTargets = exports.recordSubagentTranscripts = exports.isSubagentSessionName = exports.detectRemoteAgent = exports.sessionLiveness = exports.sessionExists = exports.liveSessionNames = exports.tmuxArgs = exports.silenceListenerLog = exports.invalidateTmuxSocketCache = exports.wheelInput = exports.wheelEvents = exports.WHEEL_STATE_FORMAT = exports.resolveWheel = exports.resolveKeys = exports.probePane = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = exports.commandsReportDue = exports.commandsFingerprint = exports.COMMAND_SCAN_INTERVAL_MS = exports.sessionsReportDue = exports.sessionsFingerprint = exports.withRunSeqs = exports.staleConnectionAction = exports.askedToReconnect = exports.MAX_STALE_CONNECTION_DROPS = exports.createReportAckWatch = exports.REPORT_ACK_TIMEOUT_MS = exports.SESSION_REPORT_HEARTBEAT_MS = exports.SESSION_REPORT_INTERVAL_MS = exports.heartbeatVerdict = exports.pingDue = exports.PING_IDLE_MS = void 0;
|
|
29
|
-
exports.
|
|
30
|
-
exports.handleListener = exports.liveScheduleRunnerDeps = exports.listenerSettings = exports.resolveWsUrl = exports.streamSession = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.displayFolder = exports.claimLanFile = exports.saveFileFromS3 = exports.downloadSealedEach = exports.answerFileBound = exports.downloadAttachments = exports.setAttachmentContext = exports.shellTextRefusal = exports.writeRemoteMarker = exports.isInventoried = exports.syncTmuxAgentNames = exports.recordInventory = void 0;
|
|
29
|
+
exports.MAX_PENDING_DECRYPTS = exports.MAX_INPUT_LANES = exports.validateInputMessage = exports.MAX_INPUT_BODY_CHARS = exports.MAX_INPUT_KEYS = exports.handleStreamControl = exports.buildStreamFrame = exports.isStreamCapReached = exports.fitSealedFrame = exports.SEALED_FRAME_MAX_BYTES = exports.stopAllStreams = exports.stopStream = exports.stopStreamOf = exports.handleTurnWatch = exports.sealTurnBatch = exports.resolveWatchTranscript = exports.MAX_CONCURRENT_STREAMS = exports.STREAM_RENEW_FRESH_MS = exports.STREAM_LEASE_MS = exports.claimFrameSend = exports.hasFrameBudget = exports.streamCadence = exports.MAX_FRAMES_PER_SEC = exports.BURST_WINDOW_MS = exports.BURST_INTERVAL_MS = exports.STREAM_INTERVAL_MS = exports.STREAM_CAPTURE_LINES = exports.scheduleInjectSnapshots = exports.historyLinesIn = exports.cursorLineFor = exports.parsePaneGeometry = exports.STREAM_GEOMETRY_FORMAT = exports.handleScreenHistoryRequest = exports.SCREEN_HISTORY_MAX_LINES = exports.handleSessionForgetRequest = exports.handleSessionExitRequest = exports.SESSION_EXIT_STEP_MS = exports.handleSessionResumeRequest = exports.SHELL_START_KIND = exports.handleBackgroundTaskOutputRequest = exports.handleBackgroundTaskStopRequest = exports.handleBackgroundTasksRequest = exports.handleScheduleListRequest = exports.handleFileBlobRequest = exports.handleFileTreeRequest = exports.handleDiffFileRequest = exports.handleDiffFilesRequest = exports.fileViewBytes = exports.handleScreenRequest = exports.setSubscriberTrust = void 0;
|
|
30
|
+
exports.handleListener = exports.liveScheduleRunnerDeps = exports.listenerSettings = exports.resolveWsUrl = exports.streamSession = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.displayFolder = exports.claimLanFile = exports.saveFileFromS3 = exports.downloadSealedEach = exports.answerFileBound = exports.downloadAttachments = exports.setAttachmentContext = exports.shellTextRefusal = exports.writeRemoteMarker = exports.isInventoried = exports.syncTmuxAgentNames = exports.recordInventory = exports.collectSessionsVerbose = exports.handleCommandInput = exports.pendingInputDecrypts = void 0;
|
|
31
31
|
const child_process_1 = require("child_process");
|
|
32
32
|
const crypto_1 = require("crypto");
|
|
33
33
|
const fs_1 = require("fs");
|
|
@@ -51,6 +51,10 @@ const gate_js_1 = require("./gate.js");
|
|
|
51
51
|
const session_registry_js_1 = require("./session-registry.js");
|
|
52
52
|
const remote_agents_js_1 = require("./remote-agents.js");
|
|
53
53
|
const subagent_transcripts_js_1 = require("./subagent-transcripts.js");
|
|
54
|
+
const background_tasks_js_1 = require("./background-tasks.js");
|
|
55
|
+
const background_tasks_requests_js_1 = require("./background-tasks-requests.js");
|
|
56
|
+
const background_tasks_output_js_1 = require("./background-tasks-output.js");
|
|
57
|
+
const background_tasks_stop_js_1 = require("./background-tasks-stop.js");
|
|
54
58
|
const transcript_tail_js_1 = require("./transcript-tail.js");
|
|
55
59
|
const listener_service_js_1 = require("./listener-service.js");
|
|
56
60
|
const agent_state_js_1 = require("./agent-state.js");
|
|
@@ -1429,6 +1433,232 @@ const handleScheduleListRequest = (req, send) => {
|
|
|
1429
1433
|
return true;
|
|
1430
1434
|
};
|
|
1431
1435
|
exports.handleScheduleListRequest = handleScheduleListRequest;
|
|
1436
|
+
/**
|
|
1437
|
+
* Answer a phone's request for the background commands a Claude session runs
|
|
1438
|
+
* (`Bash` with `run_in_background`). The rows are Claude's descriptions of
|
|
1439
|
+
* them, which can say what a command is for, so the answer is sealed for a key
|
|
1440
|
+
* that checked out and bound to the session — never in the clear, whatever
|
|
1441
|
+
* this listener's seal history. Each task is checked for a live writer on its
|
|
1442
|
+
* output now, not only at the last sweep, so a dead one is not listed.
|
|
1443
|
+
*/
|
|
1444
|
+
const handleBackgroundTasksRequest = (req, send) => {
|
|
1445
|
+
if (req.subtype !== 'agent.tasks.request')
|
|
1446
|
+
return false;
|
|
1447
|
+
if (typeof req.requestId !== 'string' || !req.requestId || req.requestId.length > MAX_REQUEST_ID_LENGTH)
|
|
1448
|
+
return false;
|
|
1449
|
+
if (typeof req.sessionName !== 'string' || !req.sessionName || req.sessionName.length > MAX_REQUEST_ID_LENGTH)
|
|
1450
|
+
return false;
|
|
1451
|
+
if (req.targetDeviceId !== (0, exports.computeListenerDeviceId)())
|
|
1452
|
+
return false;
|
|
1453
|
+
const { requestId, sessionName } = req;
|
|
1454
|
+
const frame = {
|
|
1455
|
+
subtype: 'agent.tasks.result',
|
|
1456
|
+
requestId,
|
|
1457
|
+
sessionName,
|
|
1458
|
+
...(typeof req.deviceId === 'string' && req.deviceId ? { targetDeviceId: req.deviceId } : {}),
|
|
1459
|
+
at: new Date().toISOString(),
|
|
1460
|
+
};
|
|
1461
|
+
const reply = (fields) => { send({ ...frame, ...fields }); };
|
|
1462
|
+
const asker = typeof req.deviceId === 'string' && /^[\w-]{1,64}$/.test(req.deviceId) ? req.deviceId : '';
|
|
1463
|
+
if (!(0, exports.checkRateLimit)(`tasks:list:${asker}`)) {
|
|
1464
|
+
reply({ error: 'rate_limited' });
|
|
1465
|
+
return true;
|
|
1466
|
+
}
|
|
1467
|
+
// No key, no list: a plaintext request is refused even where screens still go out in the clear.
|
|
1468
|
+
if (!asksForSeal(req)) {
|
|
1469
|
+
reply({ error: 'e2ee_required' });
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
whenSealAdmitted(req, (phoneKey) => {
|
|
1473
|
+
(0, background_tasks_requests_js_1.liveTaskRows)((0, background_tasks_js_1.backgroundTasksFor)(sessionName)?.tasks ?? [])
|
|
1474
|
+
.then(async (tasks) => {
|
|
1475
|
+
await (0, crypto_js_1.initDeviceCrypto)();
|
|
1476
|
+
return (0, crypto_js_1.sealV2)('agent.tasks', JSON.stringify({ v: 1, requestId, at: frame.at, tasks }), phoneKey, sessionName);
|
|
1477
|
+
})
|
|
1478
|
+
.then((encrypted) => reply({ encrypted }))
|
|
1479
|
+
.catch((err) => {
|
|
1480
|
+
// A seal that fails is an error, never a plaintext list.
|
|
1481
|
+
(0, listener_log_js_1.log)(`⧉ tasks ${sessionName}: seal failed (${err instanceof Error ? err.message : err})`);
|
|
1482
|
+
reply({ error: 'encrypt_failed' });
|
|
1483
|
+
});
|
|
1484
|
+
}, (reason) => { reply({ error: reason }); });
|
|
1485
|
+
return true;
|
|
1486
|
+
};
|
|
1487
|
+
exports.handleBackgroundTasksRequest = handleBackgroundTasksRequest;
|
|
1488
|
+
/** Task ids as Claude Code writes them (`STARTED` in background-tasks.ts). */
|
|
1489
|
+
const TASK_ID_PATTERN = /^[A-Za-z0-9]{1,32}$/;
|
|
1490
|
+
/** How far a stop request's own clock may sit from this machine's. */
|
|
1491
|
+
const STOP_CLOCK_SKEW_MS = 5 * 60_000;
|
|
1492
|
+
/**
|
|
1493
|
+
* Stop requests already acted on, by phone key and request id → when. Kept for
|
|
1494
|
+
* twice the skew: an envelope is taken while its own `at` is within the skew
|
|
1495
|
+
* of now, and a phone clock running ahead keeps it takeable past one window
|
|
1496
|
+
* from when it was acted on. In memory only: a listener restarted inside the
|
|
1497
|
+
* window would take a replayed stop again, which can end only a command the
|
|
1498
|
+
* session still runs, found again from this machine's own probes.
|
|
1499
|
+
*/
|
|
1500
|
+
const actedStops = new Map();
|
|
1501
|
+
const MAX_ACTED_STOPS = 512;
|
|
1502
|
+
/** True the first time a phone's request id is seen. */
|
|
1503
|
+
const firstStop = (key, now) => {
|
|
1504
|
+
for (const [seen, at] of actedStops)
|
|
1505
|
+
if (now - at > 2 * STOP_CLOCK_SKEW_MS)
|
|
1506
|
+
actedStops.delete(seen);
|
|
1507
|
+
if (actedStops.has(key))
|
|
1508
|
+
return false;
|
|
1509
|
+
if (actedStops.size >= MAX_ACTED_STOPS)
|
|
1510
|
+
actedStops.delete(actedStops.keys().next().value);
|
|
1511
|
+
actedStops.set(key, now);
|
|
1512
|
+
return true;
|
|
1513
|
+
};
|
|
1514
|
+
/**
|
|
1515
|
+
* Stop a background command a Claude session runs, for a trusted phone. The
|
|
1516
|
+
* one remote path here that ends a process, so the request has to prove who
|
|
1517
|
+
* sent it: it is sealed by the phone's key, which must be the asking device's
|
|
1518
|
+
* trusted key, and names the request, the task and its time inside the seal.
|
|
1519
|
+
* A key alone is public and proves nothing. The phone names a task by id;
|
|
1520
|
+
* which process group that is, and whether it may be signalled at all, is
|
|
1521
|
+
* decided on this machine (`background-tasks-stop.ts`). The answer is a status
|
|
1522
|
+
* in the clear: it says nothing about the task beyond whether it was stopped.
|
|
1523
|
+
*/
|
|
1524
|
+
const handleBackgroundTaskStopRequest = (req, send) => {
|
|
1525
|
+
if (req.subtype !== 'agent.task.stop.request')
|
|
1526
|
+
return false;
|
|
1527
|
+
if (typeof req.requestId !== 'string' || !req.requestId || req.requestId.length > MAX_REQUEST_ID_LENGTH)
|
|
1528
|
+
return false;
|
|
1529
|
+
if (typeof req.sessionName !== 'string' || !req.sessionName || req.sessionName.length > MAX_REQUEST_ID_LENGTH)
|
|
1530
|
+
return false;
|
|
1531
|
+
if (req.targetDeviceId !== (0, exports.computeListenerDeviceId)())
|
|
1532
|
+
return false;
|
|
1533
|
+
const { requestId, sessionName } = req;
|
|
1534
|
+
const frame = {
|
|
1535
|
+
subtype: 'agent.task.stop.result',
|
|
1536
|
+
requestId,
|
|
1537
|
+
sessionName,
|
|
1538
|
+
...(typeof req.deviceId === 'string' && req.deviceId ? { targetDeviceId: req.deviceId } : {}),
|
|
1539
|
+
at: new Date().toISOString(),
|
|
1540
|
+
};
|
|
1541
|
+
const reply = (fields) => { send({ ...frame, ...fields }); };
|
|
1542
|
+
const asker = typeof req.deviceId === 'string' && /^[\w-]{1,64}$/.test(req.deviceId) ? req.deviceId : '';
|
|
1543
|
+
if (!(0, exports.checkRateLimit)(`tasks:stop:${asker}`)) {
|
|
1544
|
+
reply({ error: 'rate_limited' });
|
|
1545
|
+
return true;
|
|
1546
|
+
}
|
|
1547
|
+
if (!asksForSeal(req)) {
|
|
1548
|
+
reply({ error: 'e2ee_required' });
|
|
1549
|
+
return true;
|
|
1550
|
+
}
|
|
1551
|
+
const sealedRequest = req.encrypted;
|
|
1552
|
+
if (!(0, crypto_js_1.isSealedV2)(sealedRequest)) {
|
|
1553
|
+
reply({ error: 'bad_request' });
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
1556
|
+
const answer = async (phoneKey) => {
|
|
1557
|
+
let asked;
|
|
1558
|
+
try {
|
|
1559
|
+
// Untrusted until the checks below: every field is `unknown`.
|
|
1560
|
+
asked = JSON.parse(await (0, crypto_js_1.openV2)('agent.task.stop', sealedRequest, phoneKey, sessionName));
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
return { error: 'bad_request' };
|
|
1564
|
+
}
|
|
1565
|
+
if (typeof asked !== 'object' || asked === null || asked.v !== 1 || asked.requestId !== requestId)
|
|
1566
|
+
return { error: 'bad_request' };
|
|
1567
|
+
if (typeof asked.taskId !== 'string' || !TASK_ID_PATTERN.test(asked.taskId))
|
|
1568
|
+
return { error: 'bad_request' };
|
|
1569
|
+
const now = Date.now();
|
|
1570
|
+
const at = typeof asked.at === 'string' ? Date.parse(asked.at) : Number.NaN;
|
|
1571
|
+
if (!Number.isFinite(at) || Math.abs(now - at) > STOP_CLOCK_SKEW_MS)
|
|
1572
|
+
return { error: 'bad_request' };
|
|
1573
|
+
// A relay that kept a sealed stop can hand it back; the seal names the request, so the second one is refused.
|
|
1574
|
+
if (!firstStop(`${phoneKey}\n${requestId}`, now))
|
|
1575
|
+
return { error: 'bad_request' };
|
|
1576
|
+
const { taskId } = asked;
|
|
1577
|
+
const running = (0, background_tasks_js_1.backgroundTasksFor)(sessionName);
|
|
1578
|
+
const task = running?.tasks.find((t) => t.id === taskId);
|
|
1579
|
+
if (!task || running?.panePid == null)
|
|
1580
|
+
return { result: 'not_found' };
|
|
1581
|
+
const result = await (0, background_tasks_stop_js_1.stopBackgroundTask)(task, running.panePid);
|
|
1582
|
+
(0, listener_log_js_1.log)(`⧉ tasks ${sessionName}: stop ${taskId} → ${result}`);
|
|
1583
|
+
return { result };
|
|
1584
|
+
};
|
|
1585
|
+
whenSealAdmitted(req, (phoneKey) => {
|
|
1586
|
+
answer(phoneKey)
|
|
1587
|
+
.then(reply)
|
|
1588
|
+
.catch((err) => {
|
|
1589
|
+
(0, listener_log_js_1.log)(`⧉ tasks ${sessionName}: stop failed (${err instanceof Error ? err.message : err})`);
|
|
1590
|
+
reply({ result: 'kill_failed' });
|
|
1591
|
+
});
|
|
1592
|
+
}, (reason) => { reply({ error: reason }); });
|
|
1593
|
+
return true;
|
|
1594
|
+
};
|
|
1595
|
+
exports.handleBackgroundTaskStopRequest = handleBackgroundTaskStopRequest;
|
|
1596
|
+
/**
|
|
1597
|
+
* Answer a phone's request for the end of a background command's output
|
|
1598
|
+
* (`readTaskOutputTail`: the last 16 KB of the file the transcript named, if
|
|
1599
|
+
* it is that task's `tasks/<id>.output`). A log can hold anything a command
|
|
1600
|
+
* printed, so the answer is sealed for a key that checked out and bound to
|
|
1601
|
+
* the session, the request and the task — never in the clear.
|
|
1602
|
+
*/
|
|
1603
|
+
const handleBackgroundTaskOutputRequest = (req, send) => {
|
|
1604
|
+
if (req.subtype !== 'agent.task.output.request')
|
|
1605
|
+
return false;
|
|
1606
|
+
if (typeof req.requestId !== 'string' || !req.requestId || req.requestId.length > MAX_REQUEST_ID_LENGTH)
|
|
1607
|
+
return false;
|
|
1608
|
+
if (typeof req.sessionName !== 'string' || !req.sessionName || req.sessionName.length > MAX_REQUEST_ID_LENGTH)
|
|
1609
|
+
return false;
|
|
1610
|
+
if (req.targetDeviceId !== (0, exports.computeListenerDeviceId)())
|
|
1611
|
+
return false;
|
|
1612
|
+
const { requestId, sessionName } = req;
|
|
1613
|
+
const frame = {
|
|
1614
|
+
subtype: 'agent.task.output.result',
|
|
1615
|
+
requestId,
|
|
1616
|
+
sessionName,
|
|
1617
|
+
...(typeof req.deviceId === 'string' && req.deviceId ? { targetDeviceId: req.deviceId } : {}),
|
|
1618
|
+
at: new Date().toISOString(),
|
|
1619
|
+
};
|
|
1620
|
+
const reply = (fields) => { send({ ...frame, ...fields }); };
|
|
1621
|
+
const asker = typeof req.deviceId === 'string' && /^[\w-]{1,64}$/.test(req.deviceId) ? req.deviceId : '';
|
|
1622
|
+
if (!(0, exports.checkRateLimit)(`tasks:output:${asker}`)) {
|
|
1623
|
+
reply({ error: 'rate_limited' });
|
|
1624
|
+
return true;
|
|
1625
|
+
}
|
|
1626
|
+
if (!asksForSeal(req)) {
|
|
1627
|
+
reply({ error: 'e2ee_required' });
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
const { taskId } = req;
|
|
1631
|
+
if (typeof taskId !== 'string' || !TASK_ID_PATTERN.test(taskId)) {
|
|
1632
|
+
reply({ error: 'bad_request' });
|
|
1633
|
+
return true;
|
|
1634
|
+
}
|
|
1635
|
+
whenSealAdmitted(req, (phoneKey) => {
|
|
1636
|
+
const task = (0, background_tasks_js_1.backgroundTasksFor)(sessionName)?.tasks.find((t) => t.id === taskId);
|
|
1637
|
+
if (!task)
|
|
1638
|
+
return reply({ error: 'not_found' });
|
|
1639
|
+
const answer = async () => {
|
|
1640
|
+
const tail = await (0, background_tasks_output_js_1.readTaskOutputTail)(task);
|
|
1641
|
+
if ('error' in tail)
|
|
1642
|
+
return { error: tail.error };
|
|
1643
|
+
const body = (0, background_tasks_output_js_1.fitTaskOutputBody)({ requestId, at: frame.at, taskId }, tail, exports.SEALED_FRAME_MAX_BYTES);
|
|
1644
|
+
try {
|
|
1645
|
+
await (0, crypto_js_1.initDeviceCrypto)();
|
|
1646
|
+
return { encrypted: await (0, crypto_js_1.sealV2)('agent.task.output', body, phoneKey, sessionName) };
|
|
1647
|
+
}
|
|
1648
|
+
catch (err) {
|
|
1649
|
+
// A seal that fails is an error, never the output in the clear.
|
|
1650
|
+
(0, listener_log_js_1.log)(`⧉ tasks ${sessionName}: output seal failed (${err instanceof Error ? err.message : err})`);
|
|
1651
|
+
return { error: 'encrypt_failed' };
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
answer().then(reply, (err) => {
|
|
1655
|
+
(0, listener_log_js_1.log)(`⧉ tasks ${sessionName}: output read failed (${err instanceof Error ? err.message : err})`);
|
|
1656
|
+
reply({ error: 'read_failed' });
|
|
1657
|
+
});
|
|
1658
|
+
}, (reason) => { reply({ error: reason }); });
|
|
1659
|
+
return true;
|
|
1660
|
+
};
|
|
1661
|
+
exports.handleBackgroundTaskOutputRequest = handleBackgroundTaskOutputRequest;
|
|
1432
1662
|
/** A run id as the registry writes it: tmux `session_created`, epoch seconds. */
|
|
1433
1663
|
const RUN_ID = /^\d{1,12}$/;
|
|
1434
1664
|
/** The name a wrapper family counts from: `hwip-x-3` → `hwip-x`. */
|
|
@@ -3497,6 +3727,8 @@ exports.handleCommandInput = handleCommandInput;
|
|
|
3497
3727
|
* viewer nothing but a re-render.
|
|
3498
3728
|
*/
|
|
3499
3729
|
const subagentScans = new Map();
|
|
3730
|
+
/** Per Claude session: what its transcript says is running in the background. */
|
|
3731
|
+
const backgroundTaskStates = new Map();
|
|
3500
3732
|
/**
|
|
3501
3733
|
* Inventory pass that also records *why* each `hwip-*` session was
|
|
3502
3734
|
* skipped. The verbose log uses the rejection notes to explain empty
|
|
@@ -3524,7 +3756,7 @@ const collectSessionsVerbose = () => {
|
|
|
3524
3756
|
// Invalidate so the next cycle re-runs full discovery instead of
|
|
3525
3757
|
// wedging the listener at "reported 0 session(s)" forever.
|
|
3526
3758
|
(0, tmux_socket_js_1.invalidateTmuxSocketCache)();
|
|
3527
|
-
return { sessions: [], rejected: [], targets: {}, subagentTranscripts: {}, recordable: {} };
|
|
3759
|
+
return { sessions: [], rejected: [], targets: {}, subagentTranscripts: {}, recordable: {}, backgroundTasks: {} };
|
|
3528
3760
|
}
|
|
3529
3761
|
const rawLines = (list.stdout ?? '').split('\n').filter(Boolean);
|
|
3530
3762
|
// Sanity-check that the format separator actually survived. tmux is
|
|
@@ -3587,6 +3819,7 @@ const collectSessionsVerbose = () => {
|
|
|
3587
3819
|
const resumeIdOf = new Map();
|
|
3588
3820
|
const targets = {};
|
|
3589
3821
|
const subagentTranscripts = {};
|
|
3822
|
+
const backgroundTasks = {};
|
|
3590
3823
|
const recordable = {};
|
|
3591
3824
|
for (const [name, group] of groups) {
|
|
3592
3825
|
// Main pane = the lowest (window, pane) index that runs an agent.
|
|
@@ -3664,7 +3897,7 @@ const collectSessionsVerbose = () => {
|
|
|
3664
3897
|
if (resumeId)
|
|
3665
3898
|
resumeIdOf.set(name, resumeId);
|
|
3666
3899
|
targets[name] = main.row.paneId;
|
|
3667
|
-
|
|
3900
|
+
const mainSession = {
|
|
3668
3901
|
name,
|
|
3669
3902
|
attached: group.attached,
|
|
3670
3903
|
agentKind: main.agent.kind,
|
|
@@ -3678,7 +3911,8 @@ const collectSessionsVerbose = () => {
|
|
|
3678
3911
|
runId: (0, exports.runIdOfEpoch)(group.created),
|
|
3679
3912
|
lastActivityAt: epochToIso(group.activity),
|
|
3680
3913
|
...(0, exports.deriveSessionState)(name, main.agent.kind, capturePaneText(name, main.row.paneId)),
|
|
3681
|
-
}
|
|
3914
|
+
};
|
|
3915
|
+
sessions.push(mainSession);
|
|
3682
3916
|
// Subagent panes: every OTHER pane of this session whose tty's
|
|
3683
3917
|
// foreground process group runs a registered agent (measured:
|
|
3684
3918
|
// pane_current_command alone says `bash` for a pi subagent — the
|
|
@@ -3740,6 +3974,15 @@ const collectSessionsVerbose = () => {
|
|
|
3740
3974
|
}
|
|
3741
3975
|
const transcriptPath = main.agent.kind === 'claude' ? mainTranscript : null;
|
|
3742
3976
|
if (transcriptPath) {
|
|
3977
|
+
// Background commands (`Bash` with run_in_background): the phone
|
|
3978
|
+
// shows how many, and asks for the rows themselves sealed.
|
|
3979
|
+
const tracked = backgroundTaskStates.get(name) ?? (0, background_tasks_js_1.initialBackgroundTaskState)();
|
|
3980
|
+
backgroundTaskStates.set(name, tracked);
|
|
3981
|
+
const tasks = (0, background_tasks_js_1.trackBackgroundTasks)(transcriptPath, tracked);
|
|
3982
|
+
if (tasks.length > 0)
|
|
3983
|
+
backgroundTasks[name] = { tasks, panePid: info.panePid ?? null };
|
|
3984
|
+
if (tasks.length > 0)
|
|
3985
|
+
mainSession.backgroundTaskCount = tasks.length;
|
|
3743
3986
|
const scan = subagentScans.get(name) ?? (0, subagent_transcripts_js_1.initialSubagentScanState)();
|
|
3744
3987
|
subagentScans.set(name, scan);
|
|
3745
3988
|
const reserved = new Set(extras.map(paneNum));
|
|
@@ -3781,6 +4024,7 @@ const collectSessionsVerbose = () => {
|
|
|
3781
4024
|
}
|
|
3782
4025
|
}
|
|
3783
4026
|
pruneToLive(subagentScans, new Set(groups.keys()));
|
|
4027
|
+
pruneToLive(backgroundTaskStates, new Set(groups.keys()));
|
|
3784
4028
|
pruneToLive(sessionStates, new Set(sessions.map((s) => s.name)));
|
|
3785
4029
|
// An id two panes both answer to belongs to at most one of them, and the
|
|
3786
4030
|
// sweep cannot tell which — opencode names the busiest session in the
|
|
@@ -3821,7 +4065,7 @@ const collectSessionsVerbose = () => {
|
|
|
3821
4065
|
providerSessionName: s.providerSessionName,
|
|
3822
4066
|
providerSessionNameSource: s.providerSessionNameSource,
|
|
3823
4067
|
})));
|
|
3824
|
-
return { sessions, rejected, targets, subagentTranscripts, recordable };
|
|
4068
|
+
return { sessions, rejected, targets, subagentTranscripts, recordable, backgroundTasks };
|
|
3825
4069
|
};
|
|
3826
4070
|
exports.collectSessionsVerbose = collectSessionsVerbose;
|
|
3827
4071
|
/**
|
|
@@ -3841,6 +4085,7 @@ const collectSessions = () => {
|
|
|
3841
4085
|
// back to the (focus-following) bare session name.
|
|
3842
4086
|
(0, tmux_pane_js_1.recordTargets)(result.targets);
|
|
3843
4087
|
(0, tmux_pane_js_1.recordSubagentTranscripts)(result.subagentTranscripts);
|
|
4088
|
+
(0, background_tasks_js_1.recordBackgroundTasks)(result.backgroundTasks);
|
|
3844
4089
|
return result.sessions;
|
|
3845
4090
|
};
|
|
3846
4091
|
/** Names from the most recent inventory sweep; null until the first one lands —
|
|
@@ -5295,11 +5540,12 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
5295
5540
|
}
|
|
5296
5541
|
};
|
|
5297
5542
|
const publishSessions = (inventory, sessions) => {
|
|
5298
|
-
const { rejected, targets, subagentTranscripts, recordable } = inventory;
|
|
5543
|
+
const { rejected, targets, subagentTranscripts, recordable, backgroundTasks } = inventory;
|
|
5299
5544
|
(0, exports.recordInventory)(sessions);
|
|
5300
5545
|
worktreeCleanup.observe(sessions.map((s) => s.name));
|
|
5301
5546
|
(0, tmux_pane_js_1.recordTargets)(targets);
|
|
5302
5547
|
(0, tmux_pane_js_1.recordSubagentTranscripts)(subagentTranscripts);
|
|
5548
|
+
(0, background_tasks_js_1.recordBackgroundTasks)(backgroundTasks);
|
|
5303
5549
|
turnRecorder?.observe(recordable);
|
|
5304
5550
|
// Let the user's own status bar say what the phone says. Change-gated
|
|
5305
5551
|
// internally, so an unchanged sweep spawns nothing.
|
|
@@ -5532,7 +5778,11 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
5532
5778
|
&& !(0, exports.handleFileTreeRequest)(m.data, sendEphemeral)
|
|
5533
5779
|
&& !(0, exports.handleFileBlobRequest)(m.data, sendEphemeral)
|
|
5534
5780
|
// Reading the schedules: files this machine wrote itself, sealed for the asker.
|
|
5535
|
-
&& !(0, exports.handleScheduleListRequest)(m.data, sendEphemeral)
|
|
5781
|
+
&& !(0, exports.handleScheduleListRequest)(m.data, sendEphemeral)
|
|
5782
|
+
// A session's background commands: what Claude said they are, sealed for the asker.
|
|
5783
|
+
&& !(0, exports.handleBackgroundTasksRequest)(m.data, sendEphemeral)
|
|
5784
|
+
&& !(0, exports.handleBackgroundTaskStopRequest)(m.data, sendEphemeral)
|
|
5785
|
+
&& !(0, exports.handleBackgroundTaskOutputRequest)(m.data, sendEphemeral)) {
|
|
5536
5786
|
void (0, exports.handleScreenRequest)(m.data)
|
|
5537
5787
|
.then((reply) => { if (reply)
|
|
5538
5788
|
sendEphemeral(reply); })
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line `hwip cc` / `hwip pi` leaves once its tmux session has ended: the
|
|
3
|
+
* command that picks the conversation back up (`claude --resume <id>`).
|
|
4
|
+
*/
|
|
5
|
+
import { type RemoteAgent } from './remote-agents.js';
|
|
6
|
+
import { type KnownSession } from './session-registry.js';
|
|
7
|
+
export interface ResumeHintDeps {
|
|
8
|
+
recall: (name: string, agentKind: string) => KnownSession | null;
|
|
9
|
+
/** Whether tmux still has the session: a detach returns from `tmux new` as well as an exit does. */
|
|
10
|
+
alive: (name: string) => boolean;
|
|
11
|
+
cwd: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* What to print after the tmux session `session` of `agent` returned, or null
|
|
15
|
+
* when there is nothing true to say. `since` is when this `hwip` launched (ms):
|
|
16
|
+
* a record the sweep has not touched since is an earlier run of the same name,
|
|
17
|
+
* whose conversation is not the one just closed. A session shorter than one
|
|
18
|
+
* sweep has no record yet, and gets no line rather than a guess.
|
|
19
|
+
*/
|
|
20
|
+
export declare const resumeHint: (session: string, agent: Pick<RemoteAgent, "kind" | "binary" | "subcommands" | "resumeArgs">, since: number, deps?: ResumeHintDeps) => string | null;
|
|
21
|
+
/**
|
|
22
|
+
* `hwip resume-hint --session <tmux name> --type <agent kind> --at <ms>` — the
|
|
23
|
+
* call `withResumeHint` leaves after tmux. Not for people: it always exits 0,
|
|
24
|
+
* so a missing record or a failed read never changes the agent's exit status.
|
|
25
|
+
*/
|
|
26
|
+
export declare const handleResumeHint: (args: Record<string, string | boolean>) => number;
|
|
27
|
+
//# sourceMappingURL=resume-hint.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The line `hwip cc` / `hwip pi` leaves once its tmux session has ended: the
|
|
4
|
+
* command that picks the conversation back up (`claude --resume <id>`).
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.handleResumeHint = exports.resumeHint = void 0;
|
|
8
|
+
const remote_agents_js_1 = require("./remote-agents.js");
|
|
9
|
+
const session_registry_js_1 = require("./session-registry.js");
|
|
10
|
+
const tmux_liveness_js_1 = require("./tmux-liveness.js");
|
|
11
|
+
const wrapper_js_1 = require("./wrapper.js");
|
|
12
|
+
const defaultDeps = () => ({
|
|
13
|
+
recall: (name, kind) => (0, session_registry_js_1.recallRun)(name, kind),
|
|
14
|
+
// `unknown` included: no tmux server answering after `tmux new` returned is the last session gone with it.
|
|
15
|
+
alive: (name) => (0, tmux_liveness_js_1.sessionLiveness)(name) === 'running',
|
|
16
|
+
cwd: process.cwd(),
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* What to print after the tmux session `session` of `agent` returned, or null
|
|
20
|
+
* when there is nothing true to say. `since` is when this `hwip` launched (ms):
|
|
21
|
+
* a record the sweep has not touched since is an earlier run of the same name,
|
|
22
|
+
* whose conversation is not the one just closed. A session shorter than one
|
|
23
|
+
* sweep has no record yet, and gets no line rather than a guess.
|
|
24
|
+
*/
|
|
25
|
+
const resumeHint = (session, agent, since, deps = defaultDeps()) => {
|
|
26
|
+
if (!agent.resumeArgs || deps.alive(session))
|
|
27
|
+
return null;
|
|
28
|
+
const run = deps.recall(session, agent.kind);
|
|
29
|
+
if (!run?.agentSessionId || Date.parse(run.lastSeenAt) < since)
|
|
30
|
+
return null;
|
|
31
|
+
const args = agent.resumeArgs(run.agentSessionId, run.cwd);
|
|
32
|
+
if (!args)
|
|
33
|
+
return null;
|
|
34
|
+
const where = run.cwd === deps.cwd ? '' : ` in ${(0, wrapper_js_1.shellQuote)(run.cwd)}`;
|
|
35
|
+
const rest = args.map(wrapper_js_1.shellQuote).join(' ');
|
|
36
|
+
return `hwip: to resume${where}: ${agent.binary} ${rest} (or hwip ${agent.subcommands[0]} ${rest})`;
|
|
37
|
+
};
|
|
38
|
+
exports.resumeHint = resumeHint;
|
|
39
|
+
/**
|
|
40
|
+
* `hwip resume-hint --session <tmux name> --type <agent kind> --at <ms>` — the
|
|
41
|
+
* call `withResumeHint` leaves after tmux. Not for people: it always exits 0,
|
|
42
|
+
* so a missing record or a failed read never changes the agent's exit status.
|
|
43
|
+
*/
|
|
44
|
+
const handleResumeHint = (args) => {
|
|
45
|
+
const agent = remote_agents_js_1.REMOTE_AGENTS.find((a) => a.kind === args.type);
|
|
46
|
+
const since = Number(args.at);
|
|
47
|
+
if (typeof args.session !== 'string' || !agent || !Number.isFinite(since))
|
|
48
|
+
return 0;
|
|
49
|
+
try {
|
|
50
|
+
const line = (0, exports.resumeHint)(args.session, agent, since);
|
|
51
|
+
if (line)
|
|
52
|
+
console.log(line);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
};
|
|
59
|
+
exports.handleResumeHint = handleResumeHint;
|
|
@@ -150,6 +150,11 @@ export declare const subagentDirFor: (parentTranscriptPath: string) => string;
|
|
|
150
150
|
* cares about the second.
|
|
151
151
|
*/
|
|
152
152
|
export declare const isSubagentTranscriptPath: (transcriptPath: string) => boolean;
|
|
153
|
+
/** The four ways Claude Code reports a background subagent stopping — measured
|
|
154
|
+
* 2026-09-14 over every parent transcript on this machine (787 completed, 12
|
|
155
|
+
* failed, 12 killed, 2 stopped, nothing else). */
|
|
156
|
+
export declare const TASK_END: RegExp;
|
|
157
|
+
export declare const TASK_ID: RegExp;
|
|
153
158
|
/**
|
|
154
159
|
* The live subagents of one Claude Code session, in chip order (by number).
|
|
155
160
|
*
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* the alternative failure is this feature vanishing in silence.
|
|
24
24
|
*/
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.scanSubagents = exports.isSubagentTranscriptPath = exports.subagentDirFor = exports.initialSubagentScanState = exports.MAX_SUBAGENT_ROWS = exports.SUBAGENT_STALE_RECHECK_MS = exports.SUBAGENT_CATCH_UP_BYTES = exports.SUBAGENT_END_SLACK_MS = exports.SUBAGENT_ADDRESSABLE_MS = exports.SUBAGENT_LIVE_MS = exports.SUBAGENT_WORKING_MS = void 0;
|
|
26
|
+
exports.scanSubagents = exports.TASK_ID = exports.TASK_END = exports.isSubagentTranscriptPath = exports.subagentDirFor = exports.initialSubagentScanState = exports.MAX_SUBAGENT_ROWS = exports.SUBAGENT_STALE_RECHECK_MS = exports.SUBAGENT_CATCH_UP_BYTES = exports.SUBAGENT_END_SLACK_MS = exports.SUBAGENT_ADDRESSABLE_MS = exports.SUBAGENT_LIVE_MS = exports.SUBAGENT_WORKING_MS = void 0;
|
|
27
27
|
const node_fs_1 = require("node:fs");
|
|
28
28
|
const node_path_1 = require("node:path");
|
|
29
29
|
const transcript_tail_js_1 = require("./transcript-tail.js");
|
|
@@ -147,8 +147,8 @@ const isAgentToolUse = (line) => {
|
|
|
147
147
|
/** The four ways Claude Code reports a background subagent stopping — measured
|
|
148
148
|
* 2026-09-14 over every parent transcript on this machine (787 completed, 12
|
|
149
149
|
* failed, 12 killed, 2 stopped, nothing else). */
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
exports.TASK_END = /<status>(?:completed|failed|killed|stopped)<\/status>/;
|
|
151
|
+
exports.TASK_ID = /<task-id>([^<]{1,200})<\/task-id>/;
|
|
152
152
|
/**
|
|
153
153
|
* The subagent a parent line says has ended, and when — or null.
|
|
154
154
|
*
|
|
@@ -182,9 +182,9 @@ const subagentEndOf = (line) => {
|
|
|
182
182
|
if (result?.status === 'completed' && typeof result.agentId === 'string')
|
|
183
183
|
return { agentId: result.agentId, at };
|
|
184
184
|
const content = entry.message?.content;
|
|
185
|
-
if (entry.origin?.kind !== 'task-notification' || typeof content !== 'string' || !TASK_END.test(content))
|
|
185
|
+
if (entry.origin?.kind !== 'task-notification' || typeof content !== 'string' || !exports.TASK_END.test(content))
|
|
186
186
|
return null;
|
|
187
|
-
const agentId = TASK_ID.exec(content)?.[1];
|
|
187
|
+
const agentId = exports.TASK_ID.exec(content)?.[1];
|
|
188
188
|
return agentId ? { agentId, at } : null;
|
|
189
189
|
};
|
|
190
190
|
/**
|
package/dist/wrapper.d.ts
CHANGED
|
@@ -121,7 +121,18 @@ export declare const splitAgentOptions: (extra: string[]) => {
|
|
|
121
121
|
rest: string[];
|
|
122
122
|
error?: string;
|
|
123
123
|
};
|
|
124
|
+
export declare const shellQuote: (s: string) => string;
|
|
124
125
|
export declare const targetForAgent: (agent: string, extra: string[], agentKind?: string, opts?: AgentLaunchOptions) => SpawnTarget;
|
|
126
|
+
/**
|
|
127
|
+
* `target` (an attached `tmux new`) run under `sh`, which outlives tmux by the
|
|
128
|
+
* one `hwip resume-hint` call and then exits with tmux's status. `sh` rather
|
|
129
|
+
* than this process waiting: the wrapper hands itself over with execve so no
|
|
130
|
+
* node heap sits resident for the whole session, and `sh` costs next to none.
|
|
131
|
+
*/
|
|
132
|
+
export declare const withResumeHint: (target: SpawnTarget, agent: Pick<RemoteAgent, "kind" | "resumeArgs">, since: number, hwip?: {
|
|
133
|
+
node: string;
|
|
134
|
+
cli: string;
|
|
135
|
+
}) => SpawnTarget;
|
|
125
136
|
/**
|
|
126
137
|
* Whether the daemon on record is running a different build than the one
|
|
127
138
|
* we're launching from. `npm i -g @hwip/cli` swaps dist/ but leaves the
|
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.startShellSession = 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.agentLaunchArgs = exports.agentLaunchEnv = exports.herdrEnvArgs = exports.herdrSessionArgs = exports.sessionSidecarArgs = exports.detectProjectName = exports.detectProjectDir = exports.safeBasename = void 0;
|
|
3
|
+
exports.handleShellSession = exports.startShellSession = exports.shellTargets = exports.readShellSessionState = exports.planShellSession = exports.handleAgentSession = exports.tmuxMissingMessage = exports.inPaneNote = exports.handOffExec = exports.ensureListenerRunning = exports.takeListenerLaunchMarker = exports.listenerVersionDrifted = exports.withResumeHint = exports.targetForAgent = exports.shellQuote = exports.splitAgentOptions = exports.sanitizeLabel = exports.findAvailableSession = exports.familyNames = exports.firstFreeSession = exports.tmuxSessionName = exports.installedTmuxVersion = exports.agentLaunchArgs = exports.agentLaunchEnv = 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
|
|
@@ -331,6 +331,7 @@ exports.splitAgentOptions = splitAgentOptions;
|
|
|
331
331
|
/** POSIX shell-quote so passthrough args survive being joined into a tmux shell-command string. */
|
|
332
332
|
const SHELL_SAFE = /^[\w\-./=:@%+,]+$/;
|
|
333
333
|
const shellQuote = (s) => s.length > 0 && SHELL_SAFE.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
334
|
+
exports.shellQuote = shellQuote;
|
|
334
335
|
const targetForAgent = (agent, extra, agentKind, opts = {}) => {
|
|
335
336
|
// Already inside tmux → no nested session, just run the agent in the
|
|
336
337
|
// current pane. Nested tmux prefix collisions are confusing and the
|
|
@@ -364,7 +365,7 @@ const targetForAgent = (agent, extra, agentKind, opts = {}) => {
|
|
|
364
365
|
// tmux joins trailing argv into a single shell-command, so flags like
|
|
365
366
|
// `--resume` would be eaten by tmux's own parser. Build one quoted
|
|
366
367
|
// shell string instead, which tmux passes through verbatim.
|
|
367
|
-
const shellCmd = [agent, ...extra].map(shellQuote).join(' ');
|
|
368
|
+
const shellCmd = [agent, ...extra].map(exports.shellQuote).join(' ');
|
|
368
369
|
// `-d`: create without attaching, so no TTY is needed and `new` fails
|
|
369
370
|
// loudly on a taken name instead of `-A` silently attaching to whatever
|
|
370
371
|
// runs there. `-c`: the agent's cwd must be the directory the session is
|
|
@@ -383,6 +384,22 @@ const targetForAgent = (agent, extra, agentKind, opts = {}) => {
|
|
|
383
384
|
return { kind: 'tmux-new', cmd: 'tmux', args: ['new', '-A', '-s', session, ...herdrEnv, ...agentEnv, shellCmd, ...sidecar, ...(0, exports.herdrSessionArgs)(session)], session };
|
|
384
385
|
};
|
|
385
386
|
exports.targetForAgent = targetForAgent;
|
|
387
|
+
/**
|
|
388
|
+
* `target` (an attached `tmux new`) run under `sh`, which outlives tmux by the
|
|
389
|
+
* one `hwip resume-hint` call and then exits with tmux's status. `sh` rather
|
|
390
|
+
* than this process waiting: the wrapper hands itself over with execve so no
|
|
391
|
+
* node heap sits resident for the whole session, and `sh` costs next to none.
|
|
392
|
+
*/
|
|
393
|
+
const withResumeHint = (target, agent, since, hwip = { node: process.execPath, cli: process.argv[1] ?? '' }) => {
|
|
394
|
+
if (target.kind !== 'tmux-new' || !target.session || !agent.resumeArgs || !hwip.cli)
|
|
395
|
+
return target;
|
|
396
|
+
const hint = [hwip.node, hwip.cli, 'resume-hint', '--session', target.session, '--type', agent.kind, '--at', String(since)]
|
|
397
|
+
.map(exports.shellQuote)
|
|
398
|
+
.join(' ');
|
|
399
|
+
const script = `tmux "$@"; s=$?; ${hint} || :; exit $s`;
|
|
400
|
+
return { ...target, cmd: 'sh', args: ['-c', script, 'sh', ...target.args] };
|
|
401
|
+
};
|
|
402
|
+
exports.withResumeHint = withResumeHint;
|
|
386
403
|
// ── Background listener auto-start ────────────────────────────────────
|
|
387
404
|
/**
|
|
388
405
|
* Whether the daemon on record is running a different build than the one
|
|
@@ -444,6 +461,15 @@ const ensureListenerRunning = async (launchedByListener = (0, exports.takeListen
|
|
|
444
461
|
}
|
|
445
462
|
if (reportFailedStart(await (0, listener_process_js_1.awaitServiceListener)(logOffset, pid)))
|
|
446
463
|
return;
|
|
464
|
+
// The service starts whatever install it was registered from, which
|
|
465
|
+
// need not be this one (`hwip` from Homebrew, the plist from an nvm
|
|
466
|
+
// node). Restarting it brings the same old build back, and every
|
|
467
|
+
// `hwip cc` after would restart it again without ever saying why.
|
|
468
|
+
const restarted = (0, listener_process_js_1.runningListenerVersion)();
|
|
469
|
+
if ((0, exports.listenerVersionDrifted)(restarted, config_js_1.VERSION)) {
|
|
470
|
+
console.error(staleServiceNote(restarted, (0, listener_service_js_1.serviceStatus)().cliPath));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
447
473
|
if (pid === null)
|
|
448
474
|
console.log(`hwip: listener started by ${owner} (log: ${listener_process_js_1.LISTENER_LOG_FILE})`);
|
|
449
475
|
else
|
|
@@ -474,6 +500,10 @@ const ensureListenerRunning = async (launchedByListener = (0, exports.takeListen
|
|
|
474
500
|
}
|
|
475
501
|
};
|
|
476
502
|
exports.ensureListenerRunning = ensureListenerRunning;
|
|
503
|
+
/** What to say when the login service came back on an older install than this one. */
|
|
504
|
+
const staleServiceNote = (running, serviceCli) => `hwip: the login service restarted the listener on ${running ?? 'an unstamped build'}, not ${config_js_1.VERSION} — ` +
|
|
505
|
+
`it runs ${serviceCli ?? 'another install'}.\n` +
|
|
506
|
+
' Point it at this install: hwip listener --install-service';
|
|
477
507
|
/** Say why a just-started listener is not running; true when it said anything. */
|
|
478
508
|
const reportFailedStart = (started) => {
|
|
479
509
|
const why = (0, listener_process_js_1.startFailure)(started);
|
|
@@ -558,6 +588,8 @@ const tmuxMissing = (command) => (0, exports.tmuxMissingMessage)(command, (0, tm
|
|
|
558
588
|
const handleAgentSession = async (agent, extra = [], opts = {}, deps = {}) => {
|
|
559
589
|
// First of all, before `ensureWorktree` runs git (its hooks would inherit the marker).
|
|
560
590
|
const launchedByListener = (0, exports.takeListenerLaunchMarker)();
|
|
591
|
+
// The resume hint trusts only a registry record the sweep wrote after this.
|
|
592
|
+
const launchedAt = Date.now();
|
|
561
593
|
// Best-effort: make sure the phone-bridge daemon is running, and running
|
|
562
594
|
// the build we were launched from. The user shouldn't need to remember a
|
|
563
595
|
// second command for the picker on their phone to work.
|
|
@@ -609,7 +641,8 @@ const handleAgentSession = async (agent, extra = [], opts = {}, deps = {}) => {
|
|
|
609
641
|
return 1;
|
|
610
642
|
}
|
|
611
643
|
}
|
|
612
|
-
|
|
644
|
+
const target = { kind, cmd, args, session, env };
|
|
645
|
+
return (deps.launch ?? launchTarget)((0, exports.withResumeHint)(target, agent, launchedAt), agent.binary);
|
|
613
646
|
};
|
|
614
647
|
exports.handleAgentSession = handleAgentSession;
|
|
615
648
|
/**
|
|
@@ -666,7 +699,7 @@ const launchTarget = async ({ kind, cmd, args, session, env: agentEnv }, what) =
|
|
|
666
699
|
// someone whose agent died inside a perfectly good pane to "run
|
|
667
700
|
// from a plain shell pane" would send them after the wrong thing.
|
|
668
701
|
if (kind === 'tmux-new' && code && code !== 0 && dur < 2000) {
|
|
669
|
-
console.error(`hwip:
|
|
702
|
+
console.error(`hwip: tmux exited ${code} after ${dur}ms.\n` +
|
|
670
703
|
` If this terminal is itself inside tmux (or an iTerm/Warp\n` +
|
|
671
704
|
` tmux-integration pane), run \`hwip cc\` from a plain shell\n` +
|
|
672
705
|
` pane instead — \`tmux new\` needs a real TTY to attach.`);
|