@hwip/cli 0.1.17 → 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 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 **past sessions**, ready to
562
- resume from the phone — not live agents. tmux does not survive a reboot
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 resume starts the agent in the directory it last ran in; for Claude Code,
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
- Each ended run of a name is its own row on the phone, and starting one
572
- again picks up that run's conversation, not the newest one. If the name
573
- is live again by then, that run starts beside it under the next free name
574
- of the family (`<name>-2`, `<name>-3`, …) instead of being refused.
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: resuming a
655
- past session from the phone needs this Mac awake to answer. Opt out with `--no-keep-awake` or `"keepAwake": false`
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 past
706
- sessions keeps runs apart the same way: closing `hwip cc` and starting it again
707
- in the same folder leaves the first run as a past session of its own (the
708
- newest ten runs of a name are kept, 100 in all, and up to 50 are reported to
709
- the phone). Each run is reported with the block of chunk numbers it took — the
710
- first and, once it has ended, the last — kept in `run-seqs.json` next to the
711
- registry, so the server reads one run's conversation from that range instead of
712
- every chunk the name ever had. Deleting a past run from the phone forgets that
713
- run on this machine too, even while a later run of the same name is going; the
714
- run that is going is refused (`still_running`).
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
@@ -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 resumes a past session
34
- // or starts one only while this Mac is awake to answer (C25 — say so
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 resume one (--no-keep-awake to disable)`);
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)
@@ -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. */
@@ -644,7 +621,7 @@ export interface SessionForgetResult {
644
621
  * running, so only actually running that name again restores it.
645
622
  *
646
623
  * The refusals: unknown name is nothing to delete rather than something to
647
- * guess at. Still running means it is not a past session at all — the phone is
624
+ * guess at. Still running means it is not an ended run at all — the phone is
648
625
  * looking at a live one, and ending it is a different request that this must
649
626
  * not quietly stand in for.
650
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.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.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 = exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = void 0;
30
- exports.handleListener = exports.liveScheduleRunnerDeps = exports.listenerSettings = exports.resolveWsUrl = exports.streamSession = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.displayFolder = exports.claimLanFile = exports.saveFileFromS3 = exports.downloadSealedEach = exports.answerFileBound = exports.downloadAttachments = exports.setAttachmentContext = exports.shellTextRefusal = exports.writeRemoteMarker = exports.isInventoried = exports.syncTmuxAgentNames = exports.recordInventory = exports.collectSessionsVerbose = exports.handleCommandInput = void 0;
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,
@@ -1490,11 +1462,6 @@ const conversationRunning = (known, live) => {
1490
1462
  return current?.agentKind === known.agentKind && current.agentSessionId === id;
1491
1463
  });
1492
1464
  };
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
1465
  /**
1499
1466
  * Start a remembered session again, detached, and tell the phone what happened.
1500
1467
  * Returns true when the message was ours to answer.
@@ -1722,7 +1689,7 @@ exports.handleSessionExitRequest = handleSessionExitRequest;
1722
1689
  * running, so only actually running that name again restores it.
1723
1690
  *
1724
1691
  * The refusals: unknown name is nothing to delete rather than something to
1725
- * guess at. Still running means it is not a past session at all — the phone is
1692
+ * guess at. Still running means it is not an ended run at all — the phone is
1726
1693
  * looking at a live one, and ending it is a different request that this must
1727
1694
  * not quietly stand in for.
1728
1695
  */
@@ -5310,14 +5277,12 @@ const streamSession = (wsUrl, apiKey) => {
5310
5277
  // Unchanged inventory + heartbeat not due → skip the send.
5311
5278
  // The 5 s local poll above still ran, so a change is never
5312
5279
  // delayed; only the no-op reports (idle listener) are culled.
5313
- // Past sessions ride the report that already exists rather than a
5314
- // request of their own: the phone is already reading this device
5315
- // record, and a second round trip would need its own timeout, its
5316
- // own failure wording and its own staleness question.
5317
- const seqs = (0, run_seqs_js_1.runSeqsByRun)();
5318
- const reported = (0, exports.withRunSeqs)(sessions, seqs);
5319
- const known = (0, exports.knownSessionsToReport)(sessions, Date.now(), seqs);
5320
- 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) : '-'}`;
5321
5286
  if (!(0, exports.sessionsReportDue)(fingerprint, lastSentFingerprint, lastSentAtMs, Date.now())) {
5322
5287
  return;
5323
5288
  }
@@ -5327,7 +5292,7 @@ const streamSession = (wsUrl, apiKey) => {
5327
5292
  // One message when it fits; past 24 KB, parts (sessions-report.ts): API Gateway closes a
5328
5293
  // socket on a frame over 32 KB, so an uncut report from a machine with ~100 sessions would
5329
5294
  // drop the connection on every send.
5330
- const plan = (0, sessions_report_js_1.planSessionsReport)(reported, known);
5295
+ const plan = (0, sessions_report_js_1.planSessionsReport)(reported, recent);
5331
5296
  if (plan.messages.length > 1) {
5332
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`);
5333
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
- * `knownSessions` (once is enough);
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
- }[], knownSessions: readonly unknown[], maxBytes?: number, reportId?: () => string) => SessionsReportPlan;
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
  /**
@@ -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
- * `knownSessions` (once is enough);
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
- const planSessionsReport = (sessions, knownSessions, maxBytes = exports.SESSIONS_REPORT_MAX_BYTES, reportId = node_crypto_1.randomUUID) => {
39
- const whole = JSON.stringify({ type: 'listener.sessions', data: { sessions, knownSessions } });
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 knownBytes = bytes(JSON.stringify({ knownSessions }));
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
- // `knownSessions`; no part gives up less than one session.
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 - knownBytes, 0);
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 ? { knownSessions } : {}) },
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",