@itookit/dsht 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js ADDED
@@ -0,0 +1,230 @@
1
+ /** Cookie-authenticated HTTP RPC and one multiplexed WebSocket; imports no Harness code. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { once } from 'node:events';
4
+ import WebSocket from 'ws';
5
+ import { array, object, string } from "./wire.js";
6
+ /** HTTP failure remains distinct from host business errors. */
7
+ export class HttpError extends Error {
8
+ status;
9
+ constructor(status, endpoint) {
10
+ super(`${endpoint}: HTTP ${status}`);
11
+ this.status = status;
12
+ }
13
+ }
14
+ /** Host business failure, including the original machine-readable details. */
15
+ export class RemoteError extends Error {
16
+ code;
17
+ details;
18
+ constructor(value) {
19
+ const error = object(value);
20
+ super(`${string(error.code)}: ${string(error.message)}`);
21
+ this.code = string(error.code);
22
+ this.details = error.details;
23
+ }
24
+ }
25
+ /** A single authenticated host connection; close before reconnecting or exiting. */
26
+ export class Client {
27
+ timeoutMs;
28
+ base;
29
+ cookie = '';
30
+ expiresAt;
31
+ socket;
32
+ listeners = new Map();
33
+ lifetime = new AbortController();
34
+ constructor(base, timeoutMs = 15_000) {
35
+ this.timeoutMs = timeoutMs;
36
+ this.base = new URL(base);
37
+ if (!['http:', 'https:'].includes(this.base.protocol) || this.base.username || this.base.password
38
+ || this.base.pathname !== '/' || this.base.search || this.base.hash) {
39
+ throw new Error('Server URL must be an HTTP(S) origin without a path, query, or credentials');
40
+ }
41
+ }
42
+ /** Exchange a startup token only at GET / and retain the server's cookie expiration. */
43
+ async authenticate(token) {
44
+ const url = new URL('/', this.base);
45
+ url.searchParams.set('token', token);
46
+ const response = await fetch(url, { redirect: 'manual', signal: this.signal() });
47
+ if (response.status !== 303) {
48
+ await response.body?.cancel();
49
+ throw new HttpError(response.status, 'Authentication failed');
50
+ }
51
+ const header = response.headers.getSetCookie().find(value => value.startsWith('dsh-auth-'));
52
+ await response.body?.cancel();
53
+ if (!header)
54
+ throw new Error('Authentication response omitted the dsh-auth cookie');
55
+ this.restoreCookie(header.split(';')[0]);
56
+ const maxAge = /;\s*max-age=(-?\d+)(?:;|$)/i.exec(header)?.[1];
57
+ const expires = /;\s*expires=([^;]+)/i.exec(header)?.[1];
58
+ const expiration = maxAge === undefined ? Date.parse(expires ?? '') : Date.now() + Number(maxAge) * 1000;
59
+ this.expiresAt = Number.isSafeInteger(expiration) ? expiration : undefined;
60
+ }
61
+ /** Restore one origin-scoped cookie read by the caller's credential store. */
62
+ restoreCookie(cookie) {
63
+ if (!/^dsh-auth-[\w-]+=[A-Za-z0-9._-]+$/.test(cookie))
64
+ throw new Error('Invalid saved authentication cookie');
65
+ this.cookie = cookie;
66
+ this.expiresAt = undefined;
67
+ }
68
+ /** Session-only cookies are not eligible for persistent storage. */
69
+ get persistentCookie() {
70
+ return this.expiresAt === undefined ? undefined : { cookie: this.cookie, expiresAt: this.expiresAt };
71
+ }
72
+ /** Invoke an exact endpoint once. Mutations are never automatically retried.
73
+ * @param endpoint - Namespace/method endpoint.
74
+ * @param args - Host parameter names and JSON values.
75
+ * @param signal - Optional caller cancellation, combined with client close and timeout.
76
+ * @returns The decoded result value; HTTP, remote, and cancellation errors reject.
77
+ */
78
+ async call(endpoint, args = {}, signal) {
79
+ if (!/^[\w$-]+\/[\w$-]+$/.test(endpoint))
80
+ throw new Error('Invalid RPC endpoint');
81
+ const rpcId = randomUUID();
82
+ const response = await fetch(new URL(`/api/${endpoint}`, this.base), {
83
+ method: 'POST', redirect: 'error', signal: signal ? AbortSignal.any([this.signal(), signal]) : this.signal(),
84
+ headers: { 'content-type': 'application/json', cookie: this.cookie },
85
+ body: JSON.stringify({ type: 'client-request', rpcId, method: endpoint, payload: { args } }),
86
+ });
87
+ if (!response.ok) {
88
+ await response.body?.cancel();
89
+ throw new HttpError(response.status, endpoint);
90
+ }
91
+ const body = object(await response.json());
92
+ if (body.type !== 'server-response' || body.rpcId !== rpcId)
93
+ throw new Error('RPC response identity mismatch');
94
+ const result = object(body.result);
95
+ if (result.ok === false)
96
+ throw new RemoteError(result.error);
97
+ if (result.ok !== true)
98
+ throw new Error('Malformed RPC result');
99
+ return result.value;
100
+ }
101
+ /** Connect the physical mux. A disconnected instance may reconnect with its cookie. */
102
+ async connect() {
103
+ if (this.socket)
104
+ throw new Error('Mux is already connected');
105
+ const url = new URL('/api/remote.mux', this.base);
106
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
107
+ const socket = new WebSocket(url, { headers: { cookie: this.cookie }, handshakeTimeout: this.timeoutMs });
108
+ this.socket = socket;
109
+ socket.on('message', (raw, binary) => {
110
+ try {
111
+ if (binary)
112
+ throw new Error('Unexpected binary mux frame');
113
+ const frame = object(JSON.parse(raw.toString()));
114
+ const id = string(frame.streamId);
115
+ if (!['item', 'end', 'error'].includes(string(frame.type)))
116
+ throw new Error('Invalid mux frame type');
117
+ const listener = this.listeners.get(id);
118
+ if (!listener)
119
+ return;
120
+ if (frame.type === 'item') {
121
+ try {
122
+ listener.item(frame.value);
123
+ }
124
+ catch (error) {
125
+ this.listeners.delete(id);
126
+ socket.send(JSON.stringify({ type: 'cancel', streamId: id }));
127
+ this.finish(listener, error instanceof Error ? error : new Error('Stream callback failed'));
128
+ }
129
+ }
130
+ else {
131
+ this.listeners.delete(id);
132
+ this.finish(listener, frame.type === 'error' ? new RemoteError(frame.error) : undefined);
133
+ }
134
+ }
135
+ catch (error) {
136
+ this.fail(error instanceof Error ? error : new Error('Invalid mux frame'));
137
+ socket.terminate();
138
+ }
139
+ });
140
+ socket.on('error', error => this.fail(error));
141
+ socket.on('close', () => {
142
+ if (this.socket === socket)
143
+ this.socket = undefined;
144
+ this.fail(new Error('Connection closed'));
145
+ });
146
+ await once(socket, 'open');
147
+ }
148
+ /** Subscribe on the existing mux; each subscription has a fresh stream identity. */
149
+ subscribe(endpoint, args, listener) {
150
+ const socket = this.socket;
151
+ if (socket?.readyState !== WebSocket.OPEN)
152
+ throw new Error('Mux is not connected');
153
+ const streamId = randomUUID();
154
+ this.listeners.set(streamId, listener);
155
+ socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } }));
156
+ return { cancel: () => {
157
+ if (!this.listeners.delete(streamId))
158
+ return;
159
+ if (socket.readyState === WebSocket.OPEN)
160
+ socket.send(JSON.stringify({ type: 'cancel', streamId }));
161
+ } };
162
+ }
163
+ /** List workspaces by consuming and cancelling the authoritative opening baseline. */
164
+ async listWorkspaces() {
165
+ return new Promise((resolve, reject) => {
166
+ let sub;
167
+ const timer = setTimeout(() => { sub.cancel(); reject(new Error('Workspace baseline timed out')); }, this.timeoutMs);
168
+ try {
169
+ sub = this.subscribe('workspace/follow', {}, {
170
+ item: value => {
171
+ clearTimeout(timer);
172
+ sub.cancel();
173
+ try {
174
+ const frame = object(value);
175
+ if (frame.type !== 'baseline')
176
+ throw new Error('Workspace stream omitted its baseline');
177
+ resolve(array(object(frame.value).items).map(object));
178
+ }
179
+ catch (error) {
180
+ reject(error);
181
+ }
182
+ },
183
+ end: error => { clearTimeout(timer); reject(error ?? new Error('Workspace stream ended before baseline')); },
184
+ });
185
+ }
186
+ catch (error) {
187
+ clearTimeout(timer);
188
+ reject(error);
189
+ }
190
+ });
191
+ }
192
+ /** List visible sessions, optionally filtering by the workspace's accounted IDs. */
193
+ async listSessions(workspaceId) {
194
+ const sessions = array(object(await this.call('session/list', { _request: {} })).items).map(object);
195
+ if (!workspaceId)
196
+ return sessions;
197
+ const workspace = (await this.listWorkspaces()).find(item => item.workspaceId === workspaceId);
198
+ if (!workspace)
199
+ throw new Error(`Workspace not found: ${workspaceId}`);
200
+ const ids = new Set(array(workspace.sessionIds).map(string));
201
+ return sessions.filter(item => ids.has(string(item.sessionId)));
202
+ }
203
+ /** Close all streams, abort in-flight HTTP, and await the physical socket's closure. */
204
+ async close() {
205
+ this.lifetime.abort();
206
+ const socket = this.socket;
207
+ this.socket = undefined;
208
+ this.fail(new Error('Client closed'));
209
+ if (socket && socket.readyState !== WebSocket.CLOSED) {
210
+ const closed = new Promise(resolve => socket.once('close', () => resolve()));
211
+ socket.terminate();
212
+ await closed;
213
+ }
214
+ }
215
+ signal() {
216
+ return AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(this.timeoutMs)]);
217
+ }
218
+ fail(error) {
219
+ const listeners = [...this.listeners.values()];
220
+ this.listeners.clear();
221
+ for (const listener of listeners)
222
+ this.finish(listener, error);
223
+ }
224
+ finish(listener, error) {
225
+ try {
226
+ listener.end(error);
227
+ }
228
+ catch { /* Termination callbacks cannot prevent other streams from closing. */ }
229
+ }
230
+ }
@@ -0,0 +1,140 @@
1
+ import { Client } from './client.ts';
2
+ import { CostLedger } from './cost.ts';
3
+ import { Telemetry } from './telemetry.ts';
4
+ import { Transcript } from './transcript.ts';
5
+ import { type FileReference } from './references.ts';
6
+ import { type Json, type ObjectValue } from './wire.ts';
7
+ /** State shared by the picker and conversation view. */
8
+ export interface State {
9
+ version: number;
10
+ online: boolean;
11
+ busy: boolean;
12
+ screen: 'workspaces' | 'sessions' | 'chat' | 'path';
13
+ status: string;
14
+ error: string;
15
+ workspaces: ObjectValue[];
16
+ sessions: ObjectValue[];
17
+ showAllSessions: boolean;
18
+ workspaceId?: string;
19
+ sessionId?: string;
20
+ pending: ObjectValue[];
21
+ controlError?: string;
22
+ modelError?: string;
23
+ defaultModel?: ObjectValue;
24
+ transcript: Transcript;
25
+ }
26
+ /** Owns reconnects and subscriptions. User commands remain single-attempt operations. */
27
+ export declare class Controller {
28
+ readonly base: string;
29
+ readonly initialSession?: string | undefined;
30
+ private makeClient;
31
+ private authenticate;
32
+ readonly costs?: CostLedger | undefined;
33
+ state: State;
34
+ private observers;
35
+ private abort;
36
+ private client;
37
+ private clientId;
38
+ private follow;
39
+ private runTask;
40
+ private generationFailed;
41
+ private selection;
42
+ telemetry: Telemetry;
43
+ private observedRunningAt;
44
+ private catalogRevision;
45
+ private catalogTasks;
46
+ private runningUpdates;
47
+ private stoppingSession?;
48
+ private interruptTask;
49
+ private admission;
50
+ private costUpdates;
51
+ private costAbort?;
52
+ private costTask;
53
+ private costTimer;
54
+ /** Host running state covers model generation, tools, and waits between assistant attempts. */
55
+ get running(): boolean;
56
+ /** Current title projection, falling back to the list title and then the session ID. */
57
+ get sessionName(): string | undefined;
58
+ /** Epoch start from the retained turn log, or when this client first observed the run. */
59
+ get workingSince(): number | undefined;
60
+ /** Stop the selected turn, or allow exit only while idle. Repeated keys share one request.
61
+ * @param force - Send an explicit cancellation even when the cached running flag is idle.
62
+ * @returns True when the caller may exit; cancellation failures retain the client.
63
+ */
64
+ interrupt(force?: boolean): Promise<boolean>;
65
+ constructor(base: string, token: string | undefined, initialSession?: string | undefined, makeClient?: () => Client, authenticate?: (client: Client) => Promise<void>, costs?: CostLedger | undefined);
66
+ /** React-compatible state subscription. */
67
+ subscribe: (listener: () => void) => (() => void);
68
+ /** Snapshot identity changes only when the controller publishes. */
69
+ snapshot: () => State;
70
+ /** Start one retry loop, with a fresh snapshot generation after every disconnect. */
71
+ start(): void;
72
+ /** Cancel retries and HTTP, close the socket, and wait for the loop to settle. */
73
+ stop(): Promise<void>;
74
+ /** Run a UI operation and expose errors without destroying the current input. */
75
+ perform(operation: () => Promise<void>): Promise<boolean>;
76
+ /** Refresh all HTTP-visible sessions without changing the selected conversation.
77
+ * @param signal - Optional cancellation for an explicit /cost refresh.
78
+ */
79
+ refreshCosts(signal?: AbortSignal): Promise<void>;
80
+ /** Refresh both lists from the host, then show the requested picker. */
81
+ showPicker(screen: 'workspaces' | 'sessions'): Promise<void>;
82
+ /** Pick a workspace, or use all sessions when the identity is omitted. */
83
+ pickWorkspace(workspaceId?: string): void;
84
+ /** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix. */
85
+ switchWorkspace(query?: string): Promise<void>;
86
+ /** Guide workspace selection, list all sessions with `all`, or resolve an exact session target. */
87
+ switchSession(query?: string): Promise<void>;
88
+ /** Prompt for a host path without starting a local agent. */
89
+ enterPath(): void;
90
+ /** Register a host directory and move to its session picker. */
91
+ createWorkspace(path: string): Promise<void>;
92
+ /** Create a session only after the user explicitly selects New session. */
93
+ createSession(): Promise<void>;
94
+ /** Replace the selected transcript and cancel its preceding follow stream. */
95
+ selectSession(sessionId: string): Promise<void>;
96
+ /** Wait for the selected follow snapshot, failing on disconnect or cancellation.
97
+ * @param signal - Cancels waiting without closing the session.
98
+ */
99
+ waitForHistory(signal: AbortSignal): Promise<void>;
100
+ /** Search the host's bounded global results, optionally retaining workspace members.
101
+ * @param query - Literal message text.
102
+ * @param workspaceOnly - Restrict returned hits to the selected workspace's session IDs.
103
+ * @param signal - Cancels the HTTP search.
104
+ * @returns Session snippets and the global truncation flag, preserved after filtering.
105
+ */
106
+ searchSessions(query: string, workspaceOnly: boolean, signal: AbortSignal): Promise<{
107
+ items: ObjectValue[];
108
+ hasMore: boolean;
109
+ }>;
110
+ /** Search paths on the host; does not read or upload file contents.
111
+ * @param query - Path text after @, relative to the selected session cwd.
112
+ * @param signal - Cancels an obsolete composer lookup.
113
+ * @returns Validated candidates in host order.
114
+ */
115
+ references(query: string, signal: AbortSignal): Promise<FileReference[]>;
116
+ /** Admit a prompt once; a failed response can have an uncertain delivery outcome. */
117
+ prompt(text: string, mode?: 'queue' | 'steer'): Promise<void>;
118
+ /** Cancel the active turn; pending queue items remain host-owned. */
119
+ cancelTurn(): Promise<void>;
120
+ /** Add a page before the retained window using its fixed opening cut. */
121
+ older(signal?: AbortSignal): Promise<void>;
122
+ /** Load the prefix required for an explicit history jump; never loop on an unadvancing page.
123
+ * @param target - Visible record sequence, or first for the oldest available history.
124
+ * @param signal - Cancels local paging without interrupting the remote agent.
125
+ */
126
+ historyThrough(target: number | 'first', signal: AbortSignal): Promise<void>;
127
+ /** Answer the oldest selected-session interaction, after explicit user action. */
128
+ answer(value: Json): Promise<void>;
129
+ /** Restrict an approval command to an approval request. */
130
+ approve(allowed: boolean): Promise<void>;
131
+ /** Present only sessions explicitly accounted to the selected workspace. */
132
+ get visibleSessions(): ObjectValue[];
133
+ private get host();
134
+ private get sessionId();
135
+ private update;
136
+ private reply;
137
+ private releasePending;
138
+ private refreshCatalog;
139
+ private run;
140
+ }