@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 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 **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.
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: resuming a
644
- 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`
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 past
695
- sessions keeps runs apart the same way: closing `hwip cc` and starting it again
696
- in the same folder leaves the first run as a past session of its own (the
697
- newest ten runs of a name are kept, 100 in all, and up to 50 are reported to
698
- the phone). Each run is reported with the block of chunk numbers it took — the
699
- first and, once it has ended, the last — kept in `run-seqs.json` next to the
700
- registry, so the server reads one run's conversation from that range instead of
701
- every chunk the name ever had. Deleting a past run from the phone forgets that
702
- run on this machine too, even while a later run of the same name is going; the
703
- 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`).
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";
@@ -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`. In one frame
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;
@@ -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. */
@@ -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 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
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.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,
@@ -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)(`⟲ resume ${sessionName}${beside}: ${agent.binary}${resumedFrom} in ${known.cwd}`);
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 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
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
- // Past sessions ride the report that already exists rather than a
5300
- // request of their own: the phone is already reading this device
5301
- // record, and a second round trip would need its own timeout, its
5302
- // own failure wording and its own staleness question.
5303
- const seqs = (0, run_seqs_js_1.runSeqsByRun)();
5304
- const reported = (0, exports.withRunSeqs)(sessions, seqs);
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, known);
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
- * `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.16",
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",