@hwip/cli 0.1.17 → 0.1.19
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 +22 -19
- package/dist/keep-awake.js +3 -3
- package/dist/listener.d.ts +6 -24
- package/dist/listener.js +18 -49
- package/dist/recent-projects.d.ts +31 -0
- package/dist/recent-projects.js +49 -0
- package/dist/sessions-report.d.ts +6 -2
- package/dist/sessions-report.js +12 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -558,20 +558,22 @@ Linux a systemd user unit (`app.hwip.listener.service`, see
|
|
|
558
558
|
`--service-status` shows what the installed one points at; `hwip install`
|
|
559
559
|
offers it during setup and `hwip uninstall` takes it away again.
|
|
560
560
|
|
|
561
|
-
What you get back after a reboot is your **
|
|
562
|
-
|
|
561
|
+
What you get back after a reboot is your **recent projects**, ready for a
|
|
562
|
+
new session from the phone — not live agents. tmux does not survive a reboot
|
|
563
563
|
either, and this doesn't try to bring those sessions back to life.
|
|
564
|
-
A
|
|
564
|
+
A new session starts the agent in the directory the project last ran in, with
|
|
565
|
+
a fresh conversation. An older app can still ask to resume a session; that
|
|
566
|
+
starts it in the same directory, and for Claude Code,
|
|
565
567
|
OpenCode, Codex, Pi and Hermes it also picks up the conversation that session
|
|
566
568
|
last had (`claude --resume <id>`, `opencode --session <id>`,
|
|
567
569
|
`codex resume <thread>`, `pi --session <file>`, `hermes --resume <id>` — the
|
|
568
570
|
id this machine read off the live pane's own process; an id two panes both
|
|
569
571
|
answered to is not kept). Claude Code, Codex and Pi start fresh when that
|
|
570
572
|
conversation is no longer on disk, and the other agents always start a new one.
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
of
|
|
573
|
+
An older app that names a run picks up that run's conversation, not the
|
|
574
|
+
newest one. If the name is live again by then, that run starts beside it
|
|
575
|
+
under the next free name of the family (`<name>-2`, `<name>-3`, …) instead
|
|
576
|
+
of being refused.
|
|
575
577
|
|
|
576
578
|
The phone can also start a **new** session in a project this machine has
|
|
577
579
|
run before: the same request with `fresh: true` starts the agent recorded
|
|
@@ -651,8 +653,8 @@ So on macOS the listener runs `caffeinate -s -w <its pid>` for as long as
|
|
|
651
653
|
it lives. `-s` is honoured **on AC power only** — on battery macOS ignores
|
|
652
654
|
it, so a laptop still sleeps and nothing drains. The display still turns
|
|
653
655
|
off; a closed lid still sleeps. Activity Monitor shows the daemon under
|
|
654
|
-
"Preventing Sleep". It holds even when no agent session is open:
|
|
655
|
-
|
|
656
|
+
"Preventing Sleep". It holds even when no agent session is open: starting a
|
|
657
|
+
session from the phone needs this Mac awake to answer. Opt out with `--no-keep-awake` or `"keepAwake": false`
|
|
656
658
|
in `~/.hwip/config.json`; the log's `keep-awake:` line says which way it
|
|
657
659
|
went.
|
|
658
660
|
|
|
@@ -702,16 +704,17 @@ tmux's `session_created` in epoch seconds). The app uses it to keep one run's
|
|
|
702
704
|
conversation apart from the one before it under the same name; a chunk read
|
|
703
705
|
after a restart keeps the run it was recorded in. What a session writes with no
|
|
704
706
|
run to name (a name whose tmux session has ended while its file is still written
|
|
705
|
-
to) is not recorded: the server takes no chunk without a run. The listener's record of
|
|
706
|
-
|
|
707
|
-
in the same folder leaves the first run as a
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
run
|
|
707
|
+
to) is not recorded: the server takes no chunk without a run. The listener's record of
|
|
708
|
+
runs keeps them apart the same way: closing `hwip cc` and starting it again
|
|
709
|
+
in the same folder leaves the first run as a run of its own (the newest ten
|
|
710
|
+
runs of a name are kept, 100 in all). The phone is told only the projects this
|
|
711
|
+
machine ran lately — up to 12, each agent of a project once with the newest
|
|
712
|
+
session it ran there — which is the list a new session is started from. A
|
|
713
|
+
running run is reported with the first chunk number it took, kept in
|
|
714
|
+
`run-seqs.json` next to the registry, so the server reads that run's
|
|
715
|
+
conversation from there instead of every chunk the name ever had. An older app
|
|
716
|
+
that deletes an ended run also asks this machine to forget it; the run that is
|
|
717
|
+
going is refused (`still_running`).
|
|
715
718
|
|
|
716
719
|
When a session waits for your approval, the listener also sends what it is waiting
|
|
717
720
|
for — the tool, the command or the diff, and the answers — so the app can show it
|
package/dist/keep-awake.js
CHANGED
|
@@ -30,10 +30,10 @@ const startKeepAwake = (opts) => {
|
|
|
30
30
|
// Never keep the event loop alive on the child's account: the daemon's
|
|
31
31
|
// lifetime is the socket loop's, and `-w` ties caffeinate to it anyway.
|
|
32
32
|
child.unref();
|
|
33
|
-
// Held with no agent session open too: the phone
|
|
34
|
-
//
|
|
33
|
+
// Held with no agent session open too: the phone starts a session
|
|
34
|
+
// only while this Mac is awake to answer (C25 — say so
|
|
35
35
|
// rather than let the Mac's sleeplessness go unexplained).
|
|
36
|
-
opts.log(`keep-awake: caffeinate -s -w ${pid} — blocks idle sleep on AC power only, even with no agent session open, so the phone can
|
|
36
|
+
opts.log(`keep-awake: caffeinate -s -w ${pid} — blocks idle sleep on AC power only, even with no agent session open, so the phone can start one (--no-keep-awake to disable)`);
|
|
37
37
|
return {
|
|
38
38
|
stop: () => {
|
|
39
39
|
if (stopped)
|
package/dist/listener.d.ts
CHANGED
|
@@ -222,31 +222,13 @@ export type StaleConnectionAction =
|
|
|
222
222
|
* tested as what it is: two guards around one lever.
|
|
223
223
|
*/
|
|
224
224
|
export declare const staleConnectionAction: (message: unknown, drops: number) => StaleConnectionAction;
|
|
225
|
-
export declare const KNOWN_SESSIONS_REPORTED = 50;
|
|
226
|
-
export interface ReportedKnownSession {
|
|
227
|
-
name: string;
|
|
228
|
-
agentKind: string;
|
|
229
|
-
project?: string;
|
|
230
|
-
label?: string;
|
|
231
|
-
/** The run's id and start (hwip docs/session-runs.md); absent for entries recorded before runs. */
|
|
232
|
-
runId?: string;
|
|
233
|
-
startedAt?: string;
|
|
234
|
-
/** The seq block the run's chunks took (Q24): the server reads its conversation from that key range alone. */
|
|
235
|
-
firstSeq?: number;
|
|
236
|
-
lastSeq?: number;
|
|
237
|
-
/** The later tmux sessions the run went on in (Q35): the server ends another run's push window at their start. */
|
|
238
|
-
tmuxRunIds?: string[];
|
|
239
|
-
/** The last name the agent gave the run, and who gave it (hwip docs/session-runs.md Q39): what the phone calls it once ended. */
|
|
240
|
-
providerSessionName?: string;
|
|
241
|
-
providerSessionNameSource?: ProviderNameSource;
|
|
242
|
-
/** Whether starting the run again picks its conversation back up (Q43) — never the conversation id itself. */
|
|
243
|
-
resumable: boolean;
|
|
244
|
-
lastSeenAt: string;
|
|
245
|
-
}
|
|
246
225
|
/** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
|
|
247
226
|
export declare const withRunSeqs: (sessions: AgentSession[], seqs?: Map<string, RunSeqs>) => AgentSession[];
|
|
248
|
-
|
|
249
|
-
|
|
227
|
+
/** What makes a cycle's inventory a change worth sending. lastActivityAt and
|
|
228
|
+
* stateChangedAt are left out: they move on every terminal output, and the
|
|
229
|
+
* server does not compare them either (REPORTED_SESSION_FIELDS). They reach
|
|
230
|
+
* the server with the next report that changes something else, or the
|
|
231
|
+
* heartbeat. */
|
|
250
232
|
export declare const sessionsFingerprint: (sessions: AgentSession[]) => string;
|
|
251
233
|
/** True when this cycle's inventory must be sent: it changed, or the
|
|
252
234
|
* idle heartbeat is due. */
|
|
@@ -644,7 +626,7 @@ export interface SessionForgetResult {
|
|
|
644
626
|
* running, so only actually running that name again restores it.
|
|
645
627
|
*
|
|
646
628
|
* The refusals: unknown name is nothing to delete rather than something to
|
|
647
|
-
* guess at. Still running means it is not
|
|
629
|
+
* guess at. Still running means it is not an ended run at all — the phone is
|
|
648
630
|
* looking at a live one, and ending it is a different request that this must
|
|
649
631
|
* not quietly stand in for.
|
|
650
632
|
*/
|
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.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.
|
|
29
|
-
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 = exports.handleScreenRequest = exports.setSubscriberTrust =
|
|
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 =
|
|
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.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 = 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 = 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");
|
|
@@ -55,6 +55,7 @@ const transcript_tail_js_1 = require("./transcript-tail.js");
|
|
|
55
55
|
const listener_service_js_1 = require("./listener-service.js");
|
|
56
56
|
const agent_state_js_1 = require("./agent-state.js");
|
|
57
57
|
const inventory_offload_js_1 = require("./inventory-offload.js");
|
|
58
|
+
const recent_projects_js_1 = require("./recent-projects.js");
|
|
58
59
|
const sessions_report_js_1 = require("./sessions-report.js");
|
|
59
60
|
const agent_rules_fetch_js_1 = require("./agent-rules-fetch.js");
|
|
60
61
|
const crypto_js_1 = require("./crypto.js");
|
|
@@ -295,47 +296,22 @@ const staleConnectionAction = (message, drops) => {
|
|
|
295
296
|
return drops < exports.MAX_STALE_CONNECTION_DROPS ? 'drop' : 'exhausted';
|
|
296
297
|
};
|
|
297
298
|
exports.staleConnectionAction = staleConnectionAction;
|
|
298
|
-
exports.KNOWN_SESSIONS_REPORTED = 50;
|
|
299
299
|
/** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
|
|
300
300
|
const withRunSeqs = (sessions, seqs = (0, run_seqs_js_1.runSeqsByRun)()) => sessions.map((s) => {
|
|
301
301
|
const block = s.runId ? seqs.get((0, run_seqs_js_1.runSeqsKey)(s.name, s.runId)) : undefined;
|
|
302
302
|
return block ? { ...s, firstSeq: block.firstSeq } : s;
|
|
303
303
|
});
|
|
304
304
|
exports.withRunSeqs = withRunSeqs;
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
// itself stays out for the reason below.
|
|
311
|
-
const running = new Set(live.map((s) => (0, session_registry_js_1.runKey)(s.name, s.agentKind, s.runId)));
|
|
312
|
-
return (0, session_registry_js_1.knownSessions)(now)
|
|
313
|
-
.filter((e) => !running.has((0, session_registry_js_1.runKey)(e.name, e.agentKind, e.runId)))
|
|
314
|
-
.slice(0, exports.KNOWN_SESSIONS_REPORTED)
|
|
315
|
-
.map((e) => ({
|
|
316
|
-
name: e.name,
|
|
317
|
-
agentKind: e.agentKind,
|
|
318
|
-
...(e.project ? { project: e.project } : {}),
|
|
319
|
-
...(e.label ? { label: e.label } : {}),
|
|
320
|
-
...(e.runId ? { runId: e.runId } : {}),
|
|
321
|
-
...(e.startedAt ? { startedAt: e.startedAt } : {}),
|
|
322
|
-
...(e.runId ? seqs.get((0, run_seqs_js_1.runSeqsKey)(e.name, e.runId)) : undefined),
|
|
323
|
-
...(e.tmuxRunIds?.length ? { tmuxRunIds: e.tmuxRunIds } : {}),
|
|
324
|
-
...(e.providerSessionName ? { providerSessionName: e.providerSessionName } : {}),
|
|
325
|
-
...(e.providerSessionName && e.providerSessionNameSource ? { providerSessionNameSource: e.providerSessionNameSource } : {}),
|
|
326
|
-
resumable: isResumable(e),
|
|
327
|
-
lastSeenAt: e.lastSeenAt,
|
|
328
|
-
}));
|
|
329
|
-
};
|
|
330
|
-
exports.knownSessionsToReport = knownSessionsToReport;
|
|
331
|
-
const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''} ${k.providerSessionNameSource ?? ''} ${k.providerSessionName ?? ''} ${k.resumable}`).sort().join('|');
|
|
332
|
-
exports.knownSessionsFingerprint = knownSessionsFingerprint;
|
|
305
|
+
/** What makes a cycle's inventory a change worth sending. lastActivityAt and
|
|
306
|
+
* stateChangedAt are left out: they move on every terminal output, and the
|
|
307
|
+
* server does not compare them either (REPORTED_SESSION_FIELDS). They reach
|
|
308
|
+
* the server with the next report that changes something else, or the
|
|
309
|
+
* heartbeat. */
|
|
333
310
|
const sessionsFingerprint = (sessions) => sessions
|
|
334
311
|
.map((s) => JSON.stringify([
|
|
335
312
|
s.name, s.attached, s.agentKind, s.agentSessionId ?? null,
|
|
336
313
|
s.project, s.label ?? null, s.providerSessionName ?? null, s.providerSessionNameSource ?? null, s.providerSessionHandle ?? null, s.createdAt ?? null, s.runId ?? null, s.firstSeq ?? null,
|
|
337
|
-
s.
|
|
338
|
-
s.stateChangedAt ?? null, s.stateRuleId ?? null,
|
|
314
|
+
s.state ?? null, s.stateRuleId ?? null,
|
|
339
315
|
s.blockedPrompt?.id ?? null, s.blockedPrompt?.at ?? null,
|
|
340
316
|
]))
|
|
341
317
|
.sort()
|
|
@@ -1490,11 +1466,6 @@ const conversationRunning = (known, live) => {
|
|
|
1490
1466
|
return current?.agentKind === known.agentKind && current.agentSessionId === id;
|
|
1491
1467
|
});
|
|
1492
1468
|
};
|
|
1493
|
-
/** What the report says about a run: the same answer a resume of it would act on. */
|
|
1494
|
-
const isResumable = (known) => {
|
|
1495
|
-
const agent = remote_agents_js_1.REMOTE_AGENTS.find((a) => a.kind === known.agentKind);
|
|
1496
|
-
return agent !== undefined && resumableConversation(agent, known) !== null;
|
|
1497
|
-
};
|
|
1498
1469
|
/**
|
|
1499
1470
|
* Start a remembered session again, detached, and tell the phone what happened.
|
|
1500
1471
|
* Returns true when the message was ours to answer.
|
|
@@ -1722,7 +1693,7 @@ exports.handleSessionExitRequest = handleSessionExitRequest;
|
|
|
1722
1693
|
* running, so only actually running that name again restores it.
|
|
1723
1694
|
*
|
|
1724
1695
|
* The refusals: unknown name is nothing to delete rather than something to
|
|
1725
|
-
* guess at. Still running means it is not
|
|
1696
|
+
* guess at. Still running means it is not an ended run at all — the phone is
|
|
1726
1697
|
* looking at a live one, and ending it is a different request that this must
|
|
1727
1698
|
* not quietly stand in for.
|
|
1728
1699
|
*/
|
|
@@ -5310,14 +5281,12 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
5310
5281
|
// Unchanged inventory + heartbeat not due → skip the send.
|
|
5311
5282
|
// The 5 s local poll above still ran, so a change is never
|
|
5312
5283
|
// delayed; only the no-op reports (idle listener) are culled.
|
|
5313
|
-
//
|
|
5314
|
-
// request of their own: the phone is
|
|
5315
|
-
//
|
|
5316
|
-
|
|
5317
|
-
const
|
|
5318
|
-
const
|
|
5319
|
-
const known = (0, exports.knownSessionsToReport)(sessions, Date.now(), seqs);
|
|
5320
|
-
const fingerprint = `${(0, exports.sessionsFingerprint)(reported)}␞${(0, exports.knownSessionsFingerprint)(known)}`;
|
|
5284
|
+
// The projects a new session can start in ride the report that
|
|
5285
|
+
// already exists rather than a request of their own: the phone is
|
|
5286
|
+
// already reading this device record (recent-projects.ts).
|
|
5287
|
+
const reported = (0, exports.withRunSeqs)(sessions);
|
|
5288
|
+
const recent = (0, recent_projects_js_1.recentProjectsToReport)();
|
|
5289
|
+
const fingerprint = `${(0, exports.sessionsFingerprint)(reported)}␞${recent ? JSON.stringify(recent) : '-'}`;
|
|
5321
5290
|
if (!(0, exports.sessionsReportDue)(fingerprint, lastSentFingerprint, lastSentAtMs, Date.now())) {
|
|
5322
5291
|
return;
|
|
5323
5292
|
}
|
|
@@ -5327,7 +5296,7 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
5327
5296
|
// One message when it fits; past 24 KB, parts (sessions-report.ts): API Gateway closes a
|
|
5328
5297
|
// socket on a frame over 32 KB, so an uncut report from a machine with ~100 sessions would
|
|
5329
5298
|
// drop the connection on every send.
|
|
5330
|
-
const plan = (0, sessions_report_js_1.planSessionsReport)(reported,
|
|
5299
|
+
const plan = (0, sessions_report_js_1.planSessionsReport)(reported, recent);
|
|
5331
5300
|
if (plan.messages.length > 1) {
|
|
5332
5301
|
(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`);
|
|
5333
5302
|
for (const name of plan.oversized)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The projects the phone can start a new session in (hwip docs/session-runs.md Q47, Q49), as the
|
|
3
|
+
* `listener.sessions` report carries them.
|
|
4
|
+
*
|
|
5
|
+
* The phone never names a directory: it names a session, and the fresh start reads that session's
|
|
6
|
+
* folder and agent back from this machine's registry (`recallSession` · `recallRun`). So each entry
|
|
7
|
+
* is one agent of one project with the newest session it ran there. The list is newest first and
|
|
8
|
+
* carries no times — a running session's `lastSeenAt` moves on every sweep, and a field that changes
|
|
9
|
+
* every five seconds would defeat the report's unchanged-inventory gate.
|
|
10
|
+
*/
|
|
11
|
+
export interface RecentProject {
|
|
12
|
+
/** The newest session of this project and agent: what a fresh start is asked from. */
|
|
13
|
+
name: string;
|
|
14
|
+
agentKind: string;
|
|
15
|
+
/** Absent when the registry has none; the phone then calls the project by the session's name. */
|
|
16
|
+
project?: string;
|
|
17
|
+
}
|
|
18
|
+
/** A short list: the projects a person works in lately, not every folder the machine ever ran one in. */
|
|
19
|
+
export declare const RECENT_PROJECTS_MAX = 12;
|
|
20
|
+
/** Each project's agents, from the newest session of each, in the order `known` gives (newest first). */
|
|
21
|
+
export declare const recentProjects: (known: readonly {
|
|
22
|
+
name: string;
|
|
23
|
+
agentKind: string;
|
|
24
|
+
project?: string;
|
|
25
|
+
}[]) => RecentProject[];
|
|
26
|
+
/**
|
|
27
|
+
* The list for this report, or undefined when the registry would not read — an empty list would
|
|
28
|
+
* clear what the server holds, and the phone would lose every project to start in.
|
|
29
|
+
*/
|
|
30
|
+
export declare const recentProjectsToReport: (now?: number) => RecentProject[] | undefined;
|
|
31
|
+
//# sourceMappingURL=recent-projects.d.ts.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The projects the phone can start a new session in (hwip docs/session-runs.md Q47, Q49), as the
|
|
4
|
+
* `listener.sessions` report carries them.
|
|
5
|
+
*
|
|
6
|
+
* The phone never names a directory: it names a session, and the fresh start reads that session's
|
|
7
|
+
* folder and agent back from this machine's registry (`recallSession` · `recallRun`). So each entry
|
|
8
|
+
* is one agent of one project with the newest session it ran there. The list is newest first and
|
|
9
|
+
* carries no times — a running session's `lastSeenAt` moves on every sweep, and a field that changes
|
|
10
|
+
* every five seconds would defeat the report's unchanged-inventory gate.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.recentProjectsToReport = exports.recentProjects = exports.RECENT_PROJECTS_MAX = void 0;
|
|
14
|
+
const session_registry_js_1 = require("./session-registry.js");
|
|
15
|
+
/** A short list: the projects a person works in lately, not every folder the machine ever ran one in. */
|
|
16
|
+
exports.RECENT_PROJECTS_MAX = 12;
|
|
17
|
+
/** `<parent>.<pane digits>` — a subagent pane has no project of its own (tmux turns `.` in a name into `_`). */
|
|
18
|
+
const isSubagent = (name) => /\.\d+$/.test(name);
|
|
19
|
+
/** A session a schedule started: starting from it would copy the scheduled run, not the project. */
|
|
20
|
+
const isScheduled = (name) => name.includes('-sched-');
|
|
21
|
+
/** Each project's agents, from the newest session of each, in the order `known` gives (newest first). */
|
|
22
|
+
const recentProjects = (known) => {
|
|
23
|
+
const projects = new Set();
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const listed = [];
|
|
26
|
+
for (const e of known) {
|
|
27
|
+
if (isSubagent(e.name) || isScheduled(e.name))
|
|
28
|
+
continue;
|
|
29
|
+
const project = e.project || e.name;
|
|
30
|
+
if (!projects.has(project)) {
|
|
31
|
+
if (projects.size >= exports.RECENT_PROJECTS_MAX)
|
|
32
|
+
continue;
|
|
33
|
+
projects.add(project);
|
|
34
|
+
}
|
|
35
|
+
const key = `${project}\u0000${e.agentKind}`;
|
|
36
|
+
if (seen.has(key))
|
|
37
|
+
continue;
|
|
38
|
+
seen.add(key);
|
|
39
|
+
listed.push({ name: e.name, agentKind: e.agentKind, ...(e.project ? { project: e.project } : {}) });
|
|
40
|
+
}
|
|
41
|
+
return listed;
|
|
42
|
+
};
|
|
43
|
+
exports.recentProjects = recentProjects;
|
|
44
|
+
/**
|
|
45
|
+
* The list for this report, or undefined when the registry would not read — an empty list would
|
|
46
|
+
* clear what the server holds, and the phone would lose every project to start in.
|
|
47
|
+
*/
|
|
48
|
+
const recentProjectsToReport = (now = Date.now()) => (0, session_registry_js_1.registryUnreadable)() ? undefined : (0, exports.recentProjects)((0, session_registry_js_1.knownSessions)(now));
|
|
49
|
+
exports.recentProjectsToReport = recentProjectsToReport;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - parts of at most `SESSIONS_REPORT_MAX_BYTES`, each `listener.sessions`
|
|
15
15
|
* with `partial: true` and some of the sessions — the server upserts what
|
|
16
16
|
* it is given, by name, and acks each part. The first part also carries
|
|
17
|
-
* `
|
|
17
|
+
* `recentProjects` (once is enough);
|
|
18
18
|
* - then `listener.sessions.names` with every session name in the report, so
|
|
19
19
|
* the server can drop the sessions that are no longer there — what a
|
|
20
20
|
* whole-list report does by replacing the list.
|
|
@@ -44,9 +44,13 @@ export interface SessionsReportPlan {
|
|
|
44
44
|
/** Identifies a report in parts; the server echoes it (and `part`) in each ack. Absent for one message. */
|
|
45
45
|
reportId?: string;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* `recentProjects` undefined leaves the field out, so the server keeps what it holds (an unreadable
|
|
49
|
+
* registry must not clear the phone's project list).
|
|
50
|
+
*/
|
|
47
51
|
export declare const planSessionsReport: (sessions: readonly {
|
|
48
52
|
name: string;
|
|
49
|
-
}[],
|
|
53
|
+
}[], recentProjects: readonly unknown[] | undefined, maxBytes?: number, reportId?: () => string) => SessionsReportPlan;
|
|
50
54
|
/** How long a part waits for its ack before the report is given up (and tried again on the next poll). */
|
|
51
55
|
export declare const SESSIONS_ACK_TIMEOUT_MS = 10000;
|
|
52
56
|
/**
|
package/dist/sessions-report.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - parts of at most `SESSIONS_REPORT_MAX_BYTES`, each `listener.sessions`
|
|
16
16
|
* with `partial: true` and some of the sessions — the server upserts what
|
|
17
17
|
* it is given, by name, and acks each part. The first part also carries
|
|
18
|
-
* `
|
|
18
|
+
* `recentProjects` (once is enough);
|
|
19
19
|
* - then `listener.sessions.names` with every session name in the report, so
|
|
20
20
|
* the server can drop the sessions that are no longer there — what a
|
|
21
21
|
* whole-list report does by replacing the list.
|
|
@@ -35,24 +35,29 @@ const node_crypto_1 = require("node:crypto");
|
|
|
35
35
|
/** Under API Gateway's 32 KB frame, with room for the envelope and a long session or two. */
|
|
36
36
|
exports.SESSIONS_REPORT_MAX_BYTES = 24 * 1024;
|
|
37
37
|
const bytes = (text) => Buffer.byteLength(text, 'utf-8');
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* `recentProjects` undefined leaves the field out, so the server keeps what it holds (an unreadable
|
|
40
|
+
* registry must not clear the phone's project list).
|
|
41
|
+
*/
|
|
42
|
+
const planSessionsReport = (sessions, recentProjects, maxBytes = exports.SESSIONS_REPORT_MAX_BYTES, reportId = node_crypto_1.randomUUID) => {
|
|
43
|
+
const extras = recentProjects ? { recentProjects } : {};
|
|
44
|
+
const whole = JSON.stringify({ type: 'listener.sessions', data: { sessions, ...extras } });
|
|
40
45
|
const wholeBytes = bytes(whole);
|
|
41
46
|
if (wholeBytes <= maxBytes) {
|
|
42
47
|
return { messages: [whole], sessionCount: sessions.length, wholeBytes, oversized: [], namesOversized: false };
|
|
43
48
|
}
|
|
44
49
|
const id = reportId();
|
|
45
|
-
const
|
|
50
|
+
const extrasBytes = bytes(JSON.stringify(extras));
|
|
46
51
|
const envelopeBytes = bytes(JSON.stringify({
|
|
47
52
|
type: 'listener.sessions',
|
|
48
53
|
data: { sessions: [], partial: true, reportId: id, part: 999, parts: 999 },
|
|
49
54
|
}));
|
|
50
55
|
// Greedy: a session goes in the current part while it fits. The first part gives up room for
|
|
51
|
-
// `
|
|
56
|
+
// `recentProjects`; no part gives up less than one session.
|
|
52
57
|
const groups = [];
|
|
53
58
|
const oversized = [];
|
|
54
59
|
let current = [];
|
|
55
|
-
let budget = Math.max(maxBytes - envelopeBytes -
|
|
60
|
+
let budget = Math.max(maxBytes - envelopeBytes - extrasBytes, 0);
|
|
56
61
|
let used = 0;
|
|
57
62
|
for (const session of sessions) {
|
|
58
63
|
// +1 for the comma between sessions in the array.
|
|
@@ -72,7 +77,7 @@ const planSessionsReport = (sessions, knownSessions, maxBytes = exports.SESSIONS
|
|
|
72
77
|
const parts = groups.length;
|
|
73
78
|
const messages = groups.map((group, index) => JSON.stringify({
|
|
74
79
|
type: 'listener.sessions',
|
|
75
|
-
data: { sessions: group, partial: true, reportId: id, part: index + 1, parts, ...(index === 0 ?
|
|
80
|
+
data: { sessions: group, partial: true, reportId: id, part: index + 1, parts, ...(index === 0 ? extras : {}) },
|
|
76
81
|
}));
|
|
77
82
|
const names = JSON.stringify({ type: 'listener.sessions.names', data: { names: sessions.map((s) => s.name), reportId: id } });
|
|
78
83
|
messages.push(names);
|