@itookit/dsht 0.2.1 → 0.3.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.
@@ -1,14 +1,34 @@
1
1
  /** UI state and connection generations for the standalone terminal client. */
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { saveSessionLog } from "./export.js";
3
4
  import { setTimeout as delay } from 'node:timers/promises';
4
5
  import { Client, HttpError, RemoteError } from "./client.js";
5
6
  import { AuthenticationRequired } from "./auth.js";
6
7
  import { resolveTarget, sessionLabel } from "./navigation.js";
7
8
  import { costRecords } from "./cost.js";
8
9
  import { Telemetry } from "./telemetry.js";
9
- import { Transcript } from "./transcript.js";
10
+ import { Transcript, toolLine } from "./transcript.js";
11
+ import { releaseHistoryLayout } from "./history.js";
12
+ import { DEFAULT_HISTORY_LIMITS } from "./memory.js";
10
13
  import { fileReferences } from "./references.js";
11
14
  import { array, errorText, object, string } from "./wire.js";
15
+ /** Wire addresses for one `session/list` row, in the order the cost scan should try them.
16
+ *
17
+ * A subagent child is reachable only under its durable parent, and the list row omits the delivery
18
+ * mode, so both modes are offered with the continuable form first.
19
+ * @param session - One row from the host session list.
20
+ * @returns One plain-session address, or both subagent forms when the row is a child.
21
+ */
22
+ export function costAddresses(session) {
23
+ const sessionId = string(session.sessionId);
24
+ const parentSessionId = typeof session.parentSessionId === 'string' ? session.parentSessionId : '';
25
+ if (session.origin !== 'subagent' || parentSessionId === '')
26
+ return [{ kind: 'session', sessionId }];
27
+ return [
28
+ { kind: 'subagent', parentSessionId, childSessionId: sessionId, mode: 'continuable' },
29
+ { kind: 'subagent', parentSessionId, childSessionId: sessionId, mode: 'one-shot' },
30
+ ];
31
+ }
12
32
  /** Owns reconnects and subscriptions. User commands remain single-attempt operations. */
13
33
  export class Controller {
14
34
  base;
@@ -16,9 +36,11 @@ export class Controller {
16
36
  makeClient;
17
37
  authenticate;
18
38
  costs;
39
+ historyLimits;
19
40
  state = { version: 0, online: false, busy: false, screen: 'workspaces', status: 'Connecting…',
20
41
  error: '', workspaces: [], sessions: [], showAllSessions: false, pending: [], transcript: new Transcript() };
21
42
  observers = new Set();
43
+ interactions = new Map();
22
44
  abort = new AbortController();
23
45
  client;
24
46
  clientId = '';
@@ -26,8 +48,10 @@ export class Controller {
26
48
  runTask;
27
49
  generationFailed;
28
50
  selection = 0;
29
- telemetry = new Telemetry();
51
+ historyPinned = false;
52
+ telemetry = new Telemetry(new Set(['title', 'modelSelection', 'contextPressure', 'tokenUsage', 'sessionStats', 'agentPreset']));
30
53
  observedRunningAt = new Map();
54
+ presetClient;
31
55
  catalogRevision = 0;
32
56
  catalogTasks = new Set();
33
57
  runningUpdates = new Map();
@@ -54,6 +78,61 @@ export class Controller {
54
78
  return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
55
79
  : row ? sessionLabel(row) : id;
56
80
  }
81
+ /** Current agent-preset name, matching the web header's built-in labels and custom metadata. */
82
+ get sessionMode() {
83
+ if (!this.state.sessionId)
84
+ return undefined;
85
+ const id = this.telemetry.view(this.state.sessionId).values.agentPreset;
86
+ if (typeof id !== 'string')
87
+ return undefined;
88
+ const preset = this.state.presets?.find(item => item.id === id);
89
+ const builtIn = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
90
+ return preset?.trust === 'system' && builtIn.has(id) ? builtIn.get(id)
91
+ : typeof preset?.name === 'string' ? preset.name : id;
92
+ }
93
+ /** Load the optional preset roster once per connection, only when a session names a preset. */
94
+ loadPresetNames() {
95
+ const client = this.client;
96
+ if (!client || !this.state.online || this.presetClient === client)
97
+ return;
98
+ this.presetClient = client;
99
+ const task = client.call('agentPresets/list', {}).then(value => {
100
+ if (client === this.client)
101
+ this.update({ presets: array(object(value).presets).map(object), presetError: undefined });
102
+ }).catch(error => {
103
+ if (client === this.client)
104
+ this.update({ presets: [], presetError: errorText(error) });
105
+ });
106
+ this.catalogTasks.add(task);
107
+ void task.finally(() => this.catalogTasks.delete(task));
108
+ }
109
+ /** Fetch current model routes and adapter-owned reasoning choices for the selected session.
110
+ * @returns Host catalog; provider failures remain available to the selector.
111
+ */
112
+ async modelCatalog() {
113
+ const sessionId = this.sessionId;
114
+ const selection = this.selection;
115
+ const value = object(await this.host.call('session/modelCatalog', {}));
116
+ if (selection !== this.selection || sessionId !== this.state.sessionId)
117
+ throw new Error('Session changed while loading models');
118
+ return value;
119
+ }
120
+ /** Select the next request's model; the host also attempts to save its deployment default.
121
+ * @param provider - Host provider route ID.
122
+ * @param model - Exact model ID.
123
+ * @param reasoningEffort - Optional adapter-owned effort ID; omission uses its default.
124
+ */
125
+ async selectModel(provider, model, reasoningEffort) {
126
+ const sessionId = this.sessionId;
127
+ const selection = this.selection;
128
+ const selected = object(object(await this.host.call('session/selectModel', { request: {
129
+ sessionId, provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
130
+ } })).selected);
131
+ if (selection !== this.selection || sessionId !== this.state.sessionId)
132
+ return;
133
+ this.update({ status: `Next request: ${string(selected.provider)} / ${string(selected.model)}${selected.reasoningEffort ? ` · ${string(selected.reasoningEffort)}` : ''}` });
134
+ this.refreshCatalog(this.host);
135
+ }
57
136
  /** Epoch start from the retained turn log, or when this client first observed the run. */
58
137
  get workingSince() {
59
138
  if (!this.running || !this.state.sessionId)
@@ -92,12 +171,13 @@ export class Controller {
92
171
  void task.finally(() => { this.interruptTask = undefined; });
93
172
  return task;
94
173
  }
95
- constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs) {
174
+ constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS) {
96
175
  this.base = base;
97
176
  this.initialSession = initialSession;
98
177
  this.makeClient = makeClient;
99
178
  this.authenticate = authenticate;
100
179
  this.costs = costs;
180
+ this.historyLimits = historyLimits;
101
181
  }
102
182
  /** React-compatible state subscription. */
103
183
  subscribe = (listener) => {
@@ -118,6 +198,28 @@ export class Controller {
118
198
  await this.interruptTask;
119
199
  await Promise.all(this.catalogTasks);
120
200
  await this.costTask;
201
+ this.releaseTranscript();
202
+ }
203
+ /** Keep history stable while the user reads, searches, or expands it; trim again at the live tail.
204
+ * @param pinned - Whether the main transcript is actively being read away from its tail.
205
+ */
206
+ pinHistory(pinned) {
207
+ this.historyPinned = pinned;
208
+ if (!pinned && this.reclaimHistory())
209
+ this.update({});
210
+ }
211
+ reclaimHistory() {
212
+ if (this.historyPinned || !this.state.online)
213
+ return 0;
214
+ const removed = this.state.transcript.trimHistory(this.historyLimits);
215
+ if (removed)
216
+ releaseHistoryLayout(this.state.transcript);
217
+ return removed;
218
+ }
219
+ releaseTranscript() {
220
+ releaseHistoryLayout(this.state.transcript);
221
+ this.state.transcript.dispose();
222
+ this.historyPinned = false;
121
223
  }
122
224
  /** Stop the selected turn and then close, so quitting does not leave host work running.
123
225
  * An idle session stays untouched, and an in-flight cancellation is awaited rather than repeated.
@@ -172,62 +274,26 @@ export class Controller {
172
274
  try {
173
275
  const sessions = array(object(await client.call('session/list', { _request: {} }, signal)).items).map(object);
174
276
  signal.throwIfAborted();
277
+ const failures = [];
175
278
  for (const session of sessions) {
176
279
  signal.throwIfAborted();
177
280
  const sessionId = string(session.sessionId);
178
281
  if (!session.running && typeof session.updatedAt === 'number' && this.costUpdates.get(sessionId) === session.updatedAt)
179
282
  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) {
283
+ try {
284
+ const history = await this.sessionCostHistory(session, signal);
285
+ await ledger.replace(sessionId, history.cursor, history.events);
286
+ if (!session.running && typeof session.updatedAt === 'number')
287
+ this.costUpdates.set(sessionId, session.updatedAt);
288
+ this.update({});
289
+ }
290
+ catch (error) {
291
+ // One unreachable or rejected session must not freeze every other session's rates.
211
292
  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');
293
+ failures.push(`${sessionId}: ${errorText(error)}`);
223
294
  }
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
295
  }
296
+ ledger.error = failures.length === 0 ? '' : `${failures.length} of ${sessions.length} sessions failed: ${failures[0]}`;
231
297
  ledger.scannedAt = Date.now();
232
298
  }
233
299
  catch (error) {
@@ -246,16 +312,132 @@ export class Controller {
246
312
  this.costTask = undefined;
247
313
  }
248
314
  }
315
+ /** Read one session's complete cost history, retrying a subagent child with its other delivery mode. */
316
+ async sessionCostHistory(session, signal) {
317
+ let lastError;
318
+ for (const address of costAddresses(session)) {
319
+ try {
320
+ return await this.readCostHistory(address, signal);
321
+ }
322
+ catch (error) {
323
+ lastError = error;
324
+ // Only a delivery-mode mismatch justifies the other form; every other failure is final here.
325
+ if (!(error instanceof RemoteError && error.code === 'subagent/unauthorized'))
326
+ throw error;
327
+ }
328
+ }
329
+ throw lastError;
330
+ }
331
+ /** Page one addressed session's history into the billing events the ledger folds. */
332
+ async readCostHistory(address, signal) {
333
+ const client = this.host;
334
+ const snapshot = await new Promise((resolve, reject) => {
335
+ let sub;
336
+ const timeout = setTimeout(() => finish(new Error('Cost history snapshot timed out')), client.timeoutMs);
337
+ const onAbort = () => finish(new Error('Cost refresh cancelled'));
338
+ const finish = (error, frame) => {
339
+ clearTimeout(timeout);
340
+ signal.removeEventListener('abort', onAbort);
341
+ sub?.cancel();
342
+ if (error)
343
+ reject(error);
344
+ else
345
+ resolve(frame);
346
+ };
347
+ signal.addEventListener('abort', onAbort, { once: true });
348
+ try {
349
+ sub = client.subscribe('session/follow', { request: { address, maxMessages: 80, assistantStream: true } }, {
350
+ item: value => { const frame = object(value); if (frame.type === 'snapshot')
351
+ finish(undefined, frame); },
352
+ end: error => finish(error ?? new Error('Cost history stream ended')),
353
+ });
354
+ }
355
+ catch (error) {
356
+ finish(error instanceof Error ? error : new Error(errorText(error)));
357
+ }
358
+ });
359
+ const cursor = snapshot.cursor;
360
+ if (typeof cursor !== 'number' || !Number.isSafeInteger(cursor))
361
+ throw new Error('Invalid cost history cursor');
362
+ let page = snapshot;
363
+ const events = [];
364
+ while (true) {
365
+ signal.throwIfAborted();
366
+ const records = array(page.records);
367
+ events.push(...costRecords(records));
368
+ if (!page.hasMore)
369
+ break;
370
+ const seqs = records.map(r => object(object(r).event).seq);
371
+ if (!seqs.length || seqs.some(n => typeof n !== 'number' || !Number.isSafeInteger(n)))
372
+ throw new Error('Invalid cost history page');
373
+ const beforeSeq = Math.min(...seqs);
374
+ page = object(await client.call('session/page', { request: { address, throughSeq: cursor, beforeSeq, maxMessages: 80 } }, signal));
375
+ if (page.hasMore && array(page.records).every(r => Number(object(object(r).event).seq) >= beforeSeq))
376
+ throw new Error('Cost history page did not advance');
377
+ }
378
+ if (object(snapshot.header).isSeeded === true && !events.some(e => e.type === 'session/end-seed' && object(e.data).inherited === true))
379
+ throw new Error('Cannot attribute inherited session usage');
380
+ return { cursor, events };
381
+ }
249
382
  /** Refresh both lists from the host, then show the requested picker. */
250
383
  async showPicker(screen) {
251
- await this.releasePending();
252
384
  const [workspaces, sessions] = await Promise.all([this.host.listWorkspaces(), this.host.listSessions()]);
253
385
  this.update({ screen, workspaces, sessions });
254
386
  }
387
+ /** Resolve a removal command to one reviewable object without changing the selection.
388
+ * @param kind - Workspace registration removal or session archival.
389
+ * @param query - Exact name, ID, or unambiguous ID prefix.
390
+ * @returns The fixed identity and display details for confirmation.
391
+ */
392
+ async removalTarget(kind, query) {
393
+ const items = kind === 'workspace' ? await this.host.listWorkspaces() : await this.host.listSessions();
394
+ const row = resolveTarget(items, query, kind === 'workspace' ? 'workspaceId' : 'sessionId', item => kind === 'workspace' ? [string(item.title), string(item.path)] : [sessionLabel(item)]);
395
+ return kind === 'workspace' ? { kind, id: string(row.workspaceId), name: string(row.title), path: string(row.path) }
396
+ : { kind, id: string(row.sessionId), name: sessionLabel(row),
397
+ empty: row.blank === true && row.running === false
398
+ && !this.runningUpdates.get(string(row.sessionId))
399
+ && !(this.telemetry.view(string(row.sessionId)).queued ?? 0)
400
+ && !(this.telemetry.view(string(row.sessionId)).jobs ?? 0)
401
+ && !(this.state.sessionId === row.sessionId && this.admission) };
402
+ }
403
+ /** Apply a confirmed removal or freshly verified empty-session archival; directories and logs are preserved.
404
+ * @param target - Exact workspace or session identity reviewed by the user.
405
+ */
406
+ async removeTarget(target) {
407
+ const client = this.host;
408
+ const receipt = object(await client.call(target.kind === 'workspace' ? 'workspace/delete' : 'workspace/archiveSession', {
409
+ request: target.kind === 'workspace' ? { workspaceId: target.id } : { sessionId: target.id },
410
+ }));
411
+ if (client !== this.client)
412
+ return;
413
+ if (target.kind === 'session') {
414
+ client.archivedSessionIds = new Set(array(receipt.archivedSessionIds).map(string));
415
+ if (this.state.sessionId === target.id) {
416
+ this.pickWorkspace(this.state.workspaceId);
417
+ }
418
+ }
419
+ else {
420
+ if (receipt.deleted !== true)
421
+ throw new Error('Host did not confirm workspace removal');
422
+ this.update({ workspaces: this.state.workspaces.filter(row => row.workspaceId !== target.id),
423
+ ...(this.state.workspaceId === target.id ? { workspaceId: undefined } : {}) });
424
+ }
425
+ this.update({ screen: target.kind === 'workspace' ? 'workspaces' : 'sessions',
426
+ status: target.kind === 'workspace' ? 'Workspace registration removed' : 'Session archived' });
427
+ // A refresh failure must not make a successful mutation look like a rejected deletion.
428
+ try {
429
+ await this.showPicker(target.kind === 'workspace' ? 'workspaces' : 'sessions');
430
+ }
431
+ catch (error) {
432
+ this.update({ error: `Removal completed; list refresh failed: ${errorText(error)}` });
433
+ }
434
+ }
255
435
  /** Pick a workspace, or use all sessions when the identity is omitted. */
256
436
  pickWorkspace(workspaceId) {
257
- this.follow?.cancel();
258
437
  this.selection++;
438
+ this.follow?.cancel();
439
+ this.follow = undefined;
440
+ this.releaseTranscript();
259
441
  this.update({ workspaceId, sessionId: undefined, showAllSessions: false, transcript: new Transcript(), screen: 'sessions' });
260
442
  }
261
443
  /** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix. */
@@ -267,7 +449,6 @@ export class Controller {
267
449
  const workspaces = await this.host.listWorkspaces();
268
450
  const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
269
451
  const sessions = await this.host.listSessions();
270
- await this.releasePending();
271
452
  this.update({ workspaces, sessions });
272
453
  this.pickWorkspace(string(workspace.workspaceId));
273
454
  }
@@ -302,9 +483,10 @@ export class Controller {
302
483
  /** Replace the selected transcript and cancel its preceding follow stream. */
303
484
  async selectSession(sessionId) {
304
485
  this.stoppingSession = undefined;
305
- await this.releasePending();
306
- this.follow?.cancel();
307
486
  const selection = ++this.selection;
487
+ this.follow?.cancel();
488
+ this.follow = undefined;
489
+ this.releaseTranscript();
308
490
  const transcript = new Transcript();
309
491
  const workspace = this.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
310
492
  const workspaceId = workspace ? string(workspace.workspaceId)
@@ -320,10 +502,11 @@ export class Controller {
320
502
  return;
321
503
  try {
322
504
  transcript.accept(value);
505
+ this.reclaimHistory();
323
506
  const frame = object(value);
324
507
  if (frame.type === 'snapshot')
325
508
  this.telemetry.snapshot(sessionId, frame.projections);
326
- this.update({ transcript, status: this.stoppingSession === sessionId ? this.state.status : transcript.liveText ? 'Responding…' : 'Connected' });
509
+ this.update({ transcript, status: this.stoppingSession === sessionId ? this.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
327
510
  }
328
511
  catch (error) {
329
512
  this.generationFailed?.(new Error(errorText(error)));
@@ -382,13 +565,50 @@ export class Controller {
382
565
  async references(query, signal) {
383
566
  return fileReferences(await this.host.call('fileReferences/list', { agentId: this.sessionId, query }, signal));
384
567
  }
385
- /** Admit a prompt once; a failed response can have an uncertain delivery outcome. */
386
- async prompt(text, mode = 'queue') {
568
+ /** Execute a human command directly, outside the model prompt queue.
569
+ * @param line - Complete slash command, including arguments.
570
+ * @param signal - Cancels the request while the host performs compaction.
571
+ * @returns The host's successful command result text.
572
+ */
573
+ async command(line, signal) {
574
+ if (!this.state.transcript.ready)
575
+ throw new Error('Wait for the session snapshot before running commands');
576
+ const execution = await this.host.call('commands/execute', {
577
+ agentId: this.sessionId, line, submittedAttachments: [],
578
+ }, signal, null);
579
+ if (execution === undefined)
580
+ throw new Error(`This host does not provide ${line.split(/\s/, 1)[0]}`);
581
+ const result = object(object(execution).result);
582
+ if ((result.kind !== 'success' && result.kind !== 'error') || (result.text !== undefined && typeof result.text !== 'string')) {
583
+ throw new Error('Invalid command result from host');
584
+ }
585
+ if (result.kind === 'error')
586
+ throw new Error(string(result.text));
587
+ return result.text === undefined ? 'Command completed.' : string(result.text);
588
+ }
589
+ /** Remove one host-owned pending input; an already claimed item reports a host error.
590
+ * @param itemId - Queue occurrence identity from session/control.
591
+ */
592
+ async removeQueued(itemId) {
593
+ await this.host.call('session/updateQueue', { request: { sessionId: this.sessionId, itemId, action: { kind: 'remove' } } });
594
+ }
595
+ /** Export the selected host log to a new local ZIP file.
596
+ * @param path - Optional local destination; existing files are never overwritten.
597
+ * @param signal - Cancels the download and removes an incomplete file.
598
+ * @returns Absolute saved filename.
599
+ */
600
+ async exportLog(path, signal) {
601
+ return saveSessionLog(this.host, this.sessionId, path, signal);
602
+ }
603
+ /** Admit text once as steering while running, or a new turn while idle; a lost response can leave delivery uncertain. */
604
+ async prompt(text) {
605
+ if (this.state.pending.length)
606
+ throw new Error('Answer the pending question or approval first');
387
607
  this.stoppingSession = undefined;
388
608
  if (!this.state.transcript.ready)
389
609
  throw new Error('Wait for the session snapshot before sending');
390
610
  const admission = this.host.call('session/prompt', { request: {
391
- sessionId: this.sessionId, requestId: randomUUID(), mode,
611
+ sessionId: this.sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
392
612
  content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
393
613
  } });
394
614
  this.admission = admission;
@@ -407,18 +627,109 @@ export class Controller {
407
627
  this.update({ status: 'Cancellation requested' });
408
628
  }
409
629
  /** Add a page before the retained window using its fixed opening cut. */
410
- async older(signal) {
411
- const transcript = this.state.transcript;
630
+ async older(signal, transcript = this.state.transcript) {
631
+ const selection = this.selection;
632
+ if (transcript === this.state.transcript)
633
+ this.historyPinned = true;
412
634
  if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
413
635
  return;
414
636
  const result = await this.host.call('session/page', { request: {
415
637
  address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
416
638
  beforeSeq: transcript.beforeSeq, maxMessages: 80,
417
639
  } }, signal);
418
- if (transcript !== this.state.transcript)
640
+ if (selection !== this.selection)
419
641
  return;
420
642
  transcript.addPage(result);
421
- this.update({ transcript });
643
+ this.update({});
644
+ }
645
+ /** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
646
+ * @param query - Literal, case-insensitive text including folded reasoning.
647
+ * @param signal - Cancels HTTP and processing without cancelling the agent.
648
+ * @returns Newest-first bounded summaries and an explicit truncation flag.
649
+ */
650
+ async searchHistory(query, signal) {
651
+ const source = this.state.transcript;
652
+ if (!source.ready)
653
+ throw new Error('Wait for the session snapshot');
654
+ const sessionId = this.sessionId;
655
+ const selection = this.selection;
656
+ const throughSeq = source.readThrough;
657
+ const needle = query.toLowerCase();
658
+ const result = { items: [], truncated: false };
659
+ const scan = (transcript) => {
660
+ const messages = transcript.messages;
661
+ for (let index = messages.length - 1; index >= 0; index--) {
662
+ signal.throwIfAborted();
663
+ const message = messages[index];
664
+ if (message.role === 'Tool')
665
+ continue;
666
+ const text = message.text;
667
+ const match = text.toLowerCase().indexOf(needle);
668
+ if (match < 0)
669
+ continue;
670
+ if (result.items.length === 200) {
671
+ result.truncated = true;
672
+ return false;
673
+ }
674
+ 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') });
675
+ }
676
+ return true;
677
+ };
678
+ signal.throwIfAborted();
679
+ if (!scan(source))
680
+ return result;
681
+ let beforeSeq = source.beforeSeq;
682
+ let hasMore = source.hasMore;
683
+ while (hasMore && beforeSeq !== undefined) {
684
+ const page = object(await this.host.call('session/page', { request: {
685
+ address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
686
+ } }, signal));
687
+ signal.throwIfAborted();
688
+ if (selection !== this.selection)
689
+ throw new Error('Session changed while searching history');
690
+ const temporary = new Transcript();
691
+ try {
692
+ temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
693
+ const next = temporary.beforeSeq;
694
+ if (temporary.hasMore && (next === undefined || next >= beforeSeq))
695
+ throw new Error('Host history page did not advance');
696
+ if (!scan(temporary))
697
+ return result;
698
+ beforeSeq = next;
699
+ hasMore = temporary.hasMore;
700
+ }
701
+ finally {
702
+ temporary.dispose();
703
+ }
704
+ }
705
+ return result;
706
+ }
707
+ /** Load a separate small window ending at a search target; the live transcript keeps following.
708
+ * @param target - Durable message sequence to display.
709
+ * @param signal - Cancels the target-page request.
710
+ * @returns A caller-owned historical window that must be disposed when closed.
711
+ */
712
+ async historyAt(target, signal) {
713
+ const source = this.state.transcript;
714
+ const selection = this.selection;
715
+ const page = object(await this.host.call('session/page', { request: {
716
+ address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
717
+ beforeSeq: target + 1, maxMessages: 80,
718
+ } }, signal));
719
+ signal.throwIfAborted();
720
+ if (selection !== this.selection)
721
+ throw new Error('Session changed while opening history');
722
+ const window = new Transcript();
723
+ try {
724
+ window.accept({ type: 'snapshot', cursor: source.readThrough, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
725
+ if (!window.messages.some(message => message.seq === target))
726
+ throw new Error('The host did not return the requested message');
727
+ return window;
728
+ }
729
+ catch (error) {
730
+ window.dispose();
731
+ throw error;
732
+ }
422
733
  }
423
734
  /** Load the prefix required for an explicit history jump; never loop on an unadvancing page.
424
735
  * @param target - Visible record sequence, or first for the oldest available history.
@@ -445,7 +756,8 @@ export class Controller {
445
756
  if (!pending)
446
757
  throw new Error('No pending interaction');
447
758
  await this.reply(pending, { kind: 'result', value });
448
- this.update({ pending: this.state.pending.filter(item => item.eventId !== pending.eventId) });
759
+ this.interactions.delete(string(pending.eventId));
760
+ this.update({});
449
761
  }
450
762
  /** Restrict an approval command to an approval request. */
451
763
  async approve(allowed) {
@@ -455,11 +767,12 @@ export class Controller {
455
767
  }
456
768
  /** Present only sessions explicitly accounted to the selected workspace. */
457
769
  get visibleSessions() {
770
+ const sessions = this.state.sessions.filter(item => !this.client?.archivedSessionIds.has(string(item.sessionId)));
458
771
  if (this.state.showAllSessions || !this.state.workspaceId)
459
- return this.state.sessions;
772
+ return sessions;
460
773
  const workspace = this.state.workspaces.find(item => item.workspaceId === this.state.workspaceId);
461
774
  const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
462
- return this.state.sessions.filter(item => ids.has(string(item.sessionId)));
775
+ return sessions.filter(item => ids.has(string(item.sessionId)));
463
776
  }
464
777
  get host() {
465
778
  if (!this.client || !this.state.online)
@@ -472,18 +785,16 @@ export class Controller {
472
785
  return this.state.sessionId;
473
786
  }
474
787
  update(patch) {
475
- this.state = { ...this.state, ...patch, version: this.state.version + 1 };
788
+ const next = { ...this.state, ...patch, version: this.state.version + 1 };
789
+ next.pending = next.online && next.screen === 'chat'
790
+ ? [...this.interactions.values()].filter(frame => frame.agentId === next.sessionId) : [];
791
+ this.state = next;
476
792
  for (const observer of this.observers)
477
793
  observer();
478
794
  }
479
795
  async reply(frame, outcome) {
480
796
  await this.host.call('$events/result', { clientId: this.clientId, eventId: string(frame.eventId), outcome });
481
797
  }
482
- async releasePending() {
483
- for (const pending of this.state.pending)
484
- await this.reply(pending, { kind: 'next' });
485
- this.update({ pending: [] });
486
- }
487
798
  refreshCatalog(client) {
488
799
  const revision = ++this.catalogRevision;
489
800
  const task = client.call('session/modelCatalog', {}).then(value => {
@@ -506,7 +817,7 @@ export class Controller {
506
817
  this.runningUpdates.clear();
507
818
  this.observedRunningAt.clear();
508
819
  this.telemetry = new Telemetry();
509
- this.update({ controlError: undefined, modelError: undefined, defaultModel: undefined });
820
+ this.update({ controlError: undefined, modelError: undefined, defaultModel: undefined, presets: undefined, presetError: undefined });
510
821
  const client = this.makeClient();
511
822
  this.client = client;
512
823
  try {
@@ -526,9 +837,9 @@ export class Controller {
526
837
  resolve();
527
838
  }
528
839
  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] });
840
+ if (['approval/request', 'user-questions/request'].includes(string(frame.event))) {
841
+ this.interactions.set(string(frame.eventId), frame);
842
+ this.update({});
532
843
  }
533
844
  else {
534
845
  void client.call('$events/result', { clientId: this.clientId,
@@ -536,7 +847,8 @@ export class Controller {
536
847
  }
537
848
  }
538
849
  else if (frame.type === 'cancel') {
539
- this.update({ pending: this.state.pending.filter(item => item.eventId !== frame.eventId) });
850
+ this.interactions.delete(string(frame.eventId));
851
+ this.update({});
540
852
  }
541
853
  else if (frame.type === 'emit' && frame.event === 'api-session/status') {
542
854
  const args = array(frame.args);
@@ -630,6 +942,7 @@ export class Controller {
630
942
  this.generationFailed = undefined;
631
943
  this.selection++;
632
944
  this.state.transcript.ready = false;
945
+ this.interactions.clear();
633
946
  this.update({ online: false, pending: [] });
634
947
  await client.close();
635
948
  await this.costTask;