/** * The local gateway interception (T23b-1): a sub-client's own DSH process * keeps serving its local UI, but calls that mention a remote session must * travel to the relay server instead. Installed directly on the RAW gateway * instance (`ctx.typertGateway[symbols.original]`) as own properties — the * load-bearing fact from docs/spike-relay.md §2.2 is that the gateway's * constructor registers arrow functions which look `openWireStream` / * `dispatchRpc` up on the instance at EVERY call, so an own property * shadows the prototype method. `checkGatewayShape` (intercept-shape.ts) * proves that property before anything is installed. * * Forwarding rules, mirroring the server side (relay-access.ts) so the two * ends disagree on nothing: * * - a call is "remote" only when a REGISTERED field of its method carries a * virtual id (`zr~~`, virtual-id.ts). Methods outside the * table get one defensive deep scan instead — a virtual id found anywhere * must never reach the local DSH, so the call is refused * (`remote-unsupported`); for registered methods only the registered * fields are read, the same decoy discipline the server applies. The three * field-less GLOBAL reads (`workspace/follow`, `session/control`, * `session/list`) never refuse on their arguments either — since T23b-2 * they are MERGED instead: the local answer passes through and the relay's * filtered answer is folded in by src/merge-streams.ts (remote workspaces * appear as `zr~`-prefixed groups after the local ones; a remote baseline * never reaches the UI as a second baseline). * - before forwarding, the call's virtual ids must all belong to ONE server * AND to the server this relay client is handshook with (`remote-mismatch`); * with no handshake at all the answer is `remote-offline`. * - a registered method forwards via `relay.invoke` / `relay.openStream` * with the registered fields restored to original ids (`request.address` * maps by kind: `session` → `sessionId`, `subagent` → `parentSessionId`; * the childSessionId is already the server's own id and passes through). * Results and stream frames are rewritten field by field — never by whole * string replacement, event bodies keep their own ids — per the verified * 0.2.0-rc.1 wire inventory (see the per-method notes on * {@link rewriteFrame} / {@link rewriteResult}). * - the wire shapes the host MANDATES are honored: every failure envelope * carries `error.details` as an object (dsh-client-connection refuses a * failure without one), every uplink the multiplex channel hands us is * released and the stream forwarded anyway (the mux always passes an * UplinkInbox — refusing uplinks would refuse every remote stream), and * every error THROWN on the stream route is marked `isDSHRemoteError` with * a string `code` (dsh-typert-protocol's remoteErrorOf folds unmarked * errors into `gateway/internal`, losing the code). * - the 0.2.0 `openWireStream` is an ASYNC method — the host's mux does * `await this.open(...)` and then `for await` over the result. The merge * routes therefore await the local original too and hands the host back a * promise of the merged iterable, the same shape the real method returns. * * T32 adds the forwarded-event pair, mirroring the server side: * * - `openWireStream('$events', …)` stays the stream the UI opened — local * frames pass through untouched — while a relay `$zr/events` leg, reopened * for as long as the relay is online, folds the SERVER's approval/question * waterfalls in. Remote frames are rewritten before the UI ever sees them: * `agentId` and `eventId` become `zr~~…` virtual ids (those are * the ONLY session ids a forwarded waterfall carries — the request bodies * are tool/question data, verified against dsh-tools/dsh-user-questions), * and the remote `ready`/`emit` frames are DROPPED — except, since T75, a * shared session's `api-session/status` emit, which crosses with its * session id virtualized (the one share-scoped emit; every other emit * broadcasts server-wide state the UI must not mistake for local * sessions). The client face of the * gateway (dsh-api-gateway lib/client.js) accepts a ready frame only as * the FIRST frame of the stream and would fail the stream on a second one. * Prompts this leg showed are closed when the leg dies — * each gets a synthesized `cancel` — so a disconnect cannot leave an * approval on screen that no server can settle anymore (T32-fix). * - `dispatchRpc('$events/result', …)` splits on the eventId: a virtual id * is swapped back to the original and answered through the relay's * `postEventResult` (the server composes the gateway payload with its own * clientId — the local payload's clientId is the local stream's and is * discarded); anything else reaches the local gateway verbatim. A relay * refusal that means "this event is already over" (`unknown-event`, * `not-shared` — the route's 403 refusals) is answered as silent * success — exactly how DSH treats a stale result — because a thrown * answer fails the UI's whole `$events` * generation (client face: pumpEvents aborts on answer failures). */ import type { RelayClient, RelayClock } from './relay-client.js'; import type { GatewayShapeCheck } from './intercept-shape.js'; import { type ResultAttachment } from './invoke-binary.js'; /** The session-locating argument fields, as registered per method. Identical * in name and meaning to relay-access.ts's `SessionField`; a field named * without a dot is TOP-LEVEL. `request.workspaceId` locates a remote create * by its target workspace (never a share-table id on the server — the route * probes the workspace and auto-shares the result); top-level * `sessionId` / `workspaceFileScopeId` ARE session ids (terminal's retain/list * half and the workspace-files group). */ export type SessionField = 'request.sessionId' | 'request.address' | 'request.parentSessionId' | 'request.childSessionId' | 'request.workspaceId' | 'parentSessionId' | 'childSessionId' | 'sessionId' | 'workspaceFileScopeId' | 'agentId'; /** * The client-side half of the server's `RELAY_METHODS` registry: which * argument field locates the session for each method. METHODS AND FIELDS * MUST stay identical to the server table (test/intercept.test.cjs pins the * equality by reading relay-access's table directly) — a method the server * does not serve would answer `forbidden-method` forever, and a field the * server does not check would strand a virtual id un-rewritten. * * The three global reads carry no field: nothing in their arguments is * session-scoped, so they are never REFUSED on their arguments — a stray * virtual id there is unowned data. Instead the T23b-2 routes merge their * answers with the relay's filtered ones (merge-streams.ts). */ export declare const CLIENT_METHOD_FIELDS: Readonly>; /** `dispatchRpc`'s failure shape as the UI mandates it: dsh-client-connection's * server-response parser (`invalid server-response failure`) and the gateway's * rpcErrorSchema both demand `code` AND `message` as strings AND `details` as * an object — a details-less refusal crashes the client's parser, so every * envelope WE construct carries `details: {}` (the host rpcFailure's * fallback). */ export type DispatchEnvelope = { ok: true; value: unknown; attachments?: ResultAttachment[]; } | { ok: false; error: { code: string; message?: string; details: Record; }; }; /** * The READ methods of the client registry (T34, whitelist form per T34-fix): * the methods allowed through while the relay is not `online`. Everything in * {@link CLIENT_METHOD_FIELDS} NOT listed here is a WRITE and is refused * locally with `remote-offline` while offline — a write can only fail out * there, and the honest local answer beats a dead round-trip. Maintaining * the READ side keeps the failure mode safe: a future table entry nobody * classified lands on the write side (refused offline), never silently * forwarded into a dead link. * * The read list, method by method: * * - every `stream: true` entry of the server table — a subscription * observes, it never mutates: `session/follow`, `session/control`, * `job/list`, `job/follow`, `workspace/follow`, and the T32 * `$zr/events` pair entry (never client-dialed, listed for the table * guard); * - `session/list` — the unscoped first-page read the merge route folds in; * - `session/page` (history read), `session/projections` (control-key * read), `session/attachment` — verified against RT: it READS one durable * image back (base64), it does not attach anything; * - `skills/list`, `messageFeedback/list`, `schedule/list` — list reads; * - `fileReferences/list` (T31) — the @-reference listing; its put/delete * siblings would be writes, but only this listing is in the table. * * Everything else — `session/prompt|cancel|rename|selectModel|updateQueue` * (send, cancel, rename, model switch, inbox mutation), `session/create` / * `session/fork` (T31: new sessions on the server), `subagents/prompt` / * `subagents/interruptByParent` (T31: prompt/interrupt a remote subagent), * `fileUploads/upload` (T31: attachments into a remote session), `job/kill`, * `messageFeedback/put|delete`, the workspace session-list mutations, and * the T41a mutations (`goals/edit|pause|resume|clear`, `commands/execute`, * `agentPresets/select`, `sessionFeedback/record`, * `terminal/create|write|resize|rename|close|follow`) — is a write. * `terminal/follow` joined the write side (T41a-fix2): its attachment is * NOT a pure read — RT dsh-api-terminal-controller follow: "Attach with * exclusive input control; an older attachment becomes read-only" — so a * follow flips which follower owns the terminal's input, and offline it * must refuse like every other mutation instead of silently stealing * control from a connection that is not there. */ export declare const REMOTE_READ_METHODS: ReadonlySet; /** Whether `endpoint` (a client-table method) is a remote WRITE: anything * the read whitelist does not name (T34-fix). */ export declare function isRemoteWrite(endpoint: string): boolean; /** * T58/T62: the vocabulary of the current-session signal. The value travels * from the BROWSER — the only place the host's persisted selection * (localStorage `dsh.sessions.current`) is readable — through the client * route `current-session` into this process, because the intercept layer * runs in the backend Node process (it wraps the typert gateway there) and * cannot read the browser's storage itself (the T58 localStorage read was * exactly that mistake: Node's global localStorage without a * `--localstorage-file` never answers, so every read degraded to * `unavailable` and tombstones never hid). * * - `unavailable`: no usable signal has arrived yet — the conservative * fallback keeps every tombstone; * - `none`: a readable signal says no session is open; * - `open`: this session's page is open (the raw id as the UI knows it — a * virtual id for a remote session, a local id otherwise). */ export type CurrentSessionRead = { kind: 'unavailable'; } | { kind: 'none'; } | { kind: 'open'; sessionId: string; }; /** One remote-call failure in the diagnostics ring. */ export interface InterceptFailureRecord { /** ISO timestamp of the moment the failure was recorded. */ time: string; endpoint: string; code: string; } /** * One INCOMPATIBLE remote call (T42): a forwarded call the SERVER refused * because the two DSH versions disagree about the interface — the runtime * symptom layer three of the version-tolerance design watches for (see * {@link INCOMPATIBLE_CALL_CODES} for exactly which codes qualify). Only * that one panel errors; the record feeds the settings page's diagnostics * so the mismatch is explainable. `time` is epoch milliseconds — the number * form the settings view's failure rows render directly. */ export interface IncompatibleCallRecord { time: number; endpoint: string; code: string; } /** * The gateway error codes that count as version-mismatch symptoms, verified * against dsh-api-gateway 0.2.0-rc.1 (`lib/index.js`): * `gateway/arguments-invalid` — the args fields do not match the endpoint's * descriptor; `gateway/input-invalid` — a wire field failed the codec's * boundary parse; `gateway/invocation-unavailable` — no active Remote method * exports the endpoint at all (the client called an interface an older * server does not have). Other gateway codes answer different questions * (a cancelled call, a missing service binding) and are NOT version * symptoms. Deliberately ABSENT: `gateway/result-invalid` — despite the * name it only means "a stream Remote method did not return an iterable" * (a server implementation bug class); the gateway does NOT schema-validate * results, so a result-shape disagreement between versions is invisible * here and stays the fingerprint layer's job. */ export declare const INCOMPATIBLE_CALL_CODES: ReadonlySet; /** The behavior self-check's verdict (spike §4.1 check 4). */ export type SelfCheckResult = { ok: true; } | { ok: false; reason: string; }; /** * One remote session the SERVER is no longer serving to this device (T34, * refined by T34-fix): the virtual id plus the close reason ('manual' | * 'client' | 'idle'; anything uncertain reads 'manual'). Two sources: an * `unshared` error frame on a session stream that was OPEN, and a * session-scoped stream refused `not-shared` at open (the closure happened * before this page arrived — no event was ever observed, so the reason * degrades to manual). Drives the session page's 远程已关闭 banner and the * `remote-status` route's `closed` map. Cleared PER SESSION only, when a * call for it succeeds again (re-shared and served) — never wholesale on a * reconnect: a link that flapped while the server still holds the closure * would otherwise flash the banner away and back (T34-fix). */ export interface ClosedSessionRecord { sessionId: string; reason: 'manual' | 'client' | 'idle'; } /** What the client status route surfaces about the interception. Contains * only shapes, counters and codes — never a token. */ export interface InterceptDiagnostics { installed: boolean; shape: GatewayShapeCheck; /** Set once the wiring's startup self-check ran; undefined while it is * still in flight. */ selfCheck?: SelfCheckResult; /** The most recent remote-call failures, oldest first, capped at 20. */ recentFailures: InterceptFailureRecord[]; /** The most recent VALIDATION-refused remote calls (T42), oldest first, * capped at 50 — the runtime-degradation half of the version-tolerance * diagnostics. */ incompatibleCalls: IncompatibleCallRecord[]; /** Remote sessions closed server-side while a page had them open (T34), * oldest first, capped at 200. */ closedSessions: ClosedSessionRecord[]; } /** PB-25b: one merged global stream's share of the KNOWN-real-session * inventory. REPLACE semantics: each `replace` call carries that stream's * FULL current set (the workspace merger's sessionHome snapshot — every real * id the sidebar currently holds as a `zr~` id), never a delta; `dispose` * drops the share (the stream ended — its merger is gone, its ids may no * longer be in the UI's view). The wiring unions the live shares into the * read-only set the plugin-route rewrite consults; nothing here hands out a * mutable reference to merge state. */ export interface SessionInventorySlot { replace(ids: ReadonlySet): void; dispose(): void; } export interface InstallInterceptOptions { /** The RAW gateway instance (`ctx.typertGateway[symbols.original]`), not * the cordis proxy — own properties on the proxy are ignored by everyone. */ raw: object; /** The client relay client (T23a) the calls travel through. */ relay: RelayClient; /** The CURRENT handshake's server id, read live per call; `undefined` * (never handshook) makes every remote call a `remote-offline` — a * different fact from pointing at the wrong server (`remote-mismatch`). */ getServerId: () => string | undefined; /** Progress logging, wired to the context logger by index.ts. */ log?: (format: string, ...args: unknown[]) => void; /** Clock/timers for the merged streams' reopen backoff (CP4); defaults to * the same real clock the relay client's ladder uses. Tests inject a * manual clock to drive the reopen delays without real waiting. */ clock?: RelayClock; /** The current-session signal, read live per use (T62): the browser half * reports the host UI's open session through the client route * (`current-session`) and the wiring holds the latest value here. The * intercept runs in the BACKEND Node process — the browser's localStorage * is not reachable from it, which is exactly what the T58 localStorage * read got wrong — so the value must be carried in. Default: permanently * `unavailable` (the conservative fallback — tombstones stay). */ getCurrentSession?: () => CurrentSessionRead; /** PB-25b: the inventory slot factory — one slot per merged global stream * (only the workspace merger ever reports; the control merger has no * session inventory). The wiring unions the live slots' shares into the * set the plugin-route rewrite reads. Absent: no shares are ever reported * and the plugin-route value scan stays exactly the request-local * behavior. */ createSessionInventorySlot?: () => SessionInventorySlot; } export interface InterceptHandle { /** Remove both own properties, restoring exactly what was installed over * (another wrapper's value, or nothing). Idempotent. */ uninstall(): void; /** The status-route view: installed flag, the shape verdict recorded at * install time, the self-check verdict, the failure ring. */ diagnostics(): InterceptDiagnostics; /** How many calls entered each wrapper (local passthroughs included) — * diagnostic traffic counters for the status surface; the behavior * self-check proves "the wrap was reached" by probe-payload identity * instead. */ wrappedCalls(): { openWireStream: number; dispatchRpc: number; }; /** Record the wiring's self-check verdict (and uninstall + log on * failure — the wiring owns that decision, this only records). */ noteSelfCheck(result: SelfCheckResult): void; /** T62: the browser just reported a new current-session value (the * client route's sink calls this). Every live merged global stream * re-judges NOW — the mergers dedupe no-change calls — instead of * waiting for the next frame or the 1s fallback poll. */ currentSessionChanged(): void; } /** Rewrite one stream frame's session ids to virtual form. */ export declare function rewriteFrame(endpoint: string, frame: unknown, serverId: string): unknown; /** Rewrite one invoke result's session ids to virtual form. */ export declare function rewriteResult(endpoint: string, value: unknown, serverId: string): unknown; /** * Rewrite one SERVER-side `$events` frame for the local UI (T32), or `null` * to drop it. Wire shapes verified against dsh-api-gateway (frames: * `openRemoteEvents`/`broadcastRemoteEvent`/`startRemoteEvent`/ * `finishRemoteEvent`; consumer validation: lib/client.js * `parseRemoteEventFrame` — every variant demands EXACT keys, so rewriting * must rename in place and never add or remove a field): * * - `waterfall`: `{type, event, eventId, agentId, request}` — `eventId` and * `agentId` become virtual; `request` passes verbatim (the projection the * gateway already stripped `agent`/`signal` from carries no session id: * approval requests are `{toolName, callId, reason?, displayReason?}`, * question requests `{questions:[…]}` — dsh-tools / dsh-user-questions). * The server already prefixed the eventId with its per-subscription token * (`.`); this layer treats that as opaque and wraps the * whole thing in `zr~~`. * - `cancel`: `{type, eventId}` — the correlation id goes virtual so the UI * can match it against the waterfall it showed and close the prompt. * - `emit`: dropped, with ONE exception (T75) — a well-formed * `api-session/status` frame (the only emit the server forwards, and only * for accessible sessions) has its `args[0]` session id virtualized and * its `args[1]` running flag passed through, so the UI's * `api-session/status` listener keeps the remote session's running state * in step with the host's (RT dsh-api-session-controller lib/client.js * `handleSessionStatus`). * - everything else — `ready`, non-status emits, non-objects, unknown * types, and any frame failing the client face's exact-keys shape — is * DROPPED (T32-fix2 mirrors the server side). `ready` and the other emits * carry facts the UI must never see (the local stream opened with ITS * ready frame; emits broadcast server-wide state); a malformed frame that * slipped through would fail `parseRemoteEventFrame` and take the UI's * whole `$events` generation down with it, failing and reconnecting in a * loop. Mirrors the server's own forwardable-shape gate, so the two ends * disagree on nothing. */ export declare function rewriteRemoteEventFrame(frame: unknown, serverId: string): unknown | null; /** * T63: the shared summary-sync hub between the merged streams. The sidebar * renders a session row only when the UI's `sessions.list` snapshot holds a * summary for it (RT dsh-client-ui-workspace orderByRecency drops a member * with `summaries[id] === void 0`, groupByWorkspace skips * `list.byId[id] === undefined`), and that snapshot is written only by the * `session/list` pull — which runs once per page load, when the relay may * still be offline — and by the `api-session/added` event. The events leg * consumes the sync (fetches the server list and synthesizes one * `api-session/added` emit per row); the workspace route produces the * mid-page nudges (a forwarded frame carrying a session id no sync has * announced yet). * * T65: the hub is pure broadcast. The announced/asked bookkeeping lives in * EACH merged `$events` stream (a new page's stream starts with empty sets, * so its first online sync is never skipped by the previous page's * knowledge), and a forwarded batch reaches EVERY live stream — a page * refresh's old and new generations each judge for themselves. */ export interface SessionSummarySync { /** The workspace route reports the virtual session ids one forwarded frame * batch carried. Broadcast to every live $events stream; each judges its * own announced/asked sets (T65). */ noteForwardedSessionIds(ids: readonly string[]): void; /** Subscribe to every forwarded batch. All live subscribers get the * broadcast (T65 — the old latest-subscriber-wins shape starved the older * stream of mid-page nudges); a disposer removes only its own listener. */ onForwardedSessionIds(fn: (ids: readonly string[]) => void): () => void; /** The most recent forwarded batch's ids, when one arrived since the last * consume — the catch-up for a nudge that landed before the $events * stream opened. Consumed on read. */ consumePendingRequest(): readonly string[]; } /** * Install the two own-property wrappers on the raw gateway. Assumes * {@link checkGatewayShape} passed (the wiring gates on it) — this function * records the verdict but does not re-gate, so it stays usable in tests * that install over deliberately odd shapes. */ export declare function installIntercept(options: InstallInterceptOptions): InterceptHandle; export interface SelfCheckOptions { /** How long the probe may wait for its first frame; default 5000 ms. */ timeoutMs?: number; /** The owner Peer the probe opens as. DSH 0.2.1-alpha.3 removed * `gateway.operatorPeer()` and requires a live Peer on every stream; see * owner-peer.ts. Undefined falls back to `operatorPeer()` where it still * exists (≤ alpha.2), else to no Peer. */ getPeer?: () => unknown; } /** * The startup behavior self-check (spike §4.1 check 4): open the one stream * the shape check cannot prove — that the wrap is actually REACHED — through * `wireStream.open('workspace/follow', …)` and demand a `baseline` first * frame whose `value.items` is an array (the real workspace baseline's * shape). The probe payload is marked in {@link probePayloads} before the * call and the wrapper flags that exact object, so the verdict covers both * faults at once: frames that are not a workspace feed, and a wire adapter * that routes around the wrapper. The real gateway's `openWireStream` is * `async` (RT dsh-api-gateway), so the adapter's `open()` returns a PROMISE * of the stream — the await sits inside the same timeout race as the * first-frame wait, so an upstream that never settles fails the check * instead of hanging startup. */ export declare function behaviorSelfCheck(raw: object, options?: SelfCheckOptions): Promise; export interface RunSelfCheckOptions { /** The handle whose self-check verdict, uninstall and installed state are * driven. */ handle: Pick; /** How long to wait before the single retry; default 3000 ms. */ retryDelayMs?: number; /** Failure logging, wired to the context logger by index.ts. */ log?: (format: string, ...args: unknown[]) => void; /** Success logging (one line once the probe passes). */ info?: (format: string, ...args: unknown[]) => void; /** Forwarded to {@link behaviorSelfCheck}. */ getPeer?: () => unknown; } /** * Run the behavior self-check with the wiring's failure policy: ONE retry * after a pause (a transiently unready upstream must not cost the whole * interception), and only a second failure uninstalls and records. Each * attempt is judged on its own — the probe payload is recognized inside the * wrapper by identity, so streams the UI opens during the check change * nothing. The plugin may be disposed while a check is in flight (a row * reload during startup), so the handle's installed state is re-read before * the retry and after every attempt: an uninstalled check exits silently — * no further probe, no verdict, no "interception removed" warning (that * removal was not ours to announce, and a post-uninstall probe would run * against the unwrapped gateway and fail spuriously). */ export declare function runSelfCheck(raw: object, options: RunSelfCheckOptions): Promise; //# sourceMappingURL=intercept.d.ts.map