@itookit/dsht 0.3.8 → 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 (166) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +33 -12
  3. package/README.zh.md +35 -14
  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 +206 -18
  8. package/dist/cli/startup.d.ts +40 -0
  9. package/dist/cli/startup.js +314 -0
  10. package/dist/cli/trace-summary.d.ts +78 -0
  11. package/dist/cli/trace-summary.js +241 -0
  12. package/dist/cli/verifier.d.ts +64 -0
  13. package/dist/cli/verifier.js +265 -0
  14. package/dist/contracts.d.ts +359 -0
  15. package/dist/contracts.js +1 -0
  16. package/dist/controller/commands.d.ts +47 -0
  17. package/dist/controller/commands.js +322 -0
  18. package/dist/controller/connection-streams.d.ts +22 -0
  19. package/dist/controller/connection-streams.js +105 -0
  20. package/dist/controller/connection.d.ts +24 -31
  21. package/dist/controller/connection.js +48 -111
  22. package/dist/controller/controller.d.ts +412 -178
  23. package/dist/controller/controller.js +713 -167
  24. package/dist/controller/foreground.d.ts +44 -0
  25. package/dist/controller/foreground.js +79 -0
  26. package/dist/controller/index.d.ts +8 -1
  27. package/dist/controller/index.js +5 -0
  28. package/dist/controller/loop-contract.d.ts +136 -0
  29. package/dist/controller/loop-contract.js +308 -0
  30. package/dist/controller/loop-coordinator.d.ts +48 -0
  31. package/dist/controller/loop-coordinator.js +647 -0
  32. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  33. package/dist/controller/loop-prompts-schema.js +144 -0
  34. package/dist/controller/loop-prompts.d.ts +55 -0
  35. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  36. package/dist/controller/loop-prompts.generated.js +185 -0
  37. package/dist/controller/loop-prompts.js +104 -0
  38. package/dist/controller/loop-protocols.d.ts +39 -0
  39. package/dist/controller/loop-protocols.js +115 -0
  40. package/dist/controller/loop.d.ts +275 -0
  41. package/dist/controller/loop.js +378 -0
  42. package/dist/controller/prompts.d.ts +54 -0
  43. package/dist/controller/prompts.js +162 -0
  44. package/dist/controller/trace-log.d.ts +45 -0
  45. package/dist/controller/trace-log.js +144 -0
  46. package/dist/controller/verifier.d.ts +130 -0
  47. package/dist/controller/verifier.js +75 -0
  48. package/dist/cost/controller.d.ts +1 -1
  49. package/dist/cost/controller.js +12 -5
  50. package/dist/cost/index.d.ts +1 -1
  51. package/dist/cost/index.js +1 -1
  52. package/dist/cost/ledger.d.ts +0 -1
  53. package/dist/cost/ledger.js +0 -1
  54. package/dist/cost/scanner.js +1 -0
  55. package/dist/json.d.ts +18 -0
  56. package/dist/json.js +19 -0
  57. package/dist/references.d.ts +25 -0
  58. package/dist/references.js +26 -0
  59. package/dist/session/connection-view.d.ts +2 -11
  60. package/dist/session/controller.d.ts +94 -105
  61. package/dist/session/controller.js +262 -536
  62. package/dist/session/history-reader.d.ts +32 -0
  63. package/dist/session/history-reader.js +170 -0
  64. package/dist/session/history.d.ts +6 -18
  65. package/dist/session/history.js +1 -24
  66. package/dist/session/index.d.ts +9 -4
  67. package/dist/session/index.js +7 -3
  68. package/dist/session/info.d.ts +20 -82
  69. package/dist/session/info.js +52 -25
  70. package/dist/session/interactions.d.ts +26 -0
  71. package/dist/session/interactions.js +75 -0
  72. package/dist/session/markdown.js +1 -1
  73. package/dist/session/math.js +1 -1
  74. package/dist/session/mutation-gate.d.ts +51 -0
  75. package/dist/session/mutation-gate.js +73 -0
  76. package/dist/session/navigation.d.ts +2 -89
  77. package/dist/session/navigation.js +2 -129
  78. package/dist/session/navigator.d.ts +47 -0
  79. package/dist/session/navigator.js +158 -0
  80. package/dist/session/peek.d.ts +38 -0
  81. package/dist/session/peek.js +103 -0
  82. package/dist/session/prompt-backfill.d.ts +23 -0
  83. package/dist/session/prompt-backfill.js +88 -0
  84. package/dist/session/references.d.ts +2 -20
  85. package/dist/session/references.js +1 -26
  86. package/dist/session/runtime.d.ts +26 -0
  87. package/dist/session/runtime.js +28 -0
  88. package/dist/session/state.d.ts +20 -0
  89. package/dist/session/state.js +1 -0
  90. package/dist/session/telemetry.d.ts +25 -17
  91. package/dist/session/telemetry.js +66 -60
  92. package/dist/session/transcript.d.ts +5 -7
  93. package/dist/session/transcript.js +2 -15
  94. package/dist/session/types.d.ts +25 -0
  95. package/dist/session/types.js +0 -1
  96. package/dist/session-title.d.ts +9 -0
  97. package/dist/session-title.js +21 -0
  98. package/dist/shell/controller.d.ts +31 -1
  99. package/dist/shell/controller.js +34 -2
  100. package/dist/shell/index.d.ts +3 -3
  101. package/dist/shell/index.js +2 -2
  102. package/dist/shell/runner.d.ts +10 -0
  103. package/dist/shell/runner.js +48 -9
  104. package/dist/slash/index.d.ts +10 -0
  105. package/dist/slash/index.js +7 -0
  106. package/dist/slash/parse.d.ts +42 -0
  107. package/dist/slash/parse.js +259 -0
  108. package/dist/slash/pipeline.d.ts +140 -0
  109. package/dist/slash/pipeline.js +115 -0
  110. package/dist/slash/registry.d.ts +88 -0
  111. package/dist/slash/registry.js +177 -0
  112. package/dist/slash/types.d.ts +126 -0
  113. package/dist/slash/types.js +1 -0
  114. package/dist/state.d.ts +16 -18
  115. package/dist/state.js +4 -3
  116. package/dist/text.d.ts +28 -0
  117. package/dist/text.js +55 -0
  118. package/dist/transport/client.d.ts +4 -3
  119. package/dist/transport/client.js +71 -25
  120. package/dist/transport/events.d.ts +104 -0
  121. package/dist/transport/events.js +149 -0
  122. package/dist/transport/wire.d.ts +9 -17
  123. package/dist/transport/wire.js +2 -27
  124. package/dist/ui/app.js +750 -550
  125. package/dist/ui/chat/header.js +1 -1
  126. package/dist/ui/chat/history-view.d.ts +1 -1
  127. package/dist/ui/chat/loop-status.d.ts +11 -0
  128. package/dist/ui/chat/loop-status.js +28 -0
  129. package/dist/ui/chat/navigation-model.d.ts +86 -0
  130. package/dist/ui/chat/navigation-model.js +107 -0
  131. package/dist/ui/chat/shell-view.d.ts +17 -2
  132. package/dist/ui/chat/shell-view.js +45 -3
  133. package/dist/ui/chat/status.d.ts +47 -3
  134. package/dist/ui/chat/status.js +65 -50
  135. package/dist/ui/chat/use-history-view.d.ts +69 -0
  136. package/dist/ui/chat/use-history-view.js +123 -0
  137. package/dist/ui/chat/viewport.d.ts +1 -1
  138. package/dist/ui/dialogs/cost.d.ts +21 -4
  139. package/dist/ui/dialogs/cost.js +7 -12
  140. package/dist/ui/dialogs/index.d.ts +22 -5
  141. package/dist/ui/dialogs/index.js +19 -3
  142. package/dist/ui/dialogs/loop.d.ts +43 -0
  143. package/dist/ui/dialogs/loop.js +224 -0
  144. package/dist/ui/dialogs/peek.d.ts +25 -0
  145. package/dist/ui/dialogs/peek.js +35 -0
  146. package/dist/ui/dialogs/picker.d.ts +2 -0
  147. package/dist/ui/dialogs/picker.js +4 -2
  148. package/dist/ui/dialogs/use-panels.d.ts +53 -0
  149. package/dist/ui/dialogs/use-panels.js +51 -0
  150. package/dist/ui/input/mouse.d.ts +12 -2
  151. package/dist/ui/input/mouse.js +20 -7
  152. package/dist/ui/input/references.d.ts +1 -1
  153. package/dist/ui/input/use-composer.d.ts +35 -0
  154. package/dist/ui/input/use-composer.js +109 -0
  155. package/dist/ui/input/use-deferred-lines.d.ts +16 -0
  156. package/dist/ui/input/use-deferred-lines.js +54 -0
  157. package/dist/ui/input/use-history-recall.d.ts +20 -0
  158. package/dist/ui/input/use-history-recall.js +47 -0
  159. package/dist/ui/status/model.d.ts +7 -0
  160. package/dist/ui/status/model.js +5 -0
  161. package/dist/ui/theme/index.d.ts +1 -1
  162. package/package.json +6 -4
  163. package/dist/ui/commands/parse.d.ts +0 -104
  164. package/dist/ui/commands/parse.js +0 -135
  165. package/dist/ui/commands/registry.d.ts +0 -33
  166. package/dist/ui/commands/registry.js +0 -73
@@ -1,20 +1,23 @@
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";
7
- import { releaseHistoryLayout } from "./history.js";
8
- import { resolveTarget, sessionLabel } from "./navigation.js";
6
+ import { historyLayout, releaseHistoryLayout } from "./history.js";
7
+ import { resolveTarget } from "./navigation.js";
8
+ import { sessionLabel } from "../session-title.js";
9
9
  import { fileReferences } from "./references.js";
10
+ import { SessionRuntime } from "./runtime.js";
10
11
  import { PromptCache } from "./info.js";
11
- import { recordPrompts, Transcript, toolLine } from "./transcript.js";
12
+ import { SessionMutationGate } from "./mutation-gate.js";
13
+ import { recordPrompts } from "./transcript.js";
14
+ import { projectionSnapshot } from "../transport/events.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";
12
19
  /** Built-in preset identifiers and the labels the web session header shows. */
13
20
  const BUILT_IN_MODES = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
14
- /** Bounded match count for one history search. */
15
- const SEARCH_MATCH_LIMIT = 200;
16
- /** Bounded page count for the prompt backfill that runs once per opened session. */
17
- const PROMPT_BACKFILL_PAGES = 200;
18
21
  /** Owns the selected session: its follow stream, transcript, history window and interactions. */
19
22
  export class SessionController {
20
23
  store;
@@ -22,27 +25,69 @@ export class SessionController {
22
25
  connection;
23
26
  historyLimits;
24
27
  follow;
25
- interactions = new Map();
28
+ history;
29
+ interactions;
30
+ navigation;
31
+ /** Reading protection: reclamation pauses while the reader is away from the live end. */
32
+ historyPinned = false;
33
+ /** Host runtime mirrors for every session this connection has seen. */
34
+ runtime = new SessionRuntime();
26
35
  /** Cancels the background prompt backfill of the previous selection. */
27
36
  promptBackfill;
28
37
  /** Prompts of sessions this process has already read, so re-opening one costs no page request. */
29
38
  promptCache = new PromptCache();
30
- /** 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. */
31
40
  get info() { return this.store.state.session; }
32
41
  get prompts() { return this.info.prompts; }
33
42
  stoppingSession;
34
43
  interruptTask;
35
44
  admission;
36
- constructor(store, host, connection, historyLimits) {
45
+ /** Admission order of this session's writes, so two concurrent decisions cannot interleave. */
46
+ mutations;
47
+ constructor(store, host, connection, historyLimits, report) {
37
48
  this.store = store;
38
49
  this.host = host;
39
50
  this.connection = connection;
40
51
  this.historyLimits = historyLimits;
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
+ });
41
86
  }
42
87
  /** Host running state covers model generation, tools, and waits between assistant attempts. */
43
88
  get running() {
44
89
  const id = this.store.state.sessionId;
45
- return id !== undefined && (this.connection.runningFor(id)
90
+ return id !== undefined && (this.runtime.runningFor(id)
46
91
  ?? this.store.state.sessions.find(row => row.sessionId === id)?.running === true);
47
92
  }
48
93
  /** Current title projection, falling back to the list title and then the session ID. */
@@ -50,19 +95,19 @@ export class SessionController {
50
95
  const id = this.store.state.sessionId;
51
96
  if (!id)
52
97
  return;
53
- const title = this.connection.telemetryView().view(id).values.title;
98
+ const title = this.runtime.telemetry.view(id).values.title;
54
99
  const row = this.store.state.sessions.find(item => item.sessionId === id);
55
100
  return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
56
101
  : row ? sessionLabel(row) : id;
57
102
  }
58
103
  /** Current agent-preset name, matching the web header's built-in labels and custom metadata. */
59
- get sessionMode() {
104
+ sessionMode(presets = []) {
60
105
  if (!this.store.state.sessionId)
61
106
  return undefined;
62
- const id = this.connection.telemetryView().view(this.store.state.sessionId).values.agentPreset;
107
+ const id = this.runtime.telemetry.view(this.store.state.sessionId).values.agentPreset;
63
108
  if (typeof id !== 'string')
64
109
  return undefined;
65
- const preset = this.store.state.presets?.find(item => item.id === id);
110
+ const preset = presets.find(item => item.id === id);
66
111
  return preset?.trust === 'system' && BUILT_IN_MODES.has(id) ? BUILT_IN_MODES.get(id)
67
112
  : typeof preset?.name === 'string' ? preset.name : id;
68
113
  }
@@ -70,40 +115,60 @@ export class SessionController {
70
115
  get workingSince() {
71
116
  if (!this.running || !this.store.state.sessionId)
72
117
  return undefined;
73
- return this.info.record.activeTurnStartedAt ?? this.connection.observedAt(this.store.state.sessionId);
118
+ return this.info.record.activeTurnStartedAt ?? this.runtime.observedAt(this.store.state.sessionId);
74
119
  }
75
120
  /** Present only sessions explicitly accounted to the selected workspace. */
76
- get visibleSessions() {
77
- const sessions = this.store.state.sessions.filter(item => !this.host.client()?.archivedSessionIds.has(string(item.sessionId)));
78
- if (this.store.state.showAllSessions || !this.store.state.workspaceId)
79
- return sessions;
80
- const workspace = this.store.state.workspaces.find(item => item.workspaceId === this.store.state.workspaceId);
81
- const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
82
- return sessions.filter(item => ids.has(string(item.sessionId)));
83
- }
121
+ get visibleSessions() { return this.navigation.visibleSessions; }
84
122
  /** Whether a turn, cancellation or prompt admission is still in flight. */
85
123
  get active() { return this.interruptTask !== undefined || this.running || this.admission !== undefined; }
124
+ /** @returns The detached history window the reader opened, if any. */
125
+ get window() { return this.info.window; }
126
+ /** Show a detached history window, releasing the one it replaces.
127
+ * @param window - Record to display, or undefined to return to the live transcript.
128
+ */
129
+ setViewWindow(window) {
130
+ if (this.info.window === window)
131
+ return;
132
+ if (this.info.window)
133
+ this.history.cancel(this.info.window);
134
+ this.info.closeWindow();
135
+ this.info.window = window;
136
+ this.store.update({});
137
+ }
138
+ /** @returns Host projection store of the current generation, owned by this session's runtime. */
139
+ get telemetry() { return this.runtime.telemetry; }
140
+ /** Lay out one record for a terminal width.
141
+ *
142
+ * The projection engine stays in this domain; the UI receives plain rows and offsets instead of
143
+ * importing it, and `Reasoning` for committed messages is always the folded default.
144
+ */
145
+ render(input) {
146
+ return historyLayout(input.transcript, input.width, 'row', input.folds, input.liveReasoning);
147
+ }
148
+ /** Apply one normalized control frame to this session's runtime. */
149
+ acceptControl(frame) { this.runtime.acceptControl(frame); }
86
150
  /** Whether reading protects the loaded window, suspending history reclamation. */
87
- get pinned() { return this.info.view.pinned; }
151
+ get pinned() { return this.historyPinned; }
88
152
  /** Drop generation-scoped state before a new connection generation begins. */
89
- beginGeneration() { this.stoppingSession = undefined; }
153
+ beginGeneration() { this.stoppingSession = undefined; this.runtime.reset(); }
90
154
  /** Invalidate in-flight work and drop transient interactions when a generation ends. */
91
155
  endGeneration() {
156
+ this.promptBackfill.cancel();
157
+ this.navigation.reset();
158
+ this.history.cancel();
92
159
  this.store.bumpSelection();
93
160
  this.info.record.ready = false;
94
161
  this.interactions.clear();
95
162
  }
96
- /** Wait for an in-flight cancellation so shutdown leaves nothing running. */
97
- 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(); }
98
165
  /** Release the selected transcript and its layout caches. */
99
- release() { this.releaseTranscript(); }
166
+ release() { this.navigation.reset(); this.releaseTranscript(); }
100
167
  /** Pending interactions for the selected chat session, derived independently of frame order.
101
- * @param state - State being published.
168
+ * @param sessionId - Session whose pending interactions are being published.
102
169
  * @returns Retained question and approval frames for that session.
103
170
  */
104
- pendingFor(state) {
105
- return [...this.interactions.values()].filter(frame => frame.agentId === state.sessionId);
106
- }
171
+ pendingFor(sessionId) { return this.interactions.pendingFor(sessionId); }
107
172
  /** Unanswered interactions by session, so a list can show who is waiting without opening them.
108
173
  *
109
174
  * The host delivers approval and question waterfalls for every session on one stream, and this
@@ -112,35 +177,22 @@ export class SessionController {
112
177
  * until the host replays the pending waterfalls.
113
178
  * @returns One count per session holding at least one unanswered interaction.
114
179
  */
115
- pendingCounts() {
116
- const counts = new Map();
117
- for (const frame of this.interactions.values()) {
118
- if (typeof frame.agentId !== 'string')
119
- continue;
120
- counts.set(frame.agentId, (counts.get(frame.agentId) ?? 0) + 1);
121
- }
122
- return counts;
123
- }
124
- /** Retain a recognized host waterfall; unknown events stay with the connection to delegate.
125
- * @param frame - One decoded waterfall frame.
126
- * @returns Whether this domain retained the frame for an answer.
180
+ pendingCounts() { return this.interactions.counts(); }
181
+ /** Retain a recognized host interaction; anything else belongs to the connection to delegate.
182
+ * @param event - One normalized host event.
183
+ * @returns Whether this domain retained the event for an answer.
127
184
  */
128
- waterfall(frame) {
129
- if (!['approval/request', 'user-questions/request'].includes(string(frame.event)))
130
- return false;
131
- this.interactions.set(string(frame.eventId), frame);
132
- this.store.update({});
133
- return true;
134
- }
185
+ accept(event) { return this.interactions.accept(event); }
135
186
  /** Drop a waterfall the host cancelled.
136
187
  * @param eventId - Correlation id previously retained.
137
188
  */
138
- cancelled(eventId) { this.interactions.delete(eventId); this.store.update({}); }
189
+ cancelled(eventId) { this.interactions.cancelled(eventId); }
139
190
  /** Apply one host running-state notification to the session list and status line.
140
191
  * @param sessionId - Session whose state changed.
141
192
  * @param running - Whether the host still runs that session.
142
193
  */
143
194
  status(sessionId, running) {
195
+ this.runtime.accept(sessionId, running);
144
196
  if (!running && this.stoppingSession === sessionId)
145
197
  this.stoppingSession = undefined;
146
198
  this.store.update({ sessions: this.store.state.sessions.map(row => row.sessionId === sessionId ? { ...row, running } : row),
@@ -148,11 +200,11 @@ export class SessionController {
148
200
  }
149
201
  /** Surface a host-reported error for the selected session.
150
202
  * @param sessionId - Session the host reported on.
151
- * @param error - Error payload as delivered by the host.
203
+ * @param error - Already-normalized error text.
152
204
  */
153
205
  reportError(sessionId, error) {
154
206
  if (sessionId === this.store.state.sessionId)
155
- this.store.update({ status: 'Agent error', error: errorText(error) });
207
+ this.store.update({ status: 'Agent error', lastFailure: error });
156
208
  }
157
209
  /** Stop the selected turn, or allow exit only while idle. Repeated keys share one request.
158
210
  * @param force - Send an explicit cancellation even when the cached running flag is idle.
@@ -162,23 +214,27 @@ export class SessionController {
162
214
  if (this.interruptTask)
163
215
  return this.interruptTask;
164
216
  if (!force && !this.running && !this.admission && this.store.state.pending.length === 0) {
165
- return Promise.resolve(!this.store.state.busy);
217
+ // An idle press may exit only when no operation owns the client; the store's failure line is
218
+ // not that fact, so the application asks itself.
219
+ return Promise.resolve(!this.store.busy());
166
220
  }
167
221
  const sessionId = this.sessionId;
168
222
  this.stoppingSession = sessionId;
169
- this.store.update({ status: 'Stopping…', error: '' });
223
+ this.store.update({ status: 'Stopping…', lastFailure: '' });
170
224
  const task = (async () => {
171
225
  try {
172
226
  // Admission must settle before cancellation can address the newly submitted turn.
173
227
  // The prompt caller reports admission failures; an existing turn still needs cancellation.
228
+ // This wait stays outside the gate: a control admission overtakes the *waiting* queue, and
229
+ // holding the gate while waiting here would block the very writes it precedes.
174
230
  await this.admission?.catch(() => undefined);
175
- await this.host.require().call('session/cancel', { request: { sessionId } });
231
+ await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
176
232
  if (this.stoppingSession === sessionId && this.store.state.sessionId === sessionId)
177
233
  this.store.update({ status: 'Cancellation requested · waiting for host' });
178
234
  }
179
235
  catch (error) {
180
236
  this.stoppingSession = undefined;
181
- this.store.update({ status: 'Cancellation failed', error: errorText(error) });
237
+ this.store.update({ status: 'Cancellation failed', lastFailure: errorText(error) });
182
238
  }
183
239
  return false;
184
240
  })();
@@ -190,23 +246,34 @@ export class SessionController {
190
246
  * @param pinned - Whether the main transcript is actively being read away from its tail.
191
247
  */
192
248
  pinHistory(pinned) {
193
- this.info.view.pinned = pinned;
249
+ if (this.historyPinned === pinned)
250
+ return;
251
+ this.historyPinned = pinned;
194
252
  if (!pinned && this.reclaimHistory())
195
253
  this.store.update({});
196
254
  }
197
255
  /** Cancel the active turn; pending queue items remain host-owned. */
198
256
  async cancelTurn() {
199
- await this.host.require().call('session/cancel', { request: { sessionId: this.sessionId } });
257
+ const sessionId = this.sessionId;
258
+ // Cancellation is a control action: it may overtake queued writes, since its whole value is speed.
259
+ await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
200
260
  this.store.update({ status: 'Cancellation requested' });
201
261
  }
202
262
  /** Refresh both lists from the host, then show the requested picker.
203
263
  * @param screen - Picker to display after the refresh.
204
264
  */
205
- async showPicker(screen) {
206
- const client = this.host.require();
207
- const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
208
- this.store.update({ screen, workspaces, sessions });
265
+ showPicker(screen, signal) {
266
+ return this.navigation.showPicker(screen, signal);
209
267
  }
268
+ /** Refresh navigation data without taking the reader to another screen. */
269
+ refreshLists() { return this.navigation.refreshLists(); }
270
+ /** Return to the selected conversation without re-selecting it.
271
+ *
272
+ * A picker opened over a conversation is a detour: leaving it must not re-subscribe, reload the
273
+ * transcript or lose the reading position, so this only changes which screen is shown.
274
+ * @returns Whether there was a selected conversation to return to.
275
+ */
276
+ showChat() { return this.navigation.showChat(); }
210
277
  /** Resolve a removal command to one reviewable object without changing the selection.
211
278
  * @param kind - Workspace registration removal or session archival.
212
279
  * @param query - Exact name, ID, or unambiguous ID prefix.
@@ -219,9 +286,9 @@ export class SessionController {
219
286
  return kind === 'workspace' ? { kind, id: string(row.workspaceId), name: string(row.title), path: string(row.path) }
220
287
  : { kind, id: string(row.sessionId), name: sessionLabel(row),
221
288
  empty: row.blank === true && row.running === false
222
- && !this.connection.runningFor(string(row.sessionId))
223
- && !(this.connection.telemetryView().view(string(row.sessionId)).queued ?? 0)
224
- && !(this.connection.telemetryView().view(string(row.sessionId)).jobs ?? 0)
289
+ && !this.runtime.runningFor(string(row.sessionId))
290
+ && !(this.runtime.telemetry.view(string(row.sessionId)).queued ?? 0)
291
+ && !(this.runtime.telemetry.view(string(row.sessionId)).jobs ?? 0)
225
292
  && !(this.store.state.sessionId === row.sessionId && this.admission) };
226
293
  }
227
294
  /** Apply a confirmed removal or freshly verified empty-session archival; directories and logs are preserved.
@@ -229,11 +296,14 @@ export class SessionController {
229
296
  */
230
297
  async removeTarget(target) {
231
298
  const client = this.host.require();
232
- const receipt = object(await client.call(target.kind === 'workspace' ? 'workspace/delete' : 'workspace/archiveSession', {
233
- request: target.kind === 'workspace' ? { workspaceId: target.id } : { sessionId: target.id },
234
- }));
299
+ const intent = this.navigation.intent;
300
+ const receipt = object(target.kind === 'workspace'
301
+ ? await client.call('workspace/delete', { request: { workspaceId: target.id } })
302
+ // Archiving a session is a write on that session, so it queues behind that session's admissions.
303
+ : await this.mutations.admit(target.id, 'normal', () => client.call('workspace/archiveSession', { request: { sessionId: target.id } })));
235
304
  if (client !== this.host.client())
236
305
  return;
306
+ const navigate = intent === this.navigation.intent;
237
307
  if (target.kind === 'session') {
238
308
  client.archivedSessionIds = new Set(array(receipt.archivedSessionIds).map(string));
239
309
  if (this.store.state.sessionId === target.id)
@@ -245,58 +315,30 @@ export class SessionController {
245
315
  this.store.update({ workspaces: this.store.state.workspaces.filter(row => row.workspaceId !== target.id),
246
316
  ...(this.store.state.workspaceId === target.id ? { workspaceId: undefined } : {}) });
247
317
  }
248
- this.store.update({ screen: target.kind === 'workspace' ? 'workspaces' : 'sessions',
318
+ this.store.update({ ...(navigate ? { screen: target.kind === 'workspace' ? 'workspaces' : 'sessions' } : {}),
249
319
  status: target.kind === 'workspace' ? 'Workspace registration removed' : 'Session archived' });
250
320
  // A refresh failure must not make a successful mutation look like a rejected deletion.
251
321
  try {
252
- await this.showPicker(target.kind === 'workspace' ? 'workspaces' : 'sessions');
322
+ await this.refreshLists();
253
323
  }
254
324
  catch (error) {
255
- this.store.update({ error: `Removal completed; list refresh failed: ${errorText(error)}` });
325
+ this.store.update({ lastFailure: `Removal completed; list refresh failed: ${errorText(error)}` });
256
326
  }
257
327
  }
258
328
  /** Pick a workspace, or use all sessions when the identity is omitted.
259
329
  * @param workspaceId - Workspace to select, if any.
260
330
  */
261
- pickWorkspace(workspaceId) {
262
- this.store.bumpSelection();
263
- this.follow?.cancel();
264
- this.follow = undefined;
265
- this.releaseTranscript();
266
- this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false, screen: 'sessions' });
267
- }
331
+ pickWorkspace(workspaceId) { this.navigation.pickWorkspace(workspaceId); }
268
332
  /** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix.
269
333
  * @param query - Workspace target, if any.
270
334
  */
271
- async switchWorkspace(query) {
272
- if (!query) {
273
- await this.showPicker('workspaces');
274
- return;
275
- }
276
- const client = this.host.require();
277
- const workspaces = await client.listWorkspaces();
278
- const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
279
- const sessions = await client.listSessions();
280
- this.store.update({ workspaces, sessions });
281
- this.pickWorkspace(string(workspace.workspaceId));
282
- }
335
+ switchWorkspace(query, signal) { return this.navigation.switchWorkspace(query, signal); }
283
336
  /** Guide workspace selection, list all sessions with `all`, or resolve an exact session target.
284
337
  * @param query - Session target, `all`, or nothing for the guided picker.
285
338
  */
286
- async switchSession(query) {
287
- if (!query || query === 'all') {
288
- await this.showPicker(query === 'all' || this.store.state.workspaceId ? 'sessions' : 'workspaces');
289
- this.store.update({ showAllSessions: query === 'all' });
290
- return;
291
- }
292
- const client = this.host.require();
293
- const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
294
- const session = resolveTarget(sessions, query, 'sessionId', item => [sessionLabel(item)]);
295
- this.store.update({ workspaces, sessions });
296
- await this.selectSession(string(session.sessionId));
297
- }
339
+ switchSession(query, signal) { return this.navigation.switchSession(query, signal); }
298
340
  /** Prompt for a host path without starting a local agent. */
299
- enterPath() { this.store.update({ screen: 'path' }); }
341
+ enterPath() { this.navigation.enterPath(); }
300
342
  /** Adopt the workspace whose registered path contains the directory this client runs in.
301
343
  *
302
344
  * Longest path wins, so a workspace nested in another is preferred, and the comparison is on whole
@@ -305,44 +347,58 @@ export class SessionController {
305
347
  * @param directory - Directory this client was started in.
306
348
  * @returns The adopted workspace's id, or undefined when none matches.
307
349
  */
308
- adoptLocalWorkspace(directory) {
309
- const slashed = (value) => value.replace(/\\/g, '/').replace(/\/+$/, '');
310
- const target = slashed(directory);
311
- if (target === '')
312
- return undefined;
313
- let best;
314
- for (const workspace of this.store.state.workspaces) {
315
- const path = slashed(string(workspace.path));
316
- if (path === '' || (target !== path && !target.startsWith(`${path}/`)))
317
- continue;
318
- if (best === undefined || path.length > best.length)
319
- best = { id: string(workspace.workspaceId), length: path.length };
320
- }
321
- if (best === undefined)
322
- return undefined;
323
- this.pickWorkspace(best.id);
324
- return best.id;
325
- }
350
+ adoptLocalWorkspace(directory) { return this.navigation.adoptLocalWorkspace(directory); }
326
351
  /** Register a host directory and move to its session picker.
327
352
  * @param path - Absolute directory path on the host.
328
353
  */
329
- async createWorkspace(path) {
330
- const result = object(await this.host.require().call('workspace/create', { request: { path } }));
331
- const workspace = object(result.workspace);
332
- await this.showPicker('workspaces');
333
- this.pickWorkspace(string(workspace.workspaceId));
334
- }
354
+ createWorkspace(path, signal) { return this.navigation.createWorkspace(path, signal); }
335
355
  /** Create a session only after the user explicitly selects New session. */
336
- async createSession() {
337
- if (!this.store.state.workspaceId)
356
+ createSession(signal) { return this.navigation.createSession(signal); }
357
+ /** Create a session for another purpose without selecting it, named so a reader can tell it apart.
358
+ *
359
+ * A verifier runs in its own session while the reviewed session stays selected, so this never
360
+ * follows the new session and never touches the reader's transcript.
361
+ * @param title - Session title to apply; hosts without a title service keep the default.
362
+ * @returns The new session's id, or undefined when its identity could not be read.
363
+ */
364
+ async createNamedSession(title) {
365
+ const workspaceId = this.store.state.workspaceId;
366
+ if (!workspaceId)
338
367
  throw new Error('Select a workspace before creating a session');
339
- const result = object(await this.host.require().call('session/create', { request: { workspaceId: this.store.state.workspaceId } }));
340
- await this.selectSession(string(result.sessionId));
368
+ const created = object(await this.host.require().call('session/create', { request: { workspaceId } }));
369
+ const sessionId = string(created.sessionId);
370
+ try {
371
+ await this.host.require().call('session/rename', { request: { sessionId, title } });
372
+ }
373
+ catch { /* A deployment without the title service still gets a usable session. */ }
374
+ return sessionId;
375
+ }
376
+ /** Stop a session this client owns but has not selected, such as a verifier's.
377
+ *
378
+ * Killing the child client leaves the host's agent generation running, so the turn has to be
379
+ * cancelled on the host itself.
380
+ * @param sessionId - Session whose turn should stop.
381
+ */
382
+ async cancelNamedSession(sessionId) {
383
+ // Keyed by the session being stopped, not the selection: the verifier's session is someone else's.
384
+ await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
341
385
  }
342
386
  /** Replace the selected transcript and cancel its preceding follow stream.
343
387
  * @param sessionId - Session to follow.
344
388
  */
345
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();
346
402
  this.stoppingSession = undefined;
347
403
  this.store.bumpSelection();
348
404
  const selection = this.store.selection();
@@ -353,8 +409,9 @@ export class SessionController {
353
409
  const workspace = this.store.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
354
410
  const workspaceId = workspace ? string(workspace.workspaceId)
355
411
  : this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
356
- this.connection.observe(sessionId);
357
- this.store.update({ sessionId, workspaceId, showAllSessions: false, screen: 'chat', status: 'Loading session…' });
412
+ this.runtime.observe(sessionId);
413
+ this.store.update({ sessionId, status: 'Loading session…',
414
+ ...(navigate ? { workspaceId, showAllSessions: false, screen: 'chat' } : {}) });
358
415
  this.follow = this.host.require().subscribe('session/follow', {
359
416
  request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
360
417
  }, {
@@ -367,7 +424,7 @@ export class SessionController {
367
424
  this.reclaimHistory();
368
425
  const frame = object(value);
369
426
  if (frame.type === 'snapshot')
370
- this.connection.telemetryView().snapshot(sessionId, frame.projections);
427
+ this.runtime.telemetry.snapshot(sessionId, projectionSnapshot(frame.projections));
371
428
  this.store.update({ status: this.stoppingSession === sessionId ? this.store.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
372
429
  }
373
430
  catch (error) {
@@ -378,26 +435,15 @@ export class SessionController {
378
435
  if (selection !== this.store.selection())
379
436
  return;
380
437
  transcript.ready = false;
381
- this.store.update({ status: 'Session disconnected', error: errorText(error ?? 'Session stream ended') });
438
+ this.store.update({ status: 'Session disconnected', lastFailure: errorText(error ?? 'Session stream ended') });
382
439
  },
383
440
  });
384
- this.backfillPrompts(sessionId, selection);
441
+ this.promptBackfill.start({ sessionId, revision: selection, record: transcript, prompts: this.prompts });
385
442
  }
386
443
  /** Wait for the selected follow snapshot, failing on disconnect or cancellation.
387
444
  * @param signal - Cancels waiting without closing the session.
388
445
  */
389
- async waitForHistory(signal) {
390
- const transcript = this.info.record;
391
- const deadline = Date.now() + this.host.require().timeoutMs;
392
- while (!transcript.ready) {
393
- signal.throwIfAborted();
394
- if (!this.store.state.online || this.info.record !== transcript)
395
- throw new Error('Session changed while loading history');
396
- if (Date.now() >= deadline)
397
- throw new Error('Session snapshot timed out');
398
- await delay(20, undefined, { signal });
399
- }
400
- }
446
+ async waitForHistory(signal) { await this.history.waitForHistory(signal); }
401
447
  /** Search the host's bounded global results, optionally retaining workspace members.
402
448
  * @param query - Literal message text.
403
449
  * @param workspaceOnly - Restrict returned hits to the selected workspace's session IDs.
@@ -434,11 +480,14 @@ export class SessionController {
434
480
  * @returns The host's successful command result text.
435
481
  */
436
482
  async command(line, signal) {
437
- if (!this.info.record.ready)
438
- throw new Error('Wait for the session snapshot before running commands');
439
- const execution = await this.host.require().call('commands/execute', {
440
- agentId: this.sessionId, line, submittedAttachments: [],
441
- }, signal, null);
483
+ const sessionId = this.sessionId;
484
+ // The section decides and issues; the host's answer is awaited outside the gate, because a
485
+ // compaction can take minutes and must not hold every other write on this session.
486
+ const execution = await this.mutations.admit(sessionId, 'normal', () => {
487
+ if (!this.info.record.ready)
488
+ throw new Error('Wait for the session snapshot before running commands');
489
+ return this.host.require().call('commands/execute', { agentId: sessionId, line, submittedAttachments: [] }, signal, null);
490
+ });
442
491
  if (execution === undefined)
443
492
  throw new Error(`This host does not provide ${line.split(/\s/, 1)[0]}`);
444
493
  const result = object(object(execution).result);
@@ -458,7 +507,10 @@ export class SessionController {
458
507
  * @param itemId - Queue occurrence identity from session/control.
459
508
  */
460
509
  async removeQueued(itemId) {
461
- await this.host.require().call('session/updateQueue', { request: { sessionId: this.sessionId, itemId, action: { kind: 'remove' } } });
510
+ const sessionId = this.sessionId;
511
+ await this.mutations.admit(sessionId, 'normal', () => this.host.require().call('session/updateQueue', {
512
+ request: { sessionId, itemId, action: { kind: 'remove' } }
513
+ }));
462
514
  }
463
515
  /** Export the selected host log to a new local ZIP file.
464
516
  * @param path - Optional local destination; existing files are never overwritten.
@@ -480,16 +532,23 @@ export class SessionController {
480
532
  * @param text - Composed prompt text.
481
533
  */
482
534
  async prompt(text) {
483
- if (this.store.state.pending.length)
484
- throw new Error('Answer the pending question or approval first');
485
- this.stoppingSession = undefined;
486
- if (!this.info.record.ready)
487
- throw new Error('Wait for the session snapshot before sending');
488
- const admission = this.host.require().call('session/prompt', { request: {
489
- sessionId: this.sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
490
- content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
491
- } });
492
- this.admission = admission;
535
+ const sessionId = this.sessionId;
536
+ // Deciding steer-versus-queue and issuing the request are one admitted step: the loop's own sends
537
+ // and everything the reader types go through this same point, so two of them cannot be decided
538
+ // against the same stale running state. The host's answer is awaited outside the gate.
539
+ const admission = await this.mutations.admit(sessionId, 'normal', () => {
540
+ if (this.store.state.pending.length)
541
+ throw new Error('Answer the pending question or approval first');
542
+ this.stoppingSession = undefined;
543
+ if (!this.info.record.ready)
544
+ throw new Error('Wait for the session snapshot before sending');
545
+ const issued = this.host.require().call('session/prompt', { request: {
546
+ sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
547
+ content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
548
+ } });
549
+ this.admission = issued;
550
+ return issued;
551
+ });
493
552
  try {
494
553
  await admission;
495
554
  }
@@ -499,41 +558,32 @@ export class SessionController {
499
558
  }
500
559
  this.store.update({ status: 'Accepted · waiting for host' });
501
560
  }
561
+ /** Send one prompt the client assembled, keeping its durable echo out of composer recall.
562
+ *
563
+ * The host records every prompt as a user message, so an agent loop that submits a turn per
564
+ * attempt would otherwise crowd out the prompts the operator actually typed. Suppressing the text
565
+ * before sending is what makes the later echo invisible to ↑/↓.
566
+ * @param text - Prompt the client assembled, not text the operator typed.
567
+ */
568
+ async promptInternal(text) {
569
+ this.prompts.suppress(text);
570
+ await this.prompt(text);
571
+ }
502
572
  /** Answer the oldest selected-session interaction, after explicit user action.
503
573
  * @param value - Structured answer value or approval outcome.
504
574
  */
505
- async answer(value) {
506
- const pending = this.store.state.pending[0];
507
- if (!pending)
508
- throw new Error('No pending interaction');
509
- await this.reply(pending, { kind: 'result', value });
510
- this.interactions.delete(string(pending.eventId));
511
- this.store.update({});
512
- }
575
+ async answer(value) { await this.interactions.answer(value); }
513
576
  /** Restrict an approval command to an approval request.
514
577
  * @param allowed - Whether the request is approved once.
515
578
  */
516
- async approve(allowed) {
517
- if (this.store.state.pending[0]?.event !== 'approval/request')
518
- throw new Error('No pending approval');
519
- await this.answer(allowed ? 'allowed-once' : 'rejected');
520
- }
579
+ async approve(allowed) { await this.interactions.approve(allowed); }
521
580
  /** Dismiss the whole selected-session question set without answering it.
522
581
  *
523
582
  * The Web client's close button settles the same waterfall the same way — reject with
524
583
  * `ASK_CANCELLED` — so the host records a user cancellation rather than an answer. A question
525
584
  * batch is answered as one request, so dismissals also discard partial local answers.
526
585
  */
527
- async dismissQuestion() {
528
- const pending = this.store.state.pending[0];
529
- if (pending?.event !== 'user-questions/request')
530
- throw new Error('No pending question');
531
- await this.reply(pending, { kind: 'rejected', error: {
532
- name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
533
- } });
534
- this.interactions.delete(string(pending.eventId));
535
- this.store.update({});
536
- }
586
+ async dismissQuestion() { await this.interactions.dismissQuestion(); }
537
587
  /** Add a page before the retained window using its fixed opening cut.
538
588
  *
539
589
  * Reclamation is deliberately not pinned here. Every caller that needs the fetched page to
@@ -544,103 +594,13 @@ export class SessionController {
544
594
  * @param signal - Cancels local paging without interrupting the remote agent.
545
595
  * @param transcript - Transcript to extend; defaults to the live one.
546
596
  */
547
- async older(signal, transcript = this.info.record) {
548
- const selection = this.store.selection();
549
- if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
550
- return;
551
- const result = await this.host.require().call('session/page', { request: {
552
- address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
553
- beforeSeq: transcript.beforeSeq, maxMessages: 80,
554
- } }, signal);
555
- if (selection !== this.store.selection())
556
- return;
557
- transcript.addPage(result);
558
- this.store.update({});
559
- }
597
+ async older(signal, transcript = this.info.record) { await this.history.older(signal, transcript); }
560
598
  /** Recall one step through the session's prompt index; navigation never touches the network.
561
599
  * @param direction - Negative for older input, positive for newer input.
562
600
  * @param current - Composer content before recall began, restored at the newest position.
563
601
  * @returns The recalled prompt, or the unsent draft.
564
602
  */
565
603
  recall(direction, current) { return this.prompts.move(direction, current); }
566
- /** Composer draft, caret and parked draft, as the selected session holds them. */
567
- get composer() { return this.info.composer; }
568
- /** Replace the composer text and caret, publishing only when either actually changed. */
569
- setComposer(draft, cursor = draft.length) {
570
- const composer = this.info.composer;
571
- if (composer.draft === draft && composer.cursor === cursor)
572
- return;
573
- composer.draft = draft;
574
- composer.cursor = cursor;
575
- this.store.update({});
576
- }
577
- /** Move the caret without changing the text. */
578
- setComposerCursor(cursor) {
579
- const composer = this.info.composer;
580
- if (composer.cursor === cursor)
581
- return;
582
- composer.cursor = cursor;
583
- this.store.update({});
584
- }
585
- /** Move a non-empty draft aside while a dialog owns the keyboard. */
586
- parkComposer() {
587
- const composer = this.info.composer;
588
- if (composer.draft === '')
589
- return;
590
- composer.parked = composer.draft;
591
- composer.draft = '';
592
- composer.cursor = 0;
593
- this.store.update({});
594
- }
595
- /** Give a parked draft back once no dialog needs the keyboard. */
596
- restoreComposer() {
597
- const composer = this.info.composer;
598
- if (composer.parked === '')
599
- return;
600
- composer.draft = composer.parked;
601
- composer.cursor = composer.parked.length;
602
- composer.parked = '';
603
- this.store.update({});
604
- }
605
- /** How the selected session's record is being read right now. */
606
- get view() { return this.info.view; }
607
- /** Show a detached history window, releasing the one it replaces.
608
- * @param window - Record to display, or undefined to return to the live transcript.
609
- */
610
- setViewWindow(window) {
611
- if (this.info.view.window === window)
612
- return;
613
- this.info.closeWindow();
614
- this.info.view.window = window;
615
- this.store.update({});
616
- }
617
- /** Move the reader's position inside the displayed record.
618
- * @param scroll - Rows scrolled back from the live end.
619
- */
620
- setScroll(scroll) {
621
- if (this.info.view.scroll === scroll)
622
- return;
623
- this.info.view.scroll = scroll;
624
- this.store.update({});
625
- }
626
- /** Replace the set of expanded reasoning blocks, keyed by message sequence.
627
- * @param folds - Sequences to expand beyond the default fold.
628
- */
629
- setFolds(folds) {
630
- if (this.info.view.folds === folds)
631
- return;
632
- this.info.view.folds = folds;
633
- this.store.update({});
634
- }
635
- /** Set the fold mode of the live attempt's completed reasoning.
636
- * @param reasoning - `row` to fold, `full` to keep the streamed text.
637
- */
638
- setLiveReasoning(reasoning) {
639
- if (this.info.view.liveReasoning === reasoning)
640
- return;
641
- this.info.view.liveReasoning = reasoning;
642
- this.store.update({});
643
- }
644
604
  /** Local answer state for the selected session's pending waterfalls. */
645
605
  get interaction() { return this.info.interaction; }
646
606
  /** Replace the partly collected answers, keyed by the waterfall event id.
@@ -668,73 +628,6 @@ export class SessionController {
668
628
  this.info.interaction.approval = approval;
669
629
  this.store.update({});
670
630
  }
671
- /** Composer-adjacent `@` reference menu state. */
672
- get reference() { return this.info.reference; }
673
- /** Highlight one row of the open reference menu.
674
- * @param index - Row index into the current matches.
675
- */
676
- setReferenceIndex(index) {
677
- if (this.info.reference.index === index)
678
- return;
679
- this.info.reference.index = index;
680
- this.store.update({});
681
- }
682
- /** Remember the draft that dismissed the reference menu, so it does not reopen while it stands.
683
- * @param draft - Composer text at dismissal, or undefined to allow the menu again.
684
- */
685
- setReferenceDismissed(draft) {
686
- if (this.info.reference.dismissed === draft)
687
- return;
688
- this.info.reference.dismissed = draft;
689
- this.store.update({});
690
- }
691
- /** Panels the selected session has open. */
692
- get panels() { return this.info.panels; }
693
- /** Show or hide the reasoning panel.
694
- * @param open - Whether `/think` is open.
695
- */
696
- openThoughts(open) {
697
- if (this.info.panels.thoughts === open)
698
- return;
699
- this.info.panels.thoughts = open;
700
- this.store.update({});
701
- }
702
- /** Show or hide the pending-input panel.
703
- * @param open - Whether `/queue` is open.
704
- */
705
- openQueue(open) {
706
- if (this.info.panels.queue === open)
707
- return;
708
- this.info.panels.queue = open;
709
- this.store.update({});
710
- }
711
- /** Show the model dialog at one step, or close it.
712
- * @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
713
- */
714
- setModelPanel(model) {
715
- if (this.info.panels.model === model)
716
- return;
717
- this.info.panels.model = model;
718
- this.store.update({});
719
- }
720
- /** Show the history or content-search dialog, or close it.
721
- * @param history - Query, content-search mode and matches; undefined closes the dialog.
722
- */
723
- setHistoryPanel(history) {
724
- if (this.info.panels.history === history)
725
- return;
726
- this.info.panels.history = history;
727
- this.store.update({});
728
- }
729
- /** Show the host session-search results, or close them.
730
- * @param search - Query, results and truncation flag; undefined closes the dialog.
731
- */
732
- setSearchPanel(search) {
733
- if (this.info.panels.search === search)
734
- return;
735
- this.info.panels.search = search;
736
- this.store.update({});
737
- }
738
631
  /** Remember a locally submitted command, which never becomes a durable session record. */
739
632
  recordRecall(value) { this.prompts.record(value); }
740
633
  /** Leave recall navigation because the composer was edited or replaced. */
@@ -763,24 +656,6 @@ export class SessionController {
763
656
  return false;
764
657
  return this.prompts.prepend(this.info.record.promptsBefore(oldest)) > 0;
765
658
  }
766
- /** Seed recall from a complete cached entry, so an open that follows a scan costs no request.
767
- * @param sessionId - Session being opened.
768
- * @returns Whether the cache covered this session.
769
- */
770
- adoptCachedPrompts(sessionId) {
771
- const cached = this.promptCache.get(sessionId);
772
- if (!cached?.complete)
773
- return false;
774
- const oldest = this.prompts.oldest;
775
- const older = oldest === undefined ? cached.prompts : cached.prompts.filter(prompt => prompt.seq < oldest);
776
- this.prompts.prepend(older);
777
- // Same budget as the walk it replaces: `settle` may shed the oldest prefix, and `markComplete`
778
- // then refuses, so the lazy backward step stays available for whatever was shed.
779
- this.prompts.settle();
780
- this.prompts.markComplete();
781
- this.store.update({});
782
- return true;
783
- }
784
659
  /** Fold one history page the cost scan already read into the prompt cache.
785
660
  *
786
661
  * The scan reads every session's whole history on connect, so this is where two readers stop
@@ -797,165 +672,34 @@ export class SessionController {
797
672
  rememberScanDone(sessionId) {
798
673
  this.promptCache.observe(sessionId, [], true);
799
674
  }
800
- /** Fold every prompt the host still holds into the recall index, in the background.
801
- *
802
- * Session start delivers only the newest window, so without this the arrows could reach older
803
- * prompts but not show them without paging first. Each page is parsed into a temporary transcript
804
- * and only its prompts are kept, so the live record, its memory window and the row cache never
805
- * grow. The walk is bounded and the next selection cancels it; anything past the bound is still
806
- * reachable through the lazy backward step.
807
- * @param sessionId - Session being opened.
808
- * @param selection - Selector generation that must still be current.
809
- */
810
- backfillPrompts(sessionId, selection) {
811
- this.promptBackfill?.abort();
812
- const abort = new AbortController();
813
- this.promptBackfill = abort;
814
- void (async () => {
815
- try {
816
- while (!this.info.record.ready) {
817
- abort.signal.throwIfAborted();
818
- await delay(20, undefined, { signal: abort.signal });
819
- }
820
- // A cost scan or an earlier open may already have this session's prompts cached.
821
- if (this.adoptCachedPrompts(sessionId))
822
- return;
823
- const throughSeq = this.info.record.readThrough;
824
- let beforeSeq = this.info.record.beforeSeq;
825
- let hasMore = this.info.record.hasMore;
826
- for (let page = 0; hasMore && beforeSeq !== undefined && page < PROMPT_BACKFILL_PAGES; page++) {
827
- abort.signal.throwIfAborted();
828
- if (selection !== this.store.selection())
829
- return;
830
- const result = object(await this.host.require().call('session/page', { request: {
831
- address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
832
- } }, abort.signal));
833
- abort.signal.throwIfAborted();
834
- if (selection !== this.store.selection())
835
- return;
836
- const temporary = new Transcript();
837
- try {
838
- temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: result.records, hasMore: result.hasMore });
839
- const next = temporary.beforeSeq;
840
- if (temporary.hasMore && (next === undefined || next >= beforeSeq))
841
- throw new Error('Host history page did not advance');
842
- this.prompts.prepend(temporary.promptsSince(-1).prompts);
843
- beforeSeq = next;
844
- hasMore = temporary.hasMore;
845
- }
846
- finally {
847
- temporary.dispose();
848
- }
849
- // A scan that finished while this walk ran already established the same list.
850
- if (this.adoptCachedPrompts(sessionId))
851
- return;
852
- }
853
- this.prompts.settle();
854
- // Only a walk that ended because the host said "no more" makes the index exhaustive; one
855
- // stopped by the page bound leaves the lazy backward step in charge of the rest.
856
- if (!hasMore)
857
- this.prompts.markComplete();
858
- // Cache only what the walk established: advertising a capped or shed list would let a later
859
- // open skip a fetch it still needs.
860
- if (!hasMore)
861
- this.promptCache.put(sessionId, { prompts: this.prompts.durableItems, complete: this.prompts.exhausted });
862
- this.store.update({});
863
- }
864
- catch {
865
- // A cancelled, disconnected or unavailable history leaves the lazy backward step in charge.
866
- }
867
- finally {
868
- if (this.promptBackfill === abort)
869
- this.promptBackfill = undefined;
870
- }
871
- })();
872
- }
873
675
  /** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
874
676
  * @param query - Literal, case-insensitive text including folded reasoning.
875
677
  * @param signal - Cancels HTTP and processing without cancelling the agent.
876
678
  * @returns Newest-first bounded summaries and an explicit truncation flag.
877
679
  */
878
- async searchHistory(query, signal) {
879
- const source = this.info.record;
880
- if (!source.ready)
881
- throw new Error('Wait for the session snapshot');
882
- const sessionId = this.sessionId;
883
- const selection = this.store.selection();
884
- const throughSeq = source.readThrough;
885
- const needle = query.toLowerCase();
886
- const result = { items: [], truncated: false };
887
- const scan = (transcript) => {
888
- const messages = transcript.messages;
889
- for (let index = messages.length - 1; index >= 0; index--) {
890
- signal.throwIfAborted();
891
- const message = messages[index];
892
- if (message.role === 'Tool')
893
- continue;
894
- const text = message.text;
895
- const match = text.toLowerCase().indexOf(needle);
896
- if (match < 0)
897
- continue;
898
- if (result.items.length === SEARCH_MATCH_LIMIT) {
899
- result.truncated = true;
900
- return false;
901
- }
902
- 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') });
903
- }
904
- return true;
905
- };
906
- signal.throwIfAborted();
907
- if (!scan(source))
908
- return result;
909
- let beforeSeq = source.beforeSeq;
910
- let hasMore = source.hasMore;
911
- while (hasMore && beforeSeq !== undefined) {
912
- const page = object(await this.host.require().call('session/page', { request: {
913
- address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
914
- } }, signal));
915
- signal.throwIfAborted();
916
- if (selection !== this.store.selection())
917
- throw new Error('Session changed while searching history');
918
- const temporary = new Transcript();
919
- try {
920
- temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
921
- const next = temporary.beforeSeq;
922
- if (temporary.hasMore && (next === undefined || next >= beforeSeq))
923
- throw new Error('Host history page did not advance');
924
- if (!scan(temporary))
925
- return result;
926
- beforeSeq = next;
927
- hasMore = temporary.hasMore;
928
- }
929
- finally {
930
- temporary.dispose();
931
- }
932
- }
933
- return result;
934
- }
935
- /** 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.
936
682
  * @param target - Durable message sequence to display.
937
683
  * @param signal - Cancels the target-page request.
938
- * @returns A caller-owned historical window that must be disposed when closed.
939
684
  */
940
- async historyAt(target, signal) {
941
- const source = this.info.record;
942
- const selection = this.store.selection();
943
- const page = object(await this.host.require().call('session/page', { request: {
944
- address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
945
- beforeSeq: target + 1, maxMessages: 80,
946
- } }, signal));
685
+ async openHistory(target, signal) {
947
686
  signal.throwIfAborted();
948
- if (selection !== this.store.selection())
949
- throw new Error('Session changed while opening history');
950
- 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);
951
693
  try {
952
- window.accept({ type: 'snapshot', cursor: source.readThrough, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
953
- if (!window.messages.some(message => message.seq === target))
954
- throw new Error('The host did not return the requested message');
955
- 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);
956
699
  }
957
700
  catch (error) {
958
- window.dispose();
701
+ if (!loaded)
702
+ window.dispose();
959
703
  throw error;
960
704
  }
961
705
  }
@@ -963,26 +707,12 @@ export class SessionController {
963
707
  * @param target - Visible record sequence, or first for the oldest available history.
964
708
  * @param signal - Cancels local paging without interrupting the remote agent.
965
709
  */
966
- async historyThrough(target, signal) {
967
- const transcript = this.info.record;
968
- if (!transcript.ready)
969
- throw new Error('Wait for the session snapshot');
970
- while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
971
- signal.throwIfAborted();
972
- const before = transcript.beforeSeq;
973
- await this.older(signal);
974
- if (this.info.record !== transcript)
975
- throw new Error('Session changed while loading history');
976
- if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
977
- throw new Error('Host history page did not advance');
978
- }
979
- }
980
- }
710
+ async historyThrough(target, signal) { await this.history.historyThrough(target, signal); }
981
711
  /** Reclaim reloadable history unless the user is reading away from the tail.
982
712
  * @returns Number of removed records.
983
713
  */
984
714
  reclaimHistory() {
985
- if (this.info.view.pinned || !this.store.state.online)
715
+ if (this.historyPinned || !this.store.state.online)
986
716
  return 0;
987
717
  const removed = this.info.record.trimHistory(this.historyLimits);
988
718
  if (removed)
@@ -991,8 +721,8 @@ export class SessionController {
991
721
  }
992
722
  /** Release the selected transcript and its layout caches. */
993
723
  releaseTranscript() {
994
- this.promptBackfill?.abort();
995
- this.promptBackfill = undefined;
724
+ this.history.cancel();
725
+ this.promptBackfill.cancel();
996
726
  this.info.reset();
997
727
  }
998
728
  /** @returns The selected session identity, or a `Select a session first` failure. */
@@ -1001,8 +731,4 @@ export class SessionController {
1001
731
  throw new Error('Select a session first');
1002
732
  return this.store.state.sessionId;
1003
733
  }
1004
- /** Answer one retained waterfall through the connection's event-result endpoint. */
1005
- async reply(frame, outcome) {
1006
- await this.connection.reply(frame, outcome);
1007
- }
1008
734
  }