@hwip/cli 0.1.0 → 0.1.2
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 +28 -4
- package/dist/agent-rules.default.d.ts +1 -1
- package/dist/agent-rules.default.js +34 -21
- package/dist/answer-files.d.ts +39 -0
- package/dist/answer-files.js +35 -0
- package/dist/ask-seal.d.ts +5 -1
- package/dist/ask-seal.js +4 -1
- package/dist/ask.d.ts +13 -0
- package/dist/ask.js +37 -6
- package/dist/blocked-prompt-seal.d.ts +6 -2
- package/dist/blocked-prompt-seal.js +7 -4
- package/dist/blocked-prompt.d.ts +7 -2
- package/dist/blocked-prompt.js +361 -24
- package/dist/config.d.ts +14 -0
- package/dist/config.js +23 -1
- package/dist/file-crypto.js +7 -1
- package/dist/listener.d.ts +50 -2
- package/dist/listener.js +166 -36
- package/dist/remote-agents.d.ts +22 -0
- package/dist/remote-agents.js +22 -5
- package/dist/run-seqs.d.ts +28 -0
- package/dist/run-seqs.js +79 -0
- package/dist/session-registry.d.ts +34 -4
- package/dist/session-registry.js +50 -14
- package/dist/templates.js +16 -5
- package/dist/tmux-liveness.d.ts +7 -0
- package/dist/tmux-liveness.js +22 -1
- package/dist/turn-recorder-wire.js +1 -1
- package/dist/turn-recorder.d.ts +8 -0
- package/dist/turn-recorder.js +10 -2
- package/dist/wrapper.d.ts +12 -4
- package/dist/wrapper.js +47 -6
- package/package.json +16 -2
package/dist/listener.js
CHANGED
|
@@ -25,9 +25,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
25
25
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
26
26
|
};
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
-
exports.
|
|
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.downloadAttachments = void 0;
|
|
28
|
+
exports.fileViewBytes = exports.handleScreenRequest = exports.setSubscriberTrust = exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.withSettledPrompts = exports.PROMPT_SEAL_WAIT_MS = exports.withBlockedPrompts = exports.setPromptSealer = exports.deriveSessionState = exports.resetSessionStates = exports.runIdOfEpoch = exports.__checkoutCacheSize = exports.parseSessionName = 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.resolveKeys = exports.probePane = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = exports.commandsReportDue = exports.commandsFingerprint = exports.COMMAND_SCAN_INTERVAL_MS = exports.sessionsReportDue = exports.sessionsFingerprint = exports.knownSessionsFingerprint = exports.knownSessionsToReport = exports.withRunSeqs = exports.KNOWN_SESSIONS_REPORTED = 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.writeRemoteMarker = exports.isInventoried = exports.syncTmuxAgentNames = exports.recordInventory = exports.collectSessionsVerbose = exports.handleCommandInput = exports.pendingInputDecrypts = 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.handleScheduleListRequest = exports.handleFileBlobRequest = exports.handleFileTreeRequest = exports.handleDiffFileRequest = exports.handleDiffFilesRequest = 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 = 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");
|
|
@@ -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 run_seqs_js_1 = require("./run-seqs.js");
|
|
103
104
|
const args_js_1 = require("./args.js");
|
|
104
105
|
const schedule_js_1 = require("./schedule.js");
|
|
105
106
|
const schedule_launch_js_1 = require("./schedule-launch.js");
|
|
@@ -292,31 +293,41 @@ const staleConnectionAction = (message, drops) => {
|
|
|
292
293
|
return drops < exports.MAX_STALE_CONNECTION_DROPS ? 'drop' : 'exhausted';
|
|
293
294
|
};
|
|
294
295
|
exports.staleConnectionAction = staleConnectionAction;
|
|
295
|
-
exports.KNOWN_SESSIONS_REPORTED =
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
296
|
+
exports.KNOWN_SESSIONS_REPORTED = 50;
|
|
297
|
+
/** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
|
|
298
|
+
const withRunSeqs = (sessions, seqs = (0, run_seqs_js_1.runSeqsByRun)()) => sessions.map((s) => {
|
|
299
|
+
const block = s.runId ? seqs.get((0, run_seqs_js_1.runSeqsKey)(s.name, s.runId)) : undefined;
|
|
300
|
+
return block ? { ...s, firstSeq: block.firstSeq } : s;
|
|
301
|
+
});
|
|
302
|
+
exports.withRunSeqs = withRunSeqs;
|
|
303
|
+
const knownSessionsToReport = (live, now = Date.now(), seqs = (0, run_seqs_js_1.runSeqsByRun)()) => {
|
|
304
|
+
// By name, kind AND run: a slot that is live under claude may still hold an
|
|
305
|
+
// ended pi run — or an ended claude run before this one — and that run is a
|
|
306
|
+
// past session. Excluding the whole name would drop it from the report
|
|
307
|
+
// exactly the way the registry used to drop it from disk. The live run
|
|
308
|
+
// itself stays out for the reason below.
|
|
309
|
+
const running = new Set(live.map((s) => (0, session_registry_js_1.runKey)(s.name, s.agentKind, s.runId)));
|
|
302
310
|
return (0, session_registry_js_1.knownSessions)(now)
|
|
303
|
-
.filter((e) => !running.has((0, session_registry_js_1.runKey)(e.name, e.agentKind)))
|
|
311
|
+
.filter((e) => !running.has((0, session_registry_js_1.runKey)(e.name, e.agentKind, e.runId)))
|
|
304
312
|
.slice(0, exports.KNOWN_SESSIONS_REPORTED)
|
|
305
313
|
.map((e) => ({
|
|
306
314
|
name: e.name,
|
|
307
315
|
agentKind: e.agentKind,
|
|
308
316
|
...(e.project ? { project: e.project } : {}),
|
|
309
317
|
...(e.label ? { label: e.label } : {}),
|
|
318
|
+
...(e.runId ? { runId: e.runId } : {}),
|
|
319
|
+
...(e.startedAt ? { startedAt: e.startedAt } : {}),
|
|
320
|
+
...(e.runId ? seqs.get((0, run_seqs_js_1.runSeqsKey)(e.name, e.runId)) : undefined),
|
|
310
321
|
lastSeenAt: e.lastSeenAt,
|
|
311
322
|
}));
|
|
312
323
|
};
|
|
313
324
|
exports.knownSessionsToReport = knownSessionsToReport;
|
|
314
|
-
const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.lastSeenAt}`).sort().join('|');
|
|
325
|
+
const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}`).sort().join('|');
|
|
315
326
|
exports.knownSessionsFingerprint = knownSessionsFingerprint;
|
|
316
327
|
const sessionsFingerprint = (sessions) => sessions
|
|
317
328
|
.map((s) => JSON.stringify([
|
|
318
329
|
s.name, s.attached, s.agentKind, s.agentSessionId ?? null,
|
|
319
|
-
s.project, s.label ?? null, s.providerSessionName ?? null, s.createdAt ?? null,
|
|
330
|
+
s.project, s.label ?? null, s.providerSessionName ?? null, s.createdAt ?? null, s.runId ?? null, s.firstSeq ?? null,
|
|
320
331
|
s.lastActivityAt ?? null, s.state ?? null,
|
|
321
332
|
s.stateChangedAt ?? null, s.stateRuleId ?? null,
|
|
322
333
|
s.blockedPrompt?.id ?? null, s.blockedPrompt?.at ?? null,
|
|
@@ -621,6 +632,17 @@ const epochToIso = (epoch) => {
|
|
|
621
632
|
return undefined;
|
|
622
633
|
return new Date(n * 1000).toISOString();
|
|
623
634
|
};
|
|
635
|
+
/**
|
|
636
|
+
* A run's id from tmux's `session_created` (hwip docs/session-runs.md): the
|
|
637
|
+
* whole epoch seconds as a decimal string. The server derives the same string
|
|
638
|
+
* from `createdAt` for listeners that do not send one (`runIdOf`), so the two
|
|
639
|
+
* must agree: `epochToIso` keeps whole seconds and this floors them.
|
|
640
|
+
*/
|
|
641
|
+
const runIdOfEpoch = (epoch) => {
|
|
642
|
+
const iso = epochToIso(epoch);
|
|
643
|
+
return iso ? String(Math.floor(Date.parse(iso) / 1000)) : undefined;
|
|
644
|
+
};
|
|
645
|
+
exports.runIdOfEpoch = runIdOfEpoch;
|
|
624
646
|
// ─── Agent state detection (SPEC-AGENT-AWARENESS §S1) ──────────────
|
|
625
647
|
// How many pane lines the state rules see. The live status area every
|
|
626
648
|
// agent renders sits in the last handful of lines; 40 leaves margin for
|
|
@@ -632,13 +654,13 @@ const resetSessionStates = () => {
|
|
|
632
654
|
sessionStates.clear();
|
|
633
655
|
};
|
|
634
656
|
exports.resetSessionStates = resetSessionStates;
|
|
635
|
-
const capturePaneText = (session, target) => {
|
|
657
|
+
const capturePaneText = (session, target, escapes = false) => {
|
|
636
658
|
// Explicit target for the sweep (subagent rows aren't in the recorded
|
|
637
659
|
// target map yet); otherwise resolve through it.
|
|
638
660
|
const resolved = target ?? (0, tmux_pane_js_1.tmuxTargetFor)(session);
|
|
639
661
|
if (resolved === null)
|
|
640
662
|
return null; // unresolvable subagent — never spawn
|
|
641
|
-
const r = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['capture-pane', '-p', '-t', resolved, '-S', `-${STATE_CAPTURE_LINES}`]), {
|
|
663
|
+
const r = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['capture-pane', '-p', ...(escapes ? ['-e'] : []), '-t', resolved, '-S', `-${STATE_CAPTURE_LINES}`]), {
|
|
642
664
|
encoding: 'utf-8',
|
|
643
665
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
644
666
|
});
|
|
@@ -700,8 +722,9 @@ const withBlockedPrompts = ({ sessions, targets }) => {
|
|
|
700
722
|
const target = targets[session.name];
|
|
701
723
|
if (session.parentName !== undefined || session.agentKind === 'shell' || target === undefined)
|
|
702
724
|
return session;
|
|
703
|
-
|
|
704
|
-
const
|
|
725
|
+
// With escapes: OpenCode draws its chosen button as a background colour only. `deliverInput` captures the same way.
|
|
726
|
+
const pane = session.state === 'blocked' ? capturePaneText(session.name, target, true) : null;
|
|
727
|
+
const blockedPrompt = sealer.observe(session.name, session.agentKind, session.state, pane);
|
|
705
728
|
return blockedPrompt ? { ...session, blockedPrompt } : session;
|
|
706
729
|
});
|
|
707
730
|
sealer.prune(new Set(sessions.map((s) => s.name)));
|
|
@@ -1312,6 +1335,21 @@ const handleScheduleListRequest = (req, send) => {
|
|
|
1312
1335
|
return true;
|
|
1313
1336
|
};
|
|
1314
1337
|
exports.handleScheduleListRequest = handleScheduleListRequest;
|
|
1338
|
+
/** What an agent conversation id looks like — a value, never an option. */
|
|
1339
|
+
const CONVERSATION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
1340
|
+
/**
|
|
1341
|
+
* The flags that pick a resumed run's last conversation back up, or null to
|
|
1342
|
+
* start it fresh. The id is one this machine read off the live pane, but the
|
|
1343
|
+
* registry is a file on disk: it reaches argv only once it reads as an id, so
|
|
1344
|
+
* nothing in it can turn into an option of the agent's own.
|
|
1345
|
+
*/
|
|
1346
|
+
const resumableConversation = (agent, known) => {
|
|
1347
|
+
const id = known.agentSessionId;
|
|
1348
|
+
if (typeof id !== 'string' || !CONVERSATION_ID.test(id))
|
|
1349
|
+
return null;
|
|
1350
|
+
const args = agent.resumeArgs?.(id, known.cwd);
|
|
1351
|
+
return args ? { id, args } : null;
|
|
1352
|
+
};
|
|
1315
1353
|
/**
|
|
1316
1354
|
* Start a remembered session again, detached, and tell the phone what happened.
|
|
1317
1355
|
* Returns true when the message was ours to answer.
|
|
@@ -1382,12 +1420,14 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1382
1420
|
const sidecar = known.label && known.project
|
|
1383
1421
|
? [';', 'set-option', config_js_1.SESSION_PROJECT_OPTION, known.project, ';', 'set-option', config_js_1.SESSION_LABEL_OPTION, known.label]
|
|
1384
1422
|
: [];
|
|
1385
|
-
const
|
|
1423
|
+
const conversation = resumableConversation(agent, known);
|
|
1424
|
+
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'] });
|
|
1386
1425
|
if (started.status !== 0) {
|
|
1387
1426
|
(0, listener_log_js_1.log)(`✗ resume ${sessionName}: tmux refused (${(started.stderr ?? '').trim() || 'no detail'})`);
|
|
1388
1427
|
return reply({ error: 'start_failed' });
|
|
1389
1428
|
}
|
|
1390
|
-
|
|
1429
|
+
const resumedFrom = conversation ? ` (conversation ${conversation.id.slice(0, 8)})` : '';
|
|
1430
|
+
(0, listener_log_js_1.log)(`⟲ resume ${sessionName}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
|
|
1391
1431
|
return reply({ resumed: true });
|
|
1392
1432
|
};
|
|
1393
1433
|
exports.handleSessionResumeRequest = handleSessionResumeRequest;
|
|
@@ -1548,14 +1588,20 @@ const handleSessionForgetRequest = (req, send) => {
|
|
|
1548
1588
|
// alternative is an irreversible delete (registry AND scrollback) justified
|
|
1549
1589
|
// by a question that failed to be asked. It says so in the log, because
|
|
1550
1590
|
// from the phone the two refusals are the same sentence.
|
|
1591
|
+
const runId = typeof req.runId === 'string' && /^\d{1,12}$/.test(req.runId) ? req.runId : undefined;
|
|
1551
1592
|
const liveness = (0, tmux_liveness_js_1.sessionLiveness)(sessionName);
|
|
1552
1593
|
if (liveness === 'unknown') {
|
|
1553
1594
|
(0, listener_log_js_1.log)(`✗ forget ${sessionName}: no tmux answered, so nothing was deleted`);
|
|
1554
1595
|
return reply({ error: 'still_running' });
|
|
1555
1596
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1597
|
+
// A name running again is not the run being forgotten: an ended run of it goes on its own (Q29).
|
|
1598
|
+
// The running run itself, a request naming no run, or a run tmux could not tell apart stay refused.
|
|
1599
|
+
if (liveness === 'running') {
|
|
1600
|
+
const live = runId ? (0, tmux_liveness_js_1.liveRunIdOf)(sessionName) : undefined;
|
|
1601
|
+
if (!runId || !live || live === runId)
|
|
1602
|
+
return reply({ error: 'still_running' });
|
|
1603
|
+
}
|
|
1604
|
+
const outcome = (0, session_registry_js_1.forgetSession)(sessionName, typeof req.agentKind === 'string' ? req.agentKind : undefined, runId, liveness === 'running');
|
|
1559
1605
|
if (outcome === 'unknown') {
|
|
1560
1606
|
(0, listener_log_js_1.log)(`✗ forget ${sessionName}: never seen on this machine`);
|
|
1561
1607
|
return reply({ error: 'unknown_session' });
|
|
@@ -2112,6 +2158,7 @@ const startTurnRecorder = (features, apiKey, baseUrl, devices) => {
|
|
|
2112
2158
|
uploadReady: () => deviceKeyRegistered,
|
|
2113
2159
|
devicePublicKey: crypto_js_1.getDevicePublicKey,
|
|
2114
2160
|
latestSeq: (0, turn_recorder_wire_js_1.createLatestSeqReader)({ baseUrl, apiKey }),
|
|
2161
|
+
noteSeqs: run_seqs_js_1.noteRunSeqs,
|
|
2115
2162
|
log: (message) => (0, listener_log_js_1.log)(message),
|
|
2116
2163
|
});
|
|
2117
2164
|
turnRecorder.start();
|
|
@@ -2961,7 +3008,7 @@ const deliverInput = (input) => {
|
|
|
2961
3008
|
const deps = { noteRefusal: (r) => { reason ??= r; } };
|
|
2962
3009
|
// Before anything is spent on it: a key bound to a prompt that is gone must not
|
|
2963
3010
|
// take a rate-limit token, and must not land on whatever is on screen now.
|
|
2964
|
-
const bound = input.ifPrompt === undefined || (0, blocked_prompt_seal_js_1.promptStillShown)(input.ifPrompt, capturePaneText(input.sessionName));
|
|
3011
|
+
const bound = input.ifPrompt === undefined || (0, blocked_prompt_seal_js_1.promptStillShown)(input.ifPrompt, capturePaneText(input.sessionName, undefined, true));
|
|
2965
3012
|
if (!bound)
|
|
2966
3013
|
reason = 'prompt_changed';
|
|
2967
3014
|
const injected = bound && hasStream(input.sessionName)
|
|
@@ -3310,6 +3357,8 @@ const collectSessionsVerbose = () => {
|
|
|
3310
3357
|
// rather than in it: AgentSession is a server/phone contract and the
|
|
3311
3358
|
// directory is local knowledge the registry needs, nothing the phone gets.
|
|
3312
3359
|
const paneCwdOf = new Map();
|
|
3360
|
+
// The conversation a resume may pick back up, beside it for the same reason.
|
|
3361
|
+
const resumeIdOf = new Map();
|
|
3313
3362
|
const targets = {};
|
|
3314
3363
|
const subagentTranscripts = {};
|
|
3315
3364
|
const recordable = {};
|
|
@@ -3340,6 +3389,7 @@ const collectSessionsVerbose = () => {
|
|
|
3340
3389
|
project: parsed.project,
|
|
3341
3390
|
label: parsed.label,
|
|
3342
3391
|
createdAt: epochToIso(group.created),
|
|
3392
|
+
runId: (0, exports.runIdOfEpoch)(group.created),
|
|
3343
3393
|
lastActivityAt: epochToIso(group.activity),
|
|
3344
3394
|
// No `state`: the server arms its completion and `gone` pushes
|
|
3345
3395
|
// on reported state, and a shell going idle is not news.
|
|
@@ -3379,6 +3429,11 @@ const collectSessionsVerbose = () => {
|
|
|
3379
3429
|
: null;
|
|
3380
3430
|
if (info.currentPath)
|
|
3381
3431
|
paneCwdOf.set(name, info.currentPath);
|
|
3432
|
+
const resumeId = info.currentPath
|
|
3433
|
+
? main.agent.resolveResumeId?.(info.currentPath, info.panePid ?? undefined)
|
|
3434
|
+
: null;
|
|
3435
|
+
if (resumeId)
|
|
3436
|
+
resumeIdOf.set(name, resumeId);
|
|
3382
3437
|
targets[name] = main.row.paneId;
|
|
3383
3438
|
sessions.push({
|
|
3384
3439
|
name,
|
|
@@ -3389,6 +3444,7 @@ const collectSessionsVerbose = () => {
|
|
|
3389
3444
|
label: parsed.label,
|
|
3390
3445
|
providerSessionName,
|
|
3391
3446
|
createdAt: epochToIso(group.created),
|
|
3447
|
+
runId: (0, exports.runIdOfEpoch)(group.created),
|
|
3392
3448
|
lastActivityAt: epochToIso(group.activity),
|
|
3393
3449
|
...(0, exports.deriveSessionState)(name, main.agent.kind, capturePaneText(name, main.row.paneId)),
|
|
3394
3450
|
});
|
|
@@ -3447,8 +3503,10 @@ const collectSessionsVerbose = () => {
|
|
|
3447
3503
|
const mainTranscript = info.currentPath && main.agent.resolveTranscript && main.agent.projectTranscript
|
|
3448
3504
|
? (main.agent.resolveTranscript(info.currentPath, info.panePid ?? undefined) ?? null)
|
|
3449
3505
|
: null;
|
|
3450
|
-
if (mainTranscript)
|
|
3451
|
-
|
|
3506
|
+
if (mainTranscript) {
|
|
3507
|
+
const runId = (0, exports.runIdOfEpoch)(group.created);
|
|
3508
|
+
recordable[name] = { path: mainTranscript, kind: main.agent.kind, ...(runId ? { runId } : {}) };
|
|
3509
|
+
}
|
|
3452
3510
|
const transcriptPath = main.agent.kind === 'claude' ? mainTranscript : null;
|
|
3453
3511
|
if (transcriptPath) {
|
|
3454
3512
|
const scan = subagentScans.get(name) ?? (0, subagent_transcripts_js_1.initialSubagentScanState)();
|
|
@@ -3493,6 +3551,21 @@ const collectSessionsVerbose = () => {
|
|
|
3493
3551
|
}
|
|
3494
3552
|
pruneToLive(subagentScans, new Set(groups.keys()));
|
|
3495
3553
|
pruneToLive(sessionStates, new Set(sessions.map((s) => s.name)));
|
|
3554
|
+
// An id two panes both answer to belongs to at most one of them, and the
|
|
3555
|
+
// sweep cannot tell which — opencode names the busiest session in the
|
|
3556
|
+
// folder — so neither may resume it.
|
|
3557
|
+
const resumeIdCount = new Map();
|
|
3558
|
+
for (const id of resumeIdOf.values())
|
|
3559
|
+
resumeIdCount.set(id, (resumeIdCount.get(id) ?? 0) + 1);
|
|
3560
|
+
// Undefined (not read this sweep) keeps the run's last id; null (shared)
|
|
3561
|
+
// forgets it, or a run that had the id alone would keep resuming into
|
|
3562
|
+
// the pane that now answers to it.
|
|
3563
|
+
const ownResumeId = (name) => {
|
|
3564
|
+
const id = resumeIdOf.get(name);
|
|
3565
|
+
if (!id)
|
|
3566
|
+
return undefined;
|
|
3567
|
+
return resumeIdCount.get(id) === 1 ? id : null;
|
|
3568
|
+
};
|
|
3496
3569
|
// Write down what each live session IS, while it still exists to be read.
|
|
3497
3570
|
// tmux forgets a session the moment it ends, which is exactly when the
|
|
3498
3571
|
// phone wants it back — and a resume must take its directory and its binary
|
|
@@ -3509,6 +3582,9 @@ const collectSessionsVerbose = () => {
|
|
|
3509
3582
|
agentKind: s.agentKind,
|
|
3510
3583
|
project: s.project,
|
|
3511
3584
|
label: s.label,
|
|
3585
|
+
runId: s.runId,
|
|
3586
|
+
startedAt: s.createdAt,
|
|
3587
|
+
agentSessionId: ownResumeId(s.name),
|
|
3512
3588
|
})));
|
|
3513
3589
|
return { sessions, rejected, targets, subagentTranscripts, recordable };
|
|
3514
3590
|
};
|
|
@@ -3880,10 +3956,10 @@ const resolveDownloadUrl = async (fileKey, ctx, fetchFn = fetch, timeoutMs = ATT
|
|
|
3880
3956
|
return downloadUrl;
|
|
3881
3957
|
};
|
|
3882
3958
|
/** A download's body, refused at the headers when it says it is over the limit. */
|
|
3883
|
-
const cappedBody = (res) => {
|
|
3884
|
-
if (Number(res.headers.get('content-length')) >
|
|
3959
|
+
const cappedBody = (res, maxBytes = file_crypto_js_1.DOWNLOAD_MAX_BYTES) => {
|
|
3960
|
+
if (Number(res.headers.get('content-length')) > maxBytes) {
|
|
3885
3961
|
void res.body?.cancel().catch(() => undefined);
|
|
3886
|
-
throw new Error(`download is over the ${
|
|
3962
|
+
throw new Error(`download is over the ${maxBytes}-byte limit`);
|
|
3887
3963
|
}
|
|
3888
3964
|
if (!res.body)
|
|
3889
3965
|
throw new Error('download had no body');
|
|
@@ -3891,7 +3967,7 @@ const cappedBody = (res) => {
|
|
|
3891
3967
|
};
|
|
3892
3968
|
/** Resolve fileKey → presigned URL → bytes. Throws on any HTTP failure so
|
|
3893
3969
|
* the caller can isolate one file's failure from the rest of the batch. */
|
|
3894
|
-
const fetchAttachmentBytes = async (fileKey, ctx, io) => {
|
|
3970
|
+
const fetchAttachmentBytes = async (fileKey, ctx, io, maxBytes = file_crypto_js_1.DOWNLOAD_MAX_BYTES) => {
|
|
3895
3971
|
const fetchFn = io.fetchFn ?? fetch;
|
|
3896
3972
|
const timeoutMs = io.timeoutMs ?? ATTACHMENT_FETCH_TIMEOUT_MS;
|
|
3897
3973
|
const url = await resolveDownloadUrl(fileKey, ctx, fetchFn, Math.min(timeoutMs, ATTACHMENT_META_TIMEOUT_MS));
|
|
@@ -3900,7 +3976,7 @@ const fetchAttachmentBytes = async (fileKey, ctx, io) => {
|
|
|
3900
3976
|
const bin = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'error' });
|
|
3901
3977
|
if (!bin.ok)
|
|
3902
3978
|
throw new Error(`download ${bin.status}`);
|
|
3903
|
-
const body = cappedBody(bin);
|
|
3979
|
+
const body = cappedBody(bin, maxBytes);
|
|
3904
3980
|
// Count as it comes: a body with no length, or a false one, is not held in full.
|
|
3905
3981
|
const chunks = [];
|
|
3906
3982
|
let seen = 0;
|
|
@@ -3910,9 +3986,9 @@ const fetchAttachmentBytes = async (fileKey, ctx, io) => {
|
|
|
3910
3986
|
if (done)
|
|
3911
3987
|
break;
|
|
3912
3988
|
seen += value.length;
|
|
3913
|
-
if (seen >
|
|
3989
|
+
if (seen > maxBytes) {
|
|
3914
3990
|
await reader.cancel().catch(() => undefined);
|
|
3915
|
-
throw new Error(`download passed the ${
|
|
3991
|
+
throw new Error(`download passed the ${maxBytes}-byte limit`);
|
|
3916
3992
|
}
|
|
3917
3993
|
chunks.push(value);
|
|
3918
3994
|
}
|
|
@@ -4026,6 +4102,58 @@ const downloadAttachments = async (pushId, files, ctx, sealed, io = {}) => {
|
|
|
4026
4102
|
return paths;
|
|
4027
4103
|
};
|
|
4028
4104
|
exports.downloadAttachments = downloadAttachments;
|
|
4105
|
+
/** Padmé: the size a file is padded to before it is sealed (the apps' `Envelope.padme`). */
|
|
4106
|
+
const padme = (size) => {
|
|
4107
|
+
if (size < 2)
|
|
4108
|
+
return size;
|
|
4109
|
+
const e = Math.floor(Math.log2(size));
|
|
4110
|
+
const step = 2 ** (e - (Math.floor(Math.log2(e)) + 1));
|
|
4111
|
+
return Math.ceil(size / step) * step;
|
|
4112
|
+
};
|
|
4113
|
+
/**
|
|
4114
|
+
* The most a sealed answer file may download as: its seal's size padded, and the tag. The relay
|
|
4115
|
+
* points at the bytes, so it could name a larger object; nothing past this is read.
|
|
4116
|
+
*/
|
|
4117
|
+
const answerFileBound = (size) => size !== undefined && Number.isSafeInteger(size) && size >= 0 ? Math.min(padme(size) + file_crypto_js_1.GCM_TAG_BYTES, file_crypto_js_1.DOWNLOAD_MAX_BYTES) : file_crypto_js_1.DOWNLOAD_MAX_BYTES;
|
|
4118
|
+
exports.answerFileBound = answerFileBound;
|
|
4119
|
+
/**
|
|
4120
|
+
* The attachments of a sealed answer (`hwip ask`, answer-files.ts), saved one by one to
|
|
4121
|
+
* `~/.hwip/attachments/<eventId>/<name>`. Unlike a command's, a file that fails does not take the
|
|
4122
|
+
* others with it: the answer is the user's word either way, and each file not saved is named with why.
|
|
4123
|
+
* `sealed.files` gives each file's meta in the same order.
|
|
4124
|
+
*/
|
|
4125
|
+
const downloadSealedEach = async (eventId, files, ctx, sealed, io = {}) => {
|
|
4126
|
+
const dir = (0, path_1.join)(ATTACHMENTS_DIR, safeSegment(eventId, 'answer'));
|
|
4127
|
+
const taken = new Map();
|
|
4128
|
+
const paths = [];
|
|
4129
|
+
const missing = [];
|
|
4130
|
+
for (const [i, f] of files.entries()) {
|
|
4131
|
+
const meta = sealed.files[i];
|
|
4132
|
+
const name = meta?.name || f.fileName || `file-${i}`;
|
|
4133
|
+
try {
|
|
4134
|
+
if (!f.fileKey)
|
|
4135
|
+
throw new Error('no fileKey');
|
|
4136
|
+
const opened = await openSealedAttachment(await fetchAttachmentBytes(f.fileKey, ctx, io, (0, exports.answerFileBound)(meta?.size)), f, sealed);
|
|
4137
|
+
// Padded before sealing: cut back to the size the seal gives.
|
|
4138
|
+
const bytes = meta && Number.isSafeInteger(meta.size) && meta.size >= 0 ? opened.subarray(0, meta.size) : opened;
|
|
4139
|
+
(0, private_files_js_1.mkdirHandedBack)(dir);
|
|
4140
|
+
const abs = attachmentPath(taken, dir, safeSegment(name, `file-${i}`), bytes);
|
|
4141
|
+
if (abs === null) {
|
|
4142
|
+
// The same bytes under a name already saved: said, so no file goes unaccounted for.
|
|
4143
|
+
missing.push(`${name}: the same file was attached twice`);
|
|
4144
|
+
continue;
|
|
4145
|
+
}
|
|
4146
|
+
(0, fs_1.writeFileSync)(abs, bytes);
|
|
4147
|
+
(0, private_files_js_1.handBackToSudoUser)(abs);
|
|
4148
|
+
paths.push(abs);
|
|
4149
|
+
}
|
|
4150
|
+
catch (err) {
|
|
4151
|
+
missing.push(`${name}: ${err.message}`);
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
return { paths, missing };
|
|
4155
|
+
};
|
|
4156
|
+
exports.downloadSealedEach = downloadSealedEach;
|
|
4029
4157
|
const defaultDownloadAttachments = (pushId, files, sealed) => {
|
|
4030
4158
|
if (!attachmentCtx) {
|
|
4031
4159
|
(0, listener_log_js_1.log)('! attachment context not initialised — skipping files');
|
|
@@ -4508,7 +4636,7 @@ const handlePushInner = async (push, deps) => {
|
|
|
4508
4636
|
return handleScheduleRequest(push, push.agentSessionName, addressed, deps);
|
|
4509
4637
|
// A command bound to a blocked prompt types only while that prompt is on screen. Checked
|
|
4510
4638
|
// before anything is downloaded or typed, and before the rate limit is spent.
|
|
4511
|
-
if (push.ifPrompt !== undefined && !(0, blocked_prompt_seal_js_1.promptStillShown)(push.ifPrompt,
|
|
4639
|
+
if (push.ifPrompt !== undefined && !(0, blocked_prompt_seal_js_1.promptStillShown)(push.ifPrompt, deps.capturePane ? deps.capturePane(push.agentSessionName) : capturePaneText(push.agentSessionName, undefined, true))) {
|
|
4512
4640
|
(0, listener_log_js_1.log)(`! ${push.agentSessionName}: the blocked prompt this command answers is gone — drop`);
|
|
4513
4641
|
deps.noteRefusal?.('prompt_changed');
|
|
4514
4642
|
return false;
|
|
@@ -4906,8 +5034,10 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
4906
5034
|
// request of their own: the phone is already reading this device
|
|
4907
5035
|
// record, and a second round trip would need its own timeout, its
|
|
4908
5036
|
// own failure wording and its own staleness question.
|
|
4909
|
-
const
|
|
4910
|
-
const
|
|
5037
|
+
const seqs = (0, run_seqs_js_1.runSeqsByRun)();
|
|
5038
|
+
const reported = (0, exports.withRunSeqs)(sessions, seqs);
|
|
5039
|
+
const known = (0, exports.knownSessionsToReport)(sessions, Date.now(), seqs);
|
|
5040
|
+
const fingerprint = `${(0, exports.sessionsFingerprint)(reported)}␞${(0, exports.knownSessionsFingerprint)(known)}`;
|
|
4911
5041
|
if (!(0, exports.sessionsReportDue)(fingerprint, lastSentFingerprint, lastSentAtMs, Date.now())) {
|
|
4912
5042
|
return;
|
|
4913
5043
|
}
|
|
@@ -4917,7 +5047,7 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
4917
5047
|
// One message when it fits; past 24 KB, parts (sessions-report.ts): API Gateway closes a
|
|
4918
5048
|
// socket on a frame over 32 KB, so an uncut report from a machine with ~100 sessions would
|
|
4919
5049
|
// drop the connection on every send.
|
|
4920
|
-
const plan = (0, sessions_report_js_1.planSessionsReport)(
|
|
5050
|
+
const plan = (0, sessions_report_js_1.planSessionsReport)(reported, known);
|
|
4921
5051
|
if (plan.messages.length > 1) {
|
|
4922
5052
|
(0, listener_log_js_1.log)(`⧉ inventory of ${plan.sessionCount} session(s), ${Math.round(plan.wholeBytes / 1024)} KB, is over the ${sessions_report_js_1.SESSIONS_REPORT_MAX_BYTES / 1024} KB message limit — sent in ${plan.messages.length - 1} parts + names`);
|
|
4923
5053
|
for (const name of plan.oversized)
|
package/dist/remote-agents.d.ts
CHANGED
|
@@ -30,6 +30,24 @@ export interface RemoteAgent {
|
|
|
30
30
|
* listener then reports agentSessionId: null.
|
|
31
31
|
*/
|
|
32
32
|
resolveSessionId?: (paneCwd: string, panePid?: number) => string | null;
|
|
33
|
+
/**
|
|
34
|
+
* The conversation id a resume may hand back to this agent: anchored to
|
|
35
|
+
* the pane's own process, never a guess at the newest conversation in the
|
|
36
|
+
* folder — `resolveSessionId` may fall back to one for reporting, and a
|
|
37
|
+
* resume built on it would continue another session's work. Where the
|
|
38
|
+
* agent's store cannot tell two panes in one folder apart, both answer the
|
|
39
|
+
* same id; the sweep records no id that more than one pane answers to.
|
|
40
|
+
*/
|
|
41
|
+
resolveResumeId?: (paneCwd: string, panePid?: number) => string | null;
|
|
42
|
+
/**
|
|
43
|
+
* The arguments that start this agent on the conversation `id` (as
|
|
44
|
+
* `resolveResumeId` read it) in `cwd`, or null when that conversation
|
|
45
|
+
* cannot be picked back up and the agent should start fresh. Carried only
|
|
46
|
+
* where `resolveResumeId` is — an agent whose id is unknown here is never
|
|
47
|
+
* pointed at "the latest conversation in this folder", which may be
|
|
48
|
+
* another session's.
|
|
49
|
+
*/
|
|
50
|
+
resumeArgs?: (id: string, cwd: string) => readonly string[] | null;
|
|
33
51
|
/**
|
|
34
52
|
* Resolve the name the agent calls this session by — what the user sees in
|
|
35
53
|
* their own terminal, which the phone shows instead of the computed
|
|
@@ -307,6 +325,8 @@ declare const REMOTE_AGENT_TABLE: readonly [{
|
|
|
307
325
|
readonly resolveSessionName: (paneCwd: string, panePid: number | undefined) => string | null;
|
|
308
326
|
readonly resolveTranscript: (paneCwd: string, panePid: number | undefined) => string | null;
|
|
309
327
|
readonly projectTranscript: TranscriptProjector;
|
|
328
|
+
readonly resolveResumeId: (paneCwd: string, panePid: number | undefined) => string | null;
|
|
329
|
+
readonly resumeArgs: (id: string, cwd: string) => string[] | null;
|
|
310
330
|
}, {
|
|
311
331
|
readonly kind: "codex";
|
|
312
332
|
readonly displayName: "Codex CLI";
|
|
@@ -351,6 +371,8 @@ declare const REMOTE_AGENT_TABLE: readonly [{
|
|
|
351
371
|
readonly resolveTranscript: (paneCwd: string, panePid: number | undefined) => string | null;
|
|
352
372
|
readonly projectTranscript: TranscriptProjector;
|
|
353
373
|
readonly readTranscript: (path: string, prev: import("./transcript-tail.js").TailState | null) => import("./transcript-tail.js").TailRead | null;
|
|
374
|
+
readonly resolveResumeId: (paneCwd: string, panePid: number | undefined) => string | null;
|
|
375
|
+
readonly resumeArgs: (id: string) => string[];
|
|
354
376
|
}, {
|
|
355
377
|
readonly kind: "kimi";
|
|
356
378
|
readonly displayName: "Kimi Code";
|
package/dist/remote-agents.js
CHANGED
|
@@ -436,10 +436,17 @@ const claudeTranscriptPath = (paneCwd, panePid) => {
|
|
|
436
436
|
return null;
|
|
437
437
|
// A mis-encoded directory yields a path that never exists, and the watcher
|
|
438
438
|
// would then poll a missing file forever without ever saying so.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
439
|
+
return claudeConversationFile(paneCwd, sessionId);
|
|
440
|
+
};
|
|
441
|
+
exports.claudeTranscriptPath = claudeTranscriptPath;
|
|
442
|
+
/**
|
|
443
|
+
* Where Claude Code keeps conversation `sessionId` begun in `cwd`, or null
|
|
444
|
+
* when no such file is there. Proven, not computed: the pid path never touches
|
|
445
|
+
* the filesystem, so without this a mis-encoded directory reads as a healthy
|
|
446
|
+
* silent session — and `claude --resume` of a missing id exits at once.
|
|
447
|
+
*/
|
|
448
|
+
const claudeConversationFile = (cwd, sessionId) => {
|
|
449
|
+
const path = (0, path_1.join)(CLAUDE_PROJECTS_DIR, (0, exports.claudeProjectDirName)(cwd), `${sessionId}.jsonl`);
|
|
443
450
|
try {
|
|
444
451
|
return (0, fs_1.statSync)(path).isFile() ? path : null;
|
|
445
452
|
}
|
|
@@ -447,7 +454,6 @@ const claudeTranscriptPath = (paneCwd, panePid) => {
|
|
|
447
454
|
return null;
|
|
448
455
|
}
|
|
449
456
|
};
|
|
450
|
-
exports.claudeTranscriptPath = claudeTranscriptPath;
|
|
451
457
|
// ── pi ───────────────────────────────────────────────────────────
|
|
452
458
|
/**
|
|
453
459
|
* Where pi keeps its sessions, honouring the override pi itself honours.
|
|
@@ -1076,6 +1082,11 @@ const REMOTE_AGENT_TABLE = [
|
|
|
1076
1082
|
resolveSessionName: (paneCwd, panePid) => panePid !== undefined ? (0, exports.detectClaudeSessionNameByPid)(panePid, paneCwd) : null,
|
|
1077
1083
|
resolveTranscript: (paneCwd, panePid) => (0, exports.claudeTranscriptPath)(paneCwd, panePid),
|
|
1078
1084
|
projectTranscript: transcript_tail_js_1.projectTranscriptEntries,
|
|
1085
|
+
// The pid match alone: the mtime fallback above can name another
|
|
1086
|
+
// `claude` in the same directory, which is a report's risk to take but
|
|
1087
|
+
// not a resume's.
|
|
1088
|
+
resolveResumeId: (paneCwd, panePid) => panePid !== undefined ? (0, exports.detectClaudeSessionIdByPid)(panePid, paneCwd) : null,
|
|
1089
|
+
resumeArgs: (id, cwd) => (claudeConversationFile(cwd, id) ? ['--resume', id] : null),
|
|
1079
1090
|
},
|
|
1080
1091
|
{
|
|
1081
1092
|
kind: 'codex',
|
|
@@ -1143,6 +1154,12 @@ const REMOTE_AGENT_TABLE = [
|
|
|
1143
1154
|
resolveTranscript: (paneCwd, panePid) => (0, exports.opencodeTranscriptPath)(paneCwd, panePid),
|
|
1144
1155
|
projectTranscript: opencode_transcript_js_1.projectOpencodeEntries,
|
|
1145
1156
|
readTranscript: (path, prev) => (0, opencode_transcript_js_1.readOpencodeTranscript)(path, prev, { query: queryOpencodeTranscript }),
|
|
1157
|
+
// Anchored to the pane's process start. Two opencode panes in one
|
|
1158
|
+
// folder both answer the busier session; the sweep drops a shared id.
|
|
1159
|
+
resolveResumeId: (paneCwd, panePid) => (0, exports.detectOpencodeSession)(paneCwd, panePid)?.id ?? null,
|
|
1160
|
+
// Not checked against the store: an unknown id makes opencode start a
|
|
1161
|
+
// new session rather than exit, so a stale one costs nothing.
|
|
1162
|
+
resumeArgs: (id) => ['--session', id],
|
|
1146
1163
|
},
|
|
1147
1164
|
// The rows below are the minimum: launch in a tmux pane and be addressable.
|
|
1148
1165
|
// Binary names come from Happier's provider table; only `copilot` and
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seq block each run of a session took (hwip docs/session-runs.md Q24).
|
|
3
|
+
*
|
|
4
|
+
* The recorder numbers a name's chunks in one rising sequence, and a new run
|
|
5
|
+
* carries the numbering on — so a run's chunks sit between the smallest and the
|
|
6
|
+
* largest seq it was handed. The listener reports that block with each run, and
|
|
7
|
+
* the server reads a run's conversation from that key range alone instead of
|
|
8
|
+
* every chunk the name ever had.
|
|
9
|
+
*
|
|
10
|
+
* Kept in a file of its own rather than on the registry entry: the registry is
|
|
11
|
+
* rewritten from the inventory sweep, and a chunk can be numbered before the
|
|
12
|
+
* sweep has written its run down. A bound that is lost reads too narrow, and a
|
|
13
|
+
* block reported too narrow hides chunks — so the smallest seq only goes down
|
|
14
|
+
* and the largest only up, here and on the server.
|
|
15
|
+
*/
|
|
16
|
+
export interface RunSeqs {
|
|
17
|
+
firstSeq: number;
|
|
18
|
+
lastSeq: number;
|
|
19
|
+
}
|
|
20
|
+
/** Widen run `runId` of `name` to cover the seqs `firstSeq`..`lastSeq` it was just handed. */
|
|
21
|
+
export declare const noteRunSeqs: (name: string, runId: string, firstSeq: number, lastSeq: number, now?: number) => void;
|
|
22
|
+
/** Every run's block, keyed by `runSeqsKey`: one read for a whole report. */
|
|
23
|
+
export declare const runSeqsByRun: () => Map<string, RunSeqs>;
|
|
24
|
+
/** NUL-separated, like the registry's `runKey`: a name holding the separator cannot forge another run's key. */
|
|
25
|
+
export declare const runSeqsKey: (name: string, runId: string) => string;
|
|
26
|
+
/** Test seam: the file this module reads and writes. */
|
|
27
|
+
export declare const runSeqsPath: () => string;
|
|
28
|
+
//# sourceMappingURL=run-seqs.d.ts.map
|
package/dist/run-seqs.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The seq block each run of a session took (hwip docs/session-runs.md Q24).
|
|
4
|
+
*
|
|
5
|
+
* The recorder numbers a name's chunks in one rising sequence, and a new run
|
|
6
|
+
* carries the numbering on — so a run's chunks sit between the smallest and the
|
|
7
|
+
* largest seq it was handed. The listener reports that block with each run, and
|
|
8
|
+
* the server reads a run's conversation from that key range alone instead of
|
|
9
|
+
* every chunk the name ever had.
|
|
10
|
+
*
|
|
11
|
+
* Kept in a file of its own rather than on the registry entry: the registry is
|
|
12
|
+
* rewritten from the inventory sweep, and a chunk can be numbered before the
|
|
13
|
+
* sweep has written its run down. A bound that is lost reads too narrow, and a
|
|
14
|
+
* block reported too narrow hides chunks — so the smallest seq only goes down
|
|
15
|
+
* and the largest only up, here and on the server.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.runSeqsPath = exports.runSeqsKey = exports.runSeqsByRun = exports.noteRunSeqs = void 0;
|
|
19
|
+
const fs_1 = require("fs");
|
|
20
|
+
const path_1 = require("path");
|
|
21
|
+
const gate_js_1 = require("./gate.js");
|
|
22
|
+
const private_files_js_1 = require("./private-files.js");
|
|
23
|
+
const session_registry_js_1 = require("./session-registry.js");
|
|
24
|
+
/** Ceiling on rows: far above the registry's own 100 runs, only so the file cannot grow without bound. */
|
|
25
|
+
const MAX_ROWS = 500;
|
|
26
|
+
const filePath = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'run-seqs.json');
|
|
27
|
+
const isSeq = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
28
|
+
/** The last parse, keyed by the file's mtime and size: the 5 s report sweep reads it without parsing again. */
|
|
29
|
+
let cached;
|
|
30
|
+
const readAll = () => {
|
|
31
|
+
const path = filePath();
|
|
32
|
+
try {
|
|
33
|
+
const stat = (0, fs_1.statSync)(path);
|
|
34
|
+
const stamp = `${stat.mtimeMs}:${stat.size}`;
|
|
35
|
+
if (cached?.path === path && cached.stamp === stamp)
|
|
36
|
+
return cached.rows;
|
|
37
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(path, 'utf-8'));
|
|
38
|
+
const rows = !Array.isArray(parsed) ? [] : parsed.filter((r) => !!r && typeof r === 'object' &&
|
|
39
|
+
typeof r.name === 'string' && typeof r.runId === 'string' && typeof r.at === 'string' &&
|
|
40
|
+
isSeq(r.firstSeq) && isSeq(r.lastSeq));
|
|
41
|
+
cached = { path, stamp, rows };
|
|
42
|
+
return rows;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const writeAll = (rows) => {
|
|
49
|
+
const path = filePath();
|
|
50
|
+
try {
|
|
51
|
+
(0, private_files_js_1.mkdirHandedBack)((0, path_1.dirname)(path), 0o700);
|
|
52
|
+
(0, fs_1.writeFileSync)(path, JSON.stringify(rows), { mode: 0o600 });
|
|
53
|
+
(0, private_files_js_1.handBackToSudoUser)(path);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A block that cannot be written costs the narrow read, not the chunk: the server reads the whole name.
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
/** Widen run `runId` of `name` to cover the seqs `firstSeq`..`lastSeq` it was just handed. */
|
|
60
|
+
const noteRunSeqs = (name, runId, firstSeq, lastSeq, now = Date.now()) => {
|
|
61
|
+
if (!isSeq(firstSeq) || !isSeq(lastSeq))
|
|
62
|
+
return;
|
|
63
|
+
const at = new Date(now).toISOString();
|
|
64
|
+
const rows = readAll().filter((r) => now - Date.parse(r.at) < session_registry_js_1.KNOWN_SESSION_TTL_MS);
|
|
65
|
+
const had = rows.find((r) => r.name === name && r.runId === runId);
|
|
66
|
+
const row = had
|
|
67
|
+
? { ...had, firstSeq: Math.min(had.firstSeq, firstSeq), lastSeq: Math.max(had.lastSeq, lastSeq), at }
|
|
68
|
+
: { name, runId, firstSeq, lastSeq, at };
|
|
69
|
+
writeAll([row, ...rows.filter((r) => r !== had)].slice(0, MAX_ROWS));
|
|
70
|
+
};
|
|
71
|
+
exports.noteRunSeqs = noteRunSeqs;
|
|
72
|
+
/** Every run's block, keyed by `runSeqsKey`: one read for a whole report. */
|
|
73
|
+
const runSeqsByRun = () => new Map(readAll().map((r) => [(0, exports.runSeqsKey)(r.name, r.runId), { firstSeq: r.firstSeq, lastSeq: r.lastSeq }]));
|
|
74
|
+
exports.runSeqsByRun = runSeqsByRun;
|
|
75
|
+
/** NUL-separated, like the registry's `runKey`: a name holding the separator cannot forge another run's key. */
|
|
76
|
+
const runSeqsKey = (name, runId) => `${name}\u0000${runId}`;
|
|
77
|
+
exports.runSeqsKey = runSeqsKey;
|
|
78
|
+
/** Test seam: the file this module reads and writes. */
|
|
79
|
+
exports.runSeqsPath = filePath;
|