@itookit/dsht 0.2.4 → 0.3.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 (129) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +54 -25
  3. package/README.zh.md +54 -25
  4. package/dist/catalog/controller.d.ts +32 -0
  5. package/dist/catalog/controller.js +88 -0
  6. package/dist/catalog/index.d.ts +2 -0
  7. package/dist/catalog/index.js +2 -0
  8. package/dist/{cli.js → cli/index.js} +40 -24
  9. package/dist/controller/connection.d.ts +80 -0
  10. package/dist/controller/connection.js +190 -0
  11. package/dist/controller/controller.d.ts +260 -0
  12. package/dist/controller/controller.js +362 -0
  13. package/dist/controller/index.d.ts +5 -0
  14. package/dist/controller/index.js +3 -0
  15. package/dist/controller/memory-log.d.ts +35 -0
  16. package/dist/controller/memory-log.js +95 -0
  17. package/dist/cost/controller.d.ts +34 -0
  18. package/dist/cost/controller.js +107 -0
  19. package/dist/cost/index.d.ts +9 -0
  20. package/dist/cost/index.js +7 -0
  21. package/dist/cost/ledger-files.d.ts +20 -0
  22. package/dist/cost/ledger-files.js +128 -0
  23. package/dist/cost/ledger.d.ts +65 -0
  24. package/dist/cost/ledger.js +136 -0
  25. package/dist/cost/pricing.d.ts +48 -0
  26. package/dist/cost/pricing.js +141 -0
  27. package/dist/cost/records.d.ts +17 -0
  28. package/dist/cost/records.js +66 -0
  29. package/dist/cost/scanner.d.ts +22 -0
  30. package/dist/cost/scanner.js +95 -0
  31. package/dist/cost/types.d.ts +69 -0
  32. package/dist/cost/types.js +3 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.js +2 -0
  35. package/dist/session/connection-view.d.ts +18 -0
  36. package/dist/session/connection-view.js +1 -0
  37. package/dist/{controller.d.ts → session/controller.d.ts} +119 -137
  38. package/dist/session/controller.js +616 -0
  39. package/dist/session/export-html.d.ts +9 -0
  40. package/dist/session/export-html.js +39 -0
  41. package/dist/session/export.d.ts +9 -0
  42. package/dist/session/export.js +38 -0
  43. package/dist/{history.d.ts → session/history.d.ts} +18 -1
  44. package/dist/session/history.js +338 -0
  45. package/dist/session/index.d.ts +17 -0
  46. package/dist/session/index.js +10 -0
  47. package/dist/session/markdown.d.ts +39 -0
  48. package/dist/session/markdown.js +255 -0
  49. package/dist/session/math.d.ts +11 -0
  50. package/dist/session/math.js +82 -0
  51. package/dist/{navigation.d.ts → session/navigation.d.ts} +1 -1
  52. package/dist/{navigation.js → session/navigation.js} +1 -1
  53. package/dist/{references.js → session/references.js} +1 -1
  54. package/dist/{telemetry.d.ts → session/telemetry.d.ts} +12 -1
  55. package/dist/{telemetry.js → session/telemetry.js} +18 -4
  56. package/dist/{transcript.d.ts → session/transcript.d.ts} +23 -1
  57. package/dist/{transcript.js → session/transcript.js} +36 -18
  58. package/dist/session/types.d.ts +18 -0
  59. package/dist/session/types.js +2 -0
  60. package/dist/state.d.ts +41 -0
  61. package/dist/state.js +9 -0
  62. package/dist/storage/directories.d.ts +14 -0
  63. package/dist/storage/directories.js +24 -0
  64. package/dist/storage/files.d.ts +43 -0
  65. package/dist/storage/files.js +135 -0
  66. package/dist/storage/index.d.ts +3 -0
  67. package/dist/storage/index.js +3 -0
  68. package/dist/transport/auth.js +65 -0
  69. package/dist/{client.d.ts → transport/client.d.ts} +8 -1
  70. package/dist/{client.js → transport/client.js} +21 -4
  71. package/dist/transport/host.d.ts +13 -0
  72. package/dist/transport/host.js +1 -0
  73. package/dist/ui/app.d.ts +11 -0
  74. package/dist/ui/app.js +726 -0
  75. package/dist/ui/chat/header.d.ts +11 -0
  76. package/dist/ui/chat/header.js +14 -0
  77. package/dist/{history-view.d.ts → ui/chat/history-view.d.ts} +1 -1
  78. package/dist/{history-view.js → ui/chat/history-view.js} +3 -3
  79. package/dist/ui/chat/status.d.ts +79 -0
  80. package/dist/ui/chat/status.js +311 -0
  81. package/dist/ui/chat/viewport.d.ts +17 -0
  82. package/dist/ui/chat/viewport.js +14 -0
  83. package/dist/ui/commands/parse.d.ts +96 -0
  84. package/dist/ui/commands/parse.js +121 -0
  85. package/dist/ui/commands/registry.d.ts +33 -0
  86. package/dist/ui/commands/registry.js +72 -0
  87. package/dist/ui/copy-mode.d.ts +4 -0
  88. package/dist/ui/copy-mode.js +6 -0
  89. package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
  90. package/dist/{cost-view.js → ui/dialogs/cost.js} +3 -3
  91. package/dist/ui/dialogs/index.d.ts +120 -0
  92. package/dist/ui/dialogs/index.js +113 -0
  93. package/dist/ui/dialogs/picker.d.ts +18 -0
  94. package/dist/ui/dialogs/picker.js +38 -0
  95. package/dist/ui/frozen.d.ts +8 -0
  96. package/dist/ui/frozen.js +7 -0
  97. package/dist/ui/input/references.d.ts +12 -0
  98. package/dist/ui/input/references.js +15 -0
  99. package/dist/ui/mount.d.ts +6 -0
  100. package/dist/ui/mount.js +11 -0
  101. package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
  102. package/dsht-m.png +0 -0
  103. package/package.json +20 -14
  104. package/dist/app.d.ts +0 -21
  105. package/dist/app.js +0 -746
  106. package/dist/auth.js +0 -108
  107. package/dist/controller.js +0 -923
  108. package/dist/cost.d.ts +0 -119
  109. package/dist/cost.js +0 -313
  110. package/dist/history.js +0 -177
  111. package/dist/status.d.ts +0 -28
  112. package/dist/status.js +0 -157
  113. package/dsht.png +0 -0
  114. /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
  115. /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
  116. /package/dist/{memory.js → session/memory.js} +0 -0
  117. /package/dist/{references.d.ts → session/references.d.ts} +0 -0
  118. /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
  119. /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
  120. /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
  121. /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
  122. /package/dist/{wire.js → transport/wire.js} +0 -0
  123. /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
  124. /package/dist/{input-history.js → ui/input/history.js} +0 -0
  125. /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
  126. /package/dist/{input.js → ui/input/input.js} +0 -0
  127. /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
  128. /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
  129. /package/dist/{theme.js → ui/theme/index.js} +0 -0
@@ -1,923 +0,0 @@
1
- /** UI state and connection generations for the standalone terminal client. */
2
- import { randomUUID } from 'node:crypto';
3
- import { setTimeout as delay } from 'node:timers/promises';
4
- import { Client, HttpError, RemoteError } from "./client.js";
5
- import { AuthenticationRequired } from "./auth.js";
6
- import { resolveTarget, sessionLabel } from "./navigation.js";
7
- import { costRecords } from "./cost.js";
8
- import { Telemetry } from "./telemetry.js";
9
- import { Transcript, toolLine } from "./transcript.js";
10
- import { releaseHistoryLayout } from "./history.js";
11
- import { DEFAULT_HISTORY_LIMITS } from "./memory.js";
12
- import { fileReferences } from "./references.js";
13
- import { array, errorText, object, string } from "./wire.js";
14
- /** Wire addresses for one `session/list` row, in the order the cost scan should try them.
15
- *
16
- * A subagent child is reachable only under its durable parent, and the list row omits the delivery
17
- * mode, so both modes are offered with the continuable form first.
18
- * @param session - One row from the host session list.
19
- * @returns One plain-session address, or both subagent forms when the row is a child.
20
- */
21
- export function costAddresses(session) {
22
- const sessionId = string(session.sessionId);
23
- const parentSessionId = typeof session.parentSessionId === 'string' ? session.parentSessionId : '';
24
- if (session.origin !== 'subagent' || parentSessionId === '')
25
- return [{ kind: 'session', sessionId }];
26
- return [
27
- { kind: 'subagent', parentSessionId, childSessionId: sessionId, mode: 'continuable' },
28
- { kind: 'subagent', parentSessionId, childSessionId: sessionId, mode: 'one-shot' },
29
- ];
30
- }
31
- /** Owns reconnects and subscriptions. User commands remain single-attempt operations. */
32
- export class Controller {
33
- base;
34
- initialSession;
35
- makeClient;
36
- authenticate;
37
- costs;
38
- historyLimits;
39
- state = { version: 0, online: false, busy: false, screen: 'workspaces', status: 'Connecting…',
40
- error: '', workspaces: [], sessions: [], showAllSessions: false, pending: [], transcript: new Transcript() };
41
- observers = new Set();
42
- interactions = new Map();
43
- abort = new AbortController();
44
- client;
45
- clientId = '';
46
- follow;
47
- runTask;
48
- generationFailed;
49
- selection = 0;
50
- historyPinned = false;
51
- telemetry = new Telemetry(new Set(['title', 'modelSelection', 'contextPressure', 'tokenUsage', 'sessionStats', 'agentPreset']));
52
- observedRunningAt = new Map();
53
- presetClient;
54
- catalogRevision = 0;
55
- catalogTasks = new Set();
56
- runningUpdates = new Map();
57
- stoppingSession;
58
- interruptTask;
59
- admission;
60
- costUpdates = new Map();
61
- costAbort;
62
- costTask;
63
- costTimer;
64
- /** Host running state covers model generation, tools, and waits between assistant attempts. */
65
- get running() {
66
- const id = this.state.sessionId;
67
- return id !== undefined && (this.runningUpdates.get(id)
68
- ?? this.state.sessions.find(row => row.sessionId === id)?.running === true);
69
- }
70
- /** Current title projection, falling back to the list title and then the session ID. */
71
- get sessionName() {
72
- const id = this.state.sessionId;
73
- if (!id)
74
- return;
75
- const title = this.telemetry.view(id).values.title;
76
- const row = this.state.sessions.find(item => item.sessionId === id);
77
- return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
78
- : row ? sessionLabel(row) : id;
79
- }
80
- /** Current agent-preset name, matching the web header's built-in labels and custom metadata. */
81
- get sessionMode() {
82
- if (!this.state.sessionId)
83
- return undefined;
84
- const id = this.telemetry.view(this.state.sessionId).values.agentPreset;
85
- if (typeof id !== 'string')
86
- return undefined;
87
- const preset = this.state.presets?.find(item => item.id === id);
88
- const builtIn = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
89
- return preset?.trust === 'system' && builtIn.has(id) ? builtIn.get(id)
90
- : typeof preset?.name === 'string' ? preset.name : id;
91
- }
92
- /** Load the optional preset roster once per connection, only when a session names a preset. */
93
- loadPresetNames() {
94
- const client = this.client;
95
- if (!client || !this.state.online || this.presetClient === client)
96
- return;
97
- this.presetClient = client;
98
- const task = client.call('agentPresets/list', {}).then(value => {
99
- if (client === this.client)
100
- this.update({ presets: array(object(value).presets).map(object), presetError: undefined });
101
- }).catch(error => {
102
- if (client === this.client)
103
- this.update({ presets: [], presetError: errorText(error) });
104
- });
105
- this.catalogTasks.add(task);
106
- void task.finally(() => this.catalogTasks.delete(task));
107
- }
108
- /** Fetch current model routes and adapter-owned reasoning choices for the selected session.
109
- * @returns Host catalog; provider failures remain available to the selector.
110
- */
111
- async modelCatalog() {
112
- const sessionId = this.sessionId;
113
- const selection = this.selection;
114
- const value = object(await this.host.call('session/modelCatalog', {}));
115
- if (selection !== this.selection || sessionId !== this.state.sessionId)
116
- throw new Error('Session changed while loading models');
117
- return value;
118
- }
119
- /** Select the next request's model; the host also attempts to save its deployment default.
120
- * @param provider - Host provider route ID.
121
- * @param model - Exact model ID.
122
- * @param reasoningEffort - Optional adapter-owned effort ID; omission uses its default.
123
- */
124
- async selectModel(provider, model, reasoningEffort) {
125
- const sessionId = this.sessionId;
126
- const selection = this.selection;
127
- const selected = object(object(await this.host.call('session/selectModel', { request: {
128
- sessionId, provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
129
- } })).selected);
130
- if (selection !== this.selection || sessionId !== this.state.sessionId)
131
- return;
132
- this.update({ status: `Next request: ${string(selected.provider)} / ${string(selected.model)}${selected.reasoningEffort ? ` · ${string(selected.reasoningEffort)}` : ''}` });
133
- this.refreshCatalog(this.host);
134
- }
135
- /** Epoch start from the retained turn log, or when this client first observed the run. */
136
- get workingSince() {
137
- if (!this.running || !this.state.sessionId)
138
- return undefined;
139
- return this.state.transcript.activeTurnStartedAt ?? this.observedRunningAt.get(this.state.sessionId);
140
- }
141
- /** Stop the selected turn, or allow exit only while idle. Repeated keys share one request.
142
- * @param force - Send an explicit cancellation even when the cached running flag is idle.
143
- * @returns True when the caller may exit; cancellation failures retain the client.
144
- */
145
- interrupt(force = false) {
146
- if (this.interruptTask)
147
- return this.interruptTask;
148
- if (!force && !this.running && !this.admission && this.state.pending.length === 0) {
149
- return Promise.resolve(!this.state.busy);
150
- }
151
- const sessionId = this.sessionId;
152
- this.stoppingSession = sessionId;
153
- this.update({ status: 'Stopping…', error: '' });
154
- const task = (async () => {
155
- try {
156
- // Admission must settle before cancellation can address the newly submitted turn.
157
- // The prompt caller reports admission failures; an existing turn still needs cancellation.
158
- await this.admission?.catch(() => undefined);
159
- await this.host.call('session/cancel', { request: { sessionId } });
160
- if (this.stoppingSession === sessionId && this.state.sessionId === sessionId)
161
- this.update({ status: 'Cancellation requested · waiting for host' });
162
- }
163
- catch (error) {
164
- this.stoppingSession = undefined;
165
- this.update({ status: 'Cancellation failed', error: errorText(error) });
166
- }
167
- return false;
168
- })();
169
- this.interruptTask = task;
170
- void task.finally(() => { this.interruptTask = undefined; });
171
- return task;
172
- }
173
- constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS) {
174
- this.base = base;
175
- this.initialSession = initialSession;
176
- this.makeClient = makeClient;
177
- this.authenticate = authenticate;
178
- this.costs = costs;
179
- this.historyLimits = historyLimits;
180
- }
181
- /** React-compatible state subscription. */
182
- subscribe = (listener) => {
183
- this.observers.add(listener);
184
- return () => this.observers.delete(listener);
185
- };
186
- /** Snapshot identity changes only when the controller publishes. */
187
- snapshot = () => this.state;
188
- /** Start one retry loop, with a fresh snapshot generation after every disconnect. */
189
- start() { this.runTask ??= this.run(); }
190
- /** Cancel retries and HTTP, close the socket, and wait for the loop to settle. */
191
- async stop() {
192
- this.abort.abort();
193
- clearInterval(this.costTimer);
194
- this.generationFailed?.(new Error('Client stopped'));
195
- await this.client?.close();
196
- await this.runTask;
197
- await this.interruptTask;
198
- await Promise.all(this.catalogTasks);
199
- await this.costTask;
200
- this.releaseTranscript();
201
- }
202
- /** Keep history stable while the user reads, searches, or expands it; trim again at the live tail.
203
- * @param pinned - Whether the main transcript is actively being read away from its tail.
204
- */
205
- pinHistory(pinned) {
206
- this.historyPinned = pinned;
207
- if (!pinned && this.reclaimHistory())
208
- this.update({});
209
- }
210
- reclaimHistory() {
211
- if (this.historyPinned || !this.state.online)
212
- return 0;
213
- const removed = this.state.transcript.trimHistory(this.historyLimits);
214
- if (removed)
215
- releaseHistoryLayout(this.state.transcript);
216
- return removed;
217
- }
218
- releaseTranscript() {
219
- releaseHistoryLayout(this.state.transcript);
220
- this.state.transcript.dispose();
221
- this.historyPinned = false;
222
- }
223
- /** Stop the selected turn and then close, so quitting does not leave host work running.
224
- * An idle session stays untouched, and an in-flight cancellation is awaited rather than repeated.
225
- */
226
- async shutdown() {
227
- if (this.interruptTask || this.running || this.admission)
228
- await this.interrupt(true);
229
- await this.stop();
230
- }
231
- /** Run a UI operation and expose errors without destroying the current input. */
232
- async perform(operation) {
233
- if (this.state.busy || !this.state.online)
234
- return false;
235
- this.update({ busy: true, error: '' });
236
- try {
237
- await operation();
238
- return true;
239
- }
240
- catch (error) {
241
- this.update({ error: errorText(error) });
242
- return false;
243
- }
244
- finally {
245
- this.update({ busy: false });
246
- }
247
- }
248
- /** Refresh all HTTP-visible sessions without changing the selected conversation.
249
- * @param signal - Optional cancellation for an explicit /cost refresh.
250
- */
251
- async refreshCosts(signal = this.abort.signal) {
252
- if (!this.costs)
253
- return;
254
- if (this.costTask) {
255
- const cancel = () => this.costAbort?.abort();
256
- signal.addEventListener('abort', cancel, { once: true });
257
- try {
258
- await this.costTask;
259
- }
260
- finally {
261
- signal.removeEventListener('abort', cancel);
262
- }
263
- return;
264
- }
265
- this.costAbort = new AbortController();
266
- signal = AbortSignal.any([signal, this.abort.signal, this.costAbort.signal]);
267
- const ledger = this.costs;
268
- const client = this.host;
269
- ledger.scanning = true;
270
- ledger.error = '';
271
- this.update({});
272
- const task = (async () => {
273
- try {
274
- const sessions = array(object(await client.call('session/list', { _request: {} }, signal)).items).map(object);
275
- signal.throwIfAborted();
276
- const failures = [];
277
- for (const session of sessions) {
278
- signal.throwIfAborted();
279
- const sessionId = string(session.sessionId);
280
- if (!session.running && typeof session.updatedAt === 'number' && this.costUpdates.get(sessionId) === session.updatedAt)
281
- continue;
282
- try {
283
- const history = await this.sessionCostHistory(session, signal);
284
- await ledger.replace(sessionId, history.cursor, history.events);
285
- if (!session.running && typeof session.updatedAt === 'number')
286
- this.costUpdates.set(sessionId, session.updatedAt);
287
- this.update({});
288
- }
289
- catch (error) {
290
- // One unreachable or rejected session must not freeze every other session's rates.
291
- signal.throwIfAborted();
292
- failures.push(`${sessionId}: ${errorText(error)}`);
293
- }
294
- }
295
- ledger.error = failures.length === 0 ? '' : `${failures.length} of ${sessions.length} sessions failed: ${failures[0]}`;
296
- ledger.scannedAt = Date.now();
297
- }
298
- catch (error) {
299
- ledger.error = errorText(error);
300
- }
301
- finally {
302
- ledger.scanning = false;
303
- this.update({});
304
- }
305
- })();
306
- this.costTask = task;
307
- try {
308
- await task;
309
- }
310
- finally {
311
- this.costTask = undefined;
312
- }
313
- }
314
- /** Read one session's complete cost history, retrying a subagent child with its other delivery mode. */
315
- async sessionCostHistory(session, signal) {
316
- let lastError;
317
- for (const address of costAddresses(session)) {
318
- try {
319
- return await this.readCostHistory(address, signal);
320
- }
321
- catch (error) {
322
- lastError = error;
323
- // Only a delivery-mode mismatch justifies the other form; every other failure is final here.
324
- if (!(error instanceof RemoteError && error.code === 'subagent/unauthorized'))
325
- throw error;
326
- }
327
- }
328
- throw lastError;
329
- }
330
- /** Page one addressed session's history into the billing events the ledger folds. */
331
- async readCostHistory(address, signal) {
332
- const client = this.host;
333
- const snapshot = await new Promise((resolve, reject) => {
334
- let sub;
335
- const timeout = setTimeout(() => finish(new Error('Cost history snapshot timed out')), client.timeoutMs);
336
- const onAbort = () => finish(new Error('Cost refresh cancelled'));
337
- const finish = (error, frame) => {
338
- clearTimeout(timeout);
339
- signal.removeEventListener('abort', onAbort);
340
- sub?.cancel();
341
- if (error)
342
- reject(error);
343
- else
344
- resolve(frame);
345
- };
346
- signal.addEventListener('abort', onAbort, { once: true });
347
- try {
348
- sub = client.subscribe('session/follow', { request: { address, maxMessages: 80, assistantStream: true } }, {
349
- item: value => { const frame = object(value); if (frame.type === 'snapshot')
350
- finish(undefined, frame); },
351
- end: error => finish(error ?? new Error('Cost history stream ended')),
352
- });
353
- }
354
- catch (error) {
355
- finish(error instanceof Error ? error : new Error(errorText(error)));
356
- }
357
- });
358
- const cursor = snapshot.cursor;
359
- if (typeof cursor !== 'number' || !Number.isSafeInteger(cursor))
360
- throw new Error('Invalid cost history cursor');
361
- let page = snapshot;
362
- const events = [];
363
- while (true) {
364
- signal.throwIfAborted();
365
- const records = array(page.records);
366
- events.push(...costRecords(records));
367
- if (!page.hasMore)
368
- break;
369
- const seqs = records.map(r => object(object(r).event).seq);
370
- if (!seqs.length || seqs.some(n => typeof n !== 'number' || !Number.isSafeInteger(n)))
371
- throw new Error('Invalid cost history page');
372
- const beforeSeq = Math.min(...seqs);
373
- page = object(await client.call('session/page', { request: { address, throughSeq: cursor, beforeSeq, maxMessages: 80 } }, signal));
374
- if (page.hasMore && array(page.records).every(r => Number(object(object(r).event).seq) >= beforeSeq))
375
- throw new Error('Cost history page did not advance');
376
- }
377
- if (object(snapshot.header).isSeeded === true && !events.some(e => e.type === 'session/end-seed' && object(e.data).inherited === true))
378
- throw new Error('Cannot attribute inherited session usage');
379
- return { cursor, events };
380
- }
381
- /** Refresh both lists from the host, then show the requested picker. */
382
- async showPicker(screen) {
383
- const [workspaces, sessions] = await Promise.all([this.host.listWorkspaces(), this.host.listSessions()]);
384
- this.update({ screen, workspaces, sessions });
385
- }
386
- /** Resolve a removal command to one reviewable object without changing the selection.
387
- * @param kind - Workspace registration removal or session archival.
388
- * @param query - Exact name, ID, or unambiguous ID prefix.
389
- * @returns The fixed identity and display details for confirmation.
390
- */
391
- async removalTarget(kind, query) {
392
- const items = kind === 'workspace' ? await this.host.listWorkspaces() : await this.host.listSessions();
393
- const row = resolveTarget(items, query, kind === 'workspace' ? 'workspaceId' : 'sessionId', item => kind === 'workspace' ? [string(item.title), string(item.path)] : [sessionLabel(item)]);
394
- return kind === 'workspace' ? { kind, id: string(row.workspaceId), name: string(row.title), path: string(row.path) }
395
- : { kind, id: string(row.sessionId), name: sessionLabel(row),
396
- empty: row.blank === true && row.running === false
397
- && !this.runningUpdates.get(string(row.sessionId))
398
- && !(this.telemetry.view(string(row.sessionId)).queued ?? 0)
399
- && !(this.telemetry.view(string(row.sessionId)).jobs ?? 0)
400
- && !(this.state.sessionId === row.sessionId && this.admission) };
401
- }
402
- /** Apply a confirmed removal or freshly verified empty-session archival; directories and logs are preserved.
403
- * @param target - Exact workspace or session identity reviewed by the user.
404
- */
405
- async removeTarget(target) {
406
- const client = this.host;
407
- const receipt = object(await client.call(target.kind === 'workspace' ? 'workspace/delete' : 'workspace/archiveSession', {
408
- request: target.kind === 'workspace' ? { workspaceId: target.id } : { sessionId: target.id },
409
- }));
410
- if (client !== this.client)
411
- return;
412
- if (target.kind === 'session') {
413
- client.archivedSessionIds = new Set(array(receipt.archivedSessionIds).map(string));
414
- if (this.state.sessionId === target.id) {
415
- this.pickWorkspace(this.state.workspaceId);
416
- }
417
- }
418
- else {
419
- if (receipt.deleted !== true)
420
- throw new Error('Host did not confirm workspace removal');
421
- this.update({ workspaces: this.state.workspaces.filter(row => row.workspaceId !== target.id),
422
- ...(this.state.workspaceId === target.id ? { workspaceId: undefined } : {}) });
423
- }
424
- this.update({ screen: target.kind === 'workspace' ? 'workspaces' : 'sessions',
425
- status: target.kind === 'workspace' ? 'Workspace registration removed' : 'Session archived' });
426
- // A refresh failure must not make a successful mutation look like a rejected deletion.
427
- try {
428
- await this.showPicker(target.kind === 'workspace' ? 'workspaces' : 'sessions');
429
- }
430
- catch (error) {
431
- this.update({ error: `Removal completed; list refresh failed: ${errorText(error)}` });
432
- }
433
- }
434
- /** Pick a workspace, or use all sessions when the identity is omitted. */
435
- pickWorkspace(workspaceId) {
436
- this.selection++;
437
- this.follow?.cancel();
438
- this.follow = undefined;
439
- this.releaseTranscript();
440
- this.update({ workspaceId, sessionId: undefined, showAllSessions: false, transcript: new Transcript(), screen: 'sessions' });
441
- }
442
- /** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix. */
443
- async switchWorkspace(query) {
444
- if (!query) {
445
- await this.showPicker('workspaces');
446
- return;
447
- }
448
- const workspaces = await this.host.listWorkspaces();
449
- const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
450
- const sessions = await this.host.listSessions();
451
- this.update({ workspaces, sessions });
452
- this.pickWorkspace(string(workspace.workspaceId));
453
- }
454
- /** Guide workspace selection, list all sessions with `all`, or resolve an exact session target. */
455
- async switchSession(query) {
456
- if (!query || query === 'all') {
457
- await this.showPicker(query === 'all' || this.state.workspaceId ? 'sessions' : 'workspaces');
458
- this.update({ showAllSessions: query === 'all' });
459
- return;
460
- }
461
- const [workspaces, sessions] = await Promise.all([this.host.listWorkspaces(), this.host.listSessions()]);
462
- const session = resolveTarget(sessions, query, 'sessionId', item => [sessionLabel(item)]);
463
- this.update({ workspaces, sessions });
464
- await this.selectSession(string(session.sessionId));
465
- }
466
- /** Prompt for a host path without starting a local agent. */
467
- enterPath() { this.update({ screen: 'path' }); }
468
- /** Register a host directory and move to its session picker. */
469
- async createWorkspace(path) {
470
- const result = object(await this.host.call('workspace/create', { request: { path } }));
471
- const workspace = object(result.workspace);
472
- await this.showPicker('workspaces');
473
- this.pickWorkspace(string(workspace.workspaceId));
474
- }
475
- /** Create a session only after the user explicitly selects New session. */
476
- async createSession() {
477
- if (!this.state.workspaceId)
478
- throw new Error('Select a workspace before creating a session');
479
- const result = object(await this.host.call('session/create', { request: { workspaceId: this.state.workspaceId } }));
480
- await this.selectSession(string(result.sessionId));
481
- }
482
- /** Replace the selected transcript and cancel its preceding follow stream. */
483
- async selectSession(sessionId) {
484
- this.stoppingSession = undefined;
485
- const selection = ++this.selection;
486
- this.follow?.cancel();
487
- this.follow = undefined;
488
- this.releaseTranscript();
489
- const transcript = new Transcript();
490
- const workspace = this.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
491
- const workspaceId = workspace ? string(workspace.workspaceId)
492
- : this.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.state.workspaceId;
493
- if (!this.observedRunningAt.has(sessionId))
494
- this.observedRunningAt.set(sessionId, Date.now());
495
- this.update({ sessionId, workspaceId, showAllSessions: false, transcript, screen: 'chat', status: 'Loading session…' });
496
- this.follow = this.host.subscribe('session/follow', {
497
- request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
498
- }, {
499
- item: value => {
500
- if (selection !== this.selection)
501
- return;
502
- try {
503
- transcript.accept(value);
504
- this.reclaimHistory();
505
- const frame = object(value);
506
- if (frame.type === 'snapshot')
507
- this.telemetry.snapshot(sessionId, frame.projections);
508
- this.update({ transcript, status: this.stoppingSession === sessionId ? this.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
509
- }
510
- catch (error) {
511
- this.generationFailed?.(new Error(errorText(error)));
512
- }
513
- },
514
- end: error => {
515
- if (selection !== this.selection)
516
- return;
517
- transcript.ready = false;
518
- this.update({ status: 'Session disconnected', error: errorText(error ?? 'Session stream ended') });
519
- },
520
- });
521
- }
522
- /** Wait for the selected follow snapshot, failing on disconnect or cancellation.
523
- * @param signal - Cancels waiting without closing the session.
524
- */
525
- async waitForHistory(signal) {
526
- const transcript = this.state.transcript;
527
- const deadline = Date.now() + this.host.timeoutMs;
528
- while (!transcript.ready) {
529
- signal.throwIfAborted();
530
- if (!this.state.online || this.state.transcript !== transcript)
531
- throw new Error('Session changed while loading history');
532
- if (Date.now() >= deadline)
533
- throw new Error('Session snapshot timed out');
534
- await delay(20, undefined, { signal });
535
- }
536
- }
537
- /** Search the host's bounded global results, optionally retaining workspace members.
538
- * @param query - Literal message text.
539
- * @param workspaceOnly - Restrict returned hits to the selected workspace's session IDs.
540
- * @param signal - Cancels the HTTP search.
541
- * @returns Session snippets and the global truncation flag, preserved after filtering.
542
- */
543
- async searchSessions(query, workspaceOnly, signal) {
544
- const workspace = this.state.workspaces.find(item => item.workspaceId === this.state.workspaceId);
545
- if (workspaceOnly && !workspace)
546
- throw new Error('Select a workspace first');
547
- const result = object(await this.host.call('session/search', { request: { query } }, signal));
548
- if (!Array.isArray(result.items) || typeof result.hasMore !== 'boolean')
549
- throw new Error('Invalid session search response');
550
- const items = result.items.map(value => {
551
- const item = object(value);
552
- if (typeof item.sessionId !== 'string' || typeof item.snippet !== 'string')
553
- throw new Error('Invalid session search item');
554
- return item;
555
- });
556
- const ids = new Set(array(workspace?.sessionIds ?? []));
557
- return { items: workspaceOnly ? items.filter(item => ids.has(item.sessionId)) : items, hasMore: result.hasMore };
558
- }
559
- /** Search paths on the host; does not read or upload file contents.
560
- * @param query - Path text after @, relative to the selected session cwd.
561
- * @param signal - Cancels an obsolete composer lookup.
562
- * @returns Validated candidates in host order.
563
- */
564
- async references(query, signal) {
565
- return fileReferences(await this.host.call('fileReferences/list', { agentId: this.sessionId, query }, signal));
566
- }
567
- /** Admit a prompt once; a failed response can have an uncertain delivery outcome. */
568
- async prompt(text, mode = 'queue') {
569
- this.stoppingSession = undefined;
570
- if (!this.state.transcript.ready)
571
- throw new Error('Wait for the session snapshot before sending');
572
- const admission = this.host.call('session/prompt', { request: {
573
- sessionId: this.sessionId, requestId: randomUUID(), mode,
574
- content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
575
- } });
576
- this.admission = admission;
577
- try {
578
- await admission;
579
- }
580
- finally {
581
- if (this.admission === admission)
582
- this.admission = undefined;
583
- }
584
- this.update({ status: 'Accepted · waiting for host' });
585
- }
586
- /** Cancel the active turn; pending queue items remain host-owned. */
587
- async cancelTurn() {
588
- await this.host.call('session/cancel', { request: { sessionId: this.sessionId } });
589
- this.update({ status: 'Cancellation requested' });
590
- }
591
- /** Add a page before the retained window using its fixed opening cut. */
592
- async older(signal, transcript = this.state.transcript) {
593
- const selection = this.selection;
594
- if (transcript === this.state.transcript)
595
- this.historyPinned = true;
596
- if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
597
- return;
598
- const result = await this.host.call('session/page', { request: {
599
- address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
600
- beforeSeq: transcript.beforeSeq, maxMessages: 80,
601
- } }, signal);
602
- if (selection !== this.selection)
603
- return;
604
- transcript.addPage(result);
605
- this.update({});
606
- }
607
- /** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
608
- * @param query - Literal, case-insensitive text including folded reasoning.
609
- * @param signal - Cancels HTTP and processing without cancelling the agent.
610
- * @returns Newest-first bounded summaries and an explicit truncation flag.
611
- */
612
- async searchHistory(query, signal) {
613
- const source = this.state.transcript;
614
- if (!source.ready)
615
- throw new Error('Wait for the session snapshot');
616
- const sessionId = this.sessionId;
617
- const selection = this.selection;
618
- const throughSeq = source.readThrough;
619
- const needle = query.toLowerCase();
620
- const result = { items: [], truncated: false };
621
- const scan = (transcript) => {
622
- const messages = transcript.messages;
623
- for (let index = messages.length - 1; index >= 0; index--) {
624
- signal.throwIfAborted();
625
- const message = messages[index];
626
- if (message.role === 'Tool')
627
- continue;
628
- const text = message.text;
629
- const match = text.toLowerCase().indexOf(needle);
630
- if (match < 0)
631
- continue;
632
- if (result.items.length === 200) {
633
- result.truncated = true;
634
- return false;
635
- }
636
- result.items.push({ seq: message.seq, role: message.role, preview: Buffer.from(toolLine(text.slice(Math.max(0, match - 40), match + needle.length + 100), 160)).toString('utf8') });
637
- }
638
- return true;
639
- };
640
- signal.throwIfAborted();
641
- if (!scan(source))
642
- return result;
643
- let beforeSeq = source.beforeSeq;
644
- let hasMore = source.hasMore;
645
- while (hasMore && beforeSeq !== undefined) {
646
- const page = object(await this.host.call('session/page', { request: {
647
- address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
648
- } }, signal));
649
- signal.throwIfAborted();
650
- if (selection !== this.selection)
651
- throw new Error('Session changed while searching history');
652
- const temporary = new Transcript();
653
- try {
654
- temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
655
- const next = temporary.beforeSeq;
656
- if (temporary.hasMore && (next === undefined || next >= beforeSeq))
657
- throw new Error('Host history page did not advance');
658
- if (!scan(temporary))
659
- return result;
660
- beforeSeq = next;
661
- hasMore = temporary.hasMore;
662
- }
663
- finally {
664
- temporary.dispose();
665
- }
666
- }
667
- return result;
668
- }
669
- /** Load a separate small window ending at a search target; the live transcript keeps following.
670
- * @param target - Durable message sequence to display.
671
- * @param signal - Cancels the target-page request.
672
- * @returns A caller-owned historical window that must be disposed when closed.
673
- */
674
- async historyAt(target, signal) {
675
- const source = this.state.transcript;
676
- const selection = this.selection;
677
- const page = object(await this.host.call('session/page', { request: {
678
- address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
679
- beforeSeq: target + 1, maxMessages: 80,
680
- } }, signal));
681
- signal.throwIfAborted();
682
- if (selection !== this.selection)
683
- throw new Error('Session changed while opening history');
684
- const window = new Transcript();
685
- try {
686
- window.accept({ type: 'snapshot', cursor: source.readThrough, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
687
- if (!window.messages.some(message => message.seq === target))
688
- throw new Error('The host did not return the requested message');
689
- return window;
690
- }
691
- catch (error) {
692
- window.dispose();
693
- throw error;
694
- }
695
- }
696
- /** Load the prefix required for an explicit history jump; never loop on an unadvancing page.
697
- * @param target - Visible record sequence, or first for the oldest available history.
698
- * @param signal - Cancels local paging without interrupting the remote agent.
699
- */
700
- async historyThrough(target, signal) {
701
- const transcript = this.state.transcript;
702
- if (!transcript.ready)
703
- throw new Error('Wait for the session snapshot');
704
- while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
705
- signal.throwIfAborted();
706
- const before = transcript.beforeSeq;
707
- await this.older(signal);
708
- if (this.state.transcript !== transcript)
709
- throw new Error('Session changed while loading history');
710
- if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
711
- throw new Error('Host history page did not advance');
712
- }
713
- }
714
- }
715
- /** Answer the oldest selected-session interaction, after explicit user action. */
716
- async answer(value) {
717
- const pending = this.state.pending[0];
718
- if (!pending)
719
- throw new Error('No pending interaction');
720
- await this.reply(pending, { kind: 'result', value });
721
- this.interactions.delete(string(pending.eventId));
722
- this.update({});
723
- }
724
- /** Restrict an approval command to an approval request. */
725
- async approve(allowed) {
726
- if (this.state.pending[0]?.event !== 'approval/request')
727
- throw new Error('No pending approval');
728
- await this.answer(allowed ? 'allowed-once' : 'rejected');
729
- }
730
- /** Present only sessions explicitly accounted to the selected workspace. */
731
- get visibleSessions() {
732
- const sessions = this.state.sessions.filter(item => !this.client?.archivedSessionIds.has(string(item.sessionId)));
733
- if (this.state.showAllSessions || !this.state.workspaceId)
734
- return sessions;
735
- const workspace = this.state.workspaces.find(item => item.workspaceId === this.state.workspaceId);
736
- const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
737
- return sessions.filter(item => ids.has(string(item.sessionId)));
738
- }
739
- get host() {
740
- if (!this.client || !this.state.online)
741
- throw new Error('Not connected');
742
- return this.client;
743
- }
744
- get sessionId() {
745
- if (!this.state.sessionId)
746
- throw new Error('Select a session first');
747
- return this.state.sessionId;
748
- }
749
- update(patch) {
750
- const next = { ...this.state, ...patch, version: this.state.version + 1 };
751
- next.pending = next.online && next.screen === 'chat'
752
- ? [...this.interactions.values()].filter(frame => frame.agentId === next.sessionId) : [];
753
- this.state = next;
754
- for (const observer of this.observers)
755
- observer();
756
- }
757
- async reply(frame, outcome) {
758
- await this.host.call('$events/result', { clientId: this.clientId, eventId: string(frame.eventId), outcome });
759
- }
760
- refreshCatalog(client) {
761
- const revision = ++this.catalogRevision;
762
- const task = client.call('session/modelCatalog', {}).then(value => {
763
- if (client === this.client && revision === this.catalogRevision)
764
- this.update({ defaultModel: object(object(value).default), modelError: undefined });
765
- }, error => {
766
- if (client === this.client && revision === this.catalogRevision)
767
- this.update({ defaultModel: undefined, modelError: errorText(error) });
768
- }).catch(error => {
769
- if (client === this.client && revision === this.catalogRevision)
770
- this.update({ defaultModel: undefined, modelError: errorText(error) });
771
- });
772
- this.catalogTasks.add(task);
773
- void task.finally(() => this.catalogTasks.delete(task));
774
- }
775
- async run() {
776
- let attempt = 0;
777
- while (!this.abort.signal.aborted) {
778
- this.stoppingSession = undefined;
779
- this.runningUpdates.clear();
780
- this.observedRunningAt.clear();
781
- this.telemetry = new Telemetry();
782
- this.update({ controlError: undefined, modelError: undefined, defaultModel: undefined, presets: undefined, presetError: undefined });
783
- const client = this.makeClient();
784
- this.client = client;
785
- try {
786
- await this.authenticate(client);
787
- await client.connect();
788
- let fail;
789
- const disconnected = new Promise(resolve => { fail = resolve; });
790
- this.generationFailed = fail;
791
- const ready = new Promise((resolve, reject) => {
792
- const timer = setTimeout(() => reject(new Error('Host ready timed out')), client.timeoutMs);
793
- client.subscribe('$events', {}, {
794
- item: value => {
795
- const frame = object(value);
796
- if (frame.type === 'ready') {
797
- this.clientId = string(frame.clientId);
798
- clearTimeout(timer);
799
- resolve();
800
- }
801
- else if (frame.type === 'waterfall') {
802
- if (['approval/request', 'user-questions/request'].includes(string(frame.event))) {
803
- this.interactions.set(string(frame.eventId), frame);
804
- this.update({});
805
- }
806
- else {
807
- void client.call('$events/result', { clientId: this.clientId,
808
- eventId: string(frame.eventId), outcome: { kind: 'next' } }).catch(error => fail(new Error(errorText(error))));
809
- }
810
- }
811
- else if (frame.type === 'cancel') {
812
- this.interactions.delete(string(frame.eventId));
813
- this.update({});
814
- }
815
- else if (frame.type === 'emit' && frame.event === 'api-session/status') {
816
- const args = array(frame.args);
817
- const sessionId = string(args[0]);
818
- if (typeof args[1] !== 'boolean')
819
- throw new Error('Invalid session running state');
820
- if (args[1] && !this.runningUpdates.get(sessionId))
821
- this.observedRunningAt.set(sessionId, Date.now());
822
- if (!args[1]) {
823
- this.observedRunningAt.delete(sessionId);
824
- if (this.stoppingSession === sessionId)
825
- this.stoppingSession = undefined;
826
- }
827
- this.runningUpdates.set(sessionId, args[1]);
828
- this.update({ sessions: this.state.sessions.map(row => row.sessionId === sessionId ? { ...row, running: args[1] } : row),
829
- ...(sessionId === this.state.sessionId ? { status: args[1] ? 'Running…' : 'Idle' } : {}) });
830
- if (!args[1] && this.costs && this.state.online)
831
- void this.refreshCosts();
832
- }
833
- else if (frame.type === 'emit' && ['llm/adapters-updated', 'settings/document-updated', 'credentials/reference-updated'].includes(String(frame.event))) {
834
- this.refreshCatalog(client);
835
- }
836
- else if (frame.type === 'emit' && frame.event === 'api-session/error') {
837
- const args = array(frame.args);
838
- if (args[0] === this.state.sessionId)
839
- this.update({ status: 'Agent error', error: errorText(args[1]) });
840
- }
841
- },
842
- end: error => { clearTimeout(timer); const reason = error ?? new Error('Event stream ended'); reject(reason); fail(reason); },
843
- });
844
- });
845
- await ready;
846
- await new Promise((resolve, reject) => {
847
- const timer = setTimeout(() => reject(new Error('Session control baseline timed out')), client.timeoutMs);
848
- client.subscribe('session/control', {}, {
849
- item: value => {
850
- try {
851
- this.telemetry.accept(value);
852
- this.update({});
853
- clearTimeout(timer);
854
- resolve();
855
- }
856
- catch (error) {
857
- clearTimeout(timer);
858
- reject(error);
859
- fail(new Error(errorText(error)));
860
- }
861
- },
862
- end: error => {
863
- clearTimeout(timer);
864
- if (error instanceof RemoteError && error.code === 'gateway/method-unavailable') {
865
- this.update({ controlError: 'Live metrics unavailable on this host' });
866
- resolve();
867
- }
868
- else {
869
- const reason = error ?? new Error('Session control stream ended');
870
- reject(reason);
871
- fail(reason);
872
- }
873
- },
874
- });
875
- });
876
- this.refreshCatalog(client);
877
- this.update({ online: true, status: 'Connected', error: '', pending: [] });
878
- const screen = this.state.screen;
879
- await this.showPicker(screen === 'sessions' ? 'sessions' : 'workspaces');
880
- const sessionId = this.state.sessionId ?? this.initialSession;
881
- if (sessionId && (screen === 'chat' || this.initialSession && !this.state.sessionId))
882
- await this.selectSession(sessionId);
883
- attempt = 0;
884
- if (this.costs) {
885
- void this.refreshCosts();
886
- clearInterval(this.costTimer);
887
- this.costTimer = setInterval(() => { if (this.state.online)
888
- void this.refreshCosts(); }, 60_000);
889
- }
890
- const error = await disconnected;
891
- if (!this.abort.signal.aborted)
892
- throw error;
893
- }
894
- catch (error) {
895
- if (error instanceof AuthenticationRequired || error instanceof HttpError && [401, 403].includes(error.status)) {
896
- this.update({ error: `${errorText(error)}. Set DSH_TOKEN and restart to log in.`, status: 'Login required' });
897
- return;
898
- }
899
- if (!this.abort.signal.aborted)
900
- this.update({ error: errorText(error), status: 'Reconnecting…' });
901
- }
902
- finally {
903
- clearInterval(this.costTimer);
904
- this.generationFailed = undefined;
905
- this.selection++;
906
- this.state.transcript.ready = false;
907
- this.interactions.clear();
908
- this.update({ online: false, pending: [] });
909
- await client.close();
910
- await this.costTask;
911
- }
912
- if (!this.abort.signal.aborted) {
913
- try {
914
- await delay(Math.min(500 * 2 ** attempt++, 10_000) * (0.8 + Math.random() * 0.4), undefined, { signal: this.abort.signal });
915
- }
916
- catch (error) {
917
- if (!this.abort.signal.aborted)
918
- throw error;
919
- }
920
- }
921
- }
922
- }
923
- }