@hwip/cli 0.1.1 → 0.1.3

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/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.handleDiffFileRequest = exports.handleDiffFilesRequest = 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.__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.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.setAttachmentContext = exports.shellTextRefusal = 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 = 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.downloadAttachments = void 0;
28
+ 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.continueRuns = 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.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 = exports.fileViewBytes = 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 = 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,42 @@ 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 = 30;
296
- const knownSessionsToReport = (live, now = Date.now()) => {
297
- // By name AND kind: a slot that is live under claude may still hold an
298
- // ended pi run, and that run is a past session — excluding the whole name
299
- // would drop it from the report exactly the way the registry used to drop
300
- // it from disk. The live run itself stays out for the reason below.
301
- const running = new Set(live.map((s) => (0, session_registry_js_1.runKey)(s.name, s.agentKind)));
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),
321
+ ...(e.tmuxRunIds?.length ? { tmuxRunIds: e.tmuxRunIds } : {}),
310
322
  lastSeenAt: e.lastSeenAt,
311
323
  }));
312
324
  };
313
325
  exports.knownSessionsToReport = knownSessionsToReport;
314
- const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.lastSeenAt}`).sort().join('|');
326
+ const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''}`).sort().join('|');
315
327
  exports.knownSessionsFingerprint = knownSessionsFingerprint;
316
328
  const sessionsFingerprint = (sessions) => sessions
317
329
  .map((s) => JSON.stringify([
318
330
  s.name, s.attached, s.agentKind, s.agentSessionId ?? null,
319
- s.project, s.label ?? null, s.providerSessionName ?? null, s.createdAt ?? null,
331
+ s.project, s.label ?? null, s.providerSessionName ?? null, s.createdAt ?? null, s.runId ?? null, s.firstSeq ?? null,
320
332
  s.lastActivityAt ?? null, s.state ?? null,
321
333
  s.stateChangedAt ?? null, s.stateRuleId ?? null,
322
334
  s.blockedPrompt?.id ?? null, s.blockedPrompt?.at ?? null,
@@ -621,6 +633,39 @@ const epochToIso = (epoch) => {
621
633
  return undefined;
622
634
  return new Date(n * 1000).toISOString();
623
635
  };
636
+ /**
637
+ * A run's id from tmux's `session_created` (hwip docs/session-runs.md): the
638
+ * whole epoch seconds as a decimal string. The server derives the same string
639
+ * from `createdAt` for listeners that do not send one (`runIdOf`), so the two
640
+ * must agree: `epochToIso` keeps whole seconds and this floors them.
641
+ */
642
+ const runIdOfEpoch = (epoch) => runIdOfIso(epochToIso(epoch));
643
+ exports.runIdOfEpoch = runIdOfEpoch;
644
+ /** The run id of the tmux session that started at `iso` (a session's `createdAt`). */
645
+ const runIdOfIso = (iso) => {
646
+ const ms = iso ? Date.parse(iso) : NaN;
647
+ return Number.isFinite(ms) ? String(Math.floor(ms / 1000)) : undefined;
648
+ };
649
+ /**
650
+ * Each live session's run once the sweep has read the conversations (hwip
651
+ * docs/session-runs.md §3): a session carrying on an earlier run's
652
+ * conversation reports — and its recorder tags — that run instead of its new
653
+ * tmux session. `sessions` and `recordable` are this sweep's, changed in place.
654
+ */
655
+ const continueRuns = (sessions, recordable, conversationOf, known = (0, session_registry_js_1.knownSessions)()) => {
656
+ for (const s of sessions) {
657
+ if (s.parentName || !s.runId)
658
+ continue;
659
+ const runId = (0, session_registry_js_1.continuedRunId)(known, s.name, s.agentKind, s.runId, conversationOf(s.name));
660
+ if (runId === s.runId)
661
+ continue;
662
+ s.runId = runId;
663
+ const source = recordable[s.name];
664
+ if (source)
665
+ recordable[s.name] = { ...source, runId };
666
+ }
667
+ };
668
+ exports.continueRuns = continueRuns;
624
669
  // ─── Agent state detection (SPEC-AGENT-AWARENESS §S1) ──────────────
625
670
  // How many pane lines the state rules see. The live status area every
626
671
  // agent renders sits in the last handful of lines; 40 leaves margin for
@@ -1313,6 +1358,21 @@ const handleScheduleListRequest = (req, send) => {
1313
1358
  return true;
1314
1359
  };
1315
1360
  exports.handleScheduleListRequest = handleScheduleListRequest;
1361
+ /** What an agent conversation id looks like — a value, never an option. */
1362
+ const CONVERSATION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
1363
+ /**
1364
+ * The flags that pick a resumed run's last conversation back up, or null to
1365
+ * start it fresh. The id is one this machine read off the live pane, but the
1366
+ * registry is a file on disk: it reaches argv only once it reads as an id, so
1367
+ * nothing in it can turn into an option of the agent's own.
1368
+ */
1369
+ const resumableConversation = (agent, known) => {
1370
+ const id = known.agentSessionId;
1371
+ if (typeof id !== 'string' || !CONVERSATION_ID.test(id))
1372
+ return null;
1373
+ const args = agent.resumeArgs?.(id, known.cwd);
1374
+ return args ? { id, args } : null;
1375
+ };
1316
1376
  /**
1317
1377
  * Start a remembered session again, detached, and tell the phone what happened.
1318
1378
  * Returns true when the message was ours to answer.
@@ -1383,12 +1443,14 @@ const handleSessionResumeRequest = (req, send) => {
1383
1443
  const sidecar = known.label && known.project
1384
1444
  ? [';', 'set-option', config_js_1.SESSION_PROJECT_OPTION, known.project, ';', 'set-option', config_js_1.SESSION_LABEL_OPTION, known.label]
1385
1445
  : [];
1386
- const started = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['new-session', '-d', '-s', sessionName, '-c', known.cwd, agent.binary, ...sidecar]), { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
1446
+ const conversation = resumableConversation(agent, known);
1447
+ 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'] });
1387
1448
  if (started.status !== 0) {
1388
1449
  (0, listener_log_js_1.log)(`✗ resume ${sessionName}: tmux refused (${(started.stderr ?? '').trim() || 'no detail'})`);
1389
1450
  return reply({ error: 'start_failed' });
1390
1451
  }
1391
- (0, listener_log_js_1.log)(`⟲ resume ${sessionName}: ${agent.binary} in ${known.cwd}`);
1452
+ const resumedFrom = conversation ? ` (conversation ${conversation.id.slice(0, 8)})` : '';
1453
+ (0, listener_log_js_1.log)(`⟲ resume ${sessionName}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
1392
1454
  return reply({ resumed: true });
1393
1455
  };
1394
1456
  exports.handleSessionResumeRequest = handleSessionResumeRequest;
@@ -1549,14 +1611,21 @@ const handleSessionForgetRequest = (req, send) => {
1549
1611
  // alternative is an irreversible delete (registry AND scrollback) justified
1550
1612
  // by a question that failed to be asked. It says so in the log, because
1551
1613
  // from the phone the two refusals are the same sentence.
1614
+ const runId = typeof req.runId === 'string' && /^\d{1,12}$/.test(req.runId) ? req.runId : undefined;
1552
1615
  const liveness = (0, tmux_liveness_js_1.sessionLiveness)(sessionName);
1553
1616
  if (liveness === 'unknown') {
1554
1617
  (0, listener_log_js_1.log)(`✗ forget ${sessionName}: no tmux answered, so nothing was deleted`);
1555
1618
  return reply({ error: 'still_running' });
1556
1619
  }
1557
- if (liveness === 'running')
1558
- return reply({ error: 'still_running' });
1559
- const outcome = (0, session_registry_js_1.forgetSession)(sessionName, typeof req.agentKind === 'string' ? req.agentKind : undefined);
1620
+ // A name running again is not the run being forgotten: an ended run of it goes on its own (Q29).
1621
+ // The running run itself, a request naming no run, or a run tmux could not tell apart stay refused.
1622
+ if (liveness === 'running') {
1623
+ const tmuxRun = runId ? (0, tmux_liveness_js_1.liveRunIdOf)(sessionName) : undefined;
1624
+ const live = tmuxRun ? (0, session_registry_js_1.runOfTmuxSession)(sessionName, tmuxRun) : undefined;
1625
+ if (!runId || !live || live === runId)
1626
+ return reply({ error: 'still_running' });
1627
+ }
1628
+ const outcome = (0, session_registry_js_1.forgetSession)(sessionName, typeof req.agentKind === 'string' ? req.agentKind : undefined, runId, liveness === 'running');
1560
1629
  if (outcome === 'unknown') {
1561
1630
  (0, listener_log_js_1.log)(`✗ forget ${sessionName}: never seen on this machine`);
1562
1631
  return reply({ error: 'unknown_session' });
@@ -2113,6 +2182,7 @@ const startTurnRecorder = (features, apiKey, baseUrl, devices) => {
2113
2182
  uploadReady: () => deviceKeyRegistered,
2114
2183
  devicePublicKey: crypto_js_1.getDevicePublicKey,
2115
2184
  latestSeq: (0, turn_recorder_wire_js_1.createLatestSeqReader)({ baseUrl, apiKey }),
2185
+ noteSeqs: run_seqs_js_1.noteRunSeqs,
2116
2186
  log: (message) => (0, listener_log_js_1.log)(message),
2117
2187
  });
2118
2188
  turnRecorder.start();
@@ -3311,6 +3381,8 @@ const collectSessionsVerbose = () => {
3311
3381
  // rather than in it: AgentSession is a server/phone contract and the
3312
3382
  // directory is local knowledge the registry needs, nothing the phone gets.
3313
3383
  const paneCwdOf = new Map();
3384
+ // The conversation a resume may pick back up, beside it for the same reason.
3385
+ const resumeIdOf = new Map();
3314
3386
  const targets = {};
3315
3387
  const subagentTranscripts = {};
3316
3388
  const recordable = {};
@@ -3341,6 +3413,7 @@ const collectSessionsVerbose = () => {
3341
3413
  project: parsed.project,
3342
3414
  label: parsed.label,
3343
3415
  createdAt: epochToIso(group.created),
3416
+ runId: (0, exports.runIdOfEpoch)(group.created),
3344
3417
  lastActivityAt: epochToIso(group.activity),
3345
3418
  // No `state`: the server arms its completion and `gone` pushes
3346
3419
  // on reported state, and a shell going idle is not news.
@@ -3380,6 +3453,11 @@ const collectSessionsVerbose = () => {
3380
3453
  : null;
3381
3454
  if (info.currentPath)
3382
3455
  paneCwdOf.set(name, info.currentPath);
3456
+ const resumeId = info.currentPath
3457
+ ? main.agent.resolveResumeId?.(info.currentPath, info.panePid ?? undefined)
3458
+ : null;
3459
+ if (resumeId)
3460
+ resumeIdOf.set(name, resumeId);
3383
3461
  targets[name] = main.row.paneId;
3384
3462
  sessions.push({
3385
3463
  name,
@@ -3390,6 +3468,7 @@ const collectSessionsVerbose = () => {
3390
3468
  label: parsed.label,
3391
3469
  providerSessionName,
3392
3470
  createdAt: epochToIso(group.created),
3471
+ runId: (0, exports.runIdOfEpoch)(group.created),
3393
3472
  lastActivityAt: epochToIso(group.activity),
3394
3473
  ...(0, exports.deriveSessionState)(name, main.agent.kind, capturePaneText(name, main.row.paneId)),
3395
3474
  });
@@ -3448,8 +3527,10 @@ const collectSessionsVerbose = () => {
3448
3527
  const mainTranscript = info.currentPath && main.agent.resolveTranscript && main.agent.projectTranscript
3449
3528
  ? (main.agent.resolveTranscript(info.currentPath, info.panePid ?? undefined) ?? null)
3450
3529
  : null;
3451
- if (mainTranscript)
3452
- recordable[name] = { path: mainTranscript, kind: main.agent.kind };
3530
+ if (mainTranscript) {
3531
+ const runId = (0, exports.runIdOfEpoch)(group.created);
3532
+ recordable[name] = { path: mainTranscript, kind: main.agent.kind, ...(runId ? { runId } : {}) };
3533
+ }
3453
3534
  const transcriptPath = main.agent.kind === 'claude' ? mainTranscript : null;
3454
3535
  if (transcriptPath) {
3455
3536
  const scan = subagentScans.get(name) ?? (0, subagent_transcripts_js_1.initialSubagentScanState)();
@@ -3494,6 +3575,22 @@ const collectSessionsVerbose = () => {
3494
3575
  }
3495
3576
  pruneToLive(subagentScans, new Set(groups.keys()));
3496
3577
  pruneToLive(sessionStates, new Set(sessions.map((s) => s.name)));
3578
+ // An id two panes both answer to belongs to at most one of them, and the
3579
+ // sweep cannot tell which — opencode names the busiest session in the
3580
+ // folder — so neither may resume it.
3581
+ const resumeIdCount = new Map();
3582
+ for (const id of resumeIdOf.values())
3583
+ resumeIdCount.set(id, (resumeIdCount.get(id) ?? 0) + 1);
3584
+ // Undefined (not read this sweep) keeps the run's last id; null (shared)
3585
+ // forgets it, or a run that had the id alone would keep resuming into
3586
+ // the pane that now answers to it.
3587
+ const ownResumeId = (name) => {
3588
+ const id = resumeIdOf.get(name);
3589
+ if (!id)
3590
+ return undefined;
3591
+ return resumeIdCount.get(id) === 1 ? id : null;
3592
+ };
3593
+ (0, exports.continueRuns)(sessions, recordable, ownResumeId);
3497
3594
  // Write down what each live session IS, while it still exists to be read.
3498
3595
  // tmux forgets a session the moment it ends, which is exactly when the
3499
3596
  // phone wants it back — and a resume must take its directory and its binary
@@ -3510,6 +3607,10 @@ const collectSessionsVerbose = () => {
3510
3607
  agentKind: s.agentKind,
3511
3608
  project: s.project,
3512
3609
  label: s.label,
3610
+ runId: s.runId,
3611
+ startedAt: s.createdAt,
3612
+ tmuxRunId: runIdOfIso(s.createdAt),
3613
+ agentSessionId: ownResumeId(s.name),
3513
3614
  })));
3514
3615
  return { sessions, rejected, targets, subagentTranscripts, recordable };
3515
3616
  };
@@ -3881,10 +3982,10 @@ const resolveDownloadUrl = async (fileKey, ctx, fetchFn = fetch, timeoutMs = ATT
3881
3982
  return downloadUrl;
3882
3983
  };
3883
3984
  /** A download's body, refused at the headers when it says it is over the limit. */
3884
- const cappedBody = (res) => {
3885
- if (Number(res.headers.get('content-length')) > file_crypto_js_1.DOWNLOAD_MAX_BYTES) {
3985
+ const cappedBody = (res, maxBytes = file_crypto_js_1.DOWNLOAD_MAX_BYTES) => {
3986
+ if (Number(res.headers.get('content-length')) > maxBytes) {
3886
3987
  void res.body?.cancel().catch(() => undefined);
3887
- throw new Error(`download is over the ${file_crypto_js_1.DOWNLOAD_MAX_BYTES}-byte limit`);
3988
+ throw new Error(`download is over the ${maxBytes}-byte limit`);
3888
3989
  }
3889
3990
  if (!res.body)
3890
3991
  throw new Error('download had no body');
@@ -3892,7 +3993,7 @@ const cappedBody = (res) => {
3892
3993
  };
3893
3994
  /** Resolve fileKey → presigned URL → bytes. Throws on any HTTP failure so
3894
3995
  * the caller can isolate one file's failure from the rest of the batch. */
3895
- const fetchAttachmentBytes = async (fileKey, ctx, io) => {
3996
+ const fetchAttachmentBytes = async (fileKey, ctx, io, maxBytes = file_crypto_js_1.DOWNLOAD_MAX_BYTES) => {
3896
3997
  const fetchFn = io.fetchFn ?? fetch;
3897
3998
  const timeoutMs = io.timeoutMs ?? ATTACHMENT_FETCH_TIMEOUT_MS;
3898
3999
  const url = await resolveDownloadUrl(fileKey, ctx, fetchFn, Math.min(timeoutMs, ATTACHMENT_META_TIMEOUT_MS));
@@ -3901,7 +4002,7 @@ const fetchAttachmentBytes = async (fileKey, ctx, io) => {
3901
4002
  const bin = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'error' });
3902
4003
  if (!bin.ok)
3903
4004
  throw new Error(`download ${bin.status}`);
3904
- const body = cappedBody(bin);
4005
+ const body = cappedBody(bin, maxBytes);
3905
4006
  // Count as it comes: a body with no length, or a false one, is not held in full.
3906
4007
  const chunks = [];
3907
4008
  let seen = 0;
@@ -3911,9 +4012,9 @@ const fetchAttachmentBytes = async (fileKey, ctx, io) => {
3911
4012
  if (done)
3912
4013
  break;
3913
4014
  seen += value.length;
3914
- if (seen > file_crypto_js_1.DOWNLOAD_MAX_BYTES) {
4015
+ if (seen > maxBytes) {
3915
4016
  await reader.cancel().catch(() => undefined);
3916
- throw new Error(`download passed the ${file_crypto_js_1.DOWNLOAD_MAX_BYTES}-byte limit`);
4017
+ throw new Error(`download passed the ${maxBytes}-byte limit`);
3917
4018
  }
3918
4019
  chunks.push(value);
3919
4020
  }
@@ -4027,6 +4128,58 @@ const downloadAttachments = async (pushId, files, ctx, sealed, io = {}) => {
4027
4128
  return paths;
4028
4129
  };
4029
4130
  exports.downloadAttachments = downloadAttachments;
4131
+ /** Padmé: the size a file is padded to before it is sealed (the apps' `Envelope.padme`). */
4132
+ const padme = (size) => {
4133
+ if (size < 2)
4134
+ return size;
4135
+ const e = Math.floor(Math.log2(size));
4136
+ const step = 2 ** (e - (Math.floor(Math.log2(e)) + 1));
4137
+ return Math.ceil(size / step) * step;
4138
+ };
4139
+ /**
4140
+ * The most a sealed answer file may download as: its seal's size padded, and the tag. The relay
4141
+ * points at the bytes, so it could name a larger object; nothing past this is read.
4142
+ */
4143
+ 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;
4144
+ exports.answerFileBound = answerFileBound;
4145
+ /**
4146
+ * The attachments of a sealed answer (`hwip ask`, answer-files.ts), saved one by one to
4147
+ * `~/.hwip/attachments/<eventId>/<name>`. Unlike a command's, a file that fails does not take the
4148
+ * others with it: the answer is the user's word either way, and each file not saved is named with why.
4149
+ * `sealed.files` gives each file's meta in the same order.
4150
+ */
4151
+ const downloadSealedEach = async (eventId, files, ctx, sealed, io = {}) => {
4152
+ const dir = (0, path_1.join)(ATTACHMENTS_DIR, safeSegment(eventId, 'answer'));
4153
+ const taken = new Map();
4154
+ const paths = [];
4155
+ const missing = [];
4156
+ for (const [i, f] of files.entries()) {
4157
+ const meta = sealed.files[i];
4158
+ const name = meta?.name || f.fileName || `file-${i}`;
4159
+ try {
4160
+ if (!f.fileKey)
4161
+ throw new Error('no fileKey');
4162
+ const opened = await openSealedAttachment(await fetchAttachmentBytes(f.fileKey, ctx, io, (0, exports.answerFileBound)(meta?.size)), f, sealed);
4163
+ // Padded before sealing: cut back to the size the seal gives.
4164
+ const bytes = meta && Number.isSafeInteger(meta.size) && meta.size >= 0 ? opened.subarray(0, meta.size) : opened;
4165
+ (0, private_files_js_1.mkdirHandedBack)(dir);
4166
+ const abs = attachmentPath(taken, dir, safeSegment(name, `file-${i}`), bytes);
4167
+ if (abs === null) {
4168
+ // The same bytes under a name already saved: said, so no file goes unaccounted for.
4169
+ missing.push(`${name}: the same file was attached twice`);
4170
+ continue;
4171
+ }
4172
+ (0, fs_1.writeFileSync)(abs, bytes);
4173
+ (0, private_files_js_1.handBackToSudoUser)(abs);
4174
+ paths.push(abs);
4175
+ }
4176
+ catch (err) {
4177
+ missing.push(`${name}: ${err.message}`);
4178
+ }
4179
+ }
4180
+ return { paths, missing };
4181
+ };
4182
+ exports.downloadSealedEach = downloadSealedEach;
4030
4183
  const defaultDownloadAttachments = (pushId, files, sealed) => {
4031
4184
  if (!attachmentCtx) {
4032
4185
  (0, listener_log_js_1.log)('! attachment context not initialised — skipping files');
@@ -4907,8 +5060,10 @@ const streamSession = (wsUrl, apiKey) => {
4907
5060
  // request of their own: the phone is already reading this device
4908
5061
  // record, and a second round trip would need its own timeout, its
4909
5062
  // own failure wording and its own staleness question.
4910
- const known = (0, exports.knownSessionsToReport)(sessions);
4911
- const fingerprint = `${(0, exports.sessionsFingerprint)(sessions)}␞${(0, exports.knownSessionsFingerprint)(known)}`;
5063
+ const seqs = (0, run_seqs_js_1.runSeqsByRun)();
5064
+ const reported = (0, exports.withRunSeqs)(sessions, seqs);
5065
+ const known = (0, exports.knownSessionsToReport)(sessions, Date.now(), seqs);
5066
+ const fingerprint = `${(0, exports.sessionsFingerprint)(reported)}␞${(0, exports.knownSessionsFingerprint)(known)}`;
4912
5067
  if (!(0, exports.sessionsReportDue)(fingerprint, lastSentFingerprint, lastSentAtMs, Date.now())) {
4913
5068
  return;
4914
5069
  }
@@ -4918,7 +5073,7 @@ const streamSession = (wsUrl, apiKey) => {
4918
5073
  // One message when it fits; past 24 KB, parts (sessions-report.ts): API Gateway closes a
4919
5074
  // socket on a frame over 32 KB, so an uncut report from a machine with ~100 sessions would
4920
5075
  // drop the connection on every send.
4921
- const plan = (0, sessions_report_js_1.planSessionsReport)(sessions, known);
5076
+ const plan = (0, sessions_report_js_1.planSessionsReport)(reported, known);
4922
5077
  if (plan.messages.length > 1) {
4923
5078
  (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`);
4924
5079
  for (const name of plan.oversized)
@@ -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";
@@ -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
- const projectDir = (0, path_1.join)(CLAUDE_PROJECTS_DIR, (0, exports.claudeProjectDirName)(paneCwd));
440
- const path = (0, path_1.join)(projectDir, `${sessionId}.jsonl`);
441
- // Prove it before handing it out. The pid path never touches the filesystem,
442
- // so without this a mis-encoded directory reads as a healthy silent session.
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
@@ -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;