@itookit/dsht 0.3.8 → 0.5.2

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.
Files changed (166) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +33 -12
  3. package/README.zh.md +35 -14
  4. package/dist/catalog/controller.d.ts +26 -6
  5. package/dist/catalog/controller.js +73 -45
  6. package/dist/catalog/index.d.ts +1 -0
  7. package/dist/cli/dsht.js +206 -18
  8. package/dist/cli/startup.d.ts +40 -0
  9. package/dist/cli/startup.js +314 -0
  10. package/dist/cli/trace-summary.d.ts +78 -0
  11. package/dist/cli/trace-summary.js +241 -0
  12. package/dist/cli/verifier.d.ts +64 -0
  13. package/dist/cli/verifier.js +265 -0
  14. package/dist/contracts.d.ts +359 -0
  15. package/dist/contracts.js +1 -0
  16. package/dist/controller/commands.d.ts +47 -0
  17. package/dist/controller/commands.js +322 -0
  18. package/dist/controller/connection-streams.d.ts +22 -0
  19. package/dist/controller/connection-streams.js +105 -0
  20. package/dist/controller/connection.d.ts +24 -31
  21. package/dist/controller/connection.js +48 -111
  22. package/dist/controller/controller.d.ts +412 -178
  23. package/dist/controller/controller.js +713 -167
  24. package/dist/controller/foreground.d.ts +44 -0
  25. package/dist/controller/foreground.js +79 -0
  26. package/dist/controller/index.d.ts +8 -1
  27. package/dist/controller/index.js +5 -0
  28. package/dist/controller/loop-contract.d.ts +136 -0
  29. package/dist/controller/loop-contract.js +308 -0
  30. package/dist/controller/loop-coordinator.d.ts +48 -0
  31. package/dist/controller/loop-coordinator.js +647 -0
  32. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  33. package/dist/controller/loop-prompts-schema.js +144 -0
  34. package/dist/controller/loop-prompts.d.ts +55 -0
  35. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  36. package/dist/controller/loop-prompts.generated.js +185 -0
  37. package/dist/controller/loop-prompts.js +104 -0
  38. package/dist/controller/loop-protocols.d.ts +39 -0
  39. package/dist/controller/loop-protocols.js +115 -0
  40. package/dist/controller/loop.d.ts +275 -0
  41. package/dist/controller/loop.js +378 -0
  42. package/dist/controller/prompts.d.ts +54 -0
  43. package/dist/controller/prompts.js +162 -0
  44. package/dist/controller/trace-log.d.ts +45 -0
  45. package/dist/controller/trace-log.js +144 -0
  46. package/dist/controller/verifier.d.ts +130 -0
  47. package/dist/controller/verifier.js +75 -0
  48. package/dist/cost/controller.d.ts +1 -1
  49. package/dist/cost/controller.js +12 -5
  50. package/dist/cost/index.d.ts +1 -1
  51. package/dist/cost/index.js +1 -1
  52. package/dist/cost/ledger.d.ts +0 -1
  53. package/dist/cost/ledger.js +0 -1
  54. package/dist/cost/scanner.js +1 -0
  55. package/dist/json.d.ts +18 -0
  56. package/dist/json.js +19 -0
  57. package/dist/references.d.ts +25 -0
  58. package/dist/references.js +26 -0
  59. package/dist/session/connection-view.d.ts +2 -11
  60. package/dist/session/controller.d.ts +94 -105
  61. package/dist/session/controller.js +262 -536
  62. package/dist/session/history-reader.d.ts +32 -0
  63. package/dist/session/history-reader.js +170 -0
  64. package/dist/session/history.d.ts +6 -18
  65. package/dist/session/history.js +1 -24
  66. package/dist/session/index.d.ts +9 -4
  67. package/dist/session/index.js +7 -3
  68. package/dist/session/info.d.ts +20 -82
  69. package/dist/session/info.js +52 -25
  70. package/dist/session/interactions.d.ts +26 -0
  71. package/dist/session/interactions.js +75 -0
  72. package/dist/session/markdown.js +1 -1
  73. package/dist/session/math.js +1 -1
  74. package/dist/session/mutation-gate.d.ts +51 -0
  75. package/dist/session/mutation-gate.js +73 -0
  76. package/dist/session/navigation.d.ts +2 -89
  77. package/dist/session/navigation.js +2 -129
  78. package/dist/session/navigator.d.ts +47 -0
  79. package/dist/session/navigator.js +158 -0
  80. package/dist/session/peek.d.ts +38 -0
  81. package/dist/session/peek.js +103 -0
  82. package/dist/session/prompt-backfill.d.ts +23 -0
  83. package/dist/session/prompt-backfill.js +88 -0
  84. package/dist/session/references.d.ts +2 -20
  85. package/dist/session/references.js +1 -26
  86. package/dist/session/runtime.d.ts +26 -0
  87. package/dist/session/runtime.js +28 -0
  88. package/dist/session/state.d.ts +20 -0
  89. package/dist/session/state.js +1 -0
  90. package/dist/session/telemetry.d.ts +25 -17
  91. package/dist/session/telemetry.js +66 -60
  92. package/dist/session/transcript.d.ts +5 -7
  93. package/dist/session/transcript.js +2 -15
  94. package/dist/session/types.d.ts +25 -0
  95. package/dist/session/types.js +0 -1
  96. package/dist/session-title.d.ts +9 -0
  97. package/dist/session-title.js +21 -0
  98. package/dist/shell/controller.d.ts +31 -1
  99. package/dist/shell/controller.js +34 -2
  100. package/dist/shell/index.d.ts +3 -3
  101. package/dist/shell/index.js +2 -2
  102. package/dist/shell/runner.d.ts +10 -0
  103. package/dist/shell/runner.js +48 -9
  104. package/dist/slash/index.d.ts +10 -0
  105. package/dist/slash/index.js +7 -0
  106. package/dist/slash/parse.d.ts +42 -0
  107. package/dist/slash/parse.js +259 -0
  108. package/dist/slash/pipeline.d.ts +140 -0
  109. package/dist/slash/pipeline.js +115 -0
  110. package/dist/slash/registry.d.ts +88 -0
  111. package/dist/slash/registry.js +177 -0
  112. package/dist/slash/types.d.ts +126 -0
  113. package/dist/slash/types.js +1 -0
  114. package/dist/state.d.ts +16 -18
  115. package/dist/state.js +4 -3
  116. package/dist/text.d.ts +28 -0
  117. package/dist/text.js +55 -0
  118. package/dist/transport/client.d.ts +4 -3
  119. package/dist/transport/client.js +71 -25
  120. package/dist/transport/events.d.ts +104 -0
  121. package/dist/transport/events.js +149 -0
  122. package/dist/transport/wire.d.ts +9 -17
  123. package/dist/transport/wire.js +2 -27
  124. package/dist/ui/app.js +750 -550
  125. package/dist/ui/chat/header.js +1 -1
  126. package/dist/ui/chat/history-view.d.ts +1 -1
  127. package/dist/ui/chat/loop-status.d.ts +11 -0
  128. package/dist/ui/chat/loop-status.js +28 -0
  129. package/dist/ui/chat/navigation-model.d.ts +86 -0
  130. package/dist/ui/chat/navigation-model.js +107 -0
  131. package/dist/ui/chat/shell-view.d.ts +17 -2
  132. package/dist/ui/chat/shell-view.js +45 -3
  133. package/dist/ui/chat/status.d.ts +47 -3
  134. package/dist/ui/chat/status.js +65 -50
  135. package/dist/ui/chat/use-history-view.d.ts +69 -0
  136. package/dist/ui/chat/use-history-view.js +123 -0
  137. package/dist/ui/chat/viewport.d.ts +1 -1
  138. package/dist/ui/dialogs/cost.d.ts +21 -4
  139. package/dist/ui/dialogs/cost.js +7 -12
  140. package/dist/ui/dialogs/index.d.ts +22 -5
  141. package/dist/ui/dialogs/index.js +19 -3
  142. package/dist/ui/dialogs/loop.d.ts +43 -0
  143. package/dist/ui/dialogs/loop.js +224 -0
  144. package/dist/ui/dialogs/peek.d.ts +25 -0
  145. package/dist/ui/dialogs/peek.js +35 -0
  146. package/dist/ui/dialogs/picker.d.ts +2 -0
  147. package/dist/ui/dialogs/picker.js +4 -2
  148. package/dist/ui/dialogs/use-panels.d.ts +53 -0
  149. package/dist/ui/dialogs/use-panels.js +51 -0
  150. package/dist/ui/input/mouse.d.ts +12 -2
  151. package/dist/ui/input/mouse.js +20 -7
  152. package/dist/ui/input/references.d.ts +1 -1
  153. package/dist/ui/input/use-composer.d.ts +35 -0
  154. package/dist/ui/input/use-composer.js +109 -0
  155. package/dist/ui/input/use-deferred-lines.d.ts +16 -0
  156. package/dist/ui/input/use-deferred-lines.js +54 -0
  157. package/dist/ui/input/use-history-recall.d.ts +20 -0
  158. package/dist/ui/input/use-history-recall.js +47 -0
  159. package/dist/ui/status/model.d.ts +7 -0
  160. package/dist/ui/status/model.js +5 -0
  161. package/dist/ui/theme/index.d.ts +1 -1
  162. package/package.json +6 -4
  163. package/dist/ui/commands/parse.d.ts +0 -104
  164. package/dist/ui/commands/parse.js +0 -135
  165. package/dist/ui/commands/registry.d.ts +0 -33
  166. package/dist/ui/commands/registry.js +0 -73
@@ -22,7 +22,7 @@ export class RemoteError extends Error {
22
22
  this.details = error.details;
23
23
  }
24
24
  }
25
- /** A single authenticated host connection; close before reconnecting or exiting. */
25
+ /** One client lifetime. Peer disconnects allow reconnect; close permanently ends the lifetime. */
26
26
  export class Client {
27
27
  timeoutMs;
28
28
  base;
@@ -31,6 +31,7 @@ export class Client {
31
31
  socket;
32
32
  listeners = new Map();
33
33
  lifetime = new AbortController();
34
+ closeTask;
34
35
  constructor(base, timeoutMs = 15_000) {
35
36
  this.timeoutMs = timeoutMs;
36
37
  this.base = new URL(base);
@@ -117,13 +118,17 @@ export class Client {
117
118
  }
118
119
  /** Connect the physical mux. A disconnected instance may reconnect with its cookie. */
119
120
  async connect() {
121
+ this.lifetime.signal.throwIfAborted();
120
122
  if (this.socket)
121
123
  throw new Error('Mux is already connected');
122
124
  const url = new URL('/api/remote.mux', this.base);
123
125
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
124
126
  const socket = new WebSocket(url, { headers: { cookie: this.cookie }, handshakeTimeout: this.timeoutMs });
125
127
  this.socket = socket;
128
+ const closed = new Promise(resolve => socket.once('close', () => resolve()));
126
129
  socket.on('message', (raw, binary) => {
130
+ if (this.socket !== socket || this.lifetime.signal.aborted)
131
+ return;
127
132
  try {
128
133
  if (binary)
129
134
  throw new Error('Unexpected binary mux frame');
@@ -154,22 +159,40 @@ export class Client {
154
159
  socket.terminate();
155
160
  }
156
161
  });
157
- socket.on('error', error => this.fail(error));
162
+ socket.on('error', error => { if (this.socket === socket)
163
+ this.fail(error); });
158
164
  socket.on('close', () => {
159
- if (this.socket === socket)
160
- this.socket = undefined;
165
+ if (this.socket !== socket)
166
+ return;
167
+ this.socket = undefined;
161
168
  this.fail(new Error('Connection closed'));
162
169
  });
163
- await once(socket, 'open');
170
+ try {
171
+ await once(socket, 'open', { signal: this.lifetime.signal });
172
+ this.lifetime.signal.throwIfAborted();
173
+ }
174
+ catch (error) {
175
+ if (socket.readyState !== WebSocket.CLOSED)
176
+ socket.terminate();
177
+ await closed;
178
+ throw error;
179
+ }
164
180
  }
165
181
  /** Subscribe on the existing mux; each subscription has a fresh stream identity. */
166
182
  subscribe(endpoint, args, listener) {
183
+ this.lifetime.signal.throwIfAborted();
167
184
  const socket = this.socket;
168
185
  if (socket?.readyState !== WebSocket.OPEN)
169
186
  throw new Error('Mux is not connected');
170
187
  const streamId = randomUUID();
171
188
  this.listeners.set(streamId, listener);
172
- socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } }));
189
+ try {
190
+ socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } }));
191
+ }
192
+ catch (error) {
193
+ this.listeners.delete(streamId);
194
+ throw error;
195
+ }
173
196
  return { cancel: () => {
174
197
  if (!this.listeners.delete(streamId))
175
198
  return;
@@ -180,57 +203,80 @@ export class Client {
180
203
  /** Archived session IDs from the latest authoritative workspace baseline. */
181
204
  archivedSessionIds = new Set();
182
205
  /** List workspaces by consuming and cancelling the authoritative opening baseline. */
183
- async listWorkspaces() {
206
+ async listWorkspaces(signal) {
207
+ signal?.throwIfAborted();
184
208
  return new Promise((resolve, reject) => {
185
209
  let sub;
186
- const timer = setTimeout(() => { sub.cancel(); reject(new Error('Workspace baseline timed out')); }, this.timeoutMs);
210
+ let settled = false;
211
+ const finish = (error, items) => {
212
+ if (settled)
213
+ return;
214
+ settled = true;
215
+ clearTimeout(timer);
216
+ signal?.removeEventListener('abort', cancel);
217
+ sub?.cancel();
218
+ if (error !== undefined)
219
+ reject(error);
220
+ else
221
+ resolve(items);
222
+ };
223
+ const cancel = () => finish(signal.reason);
224
+ const timer = setTimeout(() => finish(new Error('Workspace baseline timed out')), this.timeoutMs);
225
+ signal?.addEventListener('abort', cancel, { once: true });
187
226
  try {
188
227
  sub = this.subscribe('workspace/follow', {}, {
189
228
  item: value => {
190
- clearTimeout(timer);
191
- sub.cancel();
229
+ if (settled)
230
+ return;
192
231
  try {
193
232
  const frame = object(value);
194
233
  if (frame.type !== 'baseline')
195
234
  throw new Error('Workspace stream omitted its baseline');
196
235
  this.archivedSessionIds = new Set(array(object(frame.value).archivedSessionIds).map(string));
197
- resolve(array(object(frame.value).items).map(object));
236
+ finish(undefined, array(object(frame.value).items).map(object));
198
237
  }
199
238
  catch (error) {
200
- reject(error);
239
+ finish(error);
201
240
  }
202
241
  },
203
- end: error => { clearTimeout(timer); reject(error ?? new Error('Workspace stream ended before baseline')); },
242
+ end: error => finish(error ?? new Error('Workspace stream ended before baseline')),
204
243
  });
244
+ if (settled)
245
+ sub.cancel();
205
246
  }
206
247
  catch (error) {
207
- clearTimeout(timer);
208
- reject(error);
248
+ finish(error);
209
249
  }
210
250
  });
211
251
  }
212
252
  /** List visible sessions, optionally filtering by the workspace's accounted IDs. */
213
- async listSessions(workspaceId) {
214
- const sessions = array(object(await this.call('session/list', { _request: {} })).items).map(object);
253
+ async listSessions(workspaceId, signal) {
254
+ const sessions = array(object(await this.call('session/list', { _request: {} }, signal)).items).map(object);
215
255
  if (!workspaceId)
216
256
  return sessions;
217
- const workspace = (await this.listWorkspaces()).find(item => item.workspaceId === workspaceId);
257
+ const workspace = (await this.listWorkspaces(signal)).find(item => item.workspaceId === workspaceId);
218
258
  if (!workspace)
219
259
  throw new Error(`Workspace not found: ${workspaceId}`);
220
260
  const ids = new Set(array(workspace.sessionIds).map(string));
221
261
  return sessions.filter(item => ids.has(string(item.sessionId)));
222
262
  }
223
263
  /** Close all streams, abort in-flight HTTP, and await the physical socket's closure. */
224
- async close() {
225
- this.lifetime.abort();
264
+ close() {
265
+ if (this.closeTask)
266
+ return this.closeTask;
226
267
  const socket = this.socket;
227
268
  this.socket = undefined;
269
+ // Reserve before publishing cancellation: termination callbacks may themselves call close.
270
+ this.closeTask = Promise.resolve().then(async () => {
271
+ if (socket && socket.readyState !== WebSocket.CLOSED) {
272
+ const closed = new Promise(resolve => socket.once('close', () => resolve()));
273
+ socket.terminate();
274
+ await closed;
275
+ }
276
+ });
277
+ this.lifetime.abort();
228
278
  this.fail(new Error('Client closed'));
229
- if (socket && socket.readyState !== WebSocket.CLOSED) {
230
- const closed = new Promise(resolve => socket.once('close', () => resolve()));
231
- socket.terminate();
232
- await closed;
233
- }
279
+ return this.closeTask;
234
280
  }
235
281
  signal(timeoutMs = this.timeoutMs) {
236
282
  return timeoutMs === null ? this.lifetime.signal : AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(timeoutMs)]);
@@ -0,0 +1,104 @@
1
+ /** One place that reads DSH event field names and turns them into semantic host events.
2
+ *
3
+ * Raw wire stops here: no `Json`/`ObjectValue` payload survives into a `HostEvent`, so `connection/`,
4
+ * `session/` and the UI never learn how the host names its fields. Replying `{kind:'next'}` to an
5
+ * unrecognized waterfall is a wire-level obligation of the host's event chain, so it is modelled as
6
+ * `waterfall-delegate` rather than silently dropped.
7
+ */
8
+ import { type Json, type ObjectValue } from './wire.ts';
9
+ /** One selectable answer of a host question, already flattened out of the raw request. */
10
+ export interface QuestionOption {
11
+ label: string;
12
+ description?: string;
13
+ }
14
+ /** One question of an `user-questions/request` waterfall. */
15
+ export interface QuestionItem {
16
+ id: string;
17
+ header?: string;
18
+ question: string;
19
+ detail?: string;
20
+ multiSelect: boolean;
21
+ options: readonly QuestionOption[];
22
+ }
23
+ /** A decoded host event with protocol field names already resolved into domain meaning. */
24
+ export type HostEvent = {
25
+ kind: 'approval-request';
26
+ eventId: string;
27
+ sessionId: string;
28
+ description: string;
29
+ } | {
30
+ kind: 'question-request';
31
+ eventId: string;
32
+ sessionId: string;
33
+ questions: readonly QuestionItem[];
34
+ }
35
+ /** A waterfall this client does not answer; the caller must reply `next` to keep the host chain moving. */
36
+ | {
37
+ kind: 'waterfall-delegate';
38
+ eventId: string;
39
+ } | {
40
+ kind: 'cancel';
41
+ eventId: string;
42
+ } | {
43
+ kind: 'agent-status';
44
+ sessionId: string;
45
+ running: boolean;
46
+ } | {
47
+ kind: 'catalog-invalidated';
48
+ } | {
49
+ kind: 'session-error';
50
+ sessionId: string;
51
+ error: string;
52
+ } | {
53
+ kind: 'control';
54
+ frame: ControlFrame;
55
+ };
56
+ /** One host-owned pending input, flattened to the text the UI shows. */
57
+ export interface QueuedInput {
58
+ id: string;
59
+ placement: 'queued' | 'steering' | 'context';
60
+ text: string;
61
+ }
62
+ /** A capability value the host owns. Individual keys are typed incrementally as they are consumed. */
63
+ export type ProjectionValue = Json;
64
+ /** One session's projection values and the sequence they describe. */
65
+ export interface ProjectionSnapshot {
66
+ asOfSeq: number;
67
+ values: Readonly<Record<string, ProjectionValue>>;
68
+ }
69
+ /** One decoded `session/control` frame with protocol field names already resolved. */
70
+ export type ControlFrame = {
71
+ kind: 'baseline';
72
+ projections: ReadonlyMap<string, ProjectionSnapshot>;
73
+ queues: ReadonlyMap<string, readonly QueuedInput[]>;
74
+ jobs: ReadonlyMap<string, number>;
75
+ } | {
76
+ kind: 'projection';
77
+ sessionId: string;
78
+ key: string;
79
+ seq: number;
80
+ value: ProjectionValue;
81
+ } | {
82
+ kind: 'queue';
83
+ sessionId: string;
84
+ items: readonly QueuedInput[];
85
+ } | {
86
+ kind: 'jobs';
87
+ sessionId: string;
88
+ count: number;
89
+ };
90
+ /** Decode one projection baseline, as carried by a `session/control` baseline or a follow snapshot. */
91
+ export declare function projectionSnapshot(value: unknown): ProjectionSnapshot | undefined;
92
+ /** Turn one decoded `session/control` frame into a semantic control frame.
93
+ * @param value - One decoded `session/control` frame.
94
+ * @returns The normalized frame.
95
+ */
96
+ export declare function controlFrame(value: unknown): ControlFrame;
97
+ /** Turn one decoded `$events` frame into a semantic host event.
98
+ *
99
+ * Returns `undefined` for anything this client does not act on, including the `ready` handshake,
100
+ * which belongs to the connection lifecycle rather than to a domain.
101
+ * @param frame - One decoded `$events` frame.
102
+ * @returns The normalized event, or undefined when it is not one this client consumes.
103
+ */
104
+ export declare function hostEvent(frame: ObjectValue): HostEvent | undefined;
@@ -0,0 +1,149 @@
1
+ /** One place that reads DSH event field names and turns them into semantic host events.
2
+ *
3
+ * Raw wire stops here: no `Json`/`ObjectValue` payload survives into a `HostEvent`, so `connection/`,
4
+ * `session/` and the UI never learn how the host names its fields. Replying `{kind:'next'}` to an
5
+ * unrecognized waterfall is a wire-level obligation of the host's event chain, so it is modelled as
6
+ * `waterfall-delegate` rather than silently dropped.
7
+ */
8
+ import { array, errorText, object, string } from "./wire.js";
9
+ /** Host notifications that invalidate the model catalog. */
10
+ const CATALOG_EVENTS = new Set(['llm/adapters-updated', 'settings/document-updated', 'credentials/reference-updated']);
11
+ /** Require a projection watermark rather than accepting a rolled-back value. */
12
+ function sequence(value) {
13
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < -1)
14
+ throw new Error('Invalid projection watermark');
15
+ return value;
16
+ }
17
+ /** Flatten one session's pending-input list. */
18
+ function queuedInputs(value) {
19
+ return array(value).map(raw => {
20
+ const item = object(raw);
21
+ if (!['queued', 'steering', 'context'].includes(string(item.placement)))
22
+ throw new Error('Invalid queue placement');
23
+ return { id: string(item.id), placement: item.placement,
24
+ text: array(object(item.message).content).map(object)
25
+ .map(block => block.type === 'text' ? string(block.text) : `[${string(block.type)}]`).join(' ') };
26
+ });
27
+ }
28
+ /** Count the jobs the host still considers active. */
29
+ function activeJobs(value) {
30
+ return array(value).filter(item => ['running', 'stopping'].includes(string(object(item).status))).length;
31
+ }
32
+ /** Decode one projection baseline, as carried by a `session/control` baseline or a follow snapshot. */
33
+ export function projectionSnapshot(value) {
34
+ if (value === undefined)
35
+ return undefined;
36
+ const baseline = object(value);
37
+ return { asOfSeq: sequence(baseline.asOfSeq), values: object(baseline.values) };
38
+ }
39
+ /** Turn one decoded `session/control` frame into a semantic control frame.
40
+ * @param value - One decoded `session/control` frame.
41
+ * @returns The normalized frame.
42
+ */
43
+ export function controlFrame(value) {
44
+ const frame = object(value);
45
+ if (frame.type === 'baseline') {
46
+ const baseline = object(frame.value);
47
+ const projections = Object.entries(object(baseline.projections))
48
+ .map(([id, snapshot]) => [id, projectionSnapshot(snapshot)]);
49
+ const queues = Object.entries(object(baseline.queues)).map(([id, items]) => [id, queuedInputs(items)]);
50
+ const jobs = Object.entries(object(baseline.jobs)).map(([id, items]) => [id, activeJobs(items)]);
51
+ return { kind: 'baseline', projections: new Map(projections), queues: new Map(queues), jobs: new Map(jobs) };
52
+ }
53
+ if (frame.type === 'projection' || frame.type === 'queue' || frame.type === 'jobs') {
54
+ const sessionId = string(frame.sessionId);
55
+ if (frame.type === 'projection') {
56
+ if (frame.value === undefined)
57
+ throw new Error('Missing projection value');
58
+ return { kind: 'projection', sessionId, key: string(frame.key), seq: sequence(frame.seq), value: frame.value };
59
+ }
60
+ if (frame.type === 'queue')
61
+ return { kind: 'queue', sessionId, items: queuedInputs(frame.items) };
62
+ // The host names the job rows `jobs`; the loopback fixtures used `items`, so accept either.
63
+ return { kind: 'jobs', sessionId, count: activeJobs(frame.jobs ?? frame.items) };
64
+ }
65
+ throw new Error('Unknown session control frame');
66
+ }
67
+ /** Read a possibly-absent string field without turning protocol drift into a throw. */
68
+ function optionalString(value) {
69
+ return typeof value === 'string' ? value : undefined;
70
+ }
71
+ /** Flatten one raw question into the semantic shape the session domain retains. */
72
+ function questionItem(value) {
73
+ const item = object(value);
74
+ const options = array(item.options ?? []).map(raw => {
75
+ const option = object(raw);
76
+ const description = optionalString(option.description);
77
+ return { label: string(option.label), ...(description === undefined ? {} : { description }) };
78
+ });
79
+ const header = optionalString(item.header);
80
+ const detail = optionalString(item.detail);
81
+ return {
82
+ id: string(item.id),
83
+ ...(header === undefined ? {} : { header }),
84
+ question: string(item.question),
85
+ ...(detail === undefined ? {} : { detail }),
86
+ multiSelect: item.multiSelect === true,
87
+ options,
88
+ };
89
+ }
90
+ /** Decode one `$events` waterfall frame. */
91
+ function waterfallEvent(frame) {
92
+ const eventId = string(frame.eventId);
93
+ const sessionId = optionalString(frame.agentId) ?? '';
94
+ if (frame.event === 'approval/request') {
95
+ // The approval body is host-defined and currently rendered verbatim; formatting it here keeps
96
+ // the terminal output byte-identical while removing the raw object from the boundary.
97
+ return { kind: 'approval-request', eventId, sessionId, description: JSON.stringify(frame.request ?? null, null, 2) };
98
+ }
99
+ if (frame.event === 'user-questions/request') {
100
+ try {
101
+ const request = object(frame.request);
102
+ return { kind: 'question-request', eventId, sessionId, questions: array(request.questions ?? []).map(questionItem) };
103
+ }
104
+ catch {
105
+ // A malformed question still has to be settled, or the host's event chain blocks on it.
106
+ return { kind: 'waterfall-delegate', eventId };
107
+ }
108
+ }
109
+ return { kind: 'waterfall-delegate', eventId };
110
+ }
111
+ /** Decode one `$events` emit frame this client reacts to. */
112
+ function emitEvent(frame) {
113
+ const name = frame.event;
114
+ if (name === 'api-session/status') {
115
+ const args = array(frame.args);
116
+ if (typeof args[1] !== 'boolean')
117
+ throw new Error('Invalid session running state');
118
+ return { kind: 'agent-status', sessionId: string(args[0]), running: args[1] };
119
+ }
120
+ if (name === 'api-session/error') {
121
+ const args = array(frame.args);
122
+ return { kind: 'session-error', sessionId: optionalString(args[0]) ?? '', error: errorText(args[1]) };
123
+ }
124
+ if (typeof name === 'string' && CATALOG_EVENTS.has(name))
125
+ return { kind: 'catalog-invalidated' };
126
+ return undefined;
127
+ }
128
+ /** Turn one decoded `$events` frame into a semantic host event.
129
+ *
130
+ * Returns `undefined` for anything this client does not act on, including the `ready` handshake,
131
+ * which belongs to the connection lifecycle rather than to a domain.
132
+ * @param frame - One decoded `$events` frame.
133
+ * @returns The normalized event, or undefined when it is not one this client consumes.
134
+ */
135
+ export function hostEvent(frame) {
136
+ try {
137
+ if (frame.type === 'waterfall')
138
+ return waterfallEvent(frame);
139
+ if (frame.type === 'cancel')
140
+ return { kind: 'cancel', eventId: string(frame.eventId) };
141
+ if (frame.type === 'emit')
142
+ return emitEvent(frame);
143
+ }
144
+ catch {
145
+ // A frame this client cannot decode is not allowed to fail the whole subscription.
146
+ return undefined;
147
+ }
148
+ return undefined;
149
+ }
@@ -1,17 +1,9 @@
1
- /** JSON validation and terminal-safe presentation at the remote boundary. */
2
- export type Json = null | boolean | number | string | Json[] | {
3
- [key: string]: Json;
4
- };
5
- export type ObjectValue = {
6
- [key: string]: Json;
7
- };
8
- /** Require an object from a decoded wire message. */
9
- export declare function object(value: unknown): ObjectValue;
10
- /** Require a string field rather than silently accepting protocol drift. */
11
- export declare function string(value: unknown): string;
12
- /** Require an array field from the server. */
13
- export declare function array(value: unknown): Json[];
14
- /** Remove terminal controls from remote text while retaining line breaks and tabs. */
15
- export declare function safeText(value: string): string;
16
- /** Return a displayable error without serializing request headers or credentials. */
17
- export declare function errorText(error: unknown): string;
1
+ /** The wire's decoding surface.
2
+ *
3
+ * The JSON shapes, their strict readers and the displayable error text are dependency-free leaves
4
+ * (`src/json.ts`, `src/text.ts`), so the UI can name the same types without importing the transport
5
+ * domain. This module re-exports them as the wire-facing API the features already use.
6
+ */
7
+ export type { Json, ObjectValue } from '../json.ts';
8
+ export { array, object, string } from '../json.ts';
9
+ export { errorText, safeText } from '../text.ts';
@@ -1,27 +1,2 @@
1
- /** Require an object from a decoded wire message. */
2
- export function object(value) {
3
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
4
- throw new Error('Expected a JSON object from the server');
5
- }
6
- return value;
7
- }
8
- /** Require a string field rather than silently accepting protocol drift. */
9
- export function string(value) {
10
- if (typeof value !== 'string')
11
- throw new Error('Expected a string from the server');
12
- return value;
13
- }
14
- /** Require an array field from the server. */
15
- export function array(value) {
16
- if (!Array.isArray(value))
17
- throw new Error('Expected an array from the server');
18
- return value;
19
- }
20
- /** Remove terminal controls from remote text while retaining line breaks and tabs. */
21
- export function safeText(value) {
22
- return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '');
23
- }
24
- /** Return a displayable error without serializing request headers or credentials. */
25
- export function errorText(error) {
26
- return safeText(error instanceof Error ? error.message : String(error));
27
- }
1
+ export { array, object, string } from "../json.js";
2
+ export { errorText, safeText } from "../text.js";