@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.
@@ -0,0 +1,640 @@
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 } from "./transcript.js";
10
+ import { fileReferences } from "./references.js";
11
+ import { array, errorText, object, string } from "./wire.js";
12
+ /** Owns reconnects and subscriptions. User commands remain single-attempt operations. */
13
+ export class Controller {
14
+ base;
15
+ initialSession;
16
+ makeClient;
17
+ authenticate;
18
+ costs;
19
+ state = { version: 0, online: false, busy: false, screen: 'workspaces', status: 'Connecting…',
20
+ error: '', workspaces: [], sessions: [], showAllSessions: false, pending: [], transcript: new Transcript() };
21
+ observers = new Set();
22
+ abort = new AbortController();
23
+ client;
24
+ clientId = '';
25
+ follow;
26
+ runTask;
27
+ generationFailed;
28
+ selection = 0;
29
+ telemetry = new Telemetry();
30
+ observedRunningAt = new Map();
31
+ catalogRevision = 0;
32
+ catalogTasks = new Set();
33
+ runningUpdates = new Map();
34
+ stoppingSession;
35
+ interruptTask;
36
+ admission;
37
+ costUpdates = new Map();
38
+ costAbort;
39
+ costTask;
40
+ costTimer;
41
+ /** Host running state covers model generation, tools, and waits between assistant attempts. */
42
+ get running() {
43
+ const id = this.state.sessionId;
44
+ return id !== undefined && (this.runningUpdates.get(id)
45
+ ?? this.state.sessions.find(row => row.sessionId === id)?.running === true);
46
+ }
47
+ /** Current title projection, falling back to the list title and then the session ID. */
48
+ get sessionName() {
49
+ const id = this.state.sessionId;
50
+ if (!id)
51
+ return;
52
+ const title = this.telemetry.view(id).values.title;
53
+ const row = this.state.sessions.find(item => item.sessionId === id);
54
+ return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
55
+ : row ? sessionLabel(row) : id;
56
+ }
57
+ /** Epoch start from the retained turn log, or when this client first observed the run. */
58
+ get workingSince() {
59
+ if (!this.running || !this.state.sessionId)
60
+ return undefined;
61
+ return this.state.transcript.activeTurnStartedAt ?? this.observedRunningAt.get(this.state.sessionId);
62
+ }
63
+ /** Stop the selected turn, or allow exit only while idle. Repeated keys share one request.
64
+ * @param force - Send an explicit cancellation even when the cached running flag is idle.
65
+ * @returns True when the caller may exit; cancellation failures retain the client.
66
+ */
67
+ interrupt(force = false) {
68
+ if (this.interruptTask)
69
+ return this.interruptTask;
70
+ if (!force && !this.running && !this.admission && this.state.pending.length === 0) {
71
+ return Promise.resolve(!this.state.busy);
72
+ }
73
+ const sessionId = this.sessionId;
74
+ this.stoppingSession = sessionId;
75
+ this.update({ status: 'Stopping…', error: '' });
76
+ const task = (async () => {
77
+ try {
78
+ // Admission must settle before cancellation can address the newly submitted turn.
79
+ // The prompt caller reports admission failures; an existing turn still needs cancellation.
80
+ await this.admission?.catch(() => undefined);
81
+ await this.host.call('session/cancel', { request: { sessionId } });
82
+ if (this.stoppingSession === sessionId && this.state.sessionId === sessionId)
83
+ this.update({ status: 'Cancellation requested · waiting for host' });
84
+ }
85
+ catch (error) {
86
+ this.stoppingSession = undefined;
87
+ this.update({ status: 'Cancellation failed', error: errorText(error) });
88
+ }
89
+ return false;
90
+ })();
91
+ this.interruptTask = task;
92
+ void task.finally(() => { this.interruptTask = undefined; });
93
+ return task;
94
+ }
95
+ constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs) {
96
+ this.base = base;
97
+ this.initialSession = initialSession;
98
+ this.makeClient = makeClient;
99
+ this.authenticate = authenticate;
100
+ this.costs = costs;
101
+ }
102
+ /** React-compatible state subscription. */
103
+ subscribe = (listener) => {
104
+ this.observers.add(listener);
105
+ return () => this.observers.delete(listener);
106
+ };
107
+ /** Snapshot identity changes only when the controller publishes. */
108
+ snapshot = () => this.state;
109
+ /** Start one retry loop, with a fresh snapshot generation after every disconnect. */
110
+ start() { this.runTask ??= this.run(); }
111
+ /** Cancel retries and HTTP, close the socket, and wait for the loop to settle. */
112
+ async stop() {
113
+ this.abort.abort();
114
+ clearInterval(this.costTimer);
115
+ this.generationFailed?.(new Error('Client stopped'));
116
+ await this.client?.close();
117
+ await this.runTask;
118
+ await this.interruptTask;
119
+ await Promise.all(this.catalogTasks);
120
+ await this.costTask;
121
+ }
122
+ /** Run a UI operation and expose errors without destroying the current input. */
123
+ async perform(operation) {
124
+ if (this.state.busy || !this.state.online)
125
+ return false;
126
+ this.update({ busy: true, error: '' });
127
+ try {
128
+ await operation();
129
+ return true;
130
+ }
131
+ catch (error) {
132
+ this.update({ error: errorText(error) });
133
+ return false;
134
+ }
135
+ finally {
136
+ this.update({ busy: false });
137
+ }
138
+ }
139
+ /** Refresh all HTTP-visible sessions without changing the selected conversation.
140
+ * @param signal - Optional cancellation for an explicit /cost refresh.
141
+ */
142
+ async refreshCosts(signal = this.abort.signal) {
143
+ if (!this.costs)
144
+ return;
145
+ if (this.costTask) {
146
+ const cancel = () => this.costAbort?.abort();
147
+ signal.addEventListener('abort', cancel, { once: true });
148
+ try {
149
+ await this.costTask;
150
+ }
151
+ finally {
152
+ signal.removeEventListener('abort', cancel);
153
+ }
154
+ return;
155
+ }
156
+ this.costAbort = new AbortController();
157
+ signal = AbortSignal.any([signal, this.abort.signal, this.costAbort.signal]);
158
+ const ledger = this.costs;
159
+ const client = this.host;
160
+ ledger.scanning = true;
161
+ ledger.error = '';
162
+ this.update({});
163
+ const task = (async () => {
164
+ try {
165
+ const sessions = array(object(await client.call('session/list', { _request: {} }, signal)).items).map(object);
166
+ signal.throwIfAborted();
167
+ for (const session of sessions) {
168
+ signal.throwIfAborted();
169
+ const sessionId = string(session.sessionId);
170
+ if (!session.running && typeof session.updatedAt === 'number' && this.costUpdates.get(sessionId) === session.updatedAt)
171
+ continue;
172
+ const snapshot = await new Promise((resolve, reject) => {
173
+ let sub;
174
+ const timeout = setTimeout(() => finish(new Error('Cost history snapshot timed out')), client.timeoutMs);
175
+ const onAbort = () => finish(new Error('Cost refresh cancelled'));
176
+ const finish = (error, frame) => {
177
+ clearTimeout(timeout);
178
+ signal.removeEventListener('abort', onAbort);
179
+ sub?.cancel();
180
+ if (error)
181
+ reject(error);
182
+ else
183
+ resolve(frame);
184
+ };
185
+ signal.addEventListener('abort', onAbort, { once: true });
186
+ try {
187
+ sub = client.subscribe('session/follow', { request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true } }, {
188
+ item: value => { const frame = object(value); if (frame.type === 'snapshot')
189
+ finish(undefined, frame); },
190
+ end: error => finish(error ?? new Error('Cost history stream ended')),
191
+ });
192
+ }
193
+ catch (error) {
194
+ finish(error instanceof Error ? error : new Error(errorText(error)));
195
+ }
196
+ });
197
+ const cut = snapshot.cursor;
198
+ if (typeof cut !== 'number' || !Number.isSafeInteger(cut))
199
+ throw new Error('Invalid cost history cursor');
200
+ let page = snapshot;
201
+ const events = [];
202
+ while (true) {
203
+ signal.throwIfAborted();
204
+ const records = array(page.records);
205
+ events.push(...costRecords(records));
206
+ if (!page.hasMore)
207
+ break;
208
+ const seqs = records.map(r => object(object(r).event).seq);
209
+ if (!seqs.length || seqs.some(n => typeof n !== 'number' || !Number.isSafeInteger(n)))
210
+ throw new Error('Invalid cost history page');
211
+ const beforeSeq = Math.min(...seqs);
212
+ page = object(await client.call('session/page', { request: { address: { kind: 'session', sessionId }, throughSeq: cut, beforeSeq, maxMessages: 80 } }, signal));
213
+ if (page.hasMore && array(page.records).every(r => Number(object(object(r).event).seq) >= beforeSeq))
214
+ throw new Error('Cost history page did not advance');
215
+ }
216
+ if (object(snapshot.header).isSeeded === true && !events.some(e => e.type === 'session/end-seed' && object(e.data).inherited === true))
217
+ throw new Error('Cannot attribute inherited session usage');
218
+ await ledger.replace(sessionId, cut, events);
219
+ if (!session.running && typeof session.updatedAt === 'number')
220
+ this.costUpdates.set(sessionId, session.updatedAt);
221
+ this.update({});
222
+ }
223
+ ledger.scannedAt = Date.now();
224
+ }
225
+ catch (error) {
226
+ ledger.error = errorText(error);
227
+ }
228
+ finally {
229
+ ledger.scanning = false;
230
+ this.update({});
231
+ }
232
+ })();
233
+ this.costTask = task;
234
+ try {
235
+ await task;
236
+ }
237
+ finally {
238
+ this.costTask = undefined;
239
+ }
240
+ }
241
+ /** Refresh both lists from the host, then show the requested picker. */
242
+ async showPicker(screen) {
243
+ await this.releasePending();
244
+ const [workspaces, sessions] = await Promise.all([this.host.listWorkspaces(), this.host.listSessions()]);
245
+ this.update({ screen, workspaces, sessions });
246
+ }
247
+ /** Pick a workspace, or use all sessions when the identity is omitted. */
248
+ pickWorkspace(workspaceId) {
249
+ this.follow?.cancel();
250
+ this.selection++;
251
+ this.update({ workspaceId, sessionId: undefined, showAllSessions: false, transcript: new Transcript(), screen: 'sessions' });
252
+ }
253
+ /** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix. */
254
+ async switchWorkspace(query) {
255
+ if (!query) {
256
+ await this.showPicker('workspaces');
257
+ return;
258
+ }
259
+ const workspaces = await this.host.listWorkspaces();
260
+ const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
261
+ const sessions = await this.host.listSessions();
262
+ await this.releasePending();
263
+ this.update({ workspaces, sessions });
264
+ this.pickWorkspace(string(workspace.workspaceId));
265
+ }
266
+ /** Guide workspace selection, list all sessions with `all`, or resolve an exact session target. */
267
+ async switchSession(query) {
268
+ if (!query || query === 'all') {
269
+ await this.showPicker(query === 'all' || this.state.workspaceId ? 'sessions' : 'workspaces');
270
+ this.update({ showAllSessions: query === 'all' });
271
+ return;
272
+ }
273
+ const [workspaces, sessions] = await Promise.all([this.host.listWorkspaces(), this.host.listSessions()]);
274
+ const session = resolveTarget(sessions, query, 'sessionId', item => [sessionLabel(item)]);
275
+ this.update({ workspaces, sessions });
276
+ await this.selectSession(string(session.sessionId));
277
+ }
278
+ /** Prompt for a host path without starting a local agent. */
279
+ enterPath() { this.update({ screen: 'path' }); }
280
+ /** Register a host directory and move to its session picker. */
281
+ async createWorkspace(path) {
282
+ const result = object(await this.host.call('workspace/create', { request: { path } }));
283
+ const workspace = object(result.workspace);
284
+ await this.showPicker('workspaces');
285
+ this.pickWorkspace(string(workspace.workspaceId));
286
+ }
287
+ /** Create a session only after the user explicitly selects New session. */
288
+ async createSession() {
289
+ if (!this.state.workspaceId)
290
+ throw new Error('Select a workspace before creating a session');
291
+ const result = object(await this.host.call('session/create', { request: { workspaceId: this.state.workspaceId } }));
292
+ await this.selectSession(string(result.sessionId));
293
+ }
294
+ /** Replace the selected transcript and cancel its preceding follow stream. */
295
+ async selectSession(sessionId) {
296
+ this.stoppingSession = undefined;
297
+ await this.releasePending();
298
+ this.follow?.cancel();
299
+ const selection = ++this.selection;
300
+ const transcript = new Transcript();
301
+ const workspace = this.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
302
+ const workspaceId = workspace ? string(workspace.workspaceId)
303
+ : this.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.state.workspaceId;
304
+ if (!this.observedRunningAt.has(sessionId))
305
+ this.observedRunningAt.set(sessionId, Date.now());
306
+ this.update({ sessionId, workspaceId, showAllSessions: false, transcript, screen: 'chat', status: 'Loading session…' });
307
+ this.follow = this.host.subscribe('session/follow', {
308
+ request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
309
+ }, {
310
+ item: value => {
311
+ if (selection !== this.selection)
312
+ return;
313
+ try {
314
+ transcript.accept(value);
315
+ const frame = object(value);
316
+ if (frame.type === 'snapshot')
317
+ this.telemetry.snapshot(sessionId, frame.projections);
318
+ this.update({ transcript, status: this.stoppingSession === sessionId ? this.state.status : transcript.liveText ? 'Responding…' : 'Connected' });
319
+ }
320
+ catch (error) {
321
+ this.generationFailed?.(new Error(errorText(error)));
322
+ }
323
+ },
324
+ end: error => {
325
+ if (selection !== this.selection)
326
+ return;
327
+ transcript.ready = false;
328
+ this.update({ status: 'Session disconnected', error: errorText(error ?? 'Session stream ended') });
329
+ },
330
+ });
331
+ }
332
+ /** Wait for the selected follow snapshot, failing on disconnect or cancellation.
333
+ * @param signal - Cancels waiting without closing the session.
334
+ */
335
+ async waitForHistory(signal) {
336
+ const transcript = this.state.transcript;
337
+ const deadline = Date.now() + this.host.timeoutMs;
338
+ while (!transcript.ready) {
339
+ signal.throwIfAborted();
340
+ if (!this.state.online || this.state.transcript !== transcript)
341
+ throw new Error('Session changed while loading history');
342
+ if (Date.now() >= deadline)
343
+ throw new Error('Session snapshot timed out');
344
+ await delay(20, undefined, { signal });
345
+ }
346
+ }
347
+ /** Search the host's bounded global results, optionally retaining workspace members.
348
+ * @param query - Literal message text.
349
+ * @param workspaceOnly - Restrict returned hits to the selected workspace's session IDs.
350
+ * @param signal - Cancels the HTTP search.
351
+ * @returns Session snippets and the global truncation flag, preserved after filtering.
352
+ */
353
+ async searchSessions(query, workspaceOnly, signal) {
354
+ const workspace = this.state.workspaces.find(item => item.workspaceId === this.state.workspaceId);
355
+ if (workspaceOnly && !workspace)
356
+ throw new Error('Select a workspace first');
357
+ const result = object(await this.host.call('session/search', { request: { query } }, signal));
358
+ if (!Array.isArray(result.items) || typeof result.hasMore !== 'boolean')
359
+ throw new Error('Invalid session search response');
360
+ const items = result.items.map(value => {
361
+ const item = object(value);
362
+ if (typeof item.sessionId !== 'string' || typeof item.snippet !== 'string')
363
+ throw new Error('Invalid session search item');
364
+ return item;
365
+ });
366
+ const ids = new Set(array(workspace?.sessionIds ?? []));
367
+ return { items: workspaceOnly ? items.filter(item => ids.has(item.sessionId)) : items, hasMore: result.hasMore };
368
+ }
369
+ /** Search paths on the host; does not read or upload file contents.
370
+ * @param query - Path text after @, relative to the selected session cwd.
371
+ * @param signal - Cancels an obsolete composer lookup.
372
+ * @returns Validated candidates in host order.
373
+ */
374
+ async references(query, signal) {
375
+ return fileReferences(await this.host.call('fileReferences/list', { agentId: this.sessionId, query }, signal));
376
+ }
377
+ /** Admit a prompt once; a failed response can have an uncertain delivery outcome. */
378
+ async prompt(text, mode = 'queue') {
379
+ this.stoppingSession = undefined;
380
+ if (!this.state.transcript.ready)
381
+ throw new Error('Wait for the session snapshot before sending');
382
+ const admission = this.host.call('session/prompt', { request: {
383
+ sessionId: this.sessionId, requestId: randomUUID(), mode,
384
+ content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
385
+ } });
386
+ this.admission = admission;
387
+ try {
388
+ await admission;
389
+ }
390
+ finally {
391
+ if (this.admission === admission)
392
+ this.admission = undefined;
393
+ }
394
+ this.update({ status: 'Accepted · waiting for host' });
395
+ }
396
+ /** Cancel the active turn; pending queue items remain host-owned. */
397
+ async cancelTurn() {
398
+ await this.host.call('session/cancel', { request: { sessionId: this.sessionId } });
399
+ this.update({ status: 'Cancellation requested' });
400
+ }
401
+ /** Add a page before the retained window using its fixed opening cut. */
402
+ async older(signal) {
403
+ const transcript = this.state.transcript;
404
+ if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
405
+ return;
406
+ const result = await this.host.call('session/page', { request: {
407
+ address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
408
+ beforeSeq: transcript.beforeSeq, maxMessages: 80,
409
+ } }, signal);
410
+ if (transcript !== this.state.transcript)
411
+ return;
412
+ transcript.addPage(result);
413
+ this.update({ transcript });
414
+ }
415
+ /** Load the prefix required for an explicit history jump; never loop on an unadvancing page.
416
+ * @param target - Visible record sequence, or first for the oldest available history.
417
+ * @param signal - Cancels local paging without interrupting the remote agent.
418
+ */
419
+ async historyThrough(target, signal) {
420
+ const transcript = this.state.transcript;
421
+ if (!transcript.ready)
422
+ throw new Error('Wait for the session snapshot');
423
+ while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
424
+ signal.throwIfAborted();
425
+ const before = transcript.beforeSeq;
426
+ await this.older(signal);
427
+ if (this.state.transcript !== transcript)
428
+ throw new Error('Session changed while loading history');
429
+ if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
430
+ throw new Error('Host history page did not advance');
431
+ }
432
+ }
433
+ }
434
+ /** Answer the oldest selected-session interaction, after explicit user action. */
435
+ async answer(value) {
436
+ const pending = this.state.pending[0];
437
+ if (!pending)
438
+ throw new Error('No pending interaction');
439
+ await this.reply(pending, { kind: 'result', value });
440
+ this.update({ pending: this.state.pending.filter(item => item.eventId !== pending.eventId) });
441
+ }
442
+ /** Restrict an approval command to an approval request. */
443
+ async approve(allowed) {
444
+ if (this.state.pending[0]?.event !== 'approval/request')
445
+ throw new Error('No pending approval');
446
+ await this.answer(allowed ? 'allowed-once' : 'rejected');
447
+ }
448
+ /** Present only sessions explicitly accounted to the selected workspace. */
449
+ get visibleSessions() {
450
+ if (this.state.showAllSessions || !this.state.workspaceId)
451
+ return this.state.sessions;
452
+ const workspace = this.state.workspaces.find(item => item.workspaceId === this.state.workspaceId);
453
+ const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
454
+ return this.state.sessions.filter(item => ids.has(string(item.sessionId)));
455
+ }
456
+ get host() {
457
+ if (!this.client || !this.state.online)
458
+ throw new Error('Not connected');
459
+ return this.client;
460
+ }
461
+ get sessionId() {
462
+ if (!this.state.sessionId)
463
+ throw new Error('Select a session first');
464
+ return this.state.sessionId;
465
+ }
466
+ update(patch) {
467
+ this.state = { ...this.state, ...patch, version: this.state.version + 1 };
468
+ for (const observer of this.observers)
469
+ observer();
470
+ }
471
+ async reply(frame, outcome) {
472
+ await this.host.call('$events/result', { clientId: this.clientId, eventId: string(frame.eventId), outcome });
473
+ }
474
+ async releasePending() {
475
+ for (const pending of this.state.pending)
476
+ await this.reply(pending, { kind: 'next' });
477
+ this.update({ pending: [] });
478
+ }
479
+ refreshCatalog(client) {
480
+ const revision = ++this.catalogRevision;
481
+ const task = client.call('session/modelCatalog', {}).then(value => {
482
+ if (client === this.client && revision === this.catalogRevision)
483
+ this.update({ defaultModel: object(object(value).default), modelError: undefined });
484
+ }, error => {
485
+ if (client === this.client && revision === this.catalogRevision)
486
+ this.update({ defaultModel: undefined, modelError: errorText(error) });
487
+ }).catch(error => {
488
+ if (client === this.client && revision === this.catalogRevision)
489
+ this.update({ defaultModel: undefined, modelError: errorText(error) });
490
+ });
491
+ this.catalogTasks.add(task);
492
+ void task.finally(() => this.catalogTasks.delete(task));
493
+ }
494
+ async run() {
495
+ let attempt = 0;
496
+ while (!this.abort.signal.aborted) {
497
+ this.stoppingSession = undefined;
498
+ this.runningUpdates.clear();
499
+ this.observedRunningAt.clear();
500
+ this.telemetry = new Telemetry();
501
+ this.update({ controlError: undefined, modelError: undefined, defaultModel: undefined });
502
+ const client = this.makeClient();
503
+ this.client = client;
504
+ try {
505
+ await this.authenticate(client);
506
+ await client.connect();
507
+ let fail;
508
+ const disconnected = new Promise(resolve => { fail = resolve; });
509
+ this.generationFailed = fail;
510
+ const ready = new Promise((resolve, reject) => {
511
+ const timer = setTimeout(() => reject(new Error('Host ready timed out')), client.timeoutMs);
512
+ client.subscribe('$events', {}, {
513
+ item: value => {
514
+ const frame = object(value);
515
+ if (frame.type === 'ready') {
516
+ this.clientId = string(frame.clientId);
517
+ clearTimeout(timer);
518
+ resolve();
519
+ }
520
+ else if (frame.type === 'waterfall') {
521
+ if (frame.agentId === this.state.sessionId && this.state.screen === 'chat'
522
+ && ['approval/request', 'user-questions/request'].includes(string(frame.event))) {
523
+ this.update({ pending: [...this.state.pending, frame] });
524
+ }
525
+ else {
526
+ void client.call('$events/result', { clientId: this.clientId,
527
+ eventId: string(frame.eventId), outcome: { kind: 'next' } }).catch(error => fail(new Error(errorText(error))));
528
+ }
529
+ }
530
+ else if (frame.type === 'cancel') {
531
+ this.update({ pending: this.state.pending.filter(item => item.eventId !== frame.eventId) });
532
+ }
533
+ else if (frame.type === 'emit' && frame.event === 'api-session/status') {
534
+ const args = array(frame.args);
535
+ const sessionId = string(args[0]);
536
+ if (typeof args[1] !== 'boolean')
537
+ throw new Error('Invalid session running state');
538
+ if (args[1] && !this.runningUpdates.get(sessionId))
539
+ this.observedRunningAt.set(sessionId, Date.now());
540
+ if (!args[1]) {
541
+ this.observedRunningAt.delete(sessionId);
542
+ if (this.stoppingSession === sessionId)
543
+ this.stoppingSession = undefined;
544
+ }
545
+ this.runningUpdates.set(sessionId, args[1]);
546
+ this.update({ sessions: this.state.sessions.map(row => row.sessionId === sessionId ? { ...row, running: args[1] } : row),
547
+ ...(sessionId === this.state.sessionId ? { status: args[1] ? 'Running…' : 'Idle' } : {}) });
548
+ if (!args[1] && this.costs && this.state.online)
549
+ void this.refreshCosts();
550
+ }
551
+ else if (frame.type === 'emit' && ['llm/adapters-updated', 'settings/document-updated', 'credentials/reference-updated'].includes(String(frame.event))) {
552
+ this.refreshCatalog(client);
553
+ }
554
+ else if (frame.type === 'emit' && frame.event === 'api-session/error') {
555
+ const args = array(frame.args);
556
+ if (args[0] === this.state.sessionId)
557
+ this.update({ status: 'Agent error', error: errorText(args[1]) });
558
+ }
559
+ },
560
+ end: error => { clearTimeout(timer); const reason = error ?? new Error('Event stream ended'); reject(reason); fail(reason); },
561
+ });
562
+ });
563
+ await ready;
564
+ await new Promise((resolve, reject) => {
565
+ const timer = setTimeout(() => reject(new Error('Session control baseline timed out')), client.timeoutMs);
566
+ client.subscribe('session/control', {}, {
567
+ item: value => {
568
+ try {
569
+ this.telemetry.accept(value);
570
+ this.update({});
571
+ clearTimeout(timer);
572
+ resolve();
573
+ }
574
+ catch (error) {
575
+ clearTimeout(timer);
576
+ reject(error);
577
+ fail(new Error(errorText(error)));
578
+ }
579
+ },
580
+ end: error => {
581
+ clearTimeout(timer);
582
+ if (error instanceof RemoteError && error.code === 'gateway/method-unavailable') {
583
+ this.update({ controlError: 'Live metrics unavailable on this host' });
584
+ resolve();
585
+ }
586
+ else {
587
+ const reason = error ?? new Error('Session control stream ended');
588
+ reject(reason);
589
+ fail(reason);
590
+ }
591
+ },
592
+ });
593
+ });
594
+ this.refreshCatalog(client);
595
+ this.update({ online: true, status: 'Connected', error: '', pending: [] });
596
+ const screen = this.state.screen;
597
+ await this.showPicker(screen === 'sessions' ? 'sessions' : 'workspaces');
598
+ const sessionId = this.state.sessionId ?? this.initialSession;
599
+ if (sessionId && (screen === 'chat' || this.initialSession && !this.state.sessionId))
600
+ await this.selectSession(sessionId);
601
+ attempt = 0;
602
+ if (this.costs) {
603
+ void this.refreshCosts();
604
+ clearInterval(this.costTimer);
605
+ this.costTimer = setInterval(() => { if (this.state.online)
606
+ void this.refreshCosts(); }, 60_000);
607
+ }
608
+ const error = await disconnected;
609
+ if (!this.abort.signal.aborted)
610
+ throw error;
611
+ }
612
+ catch (error) {
613
+ if (error instanceof AuthenticationRequired || error instanceof HttpError && [401, 403].includes(error.status)) {
614
+ this.update({ error: `${errorText(error)}. Set DSH_TOKEN and restart to log in.`, status: 'Login required' });
615
+ return;
616
+ }
617
+ if (!this.abort.signal.aborted)
618
+ this.update({ error: errorText(error), status: 'Reconnecting…' });
619
+ }
620
+ finally {
621
+ clearInterval(this.costTimer);
622
+ this.generationFailed = undefined;
623
+ this.selection++;
624
+ this.state.transcript.ready = false;
625
+ this.update({ online: false, pending: [] });
626
+ await client.close();
627
+ await this.costTask;
628
+ }
629
+ if (!this.abort.signal.aborted) {
630
+ try {
631
+ await delay(Math.min(500 * 2 ** attempt++, 10_000) * (0.8 + Math.random() * 0.4), undefined, { signal: this.abort.signal });
632
+ }
633
+ catch (error) {
634
+ if (!this.abort.signal.aborted)
635
+ throw error;
636
+ }
637
+ }
638
+ }
639
+ }
640
+ }
@@ -0,0 +1,8 @@
1
+ import type { Controller } from './controller.ts';
2
+ /** Render cached totals while the independent HTTP cost scan refreshes.
3
+ * @param controller - Selected session and origin-scoped cost ledger.
4
+ * @returns Billing panel, including unpriced models and refresh errors.
5
+ */
6
+ export declare function CostPanel({ controller }: {
7
+ controller: Controller;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Billing estimates with explicit coverage, calendar and unknown-price indicators. */
3
+ import { Box, Text } from 'ink';
4
+ import { costText } from "./cost.js";
5
+ import { safeText } from "./wire.js";
6
+ /** Render cached totals while the independent HTTP cost scan refreshes.
7
+ * @param controller - Selected session and origin-scoped cost ledger.
8
+ * @returns Billing panel, including unpriced models and refresh errors.
9
+ */
10
+ export function CostPanel({ controller }) {
11
+ const costs = controller.costs;
12
+ if (!costs)
13
+ return _jsx(Text, { children: "Cost tracking is unavailable" });
14
+ const id = controller.state.sessionId;
15
+ const rows = [
16
+ ['Session', id && costs.hasSession(id) ? costs.total(id) : undefined],
17
+ ['Today', costs.total(undefined, 1)], ['3 days (today + previous 2)', costs.total(undefined, 3)],
18
+ ];
19
+ return _jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Cost \u00B7 CNY estimate \u00B7 Asia/Shanghai \u00B7 /cost closes" }), rows.map(([label, total]) => _jsxs(Text, { children: [label, ": ", total ? `${costText(total)} · ${total.unknown} unpriced / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…' : costs.scannedAt ? `Last refresh: ${new Date(costs.scannedAt).toISOString()}` : 'Partial cached totals · awaiting complete scan' }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means incomplete. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color: "yellow", children: ["Partial totals: ", safeText(costs.error)] }), costs.missing().slice(0, 6).map(reason => _jsx(Text, { color: "yellow", children: safeText(reason) }, reason))] });
20
+ }