@hwip/cli 0.0.1 → 0.1.1
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/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1983 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +305 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +39 -0
- package/dist/blocked-prompt-seal.js +92 -0
- package/dist/blocked-prompt.d.ts +62 -0
- package/dist/blocked-prompt.js +518 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +278 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5681 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hwip listener` — resident daemon that watches the user's Hwip feed
|
|
3
|
+
* over a persistent WebSocket and injects matching messages into a
|
|
4
|
+
* named tmux session via `tmux send-keys`.
|
|
5
|
+
*
|
|
6
|
+
* Solves the MCP polling-window problem: an `hwip_ask` polling cycle
|
|
7
|
+
* times out (120–600 s) and the CC/Codex session becomes unaddressable
|
|
8
|
+
* from the phone. The listener stays subscribed indefinitely and can
|
|
9
|
+
* deliver to any named tmux session at any time.
|
|
10
|
+
*
|
|
11
|
+
* Wire format: pushes with `type='agent.command'` carry the tmux
|
|
12
|
+
* session name in `agentSessionName` and the message in `body`. The
|
|
13
|
+
* "AI Agent에게 명령" sheet on the phone builds these structured
|
|
14
|
+
* pushes from the listener-reported session inventory. Other push
|
|
15
|
+
* types (Stop-hook auto-pushes, hwip_ask responses, channel
|
|
16
|
+
* broadcasts) are ignored.
|
|
17
|
+
*
|
|
18
|
+
* Transport: WebSocket against the Hwip $connect endpoint with
|
|
19
|
+
* `?apiKey=<key>`. The server fan-out pushes `{ type: 'push.new', data }`
|
|
20
|
+
* messages as new pushes are created. Reconnects with exponential
|
|
21
|
+
* backoff on transient failures; gives up on auth failures (4001/4002/4003).
|
|
22
|
+
*/
|
|
23
|
+
import { type HwipConfig } from './config.js';
|
|
24
|
+
import { type ShellAuditEntry } from './shell-audit.js';
|
|
25
|
+
import { type AgentKind } from './remote-agents.js';
|
|
26
|
+
import { type TranscriptSource } from './transcript-tail.js';
|
|
27
|
+
import { type AgentState } from './agent-state.js';
|
|
28
|
+
import { type SealedFileMeta, type SealedV2 } from './crypto.js';
|
|
29
|
+
import { type RecordableSession } from './turn-recorder.js';
|
|
30
|
+
import { type BlockedPromptWire, type PromptSealer } from './blocked-prompt-seal.js';
|
|
31
|
+
import { type CommandRefusal } from './command-refusal.js';
|
|
32
|
+
import { optionalFeatures } from './optional-features.js';
|
|
33
|
+
import { type RefusalCode, type SubscriberTrust } from './subscriber-trust.js';
|
|
34
|
+
import { type DesktopNotification } from './desktop-notify.js';
|
|
35
|
+
import { type SendTurnFrame, type TurnWatchControl } from './turn-watch.js';
|
|
36
|
+
import { type SequencedInput } from './input-sequencer.js';
|
|
37
|
+
import { type AgentCommandCatalog } from './command-scan.js';
|
|
38
|
+
import { silenceListenerLog } from './listener-log.js';
|
|
39
|
+
import { invalidateTmuxSocketCache, tmuxArgs } from './tmux-socket.js';
|
|
40
|
+
import { detectRemoteAgent, isSubagentSessionName, recordSubagentTranscripts, recordTargets, subagentTranscriptFor, tmuxTargetFor } from './tmux-pane.js';
|
|
41
|
+
import { liveSessionNames, sessionExists, sessionLiveness } from './tmux-liveness.js';
|
|
42
|
+
import { type PhoneScheduleDeps } from './schedule-remote.js';
|
|
43
|
+
import { type ScheduleRunnerDeps } from './schedule-runner.js';
|
|
44
|
+
export declare const PING_IDLE_MS = 240000;
|
|
45
|
+
/** Whether the socket has been quiet on our side long enough that a ping is due. */
|
|
46
|
+
export declare const pingDue: (lastSentAtMs: number, nowMs: number, idleMs?: number) => boolean;
|
|
47
|
+
/** What the heartbeat concludes from one look at the socket. */
|
|
48
|
+
export type HeartbeatVerdict = 'alive' | 'dead' | 'blocked';
|
|
49
|
+
/**
|
|
50
|
+
* Is this socket still worth keeping, given how long the server has said
|
|
51
|
+
* nothing (`silentMs`) and how long this thread was unavailable to hear it
|
|
52
|
+
* (`blockedMs`)?
|
|
53
|
+
*
|
|
54
|
+
* Both questions are needed because only one of them is about the socket. The
|
|
55
|
+
* deadline used to be a per-ping `setTimeout`, which asked neither: it was
|
|
56
|
+
* cancelled by the next ping before it could ever fire on a quiet link, so a
|
|
57
|
+
* genuinely dead socket slipped past it, while a thread blocked long enough to
|
|
58
|
+
* delay its own ping fired it against a connection that was fine. Making the
|
|
59
|
+
* judgment a function of silence and of the judge's own availability is what
|
|
60
|
+
* lets the fast path (`dead` at PONG_TIMEOUT_MS) stay real while a stalled
|
|
61
|
+
* daemon is still forgiven up to the wall-clock ceiling.
|
|
62
|
+
*
|
|
63
|
+
* `blocked` is not a reprieve without end: past WS_STALL_TIMEOUT_MS the socket
|
|
64
|
+
* dies whatever this thread was doing, because at that point we cannot tell the
|
|
65
|
+
* two apart and a reconnect is cheap.
|
|
66
|
+
*/
|
|
67
|
+
export declare const heartbeatVerdict: (silentMs: number, blockedMs: number, pongTimeoutMs?: number, stallTimeoutMs?: number, blockedGraceMs?: number) => HeartbeatVerdict;
|
|
68
|
+
export declare const SESSION_REPORT_INTERVAL_MS = 5000;
|
|
69
|
+
export declare const SESSION_REPORT_HEARTBEAT_MS = 120000;
|
|
70
|
+
/** How long a report may go unanswered before the socket is presumed dead and re-opened. */
|
|
71
|
+
export declare const REPORT_ACK_TIMEOUT_MS = 30000;
|
|
72
|
+
/**
|
|
73
|
+
* Whether what was sent is being answered. Every `listener.sessions` report gets an ack (or an error)
|
|
74
|
+
* back, so the oldest report still waiting for one says how long the server has been deaf to us —
|
|
75
|
+
* which a quiet link cannot tell from a dead one, and which the long silence limit
|
|
76
|
+
* (PONG_TIMEOUT_MS) no longer catches in time. Time this thread could not hear anything (its own
|
|
77
|
+
* blocking, as `loopStallMs` measures) is not counted against the server.
|
|
78
|
+
*/
|
|
79
|
+
export declare const createReportAckWatch: (timeoutMs?: number) => {
|
|
80
|
+
/** A report went out. The oldest one still unanswered is the one that counts. */
|
|
81
|
+
sent: (nowMs: number, stallMs?: number) => void;
|
|
82
|
+
/** An ack or an error came back: the server heard us. */
|
|
83
|
+
answered: () => void;
|
|
84
|
+
overdue: (nowMs: number, stallMs?: number) => boolean;
|
|
85
|
+
};
|
|
86
|
+
/** What a reported session runs: a registered agent, or a `hwip sh` shell. */
|
|
87
|
+
export type SessionKind = AgentKind | 'shell';
|
|
88
|
+
interface AgentSession {
|
|
89
|
+
name: string;
|
|
90
|
+
attached: boolean;
|
|
91
|
+
agentKind: SessionKind;
|
|
92
|
+
/** Set when this session is a SUBAGENT — an extra pane of the parent
|
|
93
|
+
* session `parentName` (`hwip-hwip.48` belongs to `hwip-hwip`). Subagent
|
|
94
|
+
* sessions are view-only: input, keys, exit, resume and forget are
|
|
95
|
+
* refused, and the server never pushes agent.state transitions for them. */
|
|
96
|
+
parentName?: string;
|
|
97
|
+
/** Calls a subagent has made so far, from its own transcript. Only a
|
|
98
|
+
* transcript subagent has one: a pane subagent is watched as a screen, and
|
|
99
|
+
* nothing counts what happens on it. */
|
|
100
|
+
toolCount?: number;
|
|
101
|
+
/** Set on a subagent with no pane at all (Claude Code's `Agent` tool): there
|
|
102
|
+
* is no screen to stream, only its transcript. Absent on a pane subagent —
|
|
103
|
+
* which can run Claude Code too, so the kind cannot stand in for this. */
|
|
104
|
+
inProcess?: true;
|
|
105
|
+
agentSessionId?: string | null;
|
|
106
|
+
project: string;
|
|
107
|
+
label?: string | null;
|
|
108
|
+
/**
|
|
109
|
+
* The name the agent itself calls this session by, when its registry
|
|
110
|
+
* exposes one (Claude Code's `hwipit-95`). A live read, not user intent —
|
|
111
|
+
* the phone's rename still wins over it. Null where the agent has no name
|
|
112
|
+
* concept, where the pane's session could not be identified, or where the
|
|
113
|
+
* name is blank.
|
|
114
|
+
*
|
|
115
|
+
* Deliberately NOT `sessionName`: everywhere else in this file and on the
|
|
116
|
+
* wire that word means the tmux session name (`agentSessionName` in a
|
|
117
|
+
* push, `AgentWatchRecord.sessionName` on the server). Two names for a
|
|
118
|
+
* session is already the problem this field exists to solve; reusing the
|
|
119
|
+
* word for both would put the ambiguity in the type.
|
|
120
|
+
*/
|
|
121
|
+
providerSessionName?: string | null;
|
|
122
|
+
createdAt?: string;
|
|
123
|
+
lastActivityAt?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Detected agent state (wire contract — flows to the server and the
|
|
126
|
+
* phone's Agents tab). Absent when the pane can't be captured or
|
|
127
|
+
* detection is unavailable. `done` never appears here: it's derived
|
|
128
|
+
* client-side (SPEC-AGENT-AWARENESS §S1/§S6).
|
|
129
|
+
*/
|
|
130
|
+
state?: AgentState;
|
|
131
|
+
stateChangedAt?: string;
|
|
132
|
+
stateRuleId?: string;
|
|
133
|
+
/**
|
|
134
|
+
* What a `blocked` session is waiting for, sealed for the trusted devices
|
|
135
|
+
* (hwip SERVER-API.md `blockedPrompt`). Only on a main session in a dialog
|
|
136
|
+
* this listener can read; absent otherwise, and the state alone is reported
|
|
137
|
+
* as before.
|
|
138
|
+
*/
|
|
139
|
+
blockedPrompt?: BlockedPromptWire;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Change-fingerprint for the report gate. Mirrors the server's
|
|
143
|
+
* REPORTED_SESSION_FIELDS comparison (hwip ws.ts sameReportedSessions):
|
|
144
|
+
* same fields, order-independent, undefined/null collapsed — so the
|
|
145
|
+
* client-side skip can never suppress a report the server would have
|
|
146
|
+
* treated as a change. Keep the field list in sync with the server.
|
|
147
|
+
*/
|
|
148
|
+
/**
|
|
149
|
+
* Sessions this machine has run and is not running now, as the phone's "past
|
|
150
|
+
* sessions" list.
|
|
151
|
+
*
|
|
152
|
+
* The list used to be derived on the phone from a page of recent pushes, which
|
|
153
|
+
* answered a different question than the one being asked: not "which sessions
|
|
154
|
+
* existed on this machine" but "what is in the newest fifty pushes". A busy day
|
|
155
|
+
* pushed real sessions out of that window while sessions the daemon had never
|
|
156
|
+
* recorded stayed in it — so the list and the resume button disagreed in both
|
|
157
|
+
* directions, offering rows that could not start and hiding rows that could.
|
|
158
|
+
*
|
|
159
|
+
* The registry is the machine's own answer to that question, and the same
|
|
160
|
+
* record resume reads, so a row that appears here is a row that can start.
|
|
161
|
+
*
|
|
162
|
+
* `cwd` is deliberately left behind. The registry keeps it because resume needs
|
|
163
|
+
* somewhere to start the agent; nothing off this machine needs a filesystem
|
|
164
|
+
* path, and sending one would hand the relay a map of the user's disk for no
|
|
165
|
+
* behaviour in return.
|
|
166
|
+
*
|
|
167
|
+
* Live sessions are excluded, which the phone would do anyway — but doing it
|
|
168
|
+
* here also keeps the payload still. Their `lastSeenAt` moves every sweep, and
|
|
169
|
+
* a field that changes every five seconds would defeat the report's
|
|
170
|
+
* unchanged-inventory gate and write to the device record all day.
|
|
171
|
+
*/
|
|
172
|
+
/**
|
|
173
|
+
* Sockets dropped in a row because the server did not know them, reset by the
|
|
174
|
+
* first report that lands. The cap is what keeps a server-side "reconnect"
|
|
175
|
+
* that reconnecting cannot fix from turning into a connect loop.
|
|
176
|
+
*/
|
|
177
|
+
export declare const MAX_STALE_CONNECTION_DROPS = 3;
|
|
178
|
+
/**
|
|
179
|
+
* Whether a `listener.sessions.error` is the server telling us this connection
|
|
180
|
+
* is not registered. Keyed on the instruction rather than the wording of the
|
|
181
|
+
* diagnosis: the server says what it wants the client to DO, and every other
|
|
182
|
+
* rejection (a malformed report, a payload too large) is not fixed by
|
|
183
|
+
* reconnecting and must not cause one.
|
|
184
|
+
*/
|
|
185
|
+
export declare const askedToReconnect: (message: unknown) => boolean;
|
|
186
|
+
/** What to do about one `listener.sessions.error`. */
|
|
187
|
+
export type StaleConnectionAction =
|
|
188
|
+
/** Drop the socket; the reconnect loop registers a fresh connection. */
|
|
189
|
+
'drop'
|
|
190
|
+
/** Not a connection problem — reconnecting would not fix it. */
|
|
191
|
+
| 'ignore'
|
|
192
|
+
/** Dropped this many times already and the reports still bounce. */
|
|
193
|
+
| 'exhausted';
|
|
194
|
+
/**
|
|
195
|
+
* The policy, separated from the socket it acts on so it can be read and
|
|
196
|
+
* tested as what it is: two guards around one lever.
|
|
197
|
+
*/
|
|
198
|
+
export declare const staleConnectionAction: (message: unknown, drops: number) => StaleConnectionAction;
|
|
199
|
+
export declare const KNOWN_SESSIONS_REPORTED = 30;
|
|
200
|
+
export interface ReportedKnownSession {
|
|
201
|
+
name: string;
|
|
202
|
+
agentKind: string;
|
|
203
|
+
project?: string;
|
|
204
|
+
label?: string;
|
|
205
|
+
lastSeenAt: string;
|
|
206
|
+
}
|
|
207
|
+
export declare const knownSessionsToReport: (live: readonly AgentSession[], now?: number) => ReportedKnownSession[];
|
|
208
|
+
export declare const knownSessionsFingerprint: (known: ReportedKnownSession[]) => string;
|
|
209
|
+
export declare const sessionsFingerprint: (sessions: AgentSession[]) => string;
|
|
210
|
+
/** True when this cycle's inventory must be sent: it changed, or the
|
|
211
|
+
* idle heartbeat is due. */
|
|
212
|
+
export declare const sessionsReportDue: (fingerprint: string, lastSentFingerprint: string | null, lastSentAtMs: number, nowMs: number) => boolean;
|
|
213
|
+
/** How often the skill catalog is rescanned for changes. Deliberately far
|
|
214
|
+
* slower than the session poll: this cycle reads the filesystem, and the
|
|
215
|
+
* 5 s session loop must never do that. */
|
|
216
|
+
export declare const COMMAND_SCAN_INTERVAL_MS = 60000;
|
|
217
|
+
export declare const commandsFingerprint: (catalog: AgentCommandCatalog) => string;
|
|
218
|
+
/** Skill catalogs have no idle heartbeat — the server persists them, so an
|
|
219
|
+
* unchanged machine has nothing to say until a skill is installed or removed. */
|
|
220
|
+
export declare const commandsReportDue: (fingerprint: string, lastSentFingerprint: string | null) => boolean;
|
|
221
|
+
export declare const AUTH_FAILURE_CODES: ReadonlySet<number>;
|
|
222
|
+
export declare const checkRateLimit: (session: string, now?: number, cost?: number) => boolean;
|
|
223
|
+
/** What the inject guard learns from one probe of a session's pinned pane. */
|
|
224
|
+
export interface PaneProbe {
|
|
225
|
+
/** `pane_current_command` — the foreground program. */
|
|
226
|
+
command: string;
|
|
227
|
+
/** The session carries `@hwip_shell` naming itself (`hwip sh`): a shell here may be typed into. */
|
|
228
|
+
shellMarked: boolean;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Read the foreground command on the named session's PINNED pane, verifying
|
|
232
|
+
* the pane still belongs to that session. A pane id is stable across restarts
|
|
233
|
+
* only within one tmux server: after a restart the same `%N` can name a pane
|
|
234
|
+
* in a different session, and typing into that pane would be RCE into someone
|
|
235
|
+
* else's shell. The owning session and its shell marker ride along in the same
|
|
236
|
+
* display-message — one spawn, no second probe — and a mismatch answers null
|
|
237
|
+
* ("no such session"), which refuses the inject until the next sweep re-pins.
|
|
238
|
+
* An unset option formats as the empty string, so an unmarked pane reads as
|
|
239
|
+
* `shellMarked: false`, never as an error. The marker must name this very
|
|
240
|
+
* session: a format reads a user option through pane → window → session →
|
|
241
|
+
* global inheritance, so a stray `set -g @hwip_shell 1` would otherwise mark
|
|
242
|
+
* every session on the server.
|
|
243
|
+
*/
|
|
244
|
+
export declare const probePane: (session: string) => PaneProbe | null;
|
|
245
|
+
/**
|
|
246
|
+
* Translate a phone-supplied key list to tmux tokens. Returns null if ANY
|
|
247
|
+
* key is unknown — a partial injection (some keys land, one is dropped)
|
|
248
|
+
* leaves the pane in a worse mid-state than none, so the whole batch is
|
|
249
|
+
* rejected.
|
|
250
|
+
*/
|
|
251
|
+
export declare const resolveKeys: (keys: string[]) => string[] | null;
|
|
252
|
+
export { invalidateTmuxSocketCache, silenceListenerLog, tmuxArgs };
|
|
253
|
+
export { liveSessionNames, sessionExists, sessionLiveness };
|
|
254
|
+
export { detectRemoteAgent, isSubagentSessionName, recordSubagentTranscripts, recordTargets, subagentTranscriptFor, tmuxTargetFor };
|
|
255
|
+
/**
|
|
256
|
+
* Parse a `hwip-*` tmux session name into `{project, label}`. The wrapper
|
|
257
|
+
* emits `hwip-<project>` and, with `--label <x>`, `hwip-<project>-<x>`; the
|
|
258
|
+
* name alone cannot tell where the project ends, so the whole tail is the
|
|
259
|
+
* project. A labelled or worktree session also carries the answer in session
|
|
260
|
+
* options (`@hwip_project`, `@hwip_session_label` — wrapper
|
|
261
|
+
* `sessionSidecarArgs`), which win: the phone groups cards by project, and
|
|
262
|
+
* the tail would open a card per label and per worktree.
|
|
263
|
+
*/
|
|
264
|
+
export declare const parseSessionName: (name: string, sidecar?: {
|
|
265
|
+
project?: string;
|
|
266
|
+
label?: string;
|
|
267
|
+
}) => {
|
|
268
|
+
project: string;
|
|
269
|
+
label: string | null;
|
|
270
|
+
} | null;
|
|
271
|
+
/** Test seam: how many directories have been looked up. */
|
|
272
|
+
export declare const __checkoutCacheSize: () => number;
|
|
273
|
+
/** Test hook. */
|
|
274
|
+
export declare const resetSessionStates: () => void;
|
|
275
|
+
/**
|
|
276
|
+
* Fold one pane capture into the per-session state machine and return
|
|
277
|
+
* the wire fields for this report cycle. Exported (with `paneText`
|
|
278
|
+
* injected) so tests can drive it without tmux.
|
|
279
|
+
*
|
|
280
|
+
* Hash short-circuit: an unchanged screen skips rule evaluation but
|
|
281
|
+
* still replays the previous observation through the debounce — cheap
|
|
282
|
+
* cycles stay cheap without wedging candidate promotion.
|
|
283
|
+
*/
|
|
284
|
+
export declare const deriveSessionState: (name: string, agentKind: AgentKind, paneText: string | null, now?: number) => Pick<AgentSession, "state" | "stateChangedAt" | "stateRuleId">;
|
|
285
|
+
/** Test hook. */
|
|
286
|
+
export declare const setPromptSealer: (sealer: PromptSealer | null) => void;
|
|
287
|
+
/**
|
|
288
|
+
* The sealed prompt of each main session blocked in a dialog this listener can
|
|
289
|
+
* read, added to a sweep's sessions before they are published. Here and not in
|
|
290
|
+
* the sweep: the sweep runs on the inventory worker (inventory-offload.ts), a
|
|
291
|
+
* module instance of its own where no sealer is ever set, so a prompt sealed
|
|
292
|
+
* there would never be. A blocked session's pane is captured once more for it;
|
|
293
|
+
* the others pass no pane, which forgets any prompt they showed. Subagent rows
|
|
294
|
+
* and shells are left alone: nothing on the phone answers for them.
|
|
295
|
+
*/
|
|
296
|
+
export declare const withBlockedPrompts: ({ sessions, targets }: Pick<CollectResult, "sessions" | "targets">) => AgentSession[];
|
|
297
|
+
/** How long a report waits for a prompt seal that started on it. Seals take milliseconds; this bounds a stuck key lookup. */
|
|
298
|
+
export declare const PROMPT_SEAL_WAIT_MS = 3000;
|
|
299
|
+
/**
|
|
300
|
+
* `withBlockedPrompts`, held until the seals it started are done (at most `PROMPT_SEAL_WAIT_MS`), then read again. The
|
|
301
|
+
* server pushes on the first report that says blocked and the phone fetches the prompt for the push's buttons right
|
|
302
|
+
* then: a prompt that rides only the next report, five seconds on, comes after the notification was drawn without them.
|
|
303
|
+
*/
|
|
304
|
+
export declare const withSettledPrompts: (inventory: Pick<CollectResult, "sessions" | "targets">) => Promise<AgentSession[]>;
|
|
305
|
+
export interface PatternWatch {
|
|
306
|
+
sessionName: string;
|
|
307
|
+
pattern: string;
|
|
308
|
+
}
|
|
309
|
+
/** Ack payload → active watch list. Prunes fired-markers for dead watches. */
|
|
310
|
+
export declare const setPatternWatches: (raw: unknown) => void;
|
|
311
|
+
/** Test hook. */
|
|
312
|
+
export declare const resetPatternWatches: () => void;
|
|
313
|
+
export interface WatchHit {
|
|
314
|
+
sessionName: string;
|
|
315
|
+
pattern: string;
|
|
316
|
+
matchedLine: string;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Probe every un-fired watch whose session is live. `capture` is
|
|
320
|
+
* injectable for tests; production passes capturePaneText.
|
|
321
|
+
*/
|
|
322
|
+
export declare const collectWatchHits: (liveNames: ReadonlySet<string>, capture?: (session: string) => string | null) => WatchHit[];
|
|
323
|
+
export interface ScreenRequest {
|
|
324
|
+
subtype?: string;
|
|
325
|
+
targetDeviceId?: string;
|
|
326
|
+
sessionName?: string;
|
|
327
|
+
requestId?: string;
|
|
328
|
+
/** The requesting device, stamped by the relay from the sending connection
|
|
329
|
+
* (server ws.ts, `ephemeral.send`) — which device's registered key
|
|
330
|
+
* `subscriberPublicKey` has to be. Relay JSON like the rest. */
|
|
331
|
+
deviceId?: string;
|
|
332
|
+
/** Asks for a sealed snapshot: the requester's device public key. */
|
|
333
|
+
subscriberPublicKey?: string;
|
|
334
|
+
/** The requester's nonce for this request, sealed with the pane. */
|
|
335
|
+
subscriberNonce?: string;
|
|
336
|
+
}
|
|
337
|
+
type ScreenSnapshotHead = {
|
|
338
|
+
subtype: 'agent.screen.snapshot';
|
|
339
|
+
requestId: string;
|
|
340
|
+
sessionName: string;
|
|
341
|
+
capturedAt: string;
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* One answer to a screen request: the pane in the clear, the pane sealed, or
|
|
345
|
+
* why there is none. Each arm rules the other arms' fields out (`?: never`),
|
|
346
|
+
* so a reply carrying both a sealed pane and a plaintext one does not
|
|
347
|
+
* type-check — `ScreenSnapshotIsOneWay` below is that claim, compiled.
|
|
348
|
+
*/
|
|
349
|
+
export type ScreenSnapshot = ScreenSnapshotHead & ({
|
|
350
|
+
content: string;
|
|
351
|
+
truncated: boolean;
|
|
352
|
+
encrypted?: never;
|
|
353
|
+
error?: never;
|
|
354
|
+
}
|
|
355
|
+
/** Envelope of a SealedScreenSnapshot. */
|
|
356
|
+
| {
|
|
357
|
+
encrypted: SealedV2;
|
|
358
|
+
truncated: boolean;
|
|
359
|
+
content?: never;
|
|
360
|
+
error?: never;
|
|
361
|
+
} | {
|
|
362
|
+
error: string;
|
|
363
|
+
content?: never;
|
|
364
|
+
truncated?: never;
|
|
365
|
+
encrypted?: never;
|
|
366
|
+
});
|
|
367
|
+
type MustBeTrue<T extends true> = T;
|
|
368
|
+
/** Fails to compile the day a snapshot may carry the pane both ways, or beside an error. */
|
|
369
|
+
export type ScreenSnapshotIsOneWay = MustBeTrue<(ScreenSnapshotHead & {
|
|
370
|
+
content: string;
|
|
371
|
+
truncated: boolean;
|
|
372
|
+
encrypted: SealedV2;
|
|
373
|
+
} extends ScreenSnapshot ? false : true) | (ScreenSnapshotHead & {
|
|
374
|
+
error: string;
|
|
375
|
+
content: string;
|
|
376
|
+
truncated: boolean;
|
|
377
|
+
} extends ScreenSnapshot ? false : true) | (ScreenSnapshotHead & {
|
|
378
|
+
error: string;
|
|
379
|
+
encrypted: SealedV2;
|
|
380
|
+
truncated: boolean;
|
|
381
|
+
} extends ScreenSnapshot ? false : true)>;
|
|
382
|
+
/** What a sealed snapshot's envelope holds (JSON): the pane, the request it
|
|
383
|
+
* answers and when it was read — so a snapshot moved onto another request,
|
|
384
|
+
* or another session's, or held back and delivered late, is not taken for
|
|
385
|
+
* this one. */
|
|
386
|
+
export type SealedScreenSnapshot = {
|
|
387
|
+
requestId: string;
|
|
388
|
+
sessionName: string;
|
|
389
|
+
nonce?: string;
|
|
390
|
+
capturedAt: string;
|
|
391
|
+
content: string;
|
|
392
|
+
truncated: boolean;
|
|
393
|
+
};
|
|
394
|
+
export declare const setSubscriberTrust: (trust: SubscriberTrust | null) => void;
|
|
395
|
+
/**
|
|
396
|
+
* Answer a phone's screen-peek request for one of OUR sessions.
|
|
397
|
+
* Returns null when the message isn't addressed to this machine (other
|
|
398
|
+
* ephemeral traffic — clipboard, mirrors — flows through constantly).
|
|
399
|
+
*
|
|
400
|
+
* Security posture: only sessions the inventory already exposes are
|
|
401
|
+
* readable — the phone can see exactly the panes it can already send
|
|
402
|
+
* commands into, nothing else. Rate-limited with the same per-session
|
|
403
|
+
* token bucket as command injection.
|
|
404
|
+
*
|
|
405
|
+
* A request carrying a key gets the pane sealed for that key, and only when
|
|
406
|
+
* the key checks out (subscriber-trust.ts); a request without one gets it in
|
|
407
|
+
* the clear only while this listener has never sealed. Either refusal answers
|
|
408
|
+
* with an error and no pane, decided before the pane is read.
|
|
409
|
+
*/
|
|
410
|
+
export declare const handleScreenRequest: (req: ScreenRequest) => Promise<ScreenSnapshot | null>;
|
|
411
|
+
/** What a diff request carries in the clear: the routing fields a relay needs,
|
|
412
|
+
* and the key the answer is sealed for. What it ASKS — which base, which file,
|
|
413
|
+
* which page — is inside `encrypted`, so the relay sees none of it. */
|
|
414
|
+
export interface DiffRequest {
|
|
415
|
+
subtype?: string;
|
|
416
|
+
targetDeviceId?: string;
|
|
417
|
+
sessionName?: string;
|
|
418
|
+
requestId?: string;
|
|
419
|
+
/** The phone's device public key: the answer is sealed for it once it checked out. */
|
|
420
|
+
subscriberPublicKey?: string;
|
|
421
|
+
/** The asking device, stamped by the relay: whose registered key the one above has to be. */
|
|
422
|
+
deviceId?: string;
|
|
423
|
+
/** The request, sealed by the phone for this listener (purpose `session.diff`). */
|
|
424
|
+
encrypted?: unknown;
|
|
425
|
+
}
|
|
426
|
+
/** D6: how much file content each session has read in the current window (blob text pages only). */
|
|
427
|
+
export declare const fileViewBytes: import("./file-view-budget.js").ByteBudget;
|
|
428
|
+
export declare const handleDiffFilesRequest: (req: DiffRequest, send: (data: Record<string, unknown>) => void) => boolean;
|
|
429
|
+
export declare const handleDiffFileRequest: (req: DiffRequest, send: (data: Record<string, unknown>) => void) => boolean;
|
|
430
|
+
export declare const handleFileTreeRequest: (req: DiffRequest, send: (data: Record<string, unknown>) => void) => boolean;
|
|
431
|
+
export declare const handleFileBlobRequest: (req: DiffRequest, send: (data: Record<string, unknown>) => void) => boolean;
|
|
432
|
+
/** What a list request carries: routing and the key the answer is sealed for. It asks for nothing else, so there is nothing to seal. */
|
|
433
|
+
export interface ScheduleListRequest {
|
|
434
|
+
subtype?: string;
|
|
435
|
+
targetDeviceId?: string;
|
|
436
|
+
requestId?: string;
|
|
437
|
+
subscriberPublicKey?: string;
|
|
438
|
+
/** The asking device, stamped by the relay: whose registered key the one above has to be. */
|
|
439
|
+
deviceId?: string;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Answer a phone's request for the schedules on this machine (F8). The list holds prompts, so it
|
|
443
|
+
* follows the screen's rule: a request with a key gets it sealed once the key checks out (the seal
|
|
444
|
+
* is bound to this machine's device id), one without gets it in the clear only while this listener
|
|
445
|
+
* has never sealed, and any other outcome is an error and no schedule. A request that asked for a
|
|
446
|
+
* seal is never answered in the clear. Directories and agent arguments are not in the list at all.
|
|
447
|
+
*/
|
|
448
|
+
export declare const handleScheduleListRequest: (req: ScheduleListRequest, send: (data: Record<string, unknown>) => void) => boolean;
|
|
449
|
+
export interface SessionResumeRequest {
|
|
450
|
+
subtype?: string;
|
|
451
|
+
targetDeviceId?: string;
|
|
452
|
+
sessionName?: string;
|
|
453
|
+
/**
|
|
454
|
+
* Which run of that name to start, when the slot has held more than one
|
|
455
|
+
* agent. Not authority the phone gains: it selects among runs THIS machine
|
|
456
|
+
* recorded, and a kind that matches nothing falls back to the newest run —
|
|
457
|
+
* the same answer a phone too old to send it gets.
|
|
458
|
+
*/
|
|
459
|
+
agentKind?: string;
|
|
460
|
+
requestId?: string;
|
|
461
|
+
}
|
|
462
|
+
export interface SessionResumeResult {
|
|
463
|
+
subtype: 'agent.session.resume.result';
|
|
464
|
+
requestId: string;
|
|
465
|
+
sessionName: string;
|
|
466
|
+
resumed?: true;
|
|
467
|
+
error?: string;
|
|
468
|
+
at: string;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Start a remembered session again, detached, and tell the phone what happened.
|
|
472
|
+
* Returns true when the message was ours to answer.
|
|
473
|
+
*
|
|
474
|
+
* The refusals are the design. Unknown name: not in the registry, so there is
|
|
475
|
+
* nothing to start and nothing to guess from. Missing directory: the project
|
|
476
|
+
* moved or was deleted, and starting an agent in the wrong place is worse than
|
|
477
|
+
* not starting one. Unknown agent kind: the record names an agent this build
|
|
478
|
+
* has no binary for — a wire enum outlives installs, and a guess here would be
|
|
479
|
+
* a command nobody chose. Already running: the session is live and the phone
|
|
480
|
+
* should be watching it, not replacing it.
|
|
481
|
+
*/
|
|
482
|
+
export declare const handleSessionResumeRequest: (req: SessionResumeRequest, send: (data: Record<string, unknown>) => void) => boolean;
|
|
483
|
+
export interface SessionExitRequest {
|
|
484
|
+
subtype?: string;
|
|
485
|
+
targetDeviceId?: string;
|
|
486
|
+
sessionName?: string;
|
|
487
|
+
requestId?: string;
|
|
488
|
+
}
|
|
489
|
+
export interface SessionExitResult {
|
|
490
|
+
subtype: 'agent.session.exit.result';
|
|
491
|
+
requestId: string;
|
|
492
|
+
sessionName: string;
|
|
493
|
+
exited?: true;
|
|
494
|
+
error?: string;
|
|
495
|
+
at: string;
|
|
496
|
+
}
|
|
497
|
+
/** Time given to each signal before the next one is tried. */
|
|
498
|
+
export declare const SESSION_EXIT_STEP_MS = 1500;
|
|
499
|
+
/**
|
|
500
|
+
* Ask a session to end, and report whether it did.
|
|
501
|
+
*
|
|
502
|
+
* Answers late on purpose: the whole point is to let the agent wind itself
|
|
503
|
+
* down, so the reply has to wait long enough to know whether it did. A session
|
|
504
|
+
* that is still there when the signals run out is reported as still there
|
|
505
|
+
* rather than forced — "I asked and it refused" is a true answer, and a phone
|
|
506
|
+
* that can silently destroy a working agent is a worse thing to have built.
|
|
507
|
+
*/
|
|
508
|
+
export declare const handleSessionExitRequest: (req: SessionExitRequest, send: (data: Record<string, unknown>) => void) => boolean;
|
|
509
|
+
export interface SessionForgetRequest {
|
|
510
|
+
subtype?: string;
|
|
511
|
+
targetDeviceId?: string;
|
|
512
|
+
sessionName?: string;
|
|
513
|
+
/** Which run of that name to delete. A name can hold one run per agent, and
|
|
514
|
+
* the phone deletes the row it is looking at. Absent (older phone) deletes
|
|
515
|
+
* every run under the name, which is what the request used to mean. */
|
|
516
|
+
agentKind?: string;
|
|
517
|
+
requestId?: string;
|
|
518
|
+
}
|
|
519
|
+
export interface SessionForgetResult {
|
|
520
|
+
subtype: 'agent.session.forget.result';
|
|
521
|
+
requestId: string;
|
|
522
|
+
sessionName: string;
|
|
523
|
+
forgotten?: true;
|
|
524
|
+
/**
|
|
525
|
+
* Set when the record went but the chat scrollback file would not delete.
|
|
526
|
+
* The session is no longer resumable either way; this says the prompts and
|
|
527
|
+
* tool targets it collected are still on the machine, which a flat
|
|
528
|
+
* `forgotten: true` would have claimed otherwise.
|
|
529
|
+
*/
|
|
530
|
+
scrollbackKept?: true;
|
|
531
|
+
error?: string;
|
|
532
|
+
at: string;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Delete one ended session from this machine's record, and say what happened.
|
|
536
|
+
* Returns true when the message was ours to answer.
|
|
537
|
+
*
|
|
538
|
+
* The mirror of resume, and destructive where that one is constructive: it
|
|
539
|
+
* takes the entry out of the registry, which is the same file resume reads its
|
|
540
|
+
* whitelist from. After this the session is neither offered nor startable, and
|
|
541
|
+
* nothing puts it back — `rememberSessions` only writes down sessions that are
|
|
542
|
+
* running, so only actually running that name again restores it.
|
|
543
|
+
*
|
|
544
|
+
* The refusals: unknown name is nothing to delete rather than something to
|
|
545
|
+
* guess at. Still running means it is not a past session at all — the phone is
|
|
546
|
+
* looking at a live one, and ending it is a different request that this must
|
|
547
|
+
* not quietly stand in for.
|
|
548
|
+
*/
|
|
549
|
+
export declare const handleSessionForgetRequest: (req: SessionForgetRequest, send: (data: Record<string, unknown>) => void) => boolean;
|
|
550
|
+
/** Ceiling on one page. A page past the frame limit arrives truncated anyway,
|
|
551
|
+
* so let the caller ask for less rather than pay for a capture we discard. */
|
|
552
|
+
export declare const SCREEN_HISTORY_MAX_LINES = 200;
|
|
553
|
+
export interface ScreenHistoryRequest {
|
|
554
|
+
subtype?: string;
|
|
555
|
+
targetDeviceId?: string;
|
|
556
|
+
/** The requesting device, stamped by the relay: which of the session's viewers is asking. */
|
|
557
|
+
deviceId?: string;
|
|
558
|
+
sessionName?: string;
|
|
559
|
+
requestId?: string;
|
|
560
|
+
/**
|
|
561
|
+
* How many lines of scrollback the caller already holds above the visible
|
|
562
|
+
* pane — the `historyLines` of its newest frame plus every history line it
|
|
563
|
+
* has pulled since. 0 asks for the page directly above the visible pane.
|
|
564
|
+
*
|
|
565
|
+
* The caller says this rather than the daemon assuming STREAM_CAPTURE_LINES,
|
|
566
|
+
* because the byte cap trims frames from the top and a caller holding fewer
|
|
567
|
+
* lines than the capture asked for would otherwise be handed a page that
|
|
568
|
+
* starts above its own content, with the gap unreachable forever after.
|
|
569
|
+
* Absent falls back to the constant — the untruncated case.
|
|
570
|
+
*/
|
|
571
|
+
before?: number;
|
|
572
|
+
/** Page size. Clamped to SCREEN_HISTORY_MAX_LINES. */
|
|
573
|
+
lines?: number;
|
|
574
|
+
}
|
|
575
|
+
export interface ScreenHistorySnapshot {
|
|
576
|
+
subtype: 'agent.screen.history.snapshot';
|
|
577
|
+
requestId: string;
|
|
578
|
+
sessionName: string;
|
|
579
|
+
content?: string;
|
|
580
|
+
/** Echo of the request's offset, so a caller with several pages in flight
|
|
581
|
+
* can tell which one this is. */
|
|
582
|
+
before?: number;
|
|
583
|
+
/** Lines actually returned — what the caller adds to `before` for the next
|
|
584
|
+
* page. Smaller than asked when the payload cap trimmed the page. */
|
|
585
|
+
lines?: number;
|
|
586
|
+
/** Whether older lines still exist above this page. */
|
|
587
|
+
hasMore?: boolean;
|
|
588
|
+
truncated?: boolean;
|
|
589
|
+
/** The page sealed for the stream's subscriber (envelope v2
|
|
590
|
+
* `screen.history`, bound to the session) — present INSTEAD of `content`
|
|
591
|
+
* whenever the stream it belongs to is encrypted. */
|
|
592
|
+
encrypted?: SealedV2;
|
|
593
|
+
error?: string;
|
|
594
|
+
capturedAt: string;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Answer a phone's request for one page of scrollback above the live window.
|
|
598
|
+
* Returns true when the message was ours to answer, so the caller stops routing
|
|
599
|
+
* it; the reply goes out through `send`, after the seal when there is one.
|
|
600
|
+
*
|
|
601
|
+
* Same security posture as the one-shot peek it sits beside — addressed to this
|
|
602
|
+
* machine, only for sessions the inventory already exposes, charged to the same
|
|
603
|
+
* per-session token bucket, and reading the pane through tmux and nothing else —
|
|
604
|
+
* plus one the peek does not have: it answers ONLY under a live stream lease.
|
|
605
|
+
* That is what makes E2EE possible here. History is the same pane text the
|
|
606
|
+
* frames carry, so on an encrypted stream it is sealed for the same subscriber
|
|
607
|
+
* key and never falls back to plaintext; without a lease there is no key to
|
|
608
|
+
* seal for, and a pull that answered anyway would be the downgrade the stream
|
|
609
|
+
* half refuses.
|
|
610
|
+
*/
|
|
611
|
+
export declare const handleScreenHistoryRequest: (req: ScreenHistoryRequest, send: (data: Record<string, unknown>) => void) => boolean;
|
|
612
|
+
/**
|
|
613
|
+
* One stream tick's read of the pane: the capture and where the cursor sits,
|
|
614
|
+
* in ONE tmux spawn (C18 — the tick runs every 120–400 ms).
|
|
615
|
+
*
|
|
616
|
+
* `capture-pane` gives cells and colors but no cursor, so the mirror has never
|
|
617
|
+
* drawn one — which makes an arrow key indistinguishable from a dropped one.
|
|
618
|
+
* tmux reports the position separately; `pane_height` is what turns a
|
|
619
|
+
* pane-relative row into a row of the captured text, since the capture reaches
|
|
620
|
+
* back into scrollback and the visible pane is only its last `pane_height`
|
|
621
|
+
* lines. `history_size` tells the viewer how deep the buffer under that window
|
|
622
|
+
* is. `display-message ; capture-pane` runs the two as consecutive commands in
|
|
623
|
+
* one client: the first output line is the geometry, the rest is the capture.
|
|
624
|
+
*/
|
|
625
|
+
export declare const STREAM_GEOMETRY_FORMAT = "#{cursor_x},#{cursor_y},#{pane_height},#{history_size}";
|
|
626
|
+
export interface PaneGeometry {
|
|
627
|
+
x: number;
|
|
628
|
+
y: number;
|
|
629
|
+
height: number;
|
|
630
|
+
/** Lines of scrollback the pane holds above the visible rows. Tells the
|
|
631
|
+
* viewer where this frame's window sits in the buffer, which is what lets
|
|
632
|
+
* it record the lines that scroll past without matching text. Absent when
|
|
633
|
+
* tmux did not answer with a usable number. */
|
|
634
|
+
historySize?: number;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Read the one tmux answer the frame path already asks for per tick.
|
|
638
|
+
*
|
|
639
|
+
* The depth is parsed separately from the cursor on purpose: a tmux that does
|
|
640
|
+
* not report `history_size` still reports the cursor, and refusing the whole
|
|
641
|
+
* answer over the missing field would cost every frame its cursor — a
|
|
642
|
+
* regression in exchange for a feature.
|
|
643
|
+
*/
|
|
644
|
+
export declare const parsePaneGeometry: (raw: string) => PaneGeometry | null;
|
|
645
|
+
/**
|
|
646
|
+
* Translate the cursor into an index into the lines the frame carries.
|
|
647
|
+
*
|
|
648
|
+
* The visible pane is the tail of the capture, so the cursor's row counts back
|
|
649
|
+
* from the end — which also makes this correct after the byte cap has cut lines
|
|
650
|
+
* off the top. A cursor that falls outside what the frame carries returns null
|
|
651
|
+
* and simply is not drawn.
|
|
652
|
+
*/
|
|
653
|
+
export declare const cursorLineFor: (content: string, cursor: {
|
|
654
|
+
x: number;
|
|
655
|
+
y: number;
|
|
656
|
+
height: number;
|
|
657
|
+
}) => {
|
|
658
|
+
line: number;
|
|
659
|
+
col: number;
|
|
660
|
+
} | null;
|
|
661
|
+
/**
|
|
662
|
+
* How many of a frame's lines are scrollback rather than the visible pane.
|
|
663
|
+
*
|
|
664
|
+
* This is the number the viewer hands back as `before` when it asks for
|
|
665
|
+
* history. STREAM_CAPTURE_LINES is only what the capture ASKED for: the byte
|
|
666
|
+
* cap trims from the top, and a coloured TUI pane hits that cap routinely, so
|
|
667
|
+
* assuming the viewer holds the full window would start the first history page
|
|
668
|
+
* above what it actually has — leaving a gap no later page can reach, since
|
|
669
|
+
* every later page walks further up.
|
|
670
|
+
*/
|
|
671
|
+
export declare const historyLinesIn: (content: string, paneHeight: number) => number;
|
|
672
|
+
/**
|
|
673
|
+
* After the phone injects keys, push fresh frames instead of waiting for its
|
|
674
|
+
* next screen request — the phone's post-tap re-pull raced the key landing
|
|
675
|
+
* (its timer starts at tap time, the key lands a REST+WS hop later), so it
|
|
676
|
+
* often re-captured the pre-keypress screen and the next update was a full
|
|
677
|
+
* auto-refresh period away. Unsolicited frames carry no requestId; the phone
|
|
678
|
+
* matches them by deviceId + sessionName. A newer tap cancels pending
|
|
679
|
+
* captures so a burst of arrow presses yields frames after the LAST press.
|
|
680
|
+
*/
|
|
681
|
+
export declare const scheduleInjectSnapshots: (sessionName: string, send: (data: Record<string, unknown>) => void, delays?: number[]) => void;
|
|
682
|
+
export interface StreamControl {
|
|
683
|
+
subtype?: string;
|
|
684
|
+
targetDeviceId?: string;
|
|
685
|
+
sessionName?: string;
|
|
686
|
+
/** Subscriber's device public key (Base64 SPKI) — presence turns on E2EE. */
|
|
687
|
+
subscriberPublicKey?: string;
|
|
688
|
+
/** The subscribing device, stamped by the relay: whose registered key the
|
|
689
|
+
* one above has to be before a frame is sealed for it. */
|
|
690
|
+
deviceId?: string;
|
|
691
|
+
/**
|
|
692
|
+
* Subscriber's nonce for this subscription, sealed into every frame with
|
|
693
|
+
* the session and the capture stamp. The viewer checks all of them, so a
|
|
694
|
+
* relay cannot move a sealed frame to another stream or replay one from an
|
|
695
|
+
* earlier subscription. Ignored unless a short string (MAX_NONCE_LENGTH).
|
|
696
|
+
*/
|
|
697
|
+
subscriberNonce?: string;
|
|
698
|
+
/**
|
|
699
|
+
* On a renew: the viewer's fresh random word, sealed back in the answer
|
|
700
|
+
* with the last frame sent (`agent.stream.renew.ok` `encrypted`). A relay
|
|
701
|
+
* cannot make that answer, or replay an old one, so a "live" badge kept
|
|
702
|
+
* up by renews says the listener is there — and its `seq` says whether
|
|
703
|
+
* frames were dropped on the way. Ignored unless a short string.
|
|
704
|
+
*/
|
|
705
|
+
challenge?: unknown;
|
|
706
|
+
/**
|
|
707
|
+
* Subscriber promises to send `agent.stream.renew` while it's watching.
|
|
708
|
+
* Only such a subscriber gets the short lease; clients that predate the
|
|
709
|
+
* renew protocol keep the 5-minute orphan guard (see STREAM_LEASE_MS).
|
|
710
|
+
*/
|
|
711
|
+
renew?: boolean;
|
|
712
|
+
/**
|
|
713
|
+
* How many terminal columns the viewer shows (on start and every renew).
|
|
714
|
+
* A detached, unsplit window is resized to it while watched — see
|
|
715
|
+
* pane-fit.ts. Absent from viewers that predate it: nothing is resized.
|
|
716
|
+
*/
|
|
717
|
+
cols?: number;
|
|
718
|
+
}
|
|
719
|
+
export declare const STREAM_CAPTURE_LINES = 200;
|
|
720
|
+
export declare const STREAM_INTERVAL_MS = 400;
|
|
721
|
+
export declare const BURST_INTERVAL_MS = 120;
|
|
722
|
+
export declare const BURST_WINDOW_MS = 2500;
|
|
723
|
+
export declare const MAX_FRAMES_PER_SEC = 8;
|
|
724
|
+
/**
|
|
725
|
+
* Delay before the next capture: burst while the last input is still echoing,
|
|
726
|
+
* idle otherwise. Exclusive at the boundary — exactly BURST_WINDOW_MS after
|
|
727
|
+
* the input is already idle, so the window can't stretch. A lastInputAt in the
|
|
728
|
+
* future (wall-clock jump) reads as fresh input rather than as an expired
|
|
729
|
+
* window: bursting is the recoverable side of that.
|
|
730
|
+
*/
|
|
731
|
+
export declare const streamCadence: (lastInputAt: number | null, now: number) => number;
|
|
732
|
+
/** Rolling one-second send budget for a single stream. */
|
|
733
|
+
export interface FrameBudget {
|
|
734
|
+
windowStartedAt: number;
|
|
735
|
+
sent: number;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Claim one send from the budget, rolling the window over when the second has
|
|
739
|
+
* passed. False means this tick must skip WITHOUT marking the frame as sent —
|
|
740
|
+
* the diff-gate would otherwise swallow that content for good.
|
|
741
|
+
*/
|
|
742
|
+
/** Is there budget left this second? A peek, so a tick can decline to pay for a
|
|
743
|
+
* blocking capture it could not send anyway — spending the token here instead
|
|
744
|
+
* would burn budget on ticks the diff-gate goes on to skip. */
|
|
745
|
+
export declare const hasFrameBudget: (budget: FrameBudget, now: number) => boolean;
|
|
746
|
+
export declare const claimFrameSend: (budget: FrameBudget, now: number) => boolean;
|
|
747
|
+
export declare const STREAM_LEASE_MS = 15000;
|
|
748
|
+
export declare const STREAM_RENEW_FRESH_MS = 8000;
|
|
749
|
+
export declare const MAX_CONCURRENT_STREAMS = 3;
|
|
750
|
+
/**
|
|
751
|
+
* The transcript a live chat watch should follow for one session name.
|
|
752
|
+
*
|
|
753
|
+
* Exported because the two branches here are where a subagent watch lives or
|
|
754
|
+
* dies, and a test that only reached the module's maps would pass while this
|
|
755
|
+
* asked tmux about a name tmux cannot parse.
|
|
756
|
+
*/
|
|
757
|
+
export declare const resolveWatchTranscript: (sessionName: string, onMiss?: (reason: string) => void) => TranscriptSource | null;
|
|
758
|
+
/** One chat-timeline batch, sealed for the watcher and bound to its session. */
|
|
759
|
+
export declare const sealTurnBatch: (plaintext: string, subscriberPublicKey: string, sessionName: string) => Promise<SealedV2>;
|
|
760
|
+
/**
|
|
761
|
+
* Route a chat-timeline control message. A start is a request for the
|
|
762
|
+
* transcript, so it passes the same door as every other request for content
|
|
763
|
+
* (subscriber-trust.ts): one that asks for sealed batches waits for its key to
|
|
764
|
+
* check out, like a stream start, and one without a key is refused once this
|
|
765
|
+
* listener only seals. Everything else goes straight to the watchers.
|
|
766
|
+
*/
|
|
767
|
+
export declare const handleTurnWatch: (req: TurnWatchControl & {
|
|
768
|
+
deviceId?: string;
|
|
769
|
+
}, send: SendTurnFrame) => boolean;
|
|
770
|
+
/**
|
|
771
|
+
* Stop one viewer's stream: the (session, device) slot. The session's pane is handed back (width,
|
|
772
|
+
* input counters) only when it was the last viewer.
|
|
773
|
+
*
|
|
774
|
+
* `keepFit` is for the restart inside a start: the same viewer is subscribing
|
|
775
|
+
* again, and handing the window back only to shrink it a moment later would
|
|
776
|
+
* make the program redraw twice.
|
|
777
|
+
*/
|
|
778
|
+
export declare const stopStreamOf: (sessionName: string, deviceId: string | undefined, { keepFit }?: {
|
|
779
|
+
keepFit?: boolean;
|
|
780
|
+
}) => void;
|
|
781
|
+
/** Stop every viewer of a session. */
|
|
782
|
+
export declare const stopStream: (sessionName: string, opts?: {
|
|
783
|
+
keepFit?: boolean;
|
|
784
|
+
}) => void;
|
|
785
|
+
export declare const stopAllStreams: () => void;
|
|
786
|
+
/** Wire shape of one live-mirror data frame (before the relay's ephemeral wrap). */
|
|
787
|
+
export type StreamFramePayload = {
|
|
788
|
+
subtype: 'agent.stream.frame';
|
|
789
|
+
sessionName: string;
|
|
790
|
+
capturedAt: string;
|
|
791
|
+
truncated: boolean;
|
|
792
|
+
/** Capture-order stamp — attached at send time; a sealed frame gets it from
|
|
793
|
+
* buildStreamFrame too, and inside its envelope (SealedStreamFrame). */
|
|
794
|
+
seq?: number;
|
|
795
|
+
/** Stream incarnation (stats.startedAt) — lets the receiver reset its
|
|
796
|
+
* seq high-water mark when the daemon restarts the stream. */
|
|
797
|
+
epoch?: number;
|
|
798
|
+
/** Where to draw the cursor: a 0-based index into the lines this frame
|
|
799
|
+
* carries, and a 0-based CELL column (not a string index — a wide glyph
|
|
800
|
+
* spans two cells, which is what the viewer positions by). Absent when the
|
|
801
|
+
* cursor is outside the captured region. Kept in the clear for unsealed
|
|
802
|
+
* streams and routing; a sealed frame carries the same values inside its
|
|
803
|
+
* envelope (SealedStreamFrame), and a viewer of a sealed stream draws the
|
|
804
|
+
* cursor, like the lines, only from there — the relay can rewrite these. */
|
|
805
|
+
cursorLine?: number;
|
|
806
|
+
cursorCol?: number;
|
|
807
|
+
/** How many of this frame's lines are scrollback rather than the visible
|
|
808
|
+
* pane — what the viewer sends back as `before` when it pulls history.
|
|
809
|
+
* Fewer than STREAM_CAPTURE_LINES whenever the byte cap trimmed the frame,
|
|
810
|
+
* which is exactly the case a constant would get wrong. Absent when the
|
|
811
|
+
* pane geometry could not be read; the viewer then falls back to the
|
|
812
|
+
* constant. In the clear as the cursor is, and sealed the same way. */
|
|
813
|
+
historyLines?: number;
|
|
814
|
+
/** How much scrollback the PANE holds above its visible rows — the depth
|
|
815
|
+
* this frame's window sits in, where `historyLines` is only the slice of
|
|
816
|
+
* it the frame carries. Together they place the frame's top edge in the
|
|
817
|
+
* buffer, which is what lets a viewer record the lines that scroll past
|
|
818
|
+
* without matching text (a redrawing TUI defeats text matching), and lets
|
|
819
|
+
* it see the buffer being cleared as the depth dropping. Absent when tmux
|
|
820
|
+
* would not say. In the clear as the cursor is, and sealed the same way. */
|
|
821
|
+
historySize?: number;
|
|
822
|
+
/** Plaintext pane content — only on unencrypted streams. */
|
|
823
|
+
content?: string;
|
|
824
|
+
/** Envelope v2 (`stream.frame`, context = session) of a SealedStreamFrame —
|
|
825
|
+
* replaces `content` on encrypted streams. */
|
|
826
|
+
encrypted?: SealedV2;
|
|
827
|
+
};
|
|
828
|
+
/**
|
|
829
|
+
* What a sealed frame's envelope holds (as JSON): the pane with everything a
|
|
830
|
+
* viewer draws from it, and the stamps the relay also sees in the clear plus
|
|
831
|
+
* the subscriber's nonce. The cleartext fields stay for routing and ordering;
|
|
832
|
+
* a viewer of a sealed stream trusts only these. What it is (a frame, not
|
|
833
|
+
* input or a snapshot) is the envelope's purpose, part of its key.
|
|
834
|
+
*/
|
|
835
|
+
export type SealedStreamFrame = {
|
|
836
|
+
sessionName: string;
|
|
837
|
+
seq?: number;
|
|
838
|
+
epoch?: number;
|
|
839
|
+
nonce?: string;
|
|
840
|
+
content: string;
|
|
841
|
+
truncated: boolean;
|
|
842
|
+
cursorLine?: number;
|
|
843
|
+
cursorCol?: number;
|
|
844
|
+
historyLines?: number;
|
|
845
|
+
historySize?: number;
|
|
846
|
+
};
|
|
847
|
+
/**
|
|
848
|
+
* Plaintext bytes of a sealed frame's JSON. Sealing base64-expands it by 4/3
|
|
849
|
+
* and the envelope and cleartext fields add about 1KB, so 20KB stays under API
|
|
850
|
+
* Gateway's 32KB WebSocket frame. Tighter than SCREEN_PEEK_MAX_BYTES because
|
|
851
|
+
* JSON escapes a `-e` capture's ESC bytes six-fold (`\u001b`).
|
|
852
|
+
*/
|
|
853
|
+
export declare const SEALED_FRAME_MAX_BYTES: number;
|
|
854
|
+
/**
|
|
855
|
+
* Trim a sealed frame from the TOP until its JSON fits SEALED_FRAME_MAX_BYTES,
|
|
856
|
+
* as capToFrameLimit does for the capture. The cursor line and the scrollback
|
|
857
|
+
* count both count from the frame's first line, so each drops by the lines cut.
|
|
858
|
+
*/
|
|
859
|
+
export declare const fitSealedFrame: (frame: SealedStreamFrame, maxBytes?: number) => SealedStreamFrame;
|
|
860
|
+
/** Wire shape of a live-mirror error frame — same subtype, no pane data.
|
|
861
|
+
* `input_rejected` refuses an ephemeral `agent.command.input`; it rides this
|
|
862
|
+
* frame so a viewer already handling stream errors can fall back to the REST
|
|
863
|
+
* push path without a second error channel. */
|
|
864
|
+
export type StreamErrorFrame = {
|
|
865
|
+
subtype: 'agent.stream.frame';
|
|
866
|
+
sessionName: string;
|
|
867
|
+
error: 'unknown_session' | 'e2ee_unavailable' | 'encrypt_failed' | 'stream_limit' | 'stream_gone' | 'input_rejected' | RefusalCode;
|
|
868
|
+
/** `input_rejected` only: the refused message's ordering stamp echoed back.
|
|
869
|
+
* A sender with several keystrokes in flight can't tell which one was
|
|
870
|
+
* refused without it. Absent when the message carried no usable stamp. */
|
|
871
|
+
seq?: number;
|
|
872
|
+
epoch?: number;
|
|
873
|
+
/** `input_rejected` only: the refused sender's deviceId, echoed so a second
|
|
874
|
+
* device whose (seq, epoch) happens to collide doesn't claim the refusal
|
|
875
|
+
* and fire a REST resend for an input that was never refused. */
|
|
876
|
+
inputDeviceId?: string;
|
|
877
|
+
/** `input_rejected` only, and only when one of this listener's checks refused
|
|
878
|
+
* the input (command-refusal.ts codes). Absent when the lease was simply
|
|
879
|
+
* gone or the message never got that far. A sealed sender never falls back to
|
|
880
|
+
* REST, so this is the only place it hears why. */
|
|
881
|
+
reason?: CommandRefusal;
|
|
882
|
+
};
|
|
883
|
+
/**
|
|
884
|
+
* Per-listener concurrency guard, as a pure predicate so the boundary is
|
|
885
|
+
* unit-testable without touching the module-scope `activeStreams` map.
|
|
886
|
+
*/
|
|
887
|
+
export declare const isStreamCapReached: (activeCount: number) => boolean;
|
|
888
|
+
/**
|
|
889
|
+
* Build the wire payload for one stream frame. With a subscriber public key
|
|
890
|
+
* the pane content rides ONLY inside the E2EE envelope; an encrypt failure
|
|
891
|
+
* (bad key, crypto not ready on the first tick) returns null so the caller
|
|
892
|
+
* drops the frame — an encrypted stream never leaks a plaintext frame.
|
|
893
|
+
*/
|
|
894
|
+
export declare const buildStreamFrame: (captured: {
|
|
895
|
+
content: string;
|
|
896
|
+
truncated: boolean;
|
|
897
|
+
}, sessionName: string, subscriberPublicKey?: string, meta?: {
|
|
898
|
+
cursor?: {
|
|
899
|
+
line: number;
|
|
900
|
+
col: number;
|
|
901
|
+
} | null;
|
|
902
|
+
historyLines?: number;
|
|
903
|
+
historySize?: number;
|
|
904
|
+
},
|
|
905
|
+
/** Capture stamp and subscription nonce, sealed with the pane. A sealed
|
|
906
|
+
* frame without it is dropped: the viewer could not check it. */
|
|
907
|
+
stamp?: {
|
|
908
|
+
seq: number;
|
|
909
|
+
epoch: number;
|
|
910
|
+
nonce?: string;
|
|
911
|
+
}) => Promise<StreamFramePayload | null>;
|
|
912
|
+
/**
|
|
913
|
+
* Handle agent.stream.start / agent.stream.stop. Returns true when the message
|
|
914
|
+
* was a stream-control message (so the caller skips the one-shot screen-peek
|
|
915
|
+
* path). start is idempotent — a repeat restarts the loop.
|
|
916
|
+
*/
|
|
917
|
+
export declare const handleStreamControl: (req: StreamControl, send: (data: Record<string, unknown>) => void) => boolean;
|
|
918
|
+
/** Wire shape of one inbound injection. Every field is optional, and none of
|
|
919
|
+
* them is proven: this shape is an unchecked cast over relay JSON, so
|
|
920
|
+
* validateInputMessage re-checks the types it acts on rather than trusting
|
|
921
|
+
* the declaration. */
|
|
922
|
+
export interface AgentCommandInput {
|
|
923
|
+
subtype?: string;
|
|
924
|
+
targetDeviceId?: string;
|
|
925
|
+
sessionName?: string;
|
|
926
|
+
/** Which device's seq/epoch run this is. Current relays stamp it from the
|
|
927
|
+
* sending connection unconditionally; relays older than that overwrite
|
|
928
|
+
* only a missing value, so a sender there can claim another device's id.
|
|
929
|
+
* Treat it as an ordering namespace, never as authentication — the worst
|
|
930
|
+
* a forged id buys is a different reorder lane for its own keystrokes. */
|
|
931
|
+
deviceId?: string;
|
|
932
|
+
/** Named keys, ALLOWED_KEYS-validated. Mutually exclusive with `body`/`insert`. */
|
|
933
|
+
keys?: string[];
|
|
934
|
+
/** Literal text; the injector appends the Enter, as on the REST path. */
|
|
935
|
+
body?: string;
|
|
936
|
+
/**
|
|
937
|
+
* Literal text delivered WITHOUT the submitting Enter — the phone
|
|
938
|
+
* answering a y/n prompt, or typing half a command so a TUI menu opens.
|
|
939
|
+
*
|
|
940
|
+
* A separate field rather than a `submit: false` flag on `body`, and that
|
|
941
|
+
* is the whole point: a daemon too old to know this field sees no `body`
|
|
942
|
+
* either, so it injects nothing. The flag spelling would have had the old
|
|
943
|
+
* daemon submit text the user never meant to send.
|
|
944
|
+
*/
|
|
945
|
+
insert?: string;
|
|
946
|
+
/** Sender's monotonic counter, and the incarnation it counts within. */
|
|
947
|
+
seq?: number;
|
|
948
|
+
epoch?: number;
|
|
949
|
+
/** Type this only while the pane still shows the blocked prompt with this
|
|
950
|
+
* id (`blockedPrompt.id` in the session report); otherwise refuse with
|
|
951
|
+
* `prompt_changed`. A sealed message reads it from inside the envelope: this
|
|
952
|
+
* plaintext field is not looked at beside one. */
|
|
953
|
+
ifPrompt?: string;
|
|
954
|
+
/** E2EE envelope sealing `{ keys | body }` plus a copy of the
|
|
955
|
+
* `sessionName`/`seq`/`epoch` stamp as JSON — the only way in on an
|
|
956
|
+
* encrypted stream, where `keys`/`body` above are refused. Present means
|
|
957
|
+
* the plaintext pair is ignored entirely: on a message the relay can
|
|
958
|
+
* append to, only the ciphertext decides what gets typed, and the sealed
|
|
959
|
+
* stamp is what proves the plaintext one was not re-written for a replay. */
|
|
960
|
+
encrypted?: unknown;
|
|
961
|
+
}
|
|
962
|
+
/** Parity with the REST path, which refuses more than MAX_KEYS_PER_COMMAND per
|
|
963
|
+
* agent.command (hwip apps/server/src/functions/pushes.ts). The lower-latency
|
|
964
|
+
* door into the same pane must not also be the wider one. */
|
|
965
|
+
export declare const MAX_INPUT_KEYS = 10;
|
|
966
|
+
/** The REST path caps no body length, so this bound is ours alone: an
|
|
967
|
+
* unbounded body is one tmux send-keys argv of unbounded size, and what it
|
|
968
|
+
* lands in is an agent prompt, not a paste buffer. */
|
|
969
|
+
export declare const MAX_INPUT_BODY_CHARS = 4096;
|
|
970
|
+
/** The relay socket a message arrived on, and the only way back to its sender. */
|
|
971
|
+
type SendEphemeral = (data: Record<string, unknown>) => void;
|
|
972
|
+
/** One validated injection. */
|
|
973
|
+
type ValidatedInput = SequencedInput & {
|
|
974
|
+
sessionName: string;
|
|
975
|
+
/** The blocked prompt this input answers; checked against a fresh capture at delivery. */
|
|
976
|
+
ifPrompt?: string;
|
|
977
|
+
/** Sender's device, when the relay stamped one — rides along so a refusal
|
|
978
|
+
* frame can name whose input was refused (inputDeviceId). */
|
|
979
|
+
deviceId?: string;
|
|
980
|
+
/** Exactly one of these is set. */
|
|
981
|
+
tokens: string[] | null;
|
|
982
|
+
text: string | null;
|
|
983
|
+
/** Whether `text` carries its own submit. False for `insert` (and, vacuously,
|
|
984
|
+
* for a key batch — the keys say for themselves what they are). */
|
|
985
|
+
submit: boolean;
|
|
986
|
+
};
|
|
987
|
+
type InputCheck = {
|
|
988
|
+
ok: true;
|
|
989
|
+
input: ValidatedInput;
|
|
990
|
+
} | {
|
|
991
|
+
ok: false;
|
|
992
|
+
reason: string;
|
|
993
|
+
};
|
|
994
|
+
/**
|
|
995
|
+
* Parse and whitelist an inbound input message. Pure — the lease gate and the
|
|
996
|
+
* tmux-side guards stay in the caller, so this is testable on its own.
|
|
997
|
+
*/
|
|
998
|
+
export declare const validateInputMessage: (msg: AgentCommandInput) => InputCheck;
|
|
999
|
+
/** Ceiling on concurrent sender lanes. The lane key includes a relay-stamped
|
|
1000
|
+
* deviceId, but a relay older than the unconditional stamp lets a sender vary
|
|
1001
|
+
* it per message — without a cap that grows the map for the lease's whole
|
|
1002
|
+
* life. Real senders are one per device; 16 is generous. */
|
|
1003
|
+
export declare const MAX_INPUT_LANES = 16;
|
|
1004
|
+
/** Ceiling on queued decrypts. The subscriber-key binding is a string compare
|
|
1005
|
+
* against a public key the relay fanned out to every same-account connection,
|
|
1006
|
+
* so it gates WHO can enqueue an ECDH derive, not how many — a flooding
|
|
1007
|
+
* client would otherwise grow the chain without bound. Mirrors
|
|
1008
|
+
* MAX_PENDING_INPUTS' role on the plaintext side. */
|
|
1009
|
+
export declare const MAX_PENDING_DECRYPTS = 32;
|
|
1010
|
+
/** Resolves once every encrypted input accepted so far has been routed. */
|
|
1011
|
+
export declare const pendingInputDecrypts: () => Promise<void>;
|
|
1012
|
+
/**
|
|
1013
|
+
* Handle agent.command.input. Returns true when the message was ours to
|
|
1014
|
+
* answer, so the caller stops routing it. Encrypted messages finish
|
|
1015
|
+
* asynchronously — the return value reports routing, not delivery, exactly as
|
|
1016
|
+
* it already did for a message the sequencer holds.
|
|
1017
|
+
*/
|
|
1018
|
+
export declare const handleCommandInput: (msg: AgentCommandInput, send: SendEphemeral) => boolean;
|
|
1019
|
+
export interface CollectResult {
|
|
1020
|
+
sessions: AgentSession[];
|
|
1021
|
+
/** Diagnostic notes per rejected session — surfaced under `--verbose`. */
|
|
1022
|
+
rejected: Array<{
|
|
1023
|
+
name: string;
|
|
1024
|
+
reason: string;
|
|
1025
|
+
}>;
|
|
1026
|
+
/** Local knowledge, NOT on the wire: every live session/subagent name →
|
|
1027
|
+
* the pane id every `-t` call for it must use (`tmuxTargetFor`). The main
|
|
1028
|
+
* thread stores it beside the snapshot (`recordTargets`). */
|
|
1029
|
+
targets: Record<string, string>;
|
|
1030
|
+
/** Also local: in-process subagent name → its transcript file. The sweep may
|
|
1031
|
+
* run on a worker, so a map built there has to travel back like `targets`
|
|
1032
|
+
* rather than live in module state the main thread cannot see. */
|
|
1033
|
+
subagentTranscripts: Record<string, string>;
|
|
1034
|
+
/** Also local, for the always-on recorder (`turn-recorder.ts`): main session
|
|
1035
|
+
* name → the transcript it writes and the agent kind whose projector reads
|
|
1036
|
+
* it. Only sessions this machine can actually read; no subagent rows. */
|
|
1037
|
+
recordable: Record<string, RecordableSession>;
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Inventory pass that also records *why* each `hwip-*` session was
|
|
1041
|
+
* skipped. The verbose log uses the rejection notes to explain empty
|
|
1042
|
+
* pickers (most common cause: tmux pane lost its start_command after a
|
|
1043
|
+
* re-attach, and the current command is `node` rather than `claude`).
|
|
1044
|
+
*/
|
|
1045
|
+
export declare const collectSessionsVerbose: () => CollectResult;
|
|
1046
|
+
export declare const recordInventory: (sessions: ReadonlyArray<{
|
|
1047
|
+
name: string;
|
|
1048
|
+
}> | null) => void;
|
|
1049
|
+
export declare const syncTmuxAgentNames: (sessions: ReadonlyArray<AgentSession>) => void;
|
|
1050
|
+
/**
|
|
1051
|
+
* Is this session in the inventory? Answered from the last sweep when it can
|
|
1052
|
+
* be: a full sweep is two tmux spawns per session on this thread, and it used
|
|
1053
|
+
* to run again on every stream start and screen request (12% of daemon wall
|
|
1054
|
+
* time, measured 2026-08-26). A miss still sweeps, so a session
|
|
1055
|
+
* younger than one poll interval is not refused, and the inventory filter stays
|
|
1056
|
+
* the security boundary: only hwip-* panes running an agent are reachable.
|
|
1057
|
+
*/
|
|
1058
|
+
export declare const isInventoried: (sessionName: string) => boolean;
|
|
1059
|
+
/**
|
|
1060
|
+
* One file riding on a push — `libs/shared` `PushFileAttachment`, the
|
|
1061
|
+
* fields this daemon reads.
|
|
1062
|
+
*
|
|
1063
|
+
* `agent.command` attachments are uploaded in *plaintext* (the phone has no
|
|
1064
|
+
* key for this host's scratch space); an encrypted one is skipped there. A
|
|
1065
|
+
* `type: 'file'` push is the opposite: encrypted per device, and this
|
|
1066
|
+
* machine opens its own `deviceKeyMap` slot (ADR-0007). `lanDeliveredTo`
|
|
1067
|
+
* marks bytes that never went to S3 — handed straight to that device over
|
|
1068
|
+
* the LAN (ADR-0013) — so there is no `fileKey` to fetch.
|
|
1069
|
+
*/
|
|
1070
|
+
interface PushFileAttachment {
|
|
1071
|
+
fileKey?: string;
|
|
1072
|
+
fileName: string;
|
|
1073
|
+
fileType?: string;
|
|
1074
|
+
fileSize?: number;
|
|
1075
|
+
iv?: string;
|
|
1076
|
+
/** Legacy account-key wrap — nothing here can open it. */
|
|
1077
|
+
encryptedKey?: string;
|
|
1078
|
+
/** deviceId → JSON `{ encryptedKey, keyIv }` (per-device wrap). */
|
|
1079
|
+
deviceKeyMap?: Record<string, string>;
|
|
1080
|
+
lanDeliveredTo?: string;
|
|
1081
|
+
transferId?: string;
|
|
1082
|
+
}
|
|
1083
|
+
interface PushItem {
|
|
1084
|
+
pushId: string;
|
|
1085
|
+
type?: string;
|
|
1086
|
+
body?: string;
|
|
1087
|
+
title?: string;
|
|
1088
|
+
createdAt?: string;
|
|
1089
|
+
isEncrypted?: boolean;
|
|
1090
|
+
senderDeviceId?: string;
|
|
1091
|
+
targetDeviceId?: string;
|
|
1092
|
+
/** E2E: the sender's per-device public key, needed to open our slot. */
|
|
1093
|
+
senderPublicKey?: string;
|
|
1094
|
+
/** E2E: per-device wrapped message key for the push body. */
|
|
1095
|
+
deviceKeyMap?: Record<string, string>;
|
|
1096
|
+
/** Set when type='agent.command' — tmux session name to inject into. */
|
|
1097
|
+
agentSessionName?: string;
|
|
1098
|
+
/** Named keys (Esc/arrows/Enter) to inject instead of body text —
|
|
1099
|
+
* drives full-screen modals the phone can't escape with text. */
|
|
1100
|
+
keys?: string[];
|
|
1101
|
+
/** Literal text delivered WITHOUT the submitting Enter (see the same field
|
|
1102
|
+
* on AgentCommandInput for why it is a field and not a flag). */
|
|
1103
|
+
insert?: string;
|
|
1104
|
+
/** Type this only while the pane still shows the blocked prompt with this
|
|
1105
|
+
* id (`blockedPrompt.id`); otherwise refuse with `prompt_changed`. */
|
|
1106
|
+
ifPrompt?: string;
|
|
1107
|
+
/** Attachments: plaintext scratch files on `agent.command`, the delivered
|
|
1108
|
+
* file(s) on `type: 'file'`. */
|
|
1109
|
+
files?: PushFileAttachment[];
|
|
1110
|
+
/** A schedule request (F8): create or remove. On a sealed push the relay's copy is `{ op }` and the sealed
|
|
1111
|
+
* body holds the rest; `handlePushInner` swaps the sealed one in before anything reads it. */
|
|
1112
|
+
schedule?: unknown;
|
|
1113
|
+
}
|
|
1114
|
+
interface HandlePushDeps {
|
|
1115
|
+
paneProbe?: (session: string) => PaneProbe | null;
|
|
1116
|
+
/** The pane's current text, for the `ifPrompt` check; defaults to a tmux capture. */
|
|
1117
|
+
capturePane?: (session: string) => string | null;
|
|
1118
|
+
/** Audit sink for input admitted into a marked shell; defaults to the state-dir log. */
|
|
1119
|
+
shellAudit?: (entry: ShellAuditEntry) => boolean;
|
|
1120
|
+
inject?: (session: string, text: string) => boolean;
|
|
1121
|
+
/** Paste-only sibling of `inject` — text without the submitting Enter. */
|
|
1122
|
+
insertText?: (session: string, text: string) => boolean;
|
|
1123
|
+
sendKeys?: (session: string, tokens: string[]) => boolean;
|
|
1124
|
+
rateLimit?: (session: string, now?: number, cost?: number) => boolean;
|
|
1125
|
+
now?: () => number;
|
|
1126
|
+
/** Injectable for tests; defaults to the REST-backed downloader. */
|
|
1127
|
+
downloadAttachments?: (pushId: string, files: PushFileAttachment[], sealed?: SealedAttachments) => Promise<string[]>;
|
|
1128
|
+
/** `type: 'file'` delivery: fetch, decrypt and save one attachment into
|
|
1129
|
+
* `~/Downloads/Hwip/`; resolves with the path written, or null when the
|
|
1130
|
+
* file was not wrapped for this device. Defaults to the streaming S3
|
|
1131
|
+
* downloader. */
|
|
1132
|
+
/** `sealedSize`: the real size the sealed body gives (the bytes are padded
|
|
1133
|
+
* to it); absent for a file whose record is all there is. */
|
|
1134
|
+
saveFile?: (push: PushItem, file: PushFileAttachment, sealedSize?: number, chunk?: number) => Promise<string | null>;
|
|
1135
|
+
/** Move a finished local transfer out of the landing zone into
|
|
1136
|
+
* `~/Downloads/Hwip/`; null when it is not there. Defaults to the
|
|
1137
|
+
* filesystem move under `~/.hwip/attachments/lan`. */
|
|
1138
|
+
claimLanFile?: (file: PushFileAttachment, sealedSize?: number) => Promise<string | null>;
|
|
1139
|
+
/** Desktop banner after a save. Defaults to osascript / notify-send. */
|
|
1140
|
+
notify?: (note: DesktopNotification) => Promise<boolean>;
|
|
1141
|
+
/** This machine's device id — what `targetDeviceId` and `deviceKeyMap` are keyed by. */
|
|
1142
|
+
deviceId?: () => string;
|
|
1143
|
+
/** Pane cwd lookup for the remote-origin marker (ADR-0002). */
|
|
1144
|
+
paneCwd?: (session: string) => string | null;
|
|
1145
|
+
/** Fired after a successful named-key injection so the WS loop can push
|
|
1146
|
+
* fresh screen frames to the phone (scheduleInjectSnapshots). */
|
|
1147
|
+
onKeysInjected?: (session: string) => void;
|
|
1148
|
+
/** Why a command was refused — called at the refusal point, before the
|
|
1149
|
+
* `false` returns. The first call wins (see `handlePush`). */
|
|
1150
|
+
noteRefusal?: (reason: CommandRefusal) => void;
|
|
1151
|
+
/** An addressed command was refused: tell the phone (command-refusal.ts). */
|
|
1152
|
+
reportRefusal?: (refusal: {
|
|
1153
|
+
sessionName: string;
|
|
1154
|
+
pushId: string;
|
|
1155
|
+
reason: CommandRefusal;
|
|
1156
|
+
}) => void;
|
|
1157
|
+
/** What a schedule request reads and writes; defaults to this machine's registry and schedule files. */
|
|
1158
|
+
schedule?: PhoneScheduleDeps;
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Record a successful phone→pane text injection so a prompt-submit hook
|
|
1162
|
+
* (Claude Code plugin's hwip-remote.sh, or `hwip remote-hook` for
|
|
1163
|
+
* Gemini/Codex) can flag the matching prompt as remote-originated and
|
|
1164
|
+
* enter sticky REMOTE mode (ADR-0002). One file per project dir,
|
|
1165
|
+
* overwritten on every inject; the hook consumes it on an exact-text match.
|
|
1166
|
+
* Best-effort: a write failure must never fail the injection itself.
|
|
1167
|
+
*/
|
|
1168
|
+
export declare const writeRemoteMarker: (paneCwd: string, text: string, now?: () => number) => boolean;
|
|
1169
|
+
/**
|
|
1170
|
+
* Why text may not go into a marked shell, or null when it may. A shell runs
|
|
1171
|
+
* what it reads, so the rules the agent prompt never needed apply here:
|
|
1172
|
+
* attachment paths would arrive as lines of their own, each a command; ESC and
|
|
1173
|
+
* other control bytes, C1 included, can leave a bracketed paste or drive the
|
|
1174
|
+
* line editor;
|
|
1175
|
+
* and an insert must not carry a newline, which a shell without bracketed
|
|
1176
|
+
* paste (macOS `/bin/bash` 3.2, `dash`) would run.
|
|
1177
|
+
*/
|
|
1178
|
+
export declare const shellTextRefusal: (text: string, opts: {
|
|
1179
|
+
submit: boolean;
|
|
1180
|
+
files?: boolean;
|
|
1181
|
+
}) => string | null;
|
|
1182
|
+
export declare const setAttachmentContext: (ctx: {
|
|
1183
|
+
apiKey: string;
|
|
1184
|
+
baseUrl: string;
|
|
1185
|
+
}) => void;
|
|
1186
|
+
/** Test seam for the command-attachment downloads; production uses the defaults. */
|
|
1187
|
+
export interface AttachmentIo {
|
|
1188
|
+
fetchFn?: typeof fetch;
|
|
1189
|
+
timeoutMs?: number;
|
|
1190
|
+
}
|
|
1191
|
+
/** The attachments of a sealed command: opened with this device's slot of
|
|
1192
|
+
* each file, from the sender whose key checked out. */
|
|
1193
|
+
export interface SealedAttachments {
|
|
1194
|
+
deviceId: string;
|
|
1195
|
+
senderPublicKey: string;
|
|
1196
|
+
/** What the sealed body says each attachment is, in the same order. */
|
|
1197
|
+
files?: SealedFileMeta[];
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Download every attachment to `~/.hwip/attachments/<pushId>/<fileName>` and
|
|
1201
|
+
* return absolute paths. Files are kept (not deleted) so the agent can read
|
|
1202
|
+
* them after injection.
|
|
1203
|
+
*
|
|
1204
|
+
* A plaintext command's attachments are best effort: an encrypted file is
|
|
1205
|
+
* skipped (no key) and one file's failure is logged and skipped, so a
|
|
1206
|
+
* partial download still injects whatever succeeded. A sealed command's
|
|
1207
|
+
* (`sealed`) are all or nothing: each must open with this machine's slot,
|
|
1208
|
+
* and the first that does not throws — half a command is not typed.
|
|
1209
|
+
*/
|
|
1210
|
+
export declare const downloadAttachments: (pushId: string, files: PushFileAttachment[], ctx: {
|
|
1211
|
+
apiKey: string;
|
|
1212
|
+
baseUrl: string;
|
|
1213
|
+
}, sealed?: SealedAttachments, io?: AttachmentIo) => Promise<string[]>;
|
|
1214
|
+
export interface SaveFileDeps {
|
|
1215
|
+
ctx: {
|
|
1216
|
+
apiKey: string;
|
|
1217
|
+
baseUrl: string;
|
|
1218
|
+
} | null;
|
|
1219
|
+
fetchFn: typeof fetch;
|
|
1220
|
+
deviceId: () => string;
|
|
1221
|
+
downloadsDir: () => string;
|
|
1222
|
+
idleTimeoutMs?: number;
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Fetch one attachment and save it under `~/Downloads/Hwip/`, decrypting
|
|
1226
|
+
* with this device's `deviceKeyMap` slot when the file is encrypted.
|
|
1227
|
+
* Resolves with the path written; null when the file is encrypted for
|
|
1228
|
+
* other devices only (a broadcast this machine is not a recipient of —
|
|
1229
|
+
* not a failure); throws when the bytes cannot be fetched or opened.
|
|
1230
|
+
* Exported with its deps so the real path is testable without S3.
|
|
1231
|
+
*/
|
|
1232
|
+
export declare const saveFileFromS3: (push: PushItem, file: PushFileAttachment, deps: SaveFileDeps,
|
|
1233
|
+
/** The real size from the sealed body; the only size the bytes are cut back to. */
|
|
1234
|
+
sealedSize?: number,
|
|
1235
|
+
/** What the sealed body says of the chunks (`chunkOf`): absent for a file sealed whole. */
|
|
1236
|
+
chunk?: number) => Promise<string | null>;
|
|
1237
|
+
/**
|
|
1238
|
+
* Move a local-transfer file the receiver already decrypted into the
|
|
1239
|
+
* Downloads folder, now that its push record has arrived. `link` + unlink
|
|
1240
|
+
* keeps the never-overwrite rule (`resolveDownloadPath` picks a free name,
|
|
1241
|
+
* link refuses a taken one); a Downloads folder on another volume gets a
|
|
1242
|
+
* copy instead. Null when the transfer is not in the landing zone — already
|
|
1243
|
+
* claimed by a duplicated push.new, or swept as an orphan.
|
|
1244
|
+
*
|
|
1245
|
+
* The receiver wrote the bytes as they were sealed, padding included: with
|
|
1246
|
+
* `sealedSize` (the size the sealed body gives) the file is cut back to it
|
|
1247
|
+
* first, as a file from the relay is, so Downloads never holds the padded one.
|
|
1248
|
+
* That size was picked by the record's `iv`, and the record is the relay's
|
|
1249
|
+
* word: it cuts only a file that landed under that same iv (the receiver left
|
|
1250
|
+
* it beside the file), and a record that names another is refused whole. From
|
|
1251
|
+
* the relay the same check is the decrypt itself, which fails under a wrong iv.
|
|
1252
|
+
*/
|
|
1253
|
+
export declare const claimLanFile: (file: {
|
|
1254
|
+
fileName: string;
|
|
1255
|
+
transferId: string;
|
|
1256
|
+
sealedSize?: number;
|
|
1257
|
+
iv?: string;
|
|
1258
|
+
}, landingDir?: string, downloads?: string) => string | null;
|
|
1259
|
+
/**
|
|
1260
|
+
* The folder a saved file is in, as a person would type it: the home prefix
|
|
1261
|
+
* shown as `~`. The title already carries the name — the one the file was
|
|
1262
|
+
* actually saved under, ` (2)` included — so the body only has to say where.
|
|
1263
|
+
*/
|
|
1264
|
+
export declare const displayFolder: (dest: string, home?: string) => string;
|
|
1265
|
+
/**
|
|
1266
|
+
* Remove attachment sub-directories whose mtime is older than `ttl`.
|
|
1267
|
+
* Best-effort: an entry that can't be statted or removed is skipped, not
|
|
1268
|
+
* fatal. Returns the count removed. `dir`/`ttl` are injectable for tests.
|
|
1269
|
+
*/
|
|
1270
|
+
export declare const gcAttachments: (now?: number, dir?: string, ttl?: number, landingDir?: string) => number;
|
|
1271
|
+
export declare const handlePush: (push: PushItem, deps?: HandlePushDeps) => Promise<boolean>;
|
|
1272
|
+
export declare const computeBackoff: (attempt: number) => number;
|
|
1273
|
+
interface SessionResult {
|
|
1274
|
+
/** Resolved with the close code if the server closed cleanly. */
|
|
1275
|
+
closeCode: number | null;
|
|
1276
|
+
/** Resolved with reason text for logging. */
|
|
1277
|
+
reason: string;
|
|
1278
|
+
/** True if the socket ever reached `open` — resets reconnect backoff. */
|
|
1279
|
+
connected: boolean;
|
|
1280
|
+
}
|
|
1281
|
+
export declare const computeListenerDeviceId: (host?: string) => string;
|
|
1282
|
+
interface StreamHandle {
|
|
1283
|
+
done: Promise<SessionResult>;
|
|
1284
|
+
terminate: () => void;
|
|
1285
|
+
/** True once the socket reached `open` — the device record exists from then on. */
|
|
1286
|
+
opened: () => boolean;
|
|
1287
|
+
/** Send one frame; false when the socket is not open. Local transfer
|
|
1288
|
+
* publishes its endpoint and this machine's public key this way
|
|
1289
|
+
* (`listener.presence`) — the HTTP device route is JWT-only. */
|
|
1290
|
+
send: (msg: object) => boolean;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Open one WebSocket and stream messages until it closes. `done` resolves
|
|
1294
|
+
* when the connection is gone; the outer loop decides whether to reconnect.
|
|
1295
|
+
* `terminate` lets a signal handler force-close from outside (otherwise
|
|
1296
|
+
* SIGINT during an open WS would hang the loop until the server closed).
|
|
1297
|
+
*/
|
|
1298
|
+
export declare const streamSession: (wsUrl: string, apiKey: string) => StreamHandle;
|
|
1299
|
+
export { resolveWsUrl } from './ws-url.js';
|
|
1300
|
+
/**
|
|
1301
|
+
* What the listener starts with that comes from the flags, the environment and `config.json`:
|
|
1302
|
+
* the key, where it goes, and the opt-out options. A config.json that is there and cannot be used
|
|
1303
|
+
* reads as every option off (`unreadableConfigDefaults`), and the listener keeps running (a clean
|
|
1304
|
+
* exit takes the launchd service down, an unclean one restarts it every 30 s). Its own function so a
|
|
1305
|
+
* test can drive it without a socket.
|
|
1306
|
+
*/
|
|
1307
|
+
export type ListenerSettings = {
|
|
1308
|
+
exit: number;
|
|
1309
|
+
message: string;
|
|
1310
|
+
} | {
|
|
1311
|
+
config: HwipConfig;
|
|
1312
|
+
apiKey: string;
|
|
1313
|
+
baseUrl: string;
|
|
1314
|
+
features: ReturnType<typeof optionalFeatures>;
|
|
1315
|
+
ignored: string | null;
|
|
1316
|
+
};
|
|
1317
|
+
export declare const listenerSettings: (args: Record<string, string | boolean>) => ListenerSettings;
|
|
1318
|
+
/**
|
|
1319
|
+
* What the schedule runner touches on this machine. A launch is `hwip <agent> --detach` run as a child of the
|
|
1320
|
+
* listener, in the schedule's directory with argv as an array; the project-dir env vars are dropped so the
|
|
1321
|
+
* child resolves its project from that directory, not from whatever shell started the listener.
|
|
1322
|
+
*/
|
|
1323
|
+
export declare const liveScheduleRunnerDeps: (notify: ScheduleRunnerDeps["notify"]) => ScheduleRunnerDeps;
|
|
1324
|
+
export declare const handleListener: (args: Record<string, string | boolean>) => Promise<number>;
|
|
1325
|
+
//# sourceMappingURL=listener.d.ts.map
|