@hwip/cli 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -19
- package/dist/device-presence.d.ts +6 -0
- package/dist/device-presence.js +10 -3
- package/dist/keep-awake.js +3 -3
- package/dist/listener.d.ts +7 -24
- package/dist/listener.js +30 -51
- 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,33 @@ 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.
|
|
577
|
+
|
|
578
|
+
The phone can also start a **new** session in a project this machine has
|
|
579
|
+
run before: the same request with `fresh: true` starts the agent recorded
|
|
580
|
+
for that session in its directory, with no conversation picked up, under
|
|
581
|
+
the session's name or — when that runs — the next free name of its family.
|
|
582
|
+
The directory and the agent still come only from this machine's record, so
|
|
583
|
+
the phone cannot name either. The listener advertises `session-start-v1`
|
|
584
|
+
so the apps offer it only to a build that knows it. Ending a session from
|
|
585
|
+
the phone is `agent.session.exit.request`: `C-c`, the agent's own quit
|
|
586
|
+
command, then `C-d` — never a forced kill, so an agent that will not quit is
|
|
587
|
+
reported as still running.
|
|
575
588
|
|
|
576
589
|
Two details worth knowing:
|
|
577
590
|
|
|
@@ -640,8 +653,8 @@ So on macOS the listener runs `caffeinate -s -w <its pid>` for as long as
|
|
|
640
653
|
it lives. `-s` is honoured **on AC power only** — on battery macOS ignores
|
|
641
654
|
it, so a laptop still sleeps and nothing drains. The display still turns
|
|
642
655
|
off; a closed lid still sleeps. Activity Monitor shows the daemon under
|
|
643
|
-
"Preventing Sleep". It holds even when no agent session is open:
|
|
644
|
-
|
|
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`
|
|
645
658
|
in `~/.hwip/config.json`; the log's `keep-awake:` line says which way it
|
|
646
659
|
went.
|
|
647
660
|
|
|
@@ -691,16 +704,17 @@ tmux's `session_created` in epoch seconds). The app uses it to keep one run's
|
|
|
691
704
|
conversation apart from the one before it under the same name; a chunk read
|
|
692
705
|
after a restart keeps the run it was recorded in. What a session writes with no
|
|
693
706
|
run to name (a name whose tmux session has ended while its file is still written
|
|
694
|
-
to) is not recorded: the server takes no chunk without a run. The listener's record of
|
|
695
|
-
|
|
696
|
-
in the same folder leaves the first run as a
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
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`).
|
|
704
718
|
|
|
705
719
|
When a session waits for your approval, the listener also sends what it is waiting
|
|
706
720
|
for — the tool, the command or the diff, and the answers — so the app can show it
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This build reads `fresh` on a resume request (`listener.ts` `handleSessionResumeRequest`): the phone may start a new
|
|
3
|
+
* session in a recorded session's project. An older listener would resume the recorded conversation instead, so the apps
|
|
4
|
+
* offer it only to a computer that lists this.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SESSION_START_CAPABILITY = "session-start-v1";
|
|
1
7
|
/** The one message both halves of presence ride; the server answers
|
|
2
8
|
* `listener.presence.ack` or `listener.presence.error`. */
|
|
3
9
|
export declare const LISTENER_PRESENCE = "listener.presence";
|
package/dist/device-presence.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = void 0;
|
|
3
|
+
exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = exports.SESSION_START_CAPABILITY = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Per-device E2E (ADR-0007) — put this machine's public key on its device
|
|
6
6
|
* record, so other devices can wrap pushes and files for it.
|
|
@@ -28,13 +28,20 @@ exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = void 0;
|
|
|
28
28
|
* a file in chunks only for a device that lists `file-chunk-v1`, and one over
|
|
29
29
|
* the relay's ceiling over LAN only to one that lists `lan-large-v1`; an app
|
|
30
30
|
* offers `once` schedules and schedules into a session only to one that lists
|
|
31
|
-
* `schedule-session-v1
|
|
31
|
+
* `schedule-session-v1`, and a new session in a project only to one that
|
|
32
|
+
* lists `session-start-v1`. In one frame
|
|
32
33
|
* with the key, because a server from before `capabilities` answers a frame
|
|
33
34
|
* that carries nothing else it knows with an error.
|
|
34
35
|
*/
|
|
35
36
|
const file_crypto_js_1 = require("./file-crypto.js");
|
|
36
37
|
const lan_receiver_js_1 = require("./lan-receiver.js");
|
|
37
38
|
const schedule_js_1 = require("./schedule.js");
|
|
39
|
+
/**
|
|
40
|
+
* This build reads `fresh` on a resume request (`listener.ts` `handleSessionResumeRequest`): the phone may start a new
|
|
41
|
+
* session in a recorded session's project. An older listener would resume the recorded conversation instead, so the apps
|
|
42
|
+
* offer it only to a computer that lists this.
|
|
43
|
+
*/
|
|
44
|
+
exports.SESSION_START_CAPABILITY = 'session-start-v1';
|
|
38
45
|
/** The one message both halves of presence ride; the server answers
|
|
39
46
|
* `listener.presence.ack` or `listener.presence.error`. */
|
|
40
47
|
exports.LISTENER_PRESENCE = 'listener.presence';
|
|
@@ -45,7 +52,7 @@ const createDeviceKeyRegistration = (deps) => {
|
|
|
45
52
|
const register = async () => {
|
|
46
53
|
try {
|
|
47
54
|
const publicKey = await deps.publicKey();
|
|
48
|
-
const sent = deps.send({ type: exports.LISTENER_PRESENCE, data: { publicKey, capabilities: [file_crypto_js_1.FILE_CHUNK_CAPABILITY, lan_receiver_js_1.LAN_LARGE_CAPABILITY, schedule_js_1.SCHEDULE_SESSION_CAPABILITY] } });
|
|
55
|
+
const sent = deps.send({ type: exports.LISTENER_PRESENCE, data: { publicKey, capabilities: [file_crypto_js_1.FILE_CHUNK_CAPABILITY, lan_receiver_js_1.LAN_LARGE_CAPABILITY, schedule_js_1.SCHEDULE_SESSION_CAPABILITY, exports.SESSION_START_CAPABILITY] } });
|
|
49
56
|
if (!sent)
|
|
50
57
|
deps.log('device key: not registered — no connection right now, retried at the next open');
|
|
51
58
|
return sent;
|
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,8 @@ 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
|
-
export declare const knownSessionsToReport: (live: readonly AgentSession[], now?: number, seqs?: Map<string, RunSeqs>) => ReportedKnownSession[];
|
|
249
|
-
export declare const knownSessionsFingerprint: (known: ReportedKnownSession[]) => string;
|
|
250
227
|
export declare const sessionsFingerprint: (sessions: AgentSession[]) => string;
|
|
251
228
|
/** True when this cycle's inventory must be sent: it changed, or the
|
|
252
229
|
* idle heartbeat is due. */
|
|
@@ -551,6 +528,12 @@ export interface SessionResumeRequest {
|
|
|
551
528
|
* it reads as a run id.
|
|
552
529
|
*/
|
|
553
530
|
runId?: string;
|
|
531
|
+
/**
|
|
532
|
+
* A new session in that session's project, not the session again (hwip docs/session-runs.md Q47): the recorded
|
|
533
|
+
* directory and agent, no conversation picked up, and beside the name when it runs. Read only when it is `true`.
|
|
534
|
+
* Advertised as `SESSION_START_CAPABILITY` (`device-presence.ts`) — an older listener would resume the recorded conversation instead.
|
|
535
|
+
*/
|
|
536
|
+
fresh?: boolean;
|
|
554
537
|
requestId?: string;
|
|
555
538
|
}
|
|
556
539
|
export interface SessionResumeResult {
|
|
@@ -638,7 +621,7 @@ export interface SessionForgetResult {
|
|
|
638
621
|
* running, so only actually running that name again restores it.
|
|
639
622
|
*
|
|
640
623
|
* The refusals: unknown name is nothing to delete rather than something to
|
|
641
|
-
* guess at. Still running means it is not
|
|
624
|
+
* guess at. Still running means it is not an ended run at all — the phone is
|
|
642
625
|
* looking at a live one, and ending it is a different request that this must
|
|
643
626
|
* not quietly stand in for.
|
|
644
627
|
*/
|
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,41 +296,12 @@ 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
|
-
const knownSessionsToReport = (live, now = Date.now(), seqs = (0, run_seqs_js_1.runSeqsByRun)()) => {
|
|
306
|
-
// By name, kind AND run: a slot that is live under claude may still hold an
|
|
307
|
-
// ended pi run — or an ended claude run before this one — and that run is a
|
|
308
|
-
// past session. Excluding the whole name would drop it from the report
|
|
309
|
-
// exactly the way the registry used to drop it from disk. The live run
|
|
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;
|
|
333
305
|
const sessionsFingerprint = (sessions) => sessions
|
|
334
306
|
.map((s) => JSON.stringify([
|
|
335
307
|
s.name, s.attached, s.agentKind, s.agentSessionId ?? null,
|
|
@@ -369,6 +341,12 @@ const RATE_LIMIT_TOKENS = 120;
|
|
|
369
341
|
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
370
342
|
/** What one command submit costs, keeping its ceiling at the old 30/min. */
|
|
371
343
|
const SUBMIT_COST = 4;
|
|
344
|
+
/**
|
|
345
|
+
* What a fresh start costs on its family's own bucket (`start:<family base>`): three new agents a minute. Each one is a
|
|
346
|
+
* process left running, and every name of the family leads to the same bucket, so a session budget of 30 a minute
|
|
347
|
+
* cannot fill the family's 20 names in one burst.
|
|
348
|
+
*/
|
|
349
|
+
const FRESH_START_COST = RATE_LIMIT_TOKENS / 3;
|
|
372
350
|
// Shells are refused: a shell prompt + send-keys = arbitrary command exec.
|
|
373
351
|
const SHELL_COMMANDS = new Set(['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'pwsh']);
|
|
374
352
|
// Auth-failure close codes: retrying with the same bad credentials hammers
|
|
@@ -1484,11 +1462,6 @@ const conversationRunning = (known, live) => {
|
|
|
1484
1462
|
return current?.agentKind === known.agentKind && current.agentSessionId === id;
|
|
1485
1463
|
});
|
|
1486
1464
|
};
|
|
1487
|
-
/** What the report says about a run: the same answer a resume of it would act on. */
|
|
1488
|
-
const isResumable = (known) => {
|
|
1489
|
-
const agent = remote_agents_js_1.REMOTE_AGENTS.find((a) => a.kind === known.agentKind);
|
|
1490
|
-
return agent !== undefined && resumableConversation(agent, known) !== null;
|
|
1491
|
-
};
|
|
1492
1465
|
/**
|
|
1493
1466
|
* Start a remembered session again, detached, and tell the phone what happened.
|
|
1494
1467
|
* Returns true when the message was ours to answer.
|
|
@@ -1546,13 +1519,16 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1546
1519
|
// the family `hwip cc` would pick (Q41), counted from the family base so a
|
|
1547
1520
|
// busy `<base>-2` leads to `<base>-3`. A request without a run id is an
|
|
1548
1521
|
// older app that only knows the name, and still gets the refusal it renders.
|
|
1522
|
+
// A fresh start asks for a new session, so a live name is no refusal either, and no conversation is picked up —
|
|
1523
|
+
// the live one holding the recorded conversation included.
|
|
1524
|
+
const fresh = req.fresh === true;
|
|
1549
1525
|
const live = new Set((0, tmux_liveness_js_1.liveSessionNames)() ?? []);
|
|
1550
1526
|
const target = !live.has(sessionName)
|
|
1551
1527
|
? sessionName
|
|
1552
|
-
: runId !== undefined
|
|
1528
|
+
: runId !== undefined || fresh
|
|
1553
1529
|
? (0, wrapper_js_1.familyNames)(familyBase(sessionName)).find((name) => !live.has(name))
|
|
1554
1530
|
: undefined;
|
|
1555
|
-
if (!target || conversationRunning(known, live))
|
|
1531
|
+
if (!target || (!fresh && conversationRunning(known, live)))
|
|
1556
1532
|
return reply({ error: 'already_running' });
|
|
1557
1533
|
if (!(0, session_registry_js_1.sessionDirectoryExists)(known)) {
|
|
1558
1534
|
(0, listener_log_js_1.log)(`✗ resume ${sessionName}: ${known.cwd} is gone`);
|
|
@@ -1563,6 +1539,11 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1563
1539
|
(0, listener_log_js_1.log)(`✗ resume ${sessionName}: no binary for agent kind ${known.agentKind}`);
|
|
1564
1540
|
return reply({ error: 'unknown_agent' });
|
|
1565
1541
|
}
|
|
1542
|
+
// A fresh start leaves one more process running each time, so the family pays on a bucket of its own as well —
|
|
1543
|
+
// charged only here, where it starts one: a refusal above says why, not that the budget ran out.
|
|
1544
|
+
if (fresh && !(0, exports.checkRateLimit)(`start:${familyBase(sessionName)}`, undefined, FRESH_START_COST)) {
|
|
1545
|
+
return reply({ error: 'rate_limited' });
|
|
1546
|
+
}
|
|
1566
1547
|
// argv, never a shell string: the binary comes from the agent table and the
|
|
1567
1548
|
// directory from our own record, and neither is concatenated into anything
|
|
1568
1549
|
// a shell would re-parse. A recorded label means the session carried
|
|
@@ -1572,7 +1553,7 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1572
1553
|
const sidecar = known.label && known.project
|
|
1573
1554
|
? [';', 'set-option', config_js_1.SESSION_PROJECT_OPTION, known.project, ';', 'set-option', config_js_1.SESSION_LABEL_OPTION, known.label]
|
|
1574
1555
|
: [];
|
|
1575
|
-
const conversation = resumableConversation(agent, known);
|
|
1556
|
+
const conversation = fresh ? null : resumableConversation(agent, known);
|
|
1576
1557
|
// tmux gives the new pane this client's PATH. A login service's PATH has none
|
|
1577
1558
|
// of the agent directories, so they go on as schedule launches add them —
|
|
1578
1559
|
// otherwise the agent is not found and the session ends as it starts.
|
|
@@ -1583,7 +1564,7 @@ const handleSessionResumeRequest = (req, send) => {
|
|
|
1583
1564
|
}
|
|
1584
1565
|
const resumedFrom = conversation ? ` (conversation ${conversation.id.slice(0, 8)})` : '';
|
|
1585
1566
|
const beside = target === sessionName ? '' : ` as ${target}`;
|
|
1586
|
-
(0, listener_log_js_1.log)(
|
|
1567
|
+
(0, listener_log_js_1.log)(`${fresh ? '+ start' : '⟲ resume'} ${sessionName}${beside}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
|
|
1587
1568
|
return reply({ resumed: true, sessionName: target });
|
|
1588
1569
|
};
|
|
1589
1570
|
exports.handleSessionResumeRequest = handleSessionResumeRequest;
|
|
@@ -1708,7 +1689,7 @@ exports.handleSessionExitRequest = handleSessionExitRequest;
|
|
|
1708
1689
|
* running, so only actually running that name again restores it.
|
|
1709
1690
|
*
|
|
1710
1691
|
* The refusals: unknown name is nothing to delete rather than something to
|
|
1711
|
-
* guess at. Still running means it is not
|
|
1692
|
+
* guess at. Still running means it is not an ended run at all — the phone is
|
|
1712
1693
|
* looking at a live one, and ending it is a different request that this must
|
|
1713
1694
|
* not quietly stand in for.
|
|
1714
1695
|
*/
|
|
@@ -5296,14 +5277,12 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
5296
5277
|
// Unchanged inventory + heartbeat not due → skip the send.
|
|
5297
5278
|
// The 5 s local poll above still ran, so a change is never
|
|
5298
5279
|
// delayed; only the no-op reports (idle listener) are culled.
|
|
5299
|
-
//
|
|
5300
|
-
// request of their own: the phone is
|
|
5301
|
-
//
|
|
5302
|
-
|
|
5303
|
-
const
|
|
5304
|
-
const
|
|
5305
|
-
const known = (0, exports.knownSessionsToReport)(sessions, Date.now(), seqs);
|
|
5306
|
-
const fingerprint = `${(0, exports.sessionsFingerprint)(reported)}␞${(0, exports.knownSessionsFingerprint)(known)}`;
|
|
5280
|
+
// The projects a new session can start in ride the report that
|
|
5281
|
+
// already exists rather than a request of their own: the phone is
|
|
5282
|
+
// already reading this device record (recent-projects.ts).
|
|
5283
|
+
const reported = (0, exports.withRunSeqs)(sessions);
|
|
5284
|
+
const recent = (0, recent_projects_js_1.recentProjectsToReport)();
|
|
5285
|
+
const fingerprint = `${(0, exports.sessionsFingerprint)(reported)}␞${recent ? JSON.stringify(recent) : '-'}`;
|
|
5307
5286
|
if (!(0, exports.sessionsReportDue)(fingerprint, lastSentFingerprint, lastSentAtMs, Date.now())) {
|
|
5308
5287
|
return;
|
|
5309
5288
|
}
|
|
@@ -5313,7 +5292,7 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
5313
5292
|
// One message when it fits; past 24 KB, parts (sessions-report.ts): API Gateway closes a
|
|
5314
5293
|
// socket on a frame over 32 KB, so an uncut report from a machine with ~100 sessions would
|
|
5315
5294
|
// drop the connection on every send.
|
|
5316
|
-
const plan = (0, sessions_report_js_1.planSessionsReport)(reported,
|
|
5295
|
+
const plan = (0, sessions_report_js_1.planSessionsReport)(reported, recent);
|
|
5317
5296
|
if (plan.messages.length > 1) {
|
|
5318
5297
|
(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`);
|
|
5319
5298
|
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);
|