@itookit/dsht 0.2.1 → 0.2.4

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.
@@ -6,9 +6,28 @@ import { AuthenticationRequired } from "./auth.js";
6
6
  import { resolveTarget, sessionLabel } from "./navigation.js";
7
7
  import { costRecords } from "./cost.js";
8
8
  import { Telemetry } from "./telemetry.js";
9
- import { Transcript } from "./transcript.js";
9
+ import { Transcript, toolLine } from "./transcript.js";
10
+ import { releaseHistoryLayout } from "./history.js";
11
+ import { DEFAULT_HISTORY_LIMITS } from "./memory.js";
10
12
  import { fileReferences } from "./references.js";
11
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
+ }
12
31
  /** Owns reconnects and subscriptions. User commands remain single-attempt operations. */
13
32
  export class Controller {
14
33
  base;
@@ -16,9 +35,11 @@ export class Controller {
16
35
  makeClient;
17
36
  authenticate;
18
37
  costs;
38
+ historyLimits;
19
39
  state = { version: 0, online: false, busy: false, screen: 'workspaces', status: 'Connecting…',
20
40
  error: '', workspaces: [], sessions: [], showAllSessions: false, pending: [], transcript: new Transcript() };
21
41
  observers = new Set();
42
+ interactions = new Map();
22
43
  abort = new AbortController();
23
44
  client;
24
45
  clientId = '';
@@ -26,8 +47,10 @@ export class Controller {
26
47
  runTask;
27
48
  generationFailed;
28
49
  selection = 0;
29
- telemetry = new Telemetry();
50
+ historyPinned = false;
51
+ telemetry = new Telemetry(new Set(['title', 'modelSelection', 'contextPressure', 'tokenUsage', 'sessionStats', 'agentPreset']));
30
52
  observedRunningAt = new Map();
53
+ presetClient;
31
54
  catalogRevision = 0;
32
55
  catalogTasks = new Set();
33
56
  runningUpdates = new Map();
@@ -54,6 +77,61 @@ export class Controller {
54
77
  return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
55
78
  : row ? sessionLabel(row) : id;
56
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
+ }
57
135
  /** Epoch start from the retained turn log, or when this client first observed the run. */
58
136
  get workingSince() {
59
137
  if (!this.running || !this.state.sessionId)
@@ -92,12 +170,13 @@ export class Controller {
92
170
  void task.finally(() => { this.interruptTask = undefined; });
93
171
  return task;
94
172
  }
95
- constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs) {
173
+ constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS) {
96
174
  this.base = base;
97
175
  this.initialSession = initialSession;
98
176
  this.makeClient = makeClient;
99
177
  this.authenticate = authenticate;
100
178
  this.costs = costs;
179
+ this.historyLimits = historyLimits;
101
180
  }
102
181
  /** React-compatible state subscription. */
103
182
  subscribe = (listener) => {
@@ -118,6 +197,28 @@ export class Controller {
118
197
  await this.interruptTask;
119
198
  await Promise.all(this.catalogTasks);
120
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;
121
222
  }
122
223
  /** Stop the selected turn and then close, so quitting does not leave host work running.
123
224
  * An idle session stays untouched, and an in-flight cancellation is awaited rather than repeated.
@@ -172,62 +273,26 @@ export class Controller {
172
273
  try {
173
274
  const sessions = array(object(await client.call('session/list', { _request: {} }, signal)).items).map(object);
174
275
  signal.throwIfAborted();
276
+ const failures = [];
175
277
  for (const session of sessions) {
176
278
  signal.throwIfAborted();
177
279
  const sessionId = string(session.sessionId);
178
280
  if (!session.running && typeof session.updatedAt === 'number' && this.costUpdates.get(sessionId) === session.updatedAt)
179
281
  continue;
180
- const snapshot = await new Promise((resolve, reject) => {
181
- let sub;
182
- const timeout = setTimeout(() => finish(new Error('Cost history snapshot timed out')), client.timeoutMs);
183
- const onAbort = () => finish(new Error('Cost refresh cancelled'));
184
- const finish = (error, frame) => {
185
- clearTimeout(timeout);
186
- signal.removeEventListener('abort', onAbort);
187
- sub?.cancel();
188
- if (error)
189
- reject(error);
190
- else
191
- resolve(frame);
192
- };
193
- signal.addEventListener('abort', onAbort, { once: true });
194
- try {
195
- sub = client.subscribe('session/follow', { request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true } }, {
196
- item: value => { const frame = object(value); if (frame.type === 'snapshot')
197
- finish(undefined, frame); },
198
- end: error => finish(error ?? new Error('Cost history stream ended')),
199
- });
200
- }
201
- catch (error) {
202
- finish(error instanceof Error ? error : new Error(errorText(error)));
203
- }
204
- });
205
- const cut = snapshot.cursor;
206
- if (typeof cut !== 'number' || !Number.isSafeInteger(cut))
207
- throw new Error('Invalid cost history cursor');
208
- let page = snapshot;
209
- const events = [];
210
- while (true) {
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.
211
291
  signal.throwIfAborted();
212
- const records = array(page.records);
213
- events.push(...costRecords(records));
214
- if (!page.hasMore)
215
- break;
216
- const seqs = records.map(r => object(object(r).event).seq);
217
- if (!seqs.length || seqs.some(n => typeof n !== 'number' || !Number.isSafeInteger(n)))
218
- throw new Error('Invalid cost history page');
219
- const beforeSeq = Math.min(...seqs);
220
- page = object(await client.call('session/page', { request: { address: { kind: 'session', sessionId }, throughSeq: cut, beforeSeq, maxMessages: 80 } }, signal));
221
- if (page.hasMore && array(page.records).every(r => Number(object(object(r).event).seq) >= beforeSeq))
222
- throw new Error('Cost history page did not advance');
292
+ failures.push(`${sessionId}: ${errorText(error)}`);
223
293
  }
224
- if (object(snapshot.header).isSeeded === true && !events.some(e => e.type === 'session/end-seed' && object(e.data).inherited === true))
225
- throw new Error('Cannot attribute inherited session usage');
226
- await ledger.replace(sessionId, cut, events);
227
- if (!session.running && typeof session.updatedAt === 'number')
228
- this.costUpdates.set(sessionId, session.updatedAt);
229
- this.update({});
230
294
  }
295
+ ledger.error = failures.length === 0 ? '' : `${failures.length} of ${sessions.length} sessions failed: ${failures[0]}`;
231
296
  ledger.scannedAt = Date.now();
232
297
  }
233
298
  catch (error) {
@@ -246,16 +311,132 @@ export class Controller {
246
311
  this.costTask = undefined;
247
312
  }
248
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
+ }
249
381
  /** Refresh both lists from the host, then show the requested picker. */
250
382
  async showPicker(screen) {
251
- await this.releasePending();
252
383
  const [workspaces, sessions] = await Promise.all([this.host.listWorkspaces(), this.host.listSessions()]);
253
384
  this.update({ screen, workspaces, sessions });
254
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
+ }
255
434
  /** Pick a workspace, or use all sessions when the identity is omitted. */
256
435
  pickWorkspace(workspaceId) {
257
- this.follow?.cancel();
258
436
  this.selection++;
437
+ this.follow?.cancel();
438
+ this.follow = undefined;
439
+ this.releaseTranscript();
259
440
  this.update({ workspaceId, sessionId: undefined, showAllSessions: false, transcript: new Transcript(), screen: 'sessions' });
260
441
  }
261
442
  /** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix. */
@@ -267,7 +448,6 @@ export class Controller {
267
448
  const workspaces = await this.host.listWorkspaces();
268
449
  const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
269
450
  const sessions = await this.host.listSessions();
270
- await this.releasePending();
271
451
  this.update({ workspaces, sessions });
272
452
  this.pickWorkspace(string(workspace.workspaceId));
273
453
  }
@@ -302,9 +482,10 @@ export class Controller {
302
482
  /** Replace the selected transcript and cancel its preceding follow stream. */
303
483
  async selectSession(sessionId) {
304
484
  this.stoppingSession = undefined;
305
- await this.releasePending();
306
- this.follow?.cancel();
307
485
  const selection = ++this.selection;
486
+ this.follow?.cancel();
487
+ this.follow = undefined;
488
+ this.releaseTranscript();
308
489
  const transcript = new Transcript();
309
490
  const workspace = this.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
310
491
  const workspaceId = workspace ? string(workspace.workspaceId)
@@ -320,10 +501,11 @@ export class Controller {
320
501
  return;
321
502
  try {
322
503
  transcript.accept(value);
504
+ this.reclaimHistory();
323
505
  const frame = object(value);
324
506
  if (frame.type === 'snapshot')
325
507
  this.telemetry.snapshot(sessionId, frame.projections);
326
- this.update({ transcript, status: this.stoppingSession === sessionId ? this.state.status : transcript.liveText ? 'Responding…' : 'Connected' });
508
+ this.update({ transcript, status: this.stoppingSession === sessionId ? this.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
327
509
  }
328
510
  catch (error) {
329
511
  this.generationFailed?.(new Error(errorText(error)));
@@ -407,18 +589,109 @@ export class Controller {
407
589
  this.update({ status: 'Cancellation requested' });
408
590
  }
409
591
  /** Add a page before the retained window using its fixed opening cut. */
410
- async older(signal) {
411
- const transcript = this.state.transcript;
592
+ async older(signal, transcript = this.state.transcript) {
593
+ const selection = this.selection;
594
+ if (transcript === this.state.transcript)
595
+ this.historyPinned = true;
412
596
  if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
413
597
  return;
414
598
  const result = await this.host.call('session/page', { request: {
415
599
  address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
416
600
  beforeSeq: transcript.beforeSeq, maxMessages: 80,
417
601
  } }, signal);
418
- if (transcript !== this.state.transcript)
602
+ if (selection !== this.selection)
419
603
  return;
420
604
  transcript.addPage(result);
421
- this.update({ transcript });
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
+ }
422
695
  }
423
696
  /** Load the prefix required for an explicit history jump; never loop on an unadvancing page.
424
697
  * @param target - Visible record sequence, or first for the oldest available history.
@@ -445,7 +718,8 @@ export class Controller {
445
718
  if (!pending)
446
719
  throw new Error('No pending interaction');
447
720
  await this.reply(pending, { kind: 'result', value });
448
- this.update({ pending: this.state.pending.filter(item => item.eventId !== pending.eventId) });
721
+ this.interactions.delete(string(pending.eventId));
722
+ this.update({});
449
723
  }
450
724
  /** Restrict an approval command to an approval request. */
451
725
  async approve(allowed) {
@@ -455,11 +729,12 @@ export class Controller {
455
729
  }
456
730
  /** Present only sessions explicitly accounted to the selected workspace. */
457
731
  get visibleSessions() {
732
+ const sessions = this.state.sessions.filter(item => !this.client?.archivedSessionIds.has(string(item.sessionId)));
458
733
  if (this.state.showAllSessions || !this.state.workspaceId)
459
- return this.state.sessions;
734
+ return sessions;
460
735
  const workspace = this.state.workspaces.find(item => item.workspaceId === this.state.workspaceId);
461
736
  const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
462
- return this.state.sessions.filter(item => ids.has(string(item.sessionId)));
737
+ return sessions.filter(item => ids.has(string(item.sessionId)));
463
738
  }
464
739
  get host() {
465
740
  if (!this.client || !this.state.online)
@@ -472,18 +747,16 @@ export class Controller {
472
747
  return this.state.sessionId;
473
748
  }
474
749
  update(patch) {
475
- this.state = { ...this.state, ...patch, version: this.state.version + 1 };
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;
476
754
  for (const observer of this.observers)
477
755
  observer();
478
756
  }
479
757
  async reply(frame, outcome) {
480
758
  await this.host.call('$events/result', { clientId: this.clientId, eventId: string(frame.eventId), outcome });
481
759
  }
482
- async releasePending() {
483
- for (const pending of this.state.pending)
484
- await this.reply(pending, { kind: 'next' });
485
- this.update({ pending: [] });
486
- }
487
760
  refreshCatalog(client) {
488
761
  const revision = ++this.catalogRevision;
489
762
  const task = client.call('session/modelCatalog', {}).then(value => {
@@ -506,7 +779,7 @@ export class Controller {
506
779
  this.runningUpdates.clear();
507
780
  this.observedRunningAt.clear();
508
781
  this.telemetry = new Telemetry();
509
- this.update({ controlError: undefined, modelError: undefined, defaultModel: undefined });
782
+ this.update({ controlError: undefined, modelError: undefined, defaultModel: undefined, presets: undefined, presetError: undefined });
510
783
  const client = this.makeClient();
511
784
  this.client = client;
512
785
  try {
@@ -526,9 +799,9 @@ export class Controller {
526
799
  resolve();
527
800
  }
528
801
  else if (frame.type === 'waterfall') {
529
- if (frame.agentId === this.state.sessionId && this.state.screen === 'chat'
530
- && ['approval/request', 'user-questions/request'].includes(string(frame.event))) {
531
- this.update({ pending: [...this.state.pending, frame] });
802
+ if (['approval/request', 'user-questions/request'].includes(string(frame.event))) {
803
+ this.interactions.set(string(frame.eventId), frame);
804
+ this.update({});
532
805
  }
533
806
  else {
534
807
  void client.call('$events/result', { clientId: this.clientId,
@@ -536,7 +809,8 @@ export class Controller {
536
809
  }
537
810
  }
538
811
  else if (frame.type === 'cancel') {
539
- this.update({ pending: this.state.pending.filter(item => item.eventId !== frame.eventId) });
812
+ this.interactions.delete(string(frame.eventId));
813
+ this.update({});
540
814
  }
541
815
  else if (frame.type === 'emit' && frame.event === 'api-session/status') {
542
816
  const args = array(frame.args);
@@ -630,6 +904,7 @@ export class Controller {
630
904
  this.generationFailed = undefined;
631
905
  this.selection++;
632
906
  this.state.transcript.ready = false;
907
+ this.interactions.clear();
633
908
  this.update({ online: false, pending: [] });
634
909
  await client.close();
635
910
  await this.costTask;
package/dist/cost-view.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /** Billing estimates with explicit coverage, calendar and unknown-price indicators. */
3
+ import { useTheme } from "./theme.js";
3
4
  import { Box, Text } from 'ink';
4
5
  import { costText } from "./cost.js";
5
6
  import { safeText } from "./wire.js";
@@ -8,6 +9,7 @@ import { safeText } from "./wire.js";
8
9
  * @returns Billing panel, including unpriced models and refresh errors.
9
10
  */
10
11
  export function CostPanel({ controller }) {
12
+ const theme = useTheme();
11
13
  const costs = controller.costs;
12
14
  if (!costs)
13
15
  return _jsx(Text, { children: "Cost tracking is unavailable" });
@@ -19,5 +21,5 @@ export function CostPanel({ controller }) {
19
21
  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.estimated ? ` · ${total.estimated} estimated` : ''} / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
20
22
  : costs.coverage === 'partial' ? 'Partial totals · awaiting a complete scan'
21
23
  : costs.scannedAt ? `Last refresh: ${new Date(costs.scannedAt).toISOString()}`
22
- : 'Cached totals from the previous run' }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. 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))] });
24
+ : 'Cached totals from the previous run' }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color: theme.colors.context, children: ["Partial totals: ", safeText(costs.error)] }), costs.missing().slice(0, 6).map(reason => _jsx(Text, { color: theme.colors.context, children: safeText(reason) }, reason))] });
23
25
  }
@@ -0,0 +1,8 @@
1
+ import type { HistoryRow } from './history.ts';
2
+ /** Render a viewport with role, reasoning, tool and result colors from the selected theme.
3
+ * @param rows - Already wrapped, terminal-safe visible rows.
4
+ * @returns Colored text with unchanged row geometry and plain-text role markers.
5
+ */
6
+ export declare function HistoryViewport({ rows }: {
7
+ rows: HistoryRow[];
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /** Only visible semantic rows become React nodes; ANSI colors are applied after remote text sanitation. */
3
+ import { Text } from 'ink';
4
+ import { useTheme } from "./theme.js";
5
+ /** Render a viewport with role, reasoning, tool and result colors from the selected theme.
6
+ * @param rows - Already wrapped, terminal-safe visible rows.
7
+ * @returns Colored text with unchanged row geometry and plain-text role markers.
8
+ */
9
+ export function HistoryViewport({ rows }) {
10
+ const theme = useTheme();
11
+ return _jsx(Text, { children: rows.map((row, index) => _jsxs(Text, { color: theme.colors[row.kind], bold: row.bold, children: [row.text, index < rows.length - 1 ? '\n' : ''] }, index)) });
12
+ }