@itookit/dsht 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -2
  3. package/README.zh.md +6 -4
  4. package/dist/catalog/controller.d.ts +26 -6
  5. package/dist/catalog/controller.js +73 -45
  6. package/dist/catalog/index.d.ts +1 -0
  7. package/dist/cli/dsht.js +4 -1
  8. package/dist/cli/startup.js +30 -11
  9. package/dist/cli/verifier.d.ts +4 -0
  10. package/dist/cli/verifier.js +28 -5
  11. package/dist/contracts.d.ts +20 -5
  12. package/dist/controller/connection-streams.d.ts +22 -0
  13. package/dist/controller/connection-streams.js +105 -0
  14. package/dist/controller/connection.d.ts +14 -3
  15. package/dist/controller/connection.js +40 -69
  16. package/dist/controller/controller.d.ts +17 -233
  17. package/dist/controller/controller.js +108 -811
  18. package/dist/controller/foreground.d.ts +44 -0
  19. package/dist/controller/foreground.js +79 -0
  20. package/dist/controller/loop-coordinator.d.ts +48 -0
  21. package/dist/controller/loop-coordinator.js +647 -0
  22. package/dist/controller/verifier.d.ts +4 -0
  23. package/dist/cost/controller.d.ts +1 -1
  24. package/dist/cost/controller.js +12 -5
  25. package/dist/cost/scanner.js +1 -0
  26. package/dist/session/controller.d.ts +23 -35
  27. package/dist/session/controller.js +113 -363
  28. package/dist/session/history-reader.d.ts +32 -0
  29. package/dist/session/history-reader.js +170 -0
  30. package/dist/session/index.d.ts +1 -1
  31. package/dist/session/info.d.ts +3 -38
  32. package/dist/session/info.js +14 -1
  33. package/dist/session/interactions.d.ts +26 -0
  34. package/dist/session/interactions.js +75 -0
  35. package/dist/session/navigator.d.ts +47 -0
  36. package/dist/session/navigator.js +158 -0
  37. package/dist/session/prompt-backfill.d.ts +23 -0
  38. package/dist/session/prompt-backfill.js +88 -0
  39. package/dist/session/state.d.ts +20 -0
  40. package/dist/session/state.js +1 -0
  41. package/dist/session/telemetry.d.ts +15 -6
  42. package/dist/session/telemetry.js +44 -7
  43. package/dist/session/transcript.d.ts +5 -1
  44. package/dist/slash/index.d.ts +1 -1
  45. package/dist/slash/parse.d.ts +2 -126
  46. package/dist/slash/registry.d.ts +1 -1
  47. package/dist/slash/types.d.ts +126 -0
  48. package/dist/slash/types.js +1 -0
  49. package/dist/state.d.ts +5 -17
  50. package/dist/state.js +1 -1
  51. package/dist/transport/client.d.ts +4 -3
  52. package/dist/transport/client.js +71 -25
  53. package/dist/ui/app.js +86 -301
  54. package/dist/ui/chat/shell-view.d.ts +2 -0
  55. package/dist/ui/chat/shell-view.js +8 -0
  56. package/dist/ui/chat/use-history-view.d.ts +69 -0
  57. package/dist/ui/chat/use-history-view.js +123 -0
  58. package/dist/ui/dialogs/use-panels.d.ts +53 -0
  59. package/dist/ui/dialogs/use-panels.js +51 -0
  60. package/dist/ui/input/use-composer.d.ts +35 -0
  61. package/dist/ui/input/use-composer.js +109 -0
  62. package/dist/ui/input/use-deferred-lines.d.ts +16 -0
  63. package/dist/ui/input/use-deferred-lines.js +54 -0
  64. package/dist/ui/input/use-history-recall.d.ts +20 -0
  65. package/dist/ui/input/use-history-recall.js +47 -0
  66. package/package.json +1 -1
@@ -1,6 +1,5 @@
1
1
  /** Session domain: selection, the follow stream, history, interaction and navigation. */
2
2
  import { randomUUID } from 'node:crypto';
3
- import { setTimeout as delay } from 'node:timers/promises';
4
3
  import { array, errorText, object, string } from "../transport/wire.js";
5
4
  import { saveSessionLog } from "./export.js";
6
5
  import { saveTranscriptHtml } from "./export-html.js";
@@ -11,15 +10,14 @@ import { fileReferences } from "./references.js";
11
10
  import { SessionRuntime } from "./runtime.js";
12
11
  import { PromptCache } from "./info.js";
13
12
  import { SessionMutationGate } from "./mutation-gate.js";
14
- import { recordPrompts, Transcript } from "./transcript.js";
13
+ import { recordPrompts } from "./transcript.js";
15
14
  import { projectionSnapshot } from "../transport/events.js";
16
- import { toolLine } from "../text.js";
15
+ import { HistoryReader } from "./history-reader.js";
16
+ import { SessionInteractions } from "./interactions.js";
17
+ import { SessionNavigator } from "./navigator.js";
18
+ import { PromptBackfill } from "./prompt-backfill.js";
17
19
  /** Built-in preset identifiers and the labels the web session header shows. */
18
20
  const BUILT_IN_MODES = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
19
- /** Bounded match count for one history search. */
20
- const SEARCH_MATCH_LIMIT = 200;
21
- /** Bounded page count for the prompt backfill that runs once per opened session. */
22
- const PROMPT_BACKFILL_PAGES = 200;
23
21
  /** Owns the selected session: its follow stream, transcript, history window and interactions. */
24
22
  export class SessionController {
25
23
  store;
@@ -27,7 +25,9 @@ export class SessionController {
27
25
  connection;
28
26
  historyLimits;
29
27
  follow;
30
- interactions = new Map();
28
+ history;
29
+ interactions;
30
+ navigation;
31
31
  /** Reading protection: reclamation pauses while the reader is away from the live end. */
32
32
  historyPinned = false;
33
33
  /** Host runtime mirrors for every session this connection has seen. */
@@ -36,7 +36,7 @@ export class SessionController {
36
36
  promptBackfill;
37
37
  /** Prompts of sessions this process has already read, so re-opening one costs no page request. */
38
38
  promptCache = new PromptCache();
39
- /** Prompt index, composer and reading view of the selected session; the instance `State.session` exposes. */
39
+ /** Prompt index, record, history window and interaction state of the selected session. */
40
40
  get info() { return this.store.state.session; }
41
41
  get prompts() { return this.info.prompts; }
42
42
  stoppingSession;
@@ -50,6 +50,39 @@ export class SessionController {
50
50
  this.connection = connection;
51
51
  this.historyLimits = historyLimits;
52
52
  this.mutations = new SessionMutationGate(report);
53
+ this.promptBackfill = new PromptBackfill({
54
+ require: () => this.host.require(), signal: () => this.host.signal(), online: () => this.host.online(),
55
+ current: selection => selection.revision === this.store.selection() && selection.record === this.info.record
56
+ && selection.sessionId === this.store.state.sessionId,
57
+ changed: () => this.store.update({}),
58
+ }, this.promptCache);
59
+ this.navigation = new SessionNavigator({
60
+ require: () => this.host.require(), client: () => this.host.client(),
61
+ online: () => this.host.online(), signal: () => this.host.signal(),
62
+ read: () => this.store.state, publish: patch => this.store.update(patch),
63
+ selection: () => this.store.selection(),
64
+ leave: () => {
65
+ this.store.bumpSelection();
66
+ this.follow?.cancel();
67
+ this.follow = undefined;
68
+ this.releaseTranscript();
69
+ },
70
+ follow: id => this.followSession(id, true),
71
+ });
72
+ this.interactions = new SessionInteractions({
73
+ focused: () => this.store.state.pending[0],
74
+ admit: async (sessionId, dispatch) => { await this.mutations.admit(sessionId, 'normal', dispatch); },
75
+ reply: (eventId, outcome) => this.connection.reply(eventId, outcome),
76
+ changed: () => this.store.update({}),
77
+ });
78
+ this.history = new HistoryReader({
79
+ require: () => this.host.require(), online: () => this.host.online(), signal: () => this.host.signal(),
80
+ selected: () => ({ sessionId: this.sessionId, revision: this.store.selection(), record: this.info.record }),
81
+ current: selection => selection.revision === this.store.selection() && selection.sessionId === this.store.state.sessionId
82
+ && selection.record === this.info.record,
83
+ owns: source => source === this.info.record || source === this.info.window,
84
+ changed: () => this.store.update({}),
85
+ });
53
86
  }
54
87
  /** Host running state covers model generation, tools, and waits between assistant attempts. */
55
88
  get running() {
@@ -68,13 +101,13 @@ export class SessionController {
68
101
  : row ? sessionLabel(row) : id;
69
102
  }
70
103
  /** Current agent-preset name, matching the web header's built-in labels and custom metadata. */
71
- get sessionMode() {
104
+ sessionMode(presets = []) {
72
105
  if (!this.store.state.sessionId)
73
106
  return undefined;
74
107
  const id = this.runtime.telemetry.view(this.store.state.sessionId).values.agentPreset;
75
108
  if (typeof id !== 'string')
76
109
  return undefined;
77
- const preset = this.store.state.presets?.find(item => item.id === id);
110
+ const preset = presets.find(item => item.id === id);
78
111
  return preset?.trust === 'system' && BUILT_IN_MODES.has(id) ? BUILT_IN_MODES.get(id)
79
112
  : typeof preset?.name === 'string' ? preset.name : id;
80
113
  }
@@ -85,14 +118,7 @@ export class SessionController {
85
118
  return this.info.record.activeTurnStartedAt ?? this.runtime.observedAt(this.store.state.sessionId);
86
119
  }
87
120
  /** Present only sessions explicitly accounted to the selected workspace. */
88
- get visibleSessions() {
89
- const sessions = this.store.state.sessions.filter(item => !this.host.client()?.archivedSessionIds.has(string(item.sessionId)));
90
- if (this.store.state.showAllSessions || !this.store.state.workspaceId)
91
- return sessions;
92
- const workspace = this.store.state.workspaces.find(item => item.workspaceId === this.store.state.workspaceId);
93
- const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
94
- return sessions.filter(item => ids.has(string(item.sessionId)));
95
- }
121
+ get visibleSessions() { return this.navigation.visibleSessions; }
96
122
  /** Whether a turn, cancellation or prompt admission is still in flight. */
97
123
  get active() { return this.interruptTask !== undefined || this.running || this.admission !== undefined; }
98
124
  /** @returns The detached history window the reader opened, if any. */
@@ -103,6 +129,8 @@ export class SessionController {
103
129
  setViewWindow(window) {
104
130
  if (this.info.window === window)
105
131
  return;
132
+ if (this.info.window)
133
+ this.history.cancel(this.info.window);
106
134
  this.info.closeWindow();
107
135
  this.info.window = window;
108
136
  this.store.update({});
@@ -125,21 +153,22 @@ export class SessionController {
125
153
  beginGeneration() { this.stoppingSession = undefined; this.runtime.reset(); }
126
154
  /** Invalidate in-flight work and drop transient interactions when a generation ends. */
127
155
  endGeneration() {
156
+ this.promptBackfill.cancel();
157
+ this.navigation.reset();
158
+ this.history.cancel();
128
159
  this.store.bumpSelection();
129
160
  this.info.record.ready = false;
130
161
  this.interactions.clear();
131
162
  }
132
- /** Wait for an in-flight cancellation so shutdown leaves nothing running. */
133
- async settle() { await this.interruptTask; }
163
+ /** Wait for cancellation and foreground history tasks before releasing their records. */
164
+ async settle() { await this.interruptTask; await this.history.settle(); await this.promptBackfill.settle(); await this.interactions.settle(); await this.navigation.settle(); }
134
165
  /** Release the selected transcript and its layout caches. */
135
- release() { this.releaseTranscript(); }
166
+ release() { this.navigation.reset(); this.releaseTranscript(); }
136
167
  /** Pending interactions for the selected chat session, derived independently of frame order.
137
- * @param state - State being published.
168
+ * @param sessionId - Session whose pending interactions are being published.
138
169
  * @returns Retained question and approval frames for that session.
139
170
  */
140
- pendingFor(state) {
141
- return [...this.interactions.values()].filter(frame => frame.sessionId === state.sessionId);
142
- }
171
+ pendingFor(sessionId) { return this.interactions.pendingFor(sessionId); }
143
172
  /** Unanswered interactions by session, so a list can show who is waiting without opening them.
144
173
  *
145
174
  * The host delivers approval and question waterfalls for every session on one stream, and this
@@ -148,37 +177,16 @@ export class SessionController {
148
177
  * until the host replays the pending waterfalls.
149
178
  * @returns One count per session holding at least one unanswered interaction.
150
179
  */
151
- pendingCounts() {
152
- const counts = new Map();
153
- for (const frame of this.interactions.values()) {
154
- if (frame.sessionId === '')
155
- continue;
156
- counts.set(frame.sessionId, (counts.get(frame.sessionId) ?? 0) + 1);
157
- }
158
- return counts;
159
- }
180
+ pendingCounts() { return this.interactions.counts(); }
160
181
  /** Retain a recognized host interaction; anything else belongs to the connection to delegate.
161
182
  * @param event - One normalized host event.
162
183
  * @returns Whether this domain retained the event for an answer.
163
184
  */
164
- accept(event) {
165
- if (event.kind === 'approval-request') {
166
- this.interactions.set(event.eventId, { kind: 'approval', eventId: event.eventId, sessionId: event.sessionId,
167
- description: event.description });
168
- }
169
- else if (event.kind === 'question-request') {
170
- this.interactions.set(event.eventId, { kind: 'question', eventId: event.eventId, sessionId: event.sessionId,
171
- questions: event.questions });
172
- }
173
- else
174
- return false;
175
- this.store.update({});
176
- return true;
177
- }
185
+ accept(event) { return this.interactions.accept(event); }
178
186
  /** Drop a waterfall the host cancelled.
179
187
  * @param eventId - Correlation id previously retained.
180
188
  */
181
- cancelled(eventId) { this.interactions.delete(eventId); this.store.update({}); }
189
+ cancelled(eventId) { this.interactions.cancelled(eventId); }
182
190
  /** Apply one host running-state notification to the session list and status line.
183
191
  * @param sessionId - Session whose state changed.
184
192
  * @param running - Whether the host still runs that session.
@@ -254,23 +262,18 @@ export class SessionController {
254
262
  /** Refresh both lists from the host, then show the requested picker.
255
263
  * @param screen - Picker to display after the refresh.
256
264
  */
257
- async showPicker(screen) {
258
- const client = this.host.require();
259
- const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
260
- this.store.update({ screen, workspaces, sessions });
265
+ showPicker(screen, signal) {
266
+ return this.navigation.showPicker(screen, signal);
261
267
  }
268
+ /** Refresh navigation data without taking the reader to another screen. */
269
+ refreshLists() { return this.navigation.refreshLists(); }
262
270
  /** Return to the selected conversation without re-selecting it.
263
271
  *
264
272
  * A picker opened over a conversation is a detour: leaving it must not re-subscribe, reload the
265
273
  * transcript or lose the reading position, so this only changes which screen is shown.
266
274
  * @returns Whether there was a selected conversation to return to.
267
275
  */
268
- showChat() {
269
- if (this.store.state.sessionId === undefined)
270
- return false;
271
- this.store.update({ screen: 'chat' });
272
- return true;
273
- }
276
+ showChat() { return this.navigation.showChat(); }
274
277
  /** Resolve a removal command to one reviewable object without changing the selection.
275
278
  * @param kind - Workspace registration removal or session archival.
276
279
  * @param query - Exact name, ID, or unambiguous ID prefix.
@@ -293,12 +296,14 @@ export class SessionController {
293
296
  */
294
297
  async removeTarget(target) {
295
298
  const client = this.host.require();
299
+ const intent = this.navigation.intent;
296
300
  const receipt = object(target.kind === 'workspace'
297
301
  ? await client.call('workspace/delete', { request: { workspaceId: target.id } })
298
302
  // Archiving a session is a write on that session, so it queues behind that session's admissions.
299
303
  : await this.mutations.admit(target.id, 'normal', () => client.call('workspace/archiveSession', { request: { sessionId: target.id } })));
300
304
  if (client !== this.host.client())
301
305
  return;
306
+ const navigate = intent === this.navigation.intent;
302
307
  if (target.kind === 'session') {
303
308
  client.archivedSessionIds = new Set(array(receipt.archivedSessionIds).map(string));
304
309
  if (this.store.state.sessionId === target.id)
@@ -310,11 +315,11 @@ export class SessionController {
310
315
  this.store.update({ workspaces: this.store.state.workspaces.filter(row => row.workspaceId !== target.id),
311
316
  ...(this.store.state.workspaceId === target.id ? { workspaceId: undefined } : {}) });
312
317
  }
313
- this.store.update({ screen: target.kind === 'workspace' ? 'workspaces' : 'sessions',
318
+ this.store.update({ ...(navigate ? { screen: target.kind === 'workspace' ? 'workspaces' : 'sessions' } : {}),
314
319
  status: target.kind === 'workspace' ? 'Workspace registration removed' : 'Session archived' });
315
320
  // A refresh failure must not make a successful mutation look like a rejected deletion.
316
321
  try {
317
- await this.showPicker(target.kind === 'workspace' ? 'workspaces' : 'sessions');
322
+ await this.refreshLists();
318
323
  }
319
324
  catch (error) {
320
325
  this.store.update({ lastFailure: `Removal completed; list refresh failed: ${errorText(error)}` });
@@ -323,45 +328,17 @@ export class SessionController {
323
328
  /** Pick a workspace, or use all sessions when the identity is omitted.
324
329
  * @param workspaceId - Workspace to select, if any.
325
330
  */
326
- pickWorkspace(workspaceId) {
327
- this.store.bumpSelection();
328
- this.follow?.cancel();
329
- this.follow = undefined;
330
- this.releaseTranscript();
331
- this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false, screen: 'sessions' });
332
- }
331
+ pickWorkspace(workspaceId) { this.navigation.pickWorkspace(workspaceId); }
333
332
  /** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix.
334
333
  * @param query - Workspace target, if any.
335
334
  */
336
- async switchWorkspace(query) {
337
- if (!query) {
338
- await this.showPicker('workspaces');
339
- return;
340
- }
341
- const client = this.host.require();
342
- const workspaces = await client.listWorkspaces();
343
- const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
344
- const sessions = await client.listSessions();
345
- this.store.update({ workspaces, sessions });
346
- this.pickWorkspace(string(workspace.workspaceId));
347
- }
335
+ switchWorkspace(query, signal) { return this.navigation.switchWorkspace(query, signal); }
348
336
  /** Guide workspace selection, list all sessions with `all`, or resolve an exact session target.
349
337
  * @param query - Session target, `all`, or nothing for the guided picker.
350
338
  */
351
- async switchSession(query) {
352
- if (!query || query === 'all') {
353
- await this.showPicker(query === 'all' || this.store.state.workspaceId ? 'sessions' : 'workspaces');
354
- this.store.update({ showAllSessions: query === 'all' });
355
- return;
356
- }
357
- const client = this.host.require();
358
- const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
359
- const session = resolveTarget(sessions, query, 'sessionId', item => [sessionLabel(item)]);
360
- this.store.update({ workspaces, sessions });
361
- await this.selectSession(string(session.sessionId));
362
- }
339
+ switchSession(query, signal) { return this.navigation.switchSession(query, signal); }
363
340
  /** Prompt for a host path without starting a local agent. */
364
- enterPath() { this.store.update({ screen: 'path' }); }
341
+ enterPath() { this.navigation.enterPath(); }
365
342
  /** Adopt the workspace whose registered path contains the directory this client runs in.
366
343
  *
367
344
  * Longest path wins, so a workspace nested in another is preferred, and the comparison is on whole
@@ -370,40 +347,13 @@ export class SessionController {
370
347
  * @param directory - Directory this client was started in.
371
348
  * @returns The adopted workspace's id, or undefined when none matches.
372
349
  */
373
- adoptLocalWorkspace(directory) {
374
- const slashed = (value) => value.replace(/\\/g, '/').replace(/\/+$/, '');
375
- const target = slashed(directory);
376
- if (target === '')
377
- return undefined;
378
- let best;
379
- for (const workspace of this.store.state.workspaces) {
380
- const path = slashed(string(workspace.path));
381
- if (path === '' || (target !== path && !target.startsWith(`${path}/`)))
382
- continue;
383
- if (best === undefined || path.length > best.length)
384
- best = { id: string(workspace.workspaceId), length: path.length };
385
- }
386
- if (best === undefined)
387
- return undefined;
388
- this.pickWorkspace(best.id);
389
- return best.id;
390
- }
350
+ adoptLocalWorkspace(directory) { return this.navigation.adoptLocalWorkspace(directory); }
391
351
  /** Register a host directory and move to its session picker.
392
352
  * @param path - Absolute directory path on the host.
393
353
  */
394
- async createWorkspace(path) {
395
- const result = object(await this.host.require().call('workspace/create', { request: { path } }));
396
- const workspace = object(result.workspace);
397
- await this.showPicker('workspaces');
398
- this.pickWorkspace(string(workspace.workspaceId));
399
- }
354
+ createWorkspace(path, signal) { return this.navigation.createWorkspace(path, signal); }
400
355
  /** Create a session only after the user explicitly selects New session. */
401
- async createSession() {
402
- if (!this.store.state.workspaceId)
403
- throw new Error('Select a workspace before creating a session');
404
- const result = object(await this.host.require().call('session/create', { request: { workspaceId: this.store.state.workspaceId } }));
405
- await this.selectSession(string(result.sessionId));
406
- }
356
+ createSession(signal) { return this.navigation.createSession(signal); }
407
357
  /** Create a session for another purpose without selecting it, named so a reader can tell it apart.
408
358
  *
409
359
  * A verifier runs in its own session while the reviewed session stays selected, so this never
@@ -437,6 +387,18 @@ export class SessionController {
437
387
  * @param sessionId - Session to follow.
438
388
  */
439
389
  async selectSession(sessionId) {
390
+ this.navigation.cancel();
391
+ this.followSession(sessionId, true);
392
+ }
393
+ /** Reattach the selected conversation after reconnect, keeping the current navigation context. */
394
+ restoreSelectedSession() {
395
+ const sessionId = this.store.state.sessionId;
396
+ if (sessionId !== undefined)
397
+ this.followSession(sessionId, false);
398
+ }
399
+ followSession(sessionId, navigate) {
400
+ this.promptBackfill.cancel();
401
+ this.history.cancel();
440
402
  this.stoppingSession = undefined;
441
403
  this.store.bumpSelection();
442
404
  const selection = this.store.selection();
@@ -448,7 +410,8 @@ export class SessionController {
448
410
  const workspaceId = workspace ? string(workspace.workspaceId)
449
411
  : this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
450
412
  this.runtime.observe(sessionId);
451
- this.store.update({ sessionId, workspaceId, showAllSessions: false, screen: 'chat', status: 'Loading session…' });
413
+ this.store.update({ sessionId, status: 'Loading session…',
414
+ ...(navigate ? { workspaceId, showAllSessions: false, screen: 'chat' } : {}) });
452
415
  this.follow = this.host.require().subscribe('session/follow', {
453
416
  request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
454
417
  }, {
@@ -475,23 +438,12 @@ export class SessionController {
475
438
  this.store.update({ status: 'Session disconnected', lastFailure: errorText(error ?? 'Session stream ended') });
476
439
  },
477
440
  });
478
- this.backfillPrompts(sessionId, selection);
441
+ this.promptBackfill.start({ sessionId, revision: selection, record: transcript, prompts: this.prompts });
479
442
  }
480
443
  /** Wait for the selected follow snapshot, failing on disconnect or cancellation.
481
444
  * @param signal - Cancels waiting without closing the session.
482
445
  */
483
- async waitForHistory(signal) {
484
- const transcript = this.info.record;
485
- const deadline = Date.now() + this.host.require().timeoutMs;
486
- while (!transcript.ready) {
487
- signal.throwIfAborted();
488
- if (!this.store.state.online || this.info.record !== transcript)
489
- throw new Error('Session changed while loading history');
490
- if (Date.now() >= deadline)
491
- throw new Error('Session snapshot timed out');
492
- await delay(20, undefined, { signal });
493
- }
494
- }
446
+ async waitForHistory(signal) { await this.history.waitForHistory(signal); }
495
447
  /** Search the host's bounded global results, optionally retaining workspace members.
496
448
  * @param query - Literal message text.
497
449
  * @param workspaceOnly - Restrict returned hits to the selected workspace's session IDs.
@@ -620,41 +572,18 @@ export class SessionController {
620
572
  /** Answer the oldest selected-session interaction, after explicit user action.
621
573
  * @param value - Structured answer value or approval outcome.
622
574
  */
623
- async answer(value) {
624
- const pending = this.store.state.pending[0];
625
- if (!pending)
626
- throw new Error('No pending interaction');
627
- await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'result', value }));
628
- this.interactions.delete(pending.eventId);
629
- this.store.update({});
630
- }
575
+ async answer(value) { await this.interactions.answer(value); }
631
576
  /** Restrict an approval command to an approval request.
632
577
  * @param allowed - Whether the request is approved once.
633
578
  */
634
- async approve(allowed) {
635
- const pending = this.store.state.pending[0];
636
- if (pending?.kind !== 'approval')
637
- throw new Error('No pending approval');
638
- await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'result', value: allowed ? 'allowed-once' : 'rejected' }));
639
- this.interactions.delete(pending.eventId);
640
- this.store.update({});
641
- }
579
+ async approve(allowed) { await this.interactions.approve(allowed); }
642
580
  /** Dismiss the whole selected-session question set without answering it.
643
581
  *
644
582
  * The Web client's close button settles the same waterfall the same way — reject with
645
583
  * `ASK_CANCELLED` — so the host records a user cancellation rather than an answer. A question
646
584
  * batch is answered as one request, so dismissals also discard partial local answers.
647
585
  */
648
- async dismissQuestion() {
649
- const pending = this.store.state.pending[0];
650
- if (pending?.kind !== 'question')
651
- throw new Error('No pending question');
652
- await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'rejected', error: {
653
- name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
654
- } }));
655
- this.interactions.delete(pending.eventId);
656
- this.store.update({});
657
- }
586
+ async dismissQuestion() { await this.interactions.dismissQuestion(); }
658
587
  /** Add a page before the retained window using its fixed opening cut.
659
588
  *
660
589
  * Reclamation is deliberately not pinned here. Every caller that needs the fetched page to
@@ -665,19 +594,7 @@ export class SessionController {
665
594
  * @param signal - Cancels local paging without interrupting the remote agent.
666
595
  * @param transcript - Transcript to extend; defaults to the live one.
667
596
  */
668
- async older(signal, transcript = this.info.record) {
669
- const selection = this.store.selection();
670
- if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
671
- return;
672
- const result = await this.host.require().call('session/page', { request: {
673
- address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
674
- beforeSeq: transcript.beforeSeq, maxMessages: 80,
675
- } }, signal);
676
- if (selection !== this.store.selection())
677
- return;
678
- transcript.addPage(result);
679
- this.store.update({});
680
- }
597
+ async older(signal, transcript = this.info.record) { await this.history.older(signal, transcript); }
681
598
  /** Recall one step through the session's prompt index; navigation never touches the network.
682
599
  * @param direction - Negative for older input, positive for newer input.
683
600
  * @param current - Composer content before recall began, restored at the newest position.
@@ -739,24 +656,6 @@ export class SessionController {
739
656
  return false;
740
657
  return this.prompts.prepend(this.info.record.promptsBefore(oldest)) > 0;
741
658
  }
742
- /** Seed recall from a complete cached entry, so an open that follows a scan costs no request.
743
- * @param sessionId - Session being opened.
744
- * @returns Whether the cache covered this session.
745
- */
746
- adoptCachedPrompts(sessionId) {
747
- const cached = this.promptCache.get(sessionId);
748
- if (!cached?.complete)
749
- return false;
750
- const oldest = this.prompts.oldest;
751
- const older = oldest === undefined ? cached.prompts : cached.prompts.filter(prompt => prompt.seq < oldest);
752
- this.prompts.prepend(older);
753
- // Same budget as the walk it replaces: `settle` may shed the oldest prefix, and `markComplete`
754
- // then refuses, so the lazy backward step stays available for whatever was shed.
755
- this.prompts.settle();
756
- this.prompts.markComplete();
757
- this.store.update({});
758
- return true;
759
- }
760
659
  /** Fold one history page the cost scan already read into the prompt cache.
761
660
  *
762
661
  * The scan reads every session's whole history on connect, so this is where two readers stop
@@ -773,165 +672,34 @@ export class SessionController {
773
672
  rememberScanDone(sessionId) {
774
673
  this.promptCache.observe(sessionId, [], true);
775
674
  }
776
- /** Fold every prompt the host still holds into the recall index, in the background.
777
- *
778
- * Session start delivers only the newest window, so without this the arrows could reach older
779
- * prompts but not show them without paging first. Each page is parsed into a temporary transcript
780
- * and only its prompts are kept, so the live record, its memory window and the row cache never
781
- * grow. The walk is bounded and the next selection cancels it; anything past the bound is still
782
- * reachable through the lazy backward step.
783
- * @param sessionId - Session being opened.
784
- * @param selection - Selector generation that must still be current.
785
- */
786
- backfillPrompts(sessionId, selection) {
787
- this.promptBackfill?.abort();
788
- const abort = new AbortController();
789
- this.promptBackfill = abort;
790
- void (async () => {
791
- try {
792
- while (!this.info.record.ready) {
793
- abort.signal.throwIfAborted();
794
- await delay(20, undefined, { signal: abort.signal });
795
- }
796
- // A cost scan or an earlier open may already have this session's prompts cached.
797
- if (this.adoptCachedPrompts(sessionId))
798
- return;
799
- const throughSeq = this.info.record.readThrough;
800
- let beforeSeq = this.info.record.beforeSeq;
801
- let hasMore = this.info.record.hasMore;
802
- for (let page = 0; hasMore && beforeSeq !== undefined && page < PROMPT_BACKFILL_PAGES; page++) {
803
- abort.signal.throwIfAborted();
804
- if (selection !== this.store.selection())
805
- return;
806
- const result = object(await this.host.require().call('session/page', { request: {
807
- address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
808
- } }, abort.signal));
809
- abort.signal.throwIfAborted();
810
- if (selection !== this.store.selection())
811
- return;
812
- const temporary = new Transcript();
813
- try {
814
- temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: result.records, hasMore: result.hasMore });
815
- const next = temporary.beforeSeq;
816
- if (temporary.hasMore && (next === undefined || next >= beforeSeq))
817
- throw new Error('Host history page did not advance');
818
- this.prompts.prepend(temporary.promptsSince(-1).prompts);
819
- beforeSeq = next;
820
- hasMore = temporary.hasMore;
821
- }
822
- finally {
823
- temporary.dispose();
824
- }
825
- // A scan that finished while this walk ran already established the same list.
826
- if (this.adoptCachedPrompts(sessionId))
827
- return;
828
- }
829
- this.prompts.settle();
830
- // Only a walk that ended because the host said "no more" makes the index exhaustive; one
831
- // stopped by the page bound leaves the lazy backward step in charge of the rest.
832
- if (!hasMore)
833
- this.prompts.markComplete();
834
- // Cache only what the walk established: advertising a capped or shed list would let a later
835
- // open skip a fetch it still needs.
836
- if (!hasMore)
837
- this.promptCache.put(sessionId, { prompts: this.prompts.durableItems, complete: this.prompts.exhausted });
838
- this.store.update({});
839
- }
840
- catch {
841
- // A cancelled, disconnected or unavailable history leaves the lazy backward step in charge.
842
- }
843
- finally {
844
- if (this.promptBackfill === abort)
845
- this.promptBackfill = undefined;
846
- }
847
- })();
848
- }
849
675
  /** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
850
676
  * @param query - Literal, case-insensitive text including folded reasoning.
851
677
  * @param signal - Cancels HTTP and processing without cancelling the agent.
852
678
  * @returns Newest-first bounded summaries and an explicit truncation flag.
853
679
  */
854
- async searchHistory(query, signal) {
855
- const source = this.info.record;
856
- if (!source.ready)
857
- throw new Error('Wait for the session snapshot');
858
- const sessionId = this.sessionId;
859
- const selection = this.store.selection();
860
- const throughSeq = source.readThrough;
861
- const needle = query.toLowerCase();
862
- const result = { items: [], truncated: false };
863
- const scan = (transcript) => {
864
- const messages = transcript.messages;
865
- for (let index = messages.length - 1; index >= 0; index--) {
866
- signal.throwIfAborted();
867
- const message = messages[index];
868
- if (message.role === 'Tool')
869
- continue;
870
- const text = message.text;
871
- const match = text.toLowerCase().indexOf(needle);
872
- if (match < 0)
873
- continue;
874
- if (result.items.length === SEARCH_MATCH_LIMIT) {
875
- result.truncated = true;
876
- return false;
877
- }
878
- 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') });
879
- }
880
- return true;
881
- };
882
- signal.throwIfAborted();
883
- if (!scan(source))
884
- return result;
885
- let beforeSeq = source.beforeSeq;
886
- let hasMore = source.hasMore;
887
- while (hasMore && beforeSeq !== undefined) {
888
- const page = object(await this.host.require().call('session/page', { request: {
889
- address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
890
- } }, signal));
891
- signal.throwIfAborted();
892
- if (selection !== this.store.selection())
893
- throw new Error('Session changed while searching history');
894
- const temporary = new Transcript();
895
- try {
896
- temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
897
- const next = temporary.beforeSeq;
898
- if (temporary.hasMore && (next === undefined || next >= beforeSeq))
899
- throw new Error('Host history page did not advance');
900
- if (!scan(temporary))
901
- return result;
902
- beforeSeq = next;
903
- hasMore = temporary.hasMore;
904
- }
905
- finally {
906
- temporary.dispose();
907
- }
908
- }
909
- return result;
910
- }
911
- /** Load a separate small window ending at a search target; the live transcript keeps following.
680
+ async searchHistory(query, signal) { return this.history.searchHistory(query, signal); }
681
+ /** Select a readable window by sequence; temporary records never escape to the UI.
912
682
  * @param target - Durable message sequence to display.
913
683
  * @param signal - Cancels the target-page request.
914
- * @returns A caller-owned historical window that must be disposed when closed.
915
684
  */
916
- async historyAt(target, signal) {
917
- const source = this.info.record;
918
- const selection = this.store.selection();
919
- const page = object(await this.host.require().call('session/page', { request: {
920
- address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
921
- beforeSeq: target + 1, maxMessages: 80,
922
- } }, signal));
685
+ async openHistory(target, signal) {
923
686
  signal.throwIfAborted();
924
- if (selection !== this.store.selection())
925
- throw new Error('Session changed while opening history');
926
- const window = new Transcript();
687
+ const selection = this.store.selection();
688
+ const record = this.info.record;
689
+ const displayed = this.info.window ?? record;
690
+ const loaded = displayed.messages.some(message => message.seq === target) ? displayed
691
+ : record.messages.some(message => message.seq === target) ? record : undefined;
692
+ const window = loaded ?? await this.history.historyAt(target, signal);
927
693
  try {
928
- window.accept({ type: 'snapshot', cursor: source.readThrough, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
929
- if (!window.messages.some(message => message.seq === target))
930
- throw new Error('The host did not return the requested message');
931
- return window;
694
+ signal.throwIfAborted();
695
+ if (selection !== this.store.selection() || record !== this.info.record || this.store.state.screen !== 'chat') {
696
+ throw new Error('Session changed while opening history');
697
+ }
698
+ this.setViewWindow(window === record ? undefined : window);
932
699
  }
933
700
  catch (error) {
934
- window.dispose();
701
+ if (!loaded)
702
+ window.dispose();
935
703
  throw error;
936
704
  }
937
705
  }
@@ -939,21 +707,7 @@ export class SessionController {
939
707
  * @param target - Visible record sequence, or first for the oldest available history.
940
708
  * @param signal - Cancels local paging without interrupting the remote agent.
941
709
  */
942
- async historyThrough(target, signal) {
943
- const transcript = this.info.record;
944
- if (!transcript.ready)
945
- throw new Error('Wait for the session snapshot');
946
- while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
947
- signal.throwIfAborted();
948
- const before = transcript.beforeSeq;
949
- await this.older(signal);
950
- if (this.info.record !== transcript)
951
- throw new Error('Session changed while loading history');
952
- if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
953
- throw new Error('Host history page did not advance');
954
- }
955
- }
956
- }
710
+ async historyThrough(target, signal) { await this.history.historyThrough(target, signal); }
957
711
  /** Reclaim reloadable history unless the user is reading away from the tail.
958
712
  * @returns Number of removed records.
959
713
  */
@@ -967,8 +721,8 @@ export class SessionController {
967
721
  }
968
722
  /** Release the selected transcript and its layout caches. */
969
723
  releaseTranscript() {
970
- this.promptBackfill?.abort();
971
- this.promptBackfill = undefined;
724
+ this.history.cancel();
725
+ this.promptBackfill.cancel();
972
726
  this.info.reset();
973
727
  }
974
728
  /** @returns The selected session identity, or a `Select a session first` failure. */
@@ -977,8 +731,4 @@ export class SessionController {
977
731
  throw new Error('Select a session first');
978
732
  return this.store.state.sessionId;
979
733
  }
980
- /** Answer one retained waterfall through the connection's event-result endpoint. */
981
- async reply(eventId, outcome) {
982
- await this.connection.reply(eventId, outcome);
983
- }
984
734
  }