@itookit/dsht 0.3.8 → 0.5.1

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 (130) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -11
  3. package/README.zh.md +30 -11
  4. package/dist/cli/dsht.js +203 -18
  5. package/dist/cli/startup.d.ts +40 -0
  6. package/dist/cli/startup.js +295 -0
  7. package/dist/cli/trace-summary.d.ts +78 -0
  8. package/dist/cli/trace-summary.js +241 -0
  9. package/dist/cli/verifier.d.ts +60 -0
  10. package/dist/cli/verifier.js +242 -0
  11. package/dist/contracts.d.ts +344 -0
  12. package/dist/contracts.js +1 -0
  13. package/dist/controller/commands.d.ts +47 -0
  14. package/dist/controller/commands.js +322 -0
  15. package/dist/controller/connection.d.ts +11 -29
  16. package/dist/controller/connection.js +26 -60
  17. package/dist/controller/controller.d.ts +616 -166
  18. package/dist/controller/controller.js +1395 -146
  19. package/dist/controller/index.d.ts +8 -1
  20. package/dist/controller/index.js +5 -0
  21. package/dist/controller/loop-contract.d.ts +136 -0
  22. package/dist/controller/loop-contract.js +308 -0
  23. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  24. package/dist/controller/loop-prompts-schema.js +144 -0
  25. package/dist/controller/loop-prompts.d.ts +55 -0
  26. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  27. package/dist/controller/loop-prompts.generated.js +185 -0
  28. package/dist/controller/loop-prompts.js +104 -0
  29. package/dist/controller/loop-protocols.d.ts +39 -0
  30. package/dist/controller/loop-protocols.js +115 -0
  31. package/dist/controller/loop.d.ts +275 -0
  32. package/dist/controller/loop.js +378 -0
  33. package/dist/controller/prompts.d.ts +54 -0
  34. package/dist/controller/prompts.js +162 -0
  35. package/dist/controller/trace-log.d.ts +45 -0
  36. package/dist/controller/trace-log.js +144 -0
  37. package/dist/controller/verifier.d.ts +126 -0
  38. package/dist/controller/verifier.js +75 -0
  39. package/dist/cost/index.d.ts +1 -1
  40. package/dist/cost/index.js +1 -1
  41. package/dist/cost/ledger.d.ts +0 -1
  42. package/dist/cost/ledger.js +0 -1
  43. package/dist/json.d.ts +18 -0
  44. package/dist/json.js +19 -0
  45. package/dist/references.d.ts +25 -0
  46. package/dist/references.js +26 -0
  47. package/dist/session/connection-view.d.ts +2 -11
  48. package/dist/session/controller.d.ts +73 -72
  49. package/dist/session/controller.js +185 -209
  50. package/dist/session/history.d.ts +6 -18
  51. package/dist/session/history.js +1 -24
  52. package/dist/session/index.d.ts +9 -4
  53. package/dist/session/index.js +7 -3
  54. package/dist/session/info.d.ts +25 -52
  55. package/dist/session/info.js +39 -25
  56. package/dist/session/markdown.js +1 -1
  57. package/dist/session/math.js +1 -1
  58. package/dist/session/mutation-gate.d.ts +51 -0
  59. package/dist/session/mutation-gate.js +73 -0
  60. package/dist/session/navigation.d.ts +2 -89
  61. package/dist/session/navigation.js +2 -129
  62. package/dist/session/peek.d.ts +38 -0
  63. package/dist/session/peek.js +103 -0
  64. package/dist/session/references.d.ts +2 -20
  65. package/dist/session/references.js +1 -26
  66. package/dist/session/runtime.d.ts +26 -0
  67. package/dist/session/runtime.js +28 -0
  68. package/dist/session/telemetry.d.ts +12 -13
  69. package/dist/session/telemetry.js +27 -58
  70. package/dist/session/transcript.d.ts +0 -6
  71. package/dist/session/transcript.js +2 -15
  72. package/dist/session/types.d.ts +25 -0
  73. package/dist/session/types.js +0 -1
  74. package/dist/session-title.d.ts +9 -0
  75. package/dist/session-title.js +21 -0
  76. package/dist/shell/controller.d.ts +31 -1
  77. package/dist/shell/controller.js +34 -2
  78. package/dist/shell/index.d.ts +3 -3
  79. package/dist/shell/index.js +2 -2
  80. package/dist/shell/runner.d.ts +10 -0
  81. package/dist/shell/runner.js +48 -9
  82. package/dist/slash/index.d.ts +10 -0
  83. package/dist/slash/index.js +7 -0
  84. package/dist/slash/parse.d.ts +166 -0
  85. package/dist/slash/parse.js +259 -0
  86. package/dist/slash/pipeline.d.ts +140 -0
  87. package/dist/slash/pipeline.js +115 -0
  88. package/dist/slash/registry.d.ts +88 -0
  89. package/dist/slash/registry.js +177 -0
  90. package/dist/state.d.ts +14 -4
  91. package/dist/state.js +3 -2
  92. package/dist/text.d.ts +28 -0
  93. package/dist/text.js +55 -0
  94. package/dist/transport/events.d.ts +104 -0
  95. package/dist/transport/events.js +149 -0
  96. package/dist/transport/wire.d.ts +9 -17
  97. package/dist/transport/wire.js +2 -27
  98. package/dist/ui/app.js +856 -441
  99. package/dist/ui/chat/header.js +1 -1
  100. package/dist/ui/chat/history-view.d.ts +1 -1
  101. package/dist/ui/chat/loop-status.d.ts +11 -0
  102. package/dist/ui/chat/loop-status.js +28 -0
  103. package/dist/ui/chat/navigation-model.d.ts +86 -0
  104. package/dist/ui/chat/navigation-model.js +107 -0
  105. package/dist/ui/chat/shell-view.d.ts +15 -2
  106. package/dist/ui/chat/shell-view.js +37 -3
  107. package/dist/ui/chat/status.d.ts +47 -3
  108. package/dist/ui/chat/status.js +65 -50
  109. package/dist/ui/chat/viewport.d.ts +1 -1
  110. package/dist/ui/dialogs/cost.d.ts +21 -4
  111. package/dist/ui/dialogs/cost.js +7 -12
  112. package/dist/ui/dialogs/index.d.ts +22 -5
  113. package/dist/ui/dialogs/index.js +19 -3
  114. package/dist/ui/dialogs/loop.d.ts +43 -0
  115. package/dist/ui/dialogs/loop.js +224 -0
  116. package/dist/ui/dialogs/peek.d.ts +25 -0
  117. package/dist/ui/dialogs/peek.js +35 -0
  118. package/dist/ui/dialogs/picker.d.ts +2 -0
  119. package/dist/ui/dialogs/picker.js +4 -2
  120. package/dist/ui/input/mouse.d.ts +12 -2
  121. package/dist/ui/input/mouse.js +20 -7
  122. package/dist/ui/input/references.d.ts +1 -1
  123. package/dist/ui/status/model.d.ts +7 -0
  124. package/dist/ui/status/model.js +5 -0
  125. package/dist/ui/theme/index.d.ts +1 -1
  126. package/package.json +6 -4
  127. package/dist/ui/commands/parse.d.ts +0 -104
  128. package/dist/ui/commands/parse.js +0 -135
  129. package/dist/ui/commands/registry.d.ts +0 -33
  130. package/dist/ui/commands/registry.js +0 -73
@@ -4,11 +4,16 @@ import { setTimeout as delay } from 'node:timers/promises';
4
4
  import { array, errorText, object, string } from "../transport/wire.js";
5
5
  import { saveSessionLog } from "./export.js";
6
6
  import { saveTranscriptHtml } from "./export-html.js";
7
- import { releaseHistoryLayout } from "./history.js";
8
- import { resolveTarget, sessionLabel } from "./navigation.js";
7
+ import { historyLayout, releaseHistoryLayout } from "./history.js";
8
+ import { resolveTarget } from "./navigation.js";
9
+ import { sessionLabel } from "../session-title.js";
9
10
  import { fileReferences } from "./references.js";
11
+ import { SessionRuntime } from "./runtime.js";
10
12
  import { PromptCache } from "./info.js";
11
- import { recordPrompts, Transcript, toolLine } from "./transcript.js";
13
+ import { SessionMutationGate } from "./mutation-gate.js";
14
+ import { recordPrompts, Transcript } from "./transcript.js";
15
+ import { projectionSnapshot } from "../transport/events.js";
16
+ import { toolLine } from "../text.js";
12
17
  /** Built-in preset identifiers and the labels the web session header shows. */
13
18
  const BUILT_IN_MODES = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
14
19
  /** Bounded match count for one history search. */
@@ -23,6 +28,10 @@ export class SessionController {
23
28
  historyLimits;
24
29
  follow;
25
30
  interactions = new Map();
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. */
@@ -33,16 +42,19 @@ export class SessionController {
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);
41
53
  }
42
54
  /** Host running state covers model generation, tools, and waits between assistant attempts. */
43
55
  get running() {
44
56
  const id = this.store.state.sessionId;
45
- return id !== undefined && (this.connection.runningFor(id)
57
+ return id !== undefined && (this.runtime.runningFor(id)
46
58
  ?? this.store.state.sessions.find(row => row.sessionId === id)?.running === true);
47
59
  }
48
60
  /** Current title projection, falling back to the list title and then the session ID. */
@@ -50,7 +62,7 @@ export class SessionController {
50
62
  const id = this.store.state.sessionId;
51
63
  if (!id)
52
64
  return;
53
- const title = this.connection.telemetryView().view(id).values.title;
65
+ const title = this.runtime.telemetry.view(id).values.title;
54
66
  const row = this.store.state.sessions.find(item => item.sessionId === id);
55
67
  return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
56
68
  : row ? sessionLabel(row) : id;
@@ -59,7 +71,7 @@ export class SessionController {
59
71
  get sessionMode() {
60
72
  if (!this.store.state.sessionId)
61
73
  return undefined;
62
- const id = this.connection.telemetryView().view(this.store.state.sessionId).values.agentPreset;
74
+ const id = this.runtime.telemetry.view(this.store.state.sessionId).values.agentPreset;
63
75
  if (typeof id !== 'string')
64
76
  return undefined;
65
77
  const preset = this.store.state.presets?.find(item => item.id === id);
@@ -70,7 +82,7 @@ export class SessionController {
70
82
  get workingSince() {
71
83
  if (!this.running || !this.store.state.sessionId)
72
84
  return undefined;
73
- return this.info.record.activeTurnStartedAt ?? this.connection.observedAt(this.store.state.sessionId);
85
+ return this.info.record.activeTurnStartedAt ?? this.runtime.observedAt(this.store.state.sessionId);
74
86
  }
75
87
  /** Present only sessions explicitly accounted to the selected workspace. */
76
88
  get visibleSessions() {
@@ -83,10 +95,34 @@ export class SessionController {
83
95
  }
84
96
  /** Whether a turn, cancellation or prompt admission is still in flight. */
85
97
  get active() { return this.interruptTask !== undefined || this.running || this.admission !== undefined; }
98
+ /** @returns The detached history window the reader opened, if any. */
99
+ get window() { return this.info.window; }
100
+ /** Show a detached history window, releasing the one it replaces.
101
+ * @param window - Record to display, or undefined to return to the live transcript.
102
+ */
103
+ setViewWindow(window) {
104
+ if (this.info.window === window)
105
+ return;
106
+ this.info.closeWindow();
107
+ this.info.window = window;
108
+ this.store.update({});
109
+ }
110
+ /** @returns Host projection store of the current generation, owned by this session's runtime. */
111
+ get telemetry() { return this.runtime.telemetry; }
112
+ /** Lay out one record for a terminal width.
113
+ *
114
+ * The projection engine stays in this domain; the UI receives plain rows and offsets instead of
115
+ * importing it, and `Reasoning` for committed messages is always the folded default.
116
+ */
117
+ render(input) {
118
+ return historyLayout(input.transcript, input.width, 'row', input.folds, input.liveReasoning);
119
+ }
120
+ /** Apply one normalized control frame to this session's runtime. */
121
+ acceptControl(frame) { this.runtime.acceptControl(frame); }
86
122
  /** Whether reading protects the loaded window, suspending history reclamation. */
87
- get pinned() { return this.info.view.pinned; }
123
+ get pinned() { return this.historyPinned; }
88
124
  /** Drop generation-scoped state before a new connection generation begins. */
89
- beginGeneration() { this.stoppingSession = undefined; }
125
+ beginGeneration() { this.stoppingSession = undefined; this.runtime.reset(); }
90
126
  /** Invalidate in-flight work and drop transient interactions when a generation ends. */
91
127
  endGeneration() {
92
128
  this.store.bumpSelection();
@@ -102,7 +138,7 @@ export class SessionController {
102
138
  * @returns Retained question and approval frames for that session.
103
139
  */
104
140
  pendingFor(state) {
105
- return [...this.interactions.values()].filter(frame => frame.agentId === state.sessionId);
141
+ return [...this.interactions.values()].filter(frame => frame.sessionId === state.sessionId);
106
142
  }
107
143
  /** Unanswered interactions by session, so a list can show who is waiting without opening them.
108
144
  *
@@ -115,20 +151,27 @@ export class SessionController {
115
151
  pendingCounts() {
116
152
  const counts = new Map();
117
153
  for (const frame of this.interactions.values()) {
118
- if (typeof frame.agentId !== 'string')
154
+ if (frame.sessionId === '')
119
155
  continue;
120
- counts.set(frame.agentId, (counts.get(frame.agentId) ?? 0) + 1);
156
+ counts.set(frame.sessionId, (counts.get(frame.sessionId) ?? 0) + 1);
121
157
  }
122
158
  return counts;
123
159
  }
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.
160
+ /** Retain a recognized host interaction; anything else belongs to the connection to delegate.
161
+ * @param event - One normalized host event.
162
+ * @returns Whether this domain retained the event for an answer.
127
163
  */
128
- waterfall(frame) {
129
- if (!['approval/request', 'user-questions/request'].includes(string(frame.event)))
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
130
174
  return false;
131
- this.interactions.set(string(frame.eventId), frame);
132
175
  this.store.update({});
133
176
  return true;
134
177
  }
@@ -141,6 +184,7 @@ export class SessionController {
141
184
  * @param running - Whether the host still runs that session.
142
185
  */
143
186
  status(sessionId, running) {
187
+ this.runtime.accept(sessionId, running);
144
188
  if (!running && this.stoppingSession === sessionId)
145
189
  this.stoppingSession = undefined;
146
190
  this.store.update({ sessions: this.store.state.sessions.map(row => row.sessionId === sessionId ? { ...row, running } : row),
@@ -148,11 +192,11 @@ export class SessionController {
148
192
  }
149
193
  /** Surface a host-reported error for the selected session.
150
194
  * @param sessionId - Session the host reported on.
151
- * @param error - Error payload as delivered by the host.
195
+ * @param error - Already-normalized error text.
152
196
  */
153
197
  reportError(sessionId, error) {
154
198
  if (sessionId === this.store.state.sessionId)
155
- this.store.update({ status: 'Agent error', error: errorText(error) });
199
+ this.store.update({ status: 'Agent error', lastFailure: error });
156
200
  }
157
201
  /** Stop the selected turn, or allow exit only while idle. Repeated keys share one request.
158
202
  * @param force - Send an explicit cancellation even when the cached running flag is idle.
@@ -162,23 +206,27 @@ export class SessionController {
162
206
  if (this.interruptTask)
163
207
  return this.interruptTask;
164
208
  if (!force && !this.running && !this.admission && this.store.state.pending.length === 0) {
165
- return Promise.resolve(!this.store.state.busy);
209
+ // An idle press may exit only when no operation owns the client; the store's failure line is
210
+ // not that fact, so the application asks itself.
211
+ return Promise.resolve(!this.store.busy());
166
212
  }
167
213
  const sessionId = this.sessionId;
168
214
  this.stoppingSession = sessionId;
169
- this.store.update({ status: 'Stopping…', error: '' });
215
+ this.store.update({ status: 'Stopping…', lastFailure: '' });
170
216
  const task = (async () => {
171
217
  try {
172
218
  // Admission must settle before cancellation can address the newly submitted turn.
173
219
  // The prompt caller reports admission failures; an existing turn still needs cancellation.
220
+ // This wait stays outside the gate: a control admission overtakes the *waiting* queue, and
221
+ // holding the gate while waiting here would block the very writes it precedes.
174
222
  await this.admission?.catch(() => undefined);
175
- await this.host.require().call('session/cancel', { request: { sessionId } });
223
+ await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
176
224
  if (this.stoppingSession === sessionId && this.store.state.sessionId === sessionId)
177
225
  this.store.update({ status: 'Cancellation requested · waiting for host' });
178
226
  }
179
227
  catch (error) {
180
228
  this.stoppingSession = undefined;
181
- this.store.update({ status: 'Cancellation failed', error: errorText(error) });
229
+ this.store.update({ status: 'Cancellation failed', lastFailure: errorText(error) });
182
230
  }
183
231
  return false;
184
232
  })();
@@ -190,13 +238,17 @@ export class SessionController {
190
238
  * @param pinned - Whether the main transcript is actively being read away from its tail.
191
239
  */
192
240
  pinHistory(pinned) {
193
- this.info.view.pinned = pinned;
241
+ if (this.historyPinned === pinned)
242
+ return;
243
+ this.historyPinned = pinned;
194
244
  if (!pinned && this.reclaimHistory())
195
245
  this.store.update({});
196
246
  }
197
247
  /** Cancel the active turn; pending queue items remain host-owned. */
198
248
  async cancelTurn() {
199
- await this.host.require().call('session/cancel', { request: { sessionId: this.sessionId } });
249
+ const sessionId = this.sessionId;
250
+ // Cancellation is a control action: it may overtake queued writes, since its whole value is speed.
251
+ await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
200
252
  this.store.update({ status: 'Cancellation requested' });
201
253
  }
202
254
  /** Refresh both lists from the host, then show the requested picker.
@@ -207,6 +259,18 @@ export class SessionController {
207
259
  const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
208
260
  this.store.update({ screen, workspaces, sessions });
209
261
  }
262
+ /** Return to the selected conversation without re-selecting it.
263
+ *
264
+ * A picker opened over a conversation is a detour: leaving it must not re-subscribe, reload the
265
+ * transcript or lose the reading position, so this only changes which screen is shown.
266
+ * @returns Whether there was a selected conversation to return to.
267
+ */
268
+ showChat() {
269
+ if (this.store.state.sessionId === undefined)
270
+ return false;
271
+ this.store.update({ screen: 'chat' });
272
+ return true;
273
+ }
210
274
  /** Resolve a removal command to one reviewable object without changing the selection.
211
275
  * @param kind - Workspace registration removal or session archival.
212
276
  * @param query - Exact name, ID, or unambiguous ID prefix.
@@ -219,9 +283,9 @@ export class SessionController {
219
283
  return kind === 'workspace' ? { kind, id: string(row.workspaceId), name: string(row.title), path: string(row.path) }
220
284
  : { kind, id: string(row.sessionId), name: sessionLabel(row),
221
285
  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)
286
+ && !this.runtime.runningFor(string(row.sessionId))
287
+ && !(this.runtime.telemetry.view(string(row.sessionId)).queued ?? 0)
288
+ && !(this.runtime.telemetry.view(string(row.sessionId)).jobs ?? 0)
225
289
  && !(this.store.state.sessionId === row.sessionId && this.admission) };
226
290
  }
227
291
  /** Apply a confirmed removal or freshly verified empty-session archival; directories and logs are preserved.
@@ -229,9 +293,10 @@ export class SessionController {
229
293
  */
230
294
  async removeTarget(target) {
231
295
  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
- }));
296
+ const receipt = object(target.kind === 'workspace'
297
+ ? await client.call('workspace/delete', { request: { workspaceId: target.id } })
298
+ // Archiving a session is a write on that session, so it queues behind that session's admissions.
299
+ : await this.mutations.admit(target.id, 'normal', () => client.call('workspace/archiveSession', { request: { sessionId: target.id } })));
235
300
  if (client !== this.host.client())
236
301
  return;
237
302
  if (target.kind === 'session') {
@@ -252,7 +317,7 @@ export class SessionController {
252
317
  await this.showPicker(target.kind === 'workspace' ? 'workspaces' : 'sessions');
253
318
  }
254
319
  catch (error) {
255
- this.store.update({ error: `Removal completed; list refresh failed: ${errorText(error)}` });
320
+ this.store.update({ lastFailure: `Removal completed; list refresh failed: ${errorText(error)}` });
256
321
  }
257
322
  }
258
323
  /** Pick a workspace, or use all sessions when the identity is omitted.
@@ -339,6 +404,35 @@ export class SessionController {
339
404
  const result = object(await this.host.require().call('session/create', { request: { workspaceId: this.store.state.workspaceId } }));
340
405
  await this.selectSession(string(result.sessionId));
341
406
  }
407
+ /** Create a session for another purpose without selecting it, named so a reader can tell it apart.
408
+ *
409
+ * A verifier runs in its own session while the reviewed session stays selected, so this never
410
+ * follows the new session and never touches the reader's transcript.
411
+ * @param title - Session title to apply; hosts without a title service keep the default.
412
+ * @returns The new session's id, or undefined when its identity could not be read.
413
+ */
414
+ async createNamedSession(title) {
415
+ const workspaceId = this.store.state.workspaceId;
416
+ if (!workspaceId)
417
+ throw new Error('Select a workspace before creating a session');
418
+ const created = object(await this.host.require().call('session/create', { request: { workspaceId } }));
419
+ const sessionId = string(created.sessionId);
420
+ try {
421
+ await this.host.require().call('session/rename', { request: { sessionId, title } });
422
+ }
423
+ catch { /* A deployment without the title service still gets a usable session. */ }
424
+ return sessionId;
425
+ }
426
+ /** Stop a session this client owns but has not selected, such as a verifier's.
427
+ *
428
+ * Killing the child client leaves the host's agent generation running, so the turn has to be
429
+ * cancelled on the host itself.
430
+ * @param sessionId - Session whose turn should stop.
431
+ */
432
+ async cancelNamedSession(sessionId) {
433
+ // Keyed by the session being stopped, not the selection: the verifier's session is someone else's.
434
+ await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
435
+ }
342
436
  /** Replace the selected transcript and cancel its preceding follow stream.
343
437
  * @param sessionId - Session to follow.
344
438
  */
@@ -353,7 +447,7 @@ export class SessionController {
353
447
  const workspace = this.store.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
354
448
  const workspaceId = workspace ? string(workspace.workspaceId)
355
449
  : this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
356
- this.connection.observe(sessionId);
450
+ this.runtime.observe(sessionId);
357
451
  this.store.update({ sessionId, workspaceId, showAllSessions: false, screen: 'chat', status: 'Loading session…' });
358
452
  this.follow = this.host.require().subscribe('session/follow', {
359
453
  request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
@@ -367,7 +461,7 @@ export class SessionController {
367
461
  this.reclaimHistory();
368
462
  const frame = object(value);
369
463
  if (frame.type === 'snapshot')
370
- this.connection.telemetryView().snapshot(sessionId, frame.projections);
464
+ this.runtime.telemetry.snapshot(sessionId, projectionSnapshot(frame.projections));
371
465
  this.store.update({ status: this.stoppingSession === sessionId ? this.store.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
372
466
  }
373
467
  catch (error) {
@@ -378,7 +472,7 @@ export class SessionController {
378
472
  if (selection !== this.store.selection())
379
473
  return;
380
474
  transcript.ready = false;
381
- this.store.update({ status: 'Session disconnected', error: errorText(error ?? 'Session stream ended') });
475
+ this.store.update({ status: 'Session disconnected', lastFailure: errorText(error ?? 'Session stream ended') });
382
476
  },
383
477
  });
384
478
  this.backfillPrompts(sessionId, selection);
@@ -434,11 +528,14 @@ export class SessionController {
434
528
  * @returns The host's successful command result text.
435
529
  */
436
530
  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);
531
+ const sessionId = this.sessionId;
532
+ // The section decides and issues; the host's answer is awaited outside the gate, because a
533
+ // compaction can take minutes and must not hold every other write on this session.
534
+ const execution = await this.mutations.admit(sessionId, 'normal', () => {
535
+ if (!this.info.record.ready)
536
+ throw new Error('Wait for the session snapshot before running commands');
537
+ return this.host.require().call('commands/execute', { agentId: sessionId, line, submittedAttachments: [] }, signal, null);
538
+ });
442
539
  if (execution === undefined)
443
540
  throw new Error(`This host does not provide ${line.split(/\s/, 1)[0]}`);
444
541
  const result = object(object(execution).result);
@@ -458,7 +555,10 @@ export class SessionController {
458
555
  * @param itemId - Queue occurrence identity from session/control.
459
556
  */
460
557
  async removeQueued(itemId) {
461
- await this.host.require().call('session/updateQueue', { request: { sessionId: this.sessionId, itemId, action: { kind: 'remove' } } });
558
+ const sessionId = this.sessionId;
559
+ await this.mutations.admit(sessionId, 'normal', () => this.host.require().call('session/updateQueue', {
560
+ request: { sessionId, itemId, action: { kind: 'remove' } }
561
+ }));
462
562
  }
463
563
  /** Export the selected host log to a new local ZIP file.
464
564
  * @param path - Optional local destination; existing files are never overwritten.
@@ -480,16 +580,23 @@ export class SessionController {
480
580
  * @param text - Composed prompt text.
481
581
  */
482
582
  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;
583
+ const sessionId = this.sessionId;
584
+ // Deciding steer-versus-queue and issuing the request are one admitted step: the loop's own sends
585
+ // and everything the reader types go through this same point, so two of them cannot be decided
586
+ // against the same stale running state. The host's answer is awaited outside the gate.
587
+ const admission = await this.mutations.admit(sessionId, 'normal', () => {
588
+ if (this.store.state.pending.length)
589
+ throw new Error('Answer the pending question or approval first');
590
+ this.stoppingSession = undefined;
591
+ if (!this.info.record.ready)
592
+ throw new Error('Wait for the session snapshot before sending');
593
+ const issued = this.host.require().call('session/prompt', { request: {
594
+ sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
595
+ content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
596
+ } });
597
+ this.admission = issued;
598
+ return issued;
599
+ });
493
600
  try {
494
601
  await admission;
495
602
  }
@@ -499,6 +606,17 @@ export class SessionController {
499
606
  }
500
607
  this.store.update({ status: 'Accepted · waiting for host' });
501
608
  }
609
+ /** Send one prompt the client assembled, keeping its durable echo out of composer recall.
610
+ *
611
+ * The host records every prompt as a user message, so an agent loop that submits a turn per
612
+ * attempt would otherwise crowd out the prompts the operator actually typed. Suppressing the text
613
+ * before sending is what makes the later echo invisible to ↑/↓.
614
+ * @param text - Prompt the client assembled, not text the operator typed.
615
+ */
616
+ async promptInternal(text) {
617
+ this.prompts.suppress(text);
618
+ await this.prompt(text);
619
+ }
502
620
  /** Answer the oldest selected-session interaction, after explicit user action.
503
621
  * @param value - Structured answer value or approval outcome.
504
622
  */
@@ -506,17 +624,20 @@ export class SessionController {
506
624
  const pending = this.store.state.pending[0];
507
625
  if (!pending)
508
626
  throw new Error('No pending interaction');
509
- await this.reply(pending, { kind: 'result', value });
510
- this.interactions.delete(string(pending.eventId));
627
+ await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'result', value }));
628
+ this.interactions.delete(pending.eventId);
511
629
  this.store.update({});
512
630
  }
513
631
  /** Restrict an approval command to an approval request.
514
632
  * @param allowed - Whether the request is approved once.
515
633
  */
516
634
  async approve(allowed) {
517
- if (this.store.state.pending[0]?.event !== 'approval/request')
635
+ const pending = this.store.state.pending[0];
636
+ if (pending?.kind !== 'approval')
518
637
  throw new Error('No pending approval');
519
- await this.answer(allowed ? 'allowed-once' : 'rejected');
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({});
520
641
  }
521
642
  /** Dismiss the whole selected-session question set without answering it.
522
643
  *
@@ -526,12 +647,12 @@ export class SessionController {
526
647
  */
527
648
  async dismissQuestion() {
528
649
  const pending = this.store.state.pending[0];
529
- if (pending?.event !== 'user-questions/request')
650
+ if (pending?.kind !== 'question')
530
651
  throw new Error('No pending question');
531
- await this.reply(pending, { kind: 'rejected', error: {
652
+ await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'rejected', error: {
532
653
  name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
533
- } });
534
- this.interactions.delete(string(pending.eventId));
654
+ } }));
655
+ this.interactions.delete(pending.eventId);
535
656
  this.store.update({});
536
657
  }
537
658
  /** Add a page before the retained window using its fixed opening cut.
@@ -563,84 +684,6 @@ export class SessionController {
563
684
  * @returns The recalled prompt, or the unsent draft.
564
685
  */
565
686
  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
687
  /** Local answer state for the selected session's pending waterfalls. */
645
688
  get interaction() { return this.info.interaction; }
646
689
  /** Replace the partly collected answers, keyed by the waterfall event id.
@@ -668,73 +711,6 @@ export class SessionController {
668
711
  this.info.interaction.approval = approval;
669
712
  this.store.update({});
670
713
  }
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
714
  /** Remember a locally submitted command, which never becomes a durable session record. */
739
715
  recordRecall(value) { this.prompts.record(value); }
740
716
  /** Leave recall navigation because the composer was edited or replaced. */
@@ -982,7 +958,7 @@ export class SessionController {
982
958
  * @returns Number of removed records.
983
959
  */
984
960
  reclaimHistory() {
985
- if (this.info.view.pinned || !this.store.state.online)
961
+ if (this.historyPinned || !this.store.state.online)
986
962
  return 0;
987
963
  const removed = this.info.record.trimHistory(this.historyLimits);
988
964
  if (removed)
@@ -1002,7 +978,7 @@ export class SessionController {
1002
978
  return this.store.state.sessionId;
1003
979
  }
1004
980
  /** Answer one retained waterfall through the connection's event-result endpoint. */
1005
- async reply(frame, outcome) {
1006
- await this.connection.reply(frame, outcome);
981
+ async reply(eventId, outcome) {
982
+ await this.connection.reply(eventId, outcome);
1007
983
  }
1008
984
  }