@hwip/cli 0.1.7 → 0.1.9

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.
@@ -283,6 +283,36 @@ export declare const probePane: (session: string) => PaneProbe | null;
283
283
  * rejected.
284
284
  */
285
285
  export declare const resolveKeys: (keys: string[]) => string[] | null;
286
+ type WheelDirection = 'up' | 'down';
287
+ /** A batch of wheel keys as directions; null unless every key is one. */
288
+ export declare const resolveWheel: (keys: string[]) => WheelDirection[] | null;
289
+ /** What a wheel tick needs from the pane, read when the tick arrives rather than from the frame the phone saw. */
290
+ export declare const WHEEL_STATE_FORMAT = "#{alternate_on},#{mouse_any_flag},#{mouse_sgr_flag},#{pane_width},#{pane_height},#{pane_current_command}";
291
+ /** Wheel keys a pane has taken toward its next event, kept across batches. */
292
+ export type WheelCarry = {
293
+ direction: WheelDirection;
294
+ lines: number;
295
+ };
296
+ /**
297
+ * The phone's per-line wheel keys as wheel events for the pane's command:
298
+ * one event per WHEEL_LINES_PER_EVENT lines, the rest carried into the next
299
+ * batch. Turning back drops the carry, as the phone drops what it did not send.
300
+ */
301
+ export declare const wheelEvents: (keys: WheelDirection[], command: string, carry?: WheelCarry) => {
302
+ events: WheelDirection[];
303
+ carry?: WheelCarry;
304
+ };
305
+ /**
306
+ * The `send-keys` arguments that scroll the TUI by `ticks`, from the pane's
307
+ * WHEEL_STATE_FORMAT answer. A TUI that reports the mouse in SGR gets wheel
308
+ * events at the pane centre (button 64 up, 65 down), written as bytes with
309
+ * `-H`; one that does not gets PPage/NPage. Every byte is built here from
310
+ * tmux's numbers, never from the wire.
311
+ *
312
+ * Null when the pane is not on the alternate screen: the phone's flag can be a
313
+ * frame stale, and a PgUp into a shell or a prompt prints junk.
314
+ */
315
+ export declare const wheelInput: (ticks: WheelDirection[], state: string) => string[] | null;
286
316
  export { invalidateTmuxSocketCache, silenceListenerLog, tmuxArgs };
287
317
  export { liveSessionNames, sessionExists, sessionLiveness };
288
318
  export { detectRemoteAgent, isSubagentSessionName, recordSubagentTranscripts, recordTargets, subagentTranscriptFor, tmuxTargetFor };
@@ -702,7 +732,7 @@ export declare const handleScreenHistoryRequest: (req: ScreenHistoryRequest, sen
702
732
  * is. `display-message ; capture-pane` runs the two as consecutive commands in
703
733
  * one client: the first output line is the geometry, the rest is the capture.
704
734
  */
705
- export declare const STREAM_GEOMETRY_FORMAT = "#{cursor_x},#{cursor_y},#{pane_height},#{history_size}";
735
+ export declare const STREAM_GEOMETRY_FORMAT = "#{cursor_x},#{cursor_y},#{pane_height},#{history_size},#{alternate_on}";
706
736
  export interface PaneGeometry {
707
737
  x: number;
708
738
  y: number;
@@ -712,6 +742,11 @@ export interface PaneGeometry {
712
742
  * it record the lines that scroll past without matching text. Absent when
713
743
  * tmux did not answer with a usable number. */
714
744
  historySize?: number;
745
+ /** The pane is on the alternate screen: a full-screen program (Claude Code's
746
+ * fullscreen renderer, an editor) draws there, and tmux keeps no scrollback
747
+ * of it — older output exists only inside the program, which scrolls it
748
+ * itself. Absent when tmux did not say. */
749
+ alternateScreen?: boolean;
715
750
  }
716
751
  /**
717
752
  * Read the one tmux answer the frame path already asks for per tick.
@@ -899,6 +934,11 @@ export type StreamFramePayload = {
899
934
  * it see the buffer being cleared as the depth dropping. Absent when tmux
900
935
  * would not say. In the clear as the cursor is, and sealed the same way. */
901
936
  historySize?: number;
937
+ /** The pane is on the alternate screen, where tmux keeps no scrollback: a
938
+ * viewer scrolls the program (wheel keys) instead of asking for history.
939
+ * Absent when tmux would not say. In the clear as the cursor is, and
940
+ * sealed the same way. */
941
+ alternateScreen?: boolean;
902
942
  /** Plaintext pane content — only on unencrypted streams. */
903
943
  content?: string;
904
944
  /** Envelope v2 (`stream.frame`, context = session) of a SealedStreamFrame —
@@ -923,6 +963,7 @@ export type SealedStreamFrame = {
923
963
  cursorCol?: number;
924
964
  historyLines?: number;
925
965
  historySize?: number;
966
+ alternateScreen?: boolean;
926
967
  };
927
968
  /**
928
969
  * Plaintext bytes of a sealed frame's JSON. Sealing base64-expands it by 4/3
@@ -981,6 +1022,7 @@ export declare const buildStreamFrame: (captured: {
981
1022
  } | null;
982
1023
  historyLines?: number;
983
1024
  historySize?: number;
1025
+ alternateScreen?: boolean;
984
1026
  },
985
1027
  /** Capture stamp and subscription nonce, sealed with the pane. A sealed
986
1028
  * frame without it is dropped: the viewer could not check it. */
@@ -1057,9 +1099,10 @@ type ValidatedInput = SequencedInput & {
1057
1099
  /** Sender's device, when the relay stamped one — rides along so a refusal
1058
1100
  * frame can name whose input was refused (inputDeviceId). */
1059
1101
  deviceId?: string;
1060
- /** Exactly one of these is set. */
1102
+ /** Exactly one of tokens, text and wheel is set. */
1061
1103
  tokens: string[] | null;
1062
1104
  text: string | null;
1105
+ wheel?: WheelDirection[];
1063
1106
  /** Whether `text` carries its own submit. False for `insert` (and, vacuously,
1064
1107
  * for a key batch — the keys say for themselves what they are). */
1065
1108
  submit: boolean;
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.setSubscriberTrust = exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.withSettledPrompts = exports.PROMPT_SEAL_WAIT_MS = exports.withBlockedPrompts = exports.setPromptSealer = exports.deriveSessionState = exports.resetSessionStates = exports.continueRuns = exports.runIdOfEpoch = exports.__checkoutCacheSize = exports.parseSessionName = exports.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.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.syncTmuxAgentNames = exports.recordInventory = exports.collectSessionsVerbose = exports.handleCommandInput = exports.pendingInputDecrypts = exports.MAX_PENDING_DECRYPTS = exports.MAX_INPUT_LANES = exports.validateInputMessage = exports.MAX_INPUT_BODY_CHARS = exports.MAX_INPUT_KEYS = exports.handleStreamControl = exports.buildStreamFrame = exports.isStreamCapReached = exports.fitSealedFrame = exports.SEALED_FRAME_MAX_BYTES = exports.stopAllStreams = exports.stopStream = exports.stopStreamOf = exports.handleTurnWatch = exports.sealTurnBatch = exports.resolveWatchTranscript = exports.MAX_CONCURRENT_STREAMS = exports.STREAM_RENEW_FRESH_MS = exports.STREAM_LEASE_MS = exports.claimFrameSend = exports.hasFrameBudget = exports.streamCadence = exports.MAX_FRAMES_PER_SEC = exports.BURST_WINDOW_MS = exports.BURST_INTERVAL_MS = exports.STREAM_INTERVAL_MS = exports.STREAM_CAPTURE_LINES = exports.scheduleInjectSnapshots = exports.historyLinesIn = exports.cursorLineFor = exports.parsePaneGeometry = exports.STREAM_GEOMETRY_FORMAT = exports.handleScreenHistoryRequest = exports.SCREEN_HISTORY_MAX_LINES = exports.handleSessionForgetRequest = exports.handleSessionExitRequest = exports.SESSION_EXIT_STEP_MS = exports.handleSessionResumeRequest = exports.handleScheduleListRequest = exports.handleFileBlobRequest = exports.handleFileTreeRequest = exports.handleDiffFileRequest = exports.handleDiffFilesRequest = exports.fileViewBytes = exports.handleScreenRequest = 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 = void 0;
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;
31
31
  const child_process_1 = require("child_process");
32
32
  const crypto_1 = require("crypto");
33
33
  const fs_1 = require("fs");
@@ -571,6 +571,83 @@ const resolveKeys = (keys) => {
571
571
  return out.length ? out : null;
572
572
  };
573
573
  exports.resolveKeys = resolveKeys;
574
+ /**
575
+ * The phone's scroll for a pane on the alternate screen, where a full-screen
576
+ * TUI draws and tmux keeps no scrollback to page. Not in ALLOWED_KEYS, so only
577
+ * stream input takes them: the REST path stores a command and shows it as a
578
+ * bubble, and a wheel tick means nothing without a live view of the pane.
579
+ */
580
+ const WHEEL_KEYS = new Map([
581
+ ['wheelup', 'up'],
582
+ ['wheeldown', 'down'],
583
+ ]);
584
+ /** A batch of wheel keys as directions; null unless every key is one. */
585
+ const resolveWheel = (keys) => {
586
+ const out = [];
587
+ for (const k of keys) {
588
+ const direction = WHEEL_KEYS.get(k.toLowerCase().trim());
589
+ if (!direction)
590
+ return null;
591
+ out.push(direction);
592
+ }
593
+ return out.length ? out : null;
594
+ };
595
+ exports.resolveWheel = resolveWheel;
596
+ /** What a wheel tick needs from the pane, read when the tick arrives rather than from the frame the phone saw. */
597
+ exports.WHEEL_STATE_FORMAT = '#{alternate_on},#{mouse_any_flag},#{mouse_sgr_flag},#{pane_width},#{pane_height},#{pane_current_command}';
598
+ /**
599
+ * Lines a TUI scrolls for one wheel event, by the pane's command, where that is
600
+ * not one. The phone sends a wheel key per line of drag; measured 2026-10-22
601
+ * with SGR wheel bytes into live panes, Claude Code and pi (`node`) scroll a
602
+ * line an event and OpenCode three.
603
+ */
604
+ const WHEEL_LINES_PER_EVENT = new Map([['opencode', 3]]);
605
+ /**
606
+ * The phone's per-line wheel keys as wheel events for the pane's command:
607
+ * one event per WHEEL_LINES_PER_EVENT lines, the rest carried into the next
608
+ * batch. Turning back drops the carry, as the phone drops what it did not send.
609
+ */
610
+ const wheelEvents = (keys, command, carry) => {
611
+ const perEvent = WHEEL_LINES_PER_EVENT.get(command) ?? 1;
612
+ const events = [];
613
+ let held = carry;
614
+ for (const direction of keys) {
615
+ const lines = (held?.direction === direction ? held.lines : 0) + 1;
616
+ if (lines >= perEvent) {
617
+ events.push(direction);
618
+ held = undefined;
619
+ }
620
+ else {
621
+ held = { direction, lines };
622
+ }
623
+ }
624
+ return { events, carry: held };
625
+ };
626
+ exports.wheelEvents = wheelEvents;
627
+ /**
628
+ * The `send-keys` arguments that scroll the TUI by `ticks`, from the pane's
629
+ * WHEEL_STATE_FORMAT answer. A TUI that reports the mouse in SGR gets wheel
630
+ * events at the pane centre (button 64 up, 65 down), written as bytes with
631
+ * `-H`; one that does not gets PPage/NPage. Every byte is built here from
632
+ * tmux's numbers, never from the wire.
633
+ *
634
+ * Null when the pane is not on the alternate screen: the phone's flag can be a
635
+ * frame stale, and a PgUp into a shell or a prompt prints junk.
636
+ */
637
+ const wheelInput = (ticks, state) => {
638
+ const [alternate, mouseAny, mouseSgr, width, height] = state.trim().split(',');
639
+ if (alternate !== '1')
640
+ return null;
641
+ const cols = Number(width);
642
+ const rows = Number(height);
643
+ if (mouseAny === '1' && mouseSgr === '1' && Number.isSafeInteger(cols) && Number.isSafeInteger(rows) && cols > 0 && rows > 0) {
644
+ const at = `${Math.floor(cols / 2) + 1};${Math.floor(rows / 2) + 1}M`;
645
+ const bytes = ticks.map((t) => `\x1b[<${t === 'up' ? 64 : 65};${at}`).join('');
646
+ return ['-H', ...Array.from(Buffer.from(bytes, 'ascii'), (b) => b.toString(16).padStart(2, '0'))];
647
+ }
648
+ return ticks.map((t) => (t === 'up' ? 'PPage' : 'NPage'));
649
+ };
650
+ exports.wheelInput = wheelInput;
574
651
  /**
575
652
  * Send named keys as keys (NOT `-l`), so tmux interprets Escape/Up/Enter as
576
653
  * key presses rather than literal text. One send-keys call carries the
@@ -1888,7 +1965,7 @@ const capturePane = (sessionName, escapes = false, lines = SCREEN_PEEK_LINES) =>
1888
1965
  * is. `display-message ; capture-pane` runs the two as consecutive commands in
1889
1966
  * one client: the first output line is the geometry, the rest is the capture.
1890
1967
  */
1891
- exports.STREAM_GEOMETRY_FORMAT = '#{cursor_x},#{cursor_y},#{pane_height},#{history_size}';
1968
+ exports.STREAM_GEOMETRY_FORMAT = '#{cursor_x},#{cursor_y},#{pane_height},#{history_size},#{alternate_on}';
1892
1969
  /**
1893
1970
  * The last capture of each mirrored session, kept for `STREAM_SHARE_MS`: viewers of one session tick
1894
1971
  * together (see the start of a stream), and the first one's capture serves the rest — a tmux spawn
@@ -1925,6 +2002,8 @@ const captureStreamPaneNow = (sessionName) => {
1925
2002
  return null;
1926
2003
  return { captured: capToFrameLimit(out.slice(split + 1)), geometry: (0, exports.parsePaneGeometry)(out.slice(0, split)) };
1927
2004
  };
2005
+ /** A tmux flag field: `1`/`0`, or undefined for an empty or unknown answer. */
2006
+ const tmuxFlag = (field) => field === '1' ? true : field === '0' ? false : undefined;
1928
2007
  /**
1929
2008
  * Read the one tmux answer the frame path already asks for per tick.
1930
2009
  *
@@ -1944,9 +2023,14 @@ const parsePaneGeometry = (raw) => {
1944
2023
  // clear; letting an empty answer arrive as one would fake the clear and
1945
2024
  // make the viewer re-record output it already holds.
1946
2025
  const depth = fields.length > 3 && fields[3] !== '' ? Number(fields[3]) : NaN;
1947
- if (!Number.isSafeInteger(depth) || depth < 0)
1948
- return { x, y, height };
1949
- return { x, y, height, historySize: depth };
2026
+ const alternateScreen = tmuxFlag(fields[4]);
2027
+ return {
2028
+ x,
2029
+ y,
2030
+ height,
2031
+ ...(Number.isSafeInteger(depth) && depth >= 0 ? { historySize: depth } : {}),
2032
+ ...(alternateScreen === undefined ? {} : { alternateScreen }),
2033
+ };
1950
2034
  };
1951
2035
  exports.parsePaneGeometry = parsePaneGeometry;
1952
2036
  /**
@@ -2573,6 +2657,7 @@ stamp) => {
2573
2657
  ...(cursor ? { cursorLine: cursor.line, cursorCol: cursor.col } : {}),
2574
2658
  ...(meta?.historyLines === undefined ? {} : { historyLines: meta.historyLines }),
2575
2659
  ...(meta?.historySize === undefined ? {} : { historySize: meta.historySize }),
2660
+ ...(meta?.alternateScreen === undefined ? {} : { alternateScreen: meta.alternateScreen }),
2576
2661
  };
2577
2662
  const head = { subtype: 'agent.stream.frame', sessionName, capturedAt: new Date().toISOString() };
2578
2663
  if (!subscriberPublicKey)
@@ -2720,12 +2805,14 @@ const handleStreamControl = (req, send) => {
2720
2805
  if (fitTarget !== null)
2721
2806
  paneFitter.fit(sessionName, fitTarget, req.cols);
2722
2807
  let lastContent = null;
2723
- // The cursor's last reported place, as `line,col`. Part of the diff-gate
2724
- // because moving the cursor is a change the CAPTURE cannot show: an arrow
2725
- // key inside a prompt leaves every character where it was, so gating on the
2726
- // text alone means the mirror's cursor never moves — which is precisely the
2727
- // feedback the cursor exists to give.
2728
- let lastCursor = null;
2808
+ // The cursor's last reported place and the screen the pane was on, as
2809
+ // `line,col|alternate`. Part of the diff-gate because both change without
2810
+ // the CAPTURE showing it: an arrow key inside a prompt leaves every
2811
+ // character where it was, so gating on the text alone means the mirror's
2812
+ // cursor never moves — which is precisely the feedback the cursor exists to
2813
+ // give; and a program opening its alternate screen over an identical paint
2814
+ // would leave the viewer paging a scrollback that is gone.
2815
+ let lastView = null;
2729
2816
  const stats = {
2730
2817
  startedAt: Date.now(),
2731
2818
  frames: 0,
@@ -2850,8 +2937,9 @@ const handleStreamControl = (req, send) => {
2850
2937
  ? (0, exports.historyLinesIn)(captured.content, cursorAt.height)
2851
2938
  : undefined;
2852
2939
  const historySize = cursorAt?.historySize;
2853
- const cursorKey = cursor ? `${cursor.line},${cursor.col}` : '';
2854
- if (captured.content === lastContent && cursorKey === lastCursor) {
2940
+ const alternateScreen = cursorAt?.alternateScreen;
2941
+ const viewKey = `${cursor ? `${cursor.line},${cursor.col}` : ''}|${alternateScreen ?? ''}`;
2942
+ if (captured.content === lastContent && viewKey === lastView) {
2855
2943
  stats.skipped++;
2856
2944
  return true; // diff-gate
2857
2945
  }
@@ -2870,14 +2958,14 @@ const handleStreamControl = (req, send) => {
2870
2958
  // fps by one frame per episode in the R2 numbers.
2871
2959
  const inBurst = armedDelay === exports.BURST_INTERVAL_MS;
2872
2960
  lastContent = captured.content;
2873
- lastCursor = cursorKey;
2961
+ lastView = viewKey;
2874
2962
  // Stamp the sequence in CAPTURE order, synchronously — frame assembly
2875
2963
  // is async (encryption) and fire-and-forget, so resolve order is not
2876
2964
  // guaranteed under load; the receiver drops any seq it has already
2877
2965
  // painted past.
2878
2966
  const seq = ++wireSeq;
2879
2967
  const stamp = { seq, epoch: stats.startedAt, nonce };
2880
- void (0, exports.buildStreamFrame)(captured, sessionName, subscriberPublicKey, { cursor, historyLines, historySize }, stamp).then((frame) => {
2968
+ void (0, exports.buildStreamFrame)(captured, sessionName, subscriberPublicKey, { cursor, historyLines, historySize, alternateScreen }, stamp).then((frame) => {
2881
2969
  // Stream stopped or restarted while this frame was in flight —
2882
2970
  // `stats` is unique per start, so it doubles as the identity
2883
2971
  // token. Don't send into a dead/replaced stream or skew its stats.
@@ -2889,7 +2977,7 @@ const handleStreamControl = (req, send) => {
2889
2977
  // subscriber key) never recovers: fail closed after a few
2890
2978
  // strikes instead of retrying every tick for 5 minutes.
2891
2979
  lastContent = null;
2892
- lastCursor = null;
2980
+ lastView = null;
2893
2981
  // Init still in flight (or failed — its own path fail-closes):
2894
2982
  // a not-yet-ready key is not a malformed key, don't strike.
2895
2983
  if ((0, crypto_js_1.getDevicePublicKey)() === null)
@@ -3067,6 +3155,10 @@ const validateInputMessage = (msg) => {
3067
3155
  if (msg.body !== undefined || msg.insert !== undefined) {
3068
3156
  return { ok: false, reason: 'keys are mutually exclusive with body/insert' };
3069
3157
  }
3158
+ // A batch is all wheel or all named keys: a mix fails both lookups and is refused whole.
3159
+ const wheel = (0, exports.resolveWheel)(msg.keys);
3160
+ if (wheel)
3161
+ return { ok: true, input: { ...base, tokens: null, text: null, wheel, submit: false } };
3070
3162
  const tokens = (0, exports.resolveKeys)(msg.keys);
3071
3163
  if (!tokens)
3072
3164
  return { ok: false, reason: `unknown key(s) [${msg.keys.join(' ')}]` };
@@ -3106,9 +3198,11 @@ const deliverInput = (input) => {
3106
3198
  reason = 'prompt_changed';
3107
3199
  const injected = bound && hasStream(input.sessionName)
3108
3200
  // Stream input arrives on this listener's own subscription: addressed.
3109
- && (input.tokens
3110
- ? tryInjectKeys(input.sessionName, input.tokens, deps, true)
3111
- : tryInject(input.sessionName, input.text ?? '', deps, { submit: input.submit, addressed: true }));
3201
+ && (input.wheel
3202
+ ? tryScroll(input.sessionName, input.wheel, deps)
3203
+ : input.tokens
3204
+ ? tryInjectKeys(input.sessionName, input.tokens, deps, true)
3205
+ : tryInject(input.sessionName, input.text ?? '', deps, { submit: input.submit, addressed: true }));
3112
3206
  // Every refusal reachable from here — dead lease, shell pane, rate limit,
3113
3207
  // a failed send-keys — used to be silent, which contradicts the contract
3114
3208
  // above: the sender would keep waiting on a keystroke that never lands
@@ -3116,7 +3210,8 @@ const deliverInput = (input) => {
3116
3210
  // Only a reason that does not depend on the text: this input may be sealed.
3117
3211
  if (!injected)
3118
3212
  input.send(streamErrorFrame(input.sessionName, 'input_rejected', input, reason && (0, command_refusal_js_1.isSessionStateRefusal)(reason) ? reason : undefined));
3119
- else
3213
+ // Scrolling is looking, not typing: it does not size a window a desktop took back.
3214
+ else if (!input.wheel)
3120
3215
  takeWidthBack(input.sessionName, input.deviceId);
3121
3216
  };
3122
3217
  /**
@@ -3829,7 +3924,7 @@ exports.isInventoried = isInventoried;
3829
3924
  *
3830
3925
  * Returns what the probe saw when the input is admitted, null when refused.
3831
3926
  */
3832
- const passesInjectGuards = (session, deps, addressed, cost = 1) => {
3927
+ const passesInjectGuards = (session, deps, addressed, cost = 1, bucket = session) => {
3833
3928
  // Subagent panes are view-only: the phone reads them but the agent
3834
3929
  // itself answers at the terminal. This sits above the rate bucket so a
3835
3930
  // flood of refused subagent writes costs nothing and spawns nothing —
@@ -3843,7 +3938,7 @@ const passesInjectGuards = (session, deps, addressed, cost = 1) => {
3843
3938
  // Rate bucket first: the pane probe below is a blocking tmux spawnSync,
3844
3939
  // and the sequencer can flush several held messages back-to-back — an
3845
3940
  // empty bucket must refuse before paying that probe N times, not after.
3846
- if (!(deps.rateLimit ?? exports.checkRateLimit)(session, Date.now(), cost)) {
3941
+ if (!(deps.rateLimit ?? exports.checkRateLimit)(bucket, Date.now(), cost)) {
3847
3942
  (0, listener_log_js_1.log)(`! ${session}: rate-limited — drop`);
3848
3943
  deps.noteRefusal?.('rate_limited');
3849
3944
  return null;
@@ -4008,6 +4103,51 @@ const tryInjectKeys = (session, tokens, deps, addressed) => {
4008
4103
  deps.noteRefusal?.('inject_failed');
4009
4104
  return ok;
4010
4105
  };
4106
+ /**
4107
+ * Wheel sibling of tryInjectKeys, under the same pane/shell guards. Its own
4108
+ * rate bucket: the phone sends a tick batch every few hundred ms while a
4109
+ * finger drags, and a minute of scrolling must not spend the typing budget.
4110
+ * A pane that left the alternate screen takes nothing and is not a refusal —
4111
+ * the scroll simply has nowhere to go.
4112
+ */
4113
+ const wheelCarry = new Map();
4114
+ const tryScroll = (session, ticks, deps) => {
4115
+ const probe = passesInjectGuards(session, deps, true, 1, `wheel:${session}`);
4116
+ if (!probe)
4117
+ return false;
4118
+ const target = (0, tmux_pane_js_1.tmuxTargetFor)(session);
4119
+ if (target === null)
4120
+ return false;
4121
+ const names = ticks.map((t) => `wheel${t}`).join(' ');
4122
+ const state = runTmux(['display-message', '-p', '-t', target, exports.WHEEL_STATE_FORMAT]);
4123
+ const command = state.stdout.trim().split(',').slice(5).join(',');
4124
+ const { events, carry } = (0, exports.wheelEvents)(ticks, command, wheelCarry.get(session));
4125
+ const args = state.status === 0 ? (0, exports.wheelInput)(events, state.stdout) : undefined;
4126
+ if (args === null) {
4127
+ wheelCarry.delete(session);
4128
+ (0, listener_log_js_1.log)(`· ${session}: wheel for a pane off the alternate screen — skip`);
4129
+ return true;
4130
+ }
4131
+ if (carry)
4132
+ wheelCarry.set(session, carry);
4133
+ else
4134
+ wheelCarry.delete(session);
4135
+ if (args !== undefined && events.length === 0) {
4136
+ (0, listener_log_js_1.log)(`· ${session}: [${names}] held toward ${command}'s next wheel event`);
4137
+ return true;
4138
+ }
4139
+ const ok = args !== undefined
4140
+ && (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['send-keys', '-t', target, ...args]), { stdio: ['ignore', 'ignore', 'pipe'] }).status === 0;
4141
+ if (ok) {
4142
+ noteStreamInput(session);
4143
+ if (probe.shellMarked)
4144
+ auditShellInput({ session, probe, kind: 'keys', text: names }, deps);
4145
+ }
4146
+ (0, listener_log_js_1.log)(`${ok ? '⌨' : '✗'} ${session}: [${names}]`);
4147
+ if (!ok)
4148
+ deps.noteRefusal?.('inject_failed');
4149
+ return ok;
4150
+ };
4011
4151
  // ─── Attachment download (agent.command files[]) ────────────────────
4012
4152
  const ATTACHMENTS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.hwip', 'attachments');
4013
4153
  /** Local-transfer landing zone: `lan/<transferId>/<fileName>` until its push record arrives. */
package/dist/templates.js CHANGED
@@ -789,6 +789,17 @@ const LOOKUP_TIMEOUT_MS = 5000;
789
789
  const RESUBSCRIBE_MIN_MS = 1000;
790
790
  const RESUBSCRIBE_MAX_MS = 30000;
791
791
 
792
+ // The newest instance per directory. 2.x evicts a directory's instance after an
793
+ // idle hour (\`experimental.location_idle_timeout\`) and runs \`setup\` again on the
794
+ // next request, but hands \`setup\` no way to learn of the eviction — and the
795
+ // evicted instance's stream keeps delivering. Each eviction would leave one more
796
+ // loop pushing every turn. The module is evaluated afresh per instance (2.1.2,
797
+ // 2026-10-22), so only \`globalThis\` outlives one; an outdated loop stops at its
798
+ // next event.
799
+ const CURRENT_KEY = Symbol.for("hwip.opencode.current");
800
+ const currentByDirectory = (): Map<string, object> =>
801
+ ((globalThis as Record<symbol, Map<string, object> | undefined>)[CURRENT_KEY] ??= new Map());
802
+
792
803
  const setup = (ctx: V2Context): void => {
793
804
  // 1.18 calls \`setup\` as well as \`server\`, with a context that has no event
794
805
  // stream. Its hooks are \`server\`'s, so there is nothing to do here.
@@ -797,6 +808,10 @@ const setup = (ctx: V2Context): void => {
797
808
  const subscribe = stream.subscribe.bind(stream);
798
809
  const here = ctx.location?.directory;
799
810
  const turns = createTurns();
811
+ const instance = {};
812
+ const key = here ?? "";
813
+ currentByDirectory().set(key, instance);
814
+ const outdated = (): boolean => currentByDirectory().get(key) !== instance;
800
815
 
801
816
  // One server runs an instance of this plugin per directory and hands every
802
817
  // instance every session's events, so the session's own directory decides
@@ -854,9 +869,10 @@ const setup = (ctx: V2Context): void => {
854
869
  // \`notify\` drops them — there is no channel the user would read.
855
870
  const consume = async (): Promise<void> => {
856
871
  let wait = RESUBSCRIBE_MIN_MS;
857
- for (;;) {
872
+ while (!outdated()) {
858
873
  try {
859
874
  for await (const event of subscribe()) {
875
+ if (outdated()) return;
860
876
  wait = RESUBSCRIBE_MIN_MS;
861
877
  try {
862
878
  await handle(event);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",