@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.
- package/dist/listener.d.ts +45 -2
- package/dist/listener.js +164 -24
- package/dist/templates.js +17 -1
- package/package.json +1 -1
package/dist/listener.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
29
|
-
exports.
|
|
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
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
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
|
|
2724
|
-
//
|
|
2725
|
-
// key inside a prompt leaves every
|
|
2726
|
-
//
|
|
2727
|
-
// feedback the cursor exists to
|
|
2728
|
-
|
|
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
|
|
2854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3110
|
-
?
|
|
3111
|
-
:
|
|
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
|
-
|
|
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)(
|
|
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
|
-
|
|
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);
|