@itookit/dsht 0.3.7 → 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 (132) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +31 -11
  3. package/README.zh.md +31 -11
  4. package/dist/cli/dsht.js +207 -19
  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 +619 -164
  18. package/dist/controller/controller.js +1420 -141
  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 +82 -72
  49. package/dist/session/controller.js +211 -209
  50. package/dist/session/history.d.ts +9 -1
  51. package/dist/session/history.js +1 -9
  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 +97 -0
  77. package/dist/shell/controller.js +158 -0
  78. package/dist/shell/index.d.ts +5 -0
  79. package/dist/shell/index.js +3 -0
  80. package/dist/shell/runner.d.ts +38 -0
  81. package/dist/shell/runner.js +147 -0
  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 +865 -431
  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/history-view.js +1 -1
  102. package/dist/ui/chat/loop-status.d.ts +11 -0
  103. package/dist/ui/chat/loop-status.js +28 -0
  104. package/dist/ui/chat/navigation-model.d.ts +86 -0
  105. package/dist/ui/chat/navigation-model.js +107 -0
  106. package/dist/ui/chat/shell-view.d.ts +47 -0
  107. package/dist/ui/chat/shell-view.js +145 -0
  108. package/dist/ui/chat/status.d.ts +47 -3
  109. package/dist/ui/chat/status.js +65 -50
  110. package/dist/ui/chat/viewport.d.ts +1 -1
  111. package/dist/ui/dialogs/cost.d.ts +21 -4
  112. package/dist/ui/dialogs/cost.js +7 -12
  113. package/dist/ui/dialogs/index.d.ts +22 -5
  114. package/dist/ui/dialogs/index.js +19 -3
  115. package/dist/ui/dialogs/loop.d.ts +43 -0
  116. package/dist/ui/dialogs/loop.js +224 -0
  117. package/dist/ui/dialogs/peek.d.ts +25 -0
  118. package/dist/ui/dialogs/peek.js +35 -0
  119. package/dist/ui/dialogs/picker.d.ts +2 -0
  120. package/dist/ui/dialogs/picker.js +4 -2
  121. package/dist/ui/input/mouse.d.ts +12 -2
  122. package/dist/ui/input/mouse.js +20 -7
  123. package/dist/ui/input/references.d.ts +1 -1
  124. package/dist/ui/status/model.d.ts +7 -0
  125. package/dist/ui/status/model.js +5 -0
  126. package/dist/ui/theme/index.d.ts +6 -1
  127. package/dist/ui/theme/index.js +2 -1
  128. package/package.json +6 -4
  129. package/dist/ui/commands/parse.d.ts +0 -99
  130. package/dist/ui/commands/parse.js +0 -126
  131. package/dist/ui/commands/registry.d.ts +0 -33
  132. 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.
@@ -297,6 +362,32 @@ export class SessionController {
297
362
  }
298
363
  /** Prompt for a host path without starting a local agent. */
299
364
  enterPath() { this.store.update({ screen: 'path' }); }
365
+ /** Adopt the workspace whose registered path contains the directory this client runs in.
366
+ *
367
+ * Longest path wins, so a workspace nested in another is preferred, and the comparison is on whole
368
+ * path segments so `/srv/app-old` cannot match `/srv/app`. A remote host's paths usually differ
369
+ * from the client's, in which case nothing matches and the picker stays exactly as before.
370
+ * @param directory - Directory this client was started in.
371
+ * @returns The adopted workspace's id, or undefined when none matches.
372
+ */
373
+ adoptLocalWorkspace(directory) {
374
+ const slashed = (value) => value.replace(/\\/g, '/').replace(/\/+$/, '');
375
+ const target = slashed(directory);
376
+ if (target === '')
377
+ return undefined;
378
+ let best;
379
+ for (const workspace of this.store.state.workspaces) {
380
+ const path = slashed(string(workspace.path));
381
+ if (path === '' || (target !== path && !target.startsWith(`${path}/`)))
382
+ continue;
383
+ if (best === undefined || path.length > best.length)
384
+ best = { id: string(workspace.workspaceId), length: path.length };
385
+ }
386
+ if (best === undefined)
387
+ return undefined;
388
+ this.pickWorkspace(best.id);
389
+ return best.id;
390
+ }
300
391
  /** Register a host directory and move to its session picker.
301
392
  * @param path - Absolute directory path on the host.
302
393
  */
@@ -313,6 +404,35 @@ export class SessionController {
313
404
  const result = object(await this.host.require().call('session/create', { request: { workspaceId: this.store.state.workspaceId } }));
314
405
  await this.selectSession(string(result.sessionId));
315
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
+ }
316
436
  /** Replace the selected transcript and cancel its preceding follow stream.
317
437
  * @param sessionId - Session to follow.
318
438
  */
@@ -327,7 +447,7 @@ export class SessionController {
327
447
  const workspace = this.store.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
328
448
  const workspaceId = workspace ? string(workspace.workspaceId)
329
449
  : this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
330
- this.connection.observe(sessionId);
450
+ this.runtime.observe(sessionId);
331
451
  this.store.update({ sessionId, workspaceId, showAllSessions: false, screen: 'chat', status: 'Loading session…' });
332
452
  this.follow = this.host.require().subscribe('session/follow', {
333
453
  request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
@@ -341,7 +461,7 @@ export class SessionController {
341
461
  this.reclaimHistory();
342
462
  const frame = object(value);
343
463
  if (frame.type === 'snapshot')
344
- this.connection.telemetryView().snapshot(sessionId, frame.projections);
464
+ this.runtime.telemetry.snapshot(sessionId, projectionSnapshot(frame.projections));
345
465
  this.store.update({ status: this.stoppingSession === sessionId ? this.store.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
346
466
  }
347
467
  catch (error) {
@@ -352,7 +472,7 @@ export class SessionController {
352
472
  if (selection !== this.store.selection())
353
473
  return;
354
474
  transcript.ready = false;
355
- 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') });
356
476
  },
357
477
  });
358
478
  this.backfillPrompts(sessionId, selection);
@@ -408,11 +528,14 @@ export class SessionController {
408
528
  * @returns The host's successful command result text.
409
529
  */
410
530
  async command(line, signal) {
411
- if (!this.info.record.ready)
412
- throw new Error('Wait for the session snapshot before running commands');
413
- const execution = await this.host.require().call('commands/execute', {
414
- agentId: this.sessionId, line, submittedAttachments: [],
415
- }, 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
+ });
416
539
  if (execution === undefined)
417
540
  throw new Error(`This host does not provide ${line.split(/\s/, 1)[0]}`);
418
541
  const result = object(object(execution).result);
@@ -432,7 +555,10 @@ export class SessionController {
432
555
  * @param itemId - Queue occurrence identity from session/control.
433
556
  */
434
557
  async removeQueued(itemId) {
435
- 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
+ }));
436
562
  }
437
563
  /** Export the selected host log to a new local ZIP file.
438
564
  * @param path - Optional local destination; existing files are never overwritten.
@@ -454,16 +580,23 @@ export class SessionController {
454
580
  * @param text - Composed prompt text.
455
581
  */
456
582
  async prompt(text) {
457
- if (this.store.state.pending.length)
458
- throw new Error('Answer the pending question or approval first');
459
- this.stoppingSession = undefined;
460
- if (!this.info.record.ready)
461
- throw new Error('Wait for the session snapshot before sending');
462
- const admission = this.host.require().call('session/prompt', { request: {
463
- sessionId: this.sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
464
- content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
465
- } });
466
- 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
+ });
467
600
  try {
468
601
  await admission;
469
602
  }
@@ -473,6 +606,17 @@ export class SessionController {
473
606
  }
474
607
  this.store.update({ status: 'Accepted · waiting for host' });
475
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
+ }
476
620
  /** Answer the oldest selected-session interaction, after explicit user action.
477
621
  * @param value - Structured answer value or approval outcome.
478
622
  */
@@ -480,17 +624,20 @@ export class SessionController {
480
624
  const pending = this.store.state.pending[0];
481
625
  if (!pending)
482
626
  throw new Error('No pending interaction');
483
- await this.reply(pending, { kind: 'result', value });
484
- 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);
485
629
  this.store.update({});
486
630
  }
487
631
  /** Restrict an approval command to an approval request.
488
632
  * @param allowed - Whether the request is approved once.
489
633
  */
490
634
  async approve(allowed) {
491
- if (this.store.state.pending[0]?.event !== 'approval/request')
635
+ const pending = this.store.state.pending[0];
636
+ if (pending?.kind !== 'approval')
492
637
  throw new Error('No pending approval');
493
- 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({});
494
641
  }
495
642
  /** Dismiss the whole selected-session question set without answering it.
496
643
  *
@@ -500,12 +647,12 @@ export class SessionController {
500
647
  */
501
648
  async dismissQuestion() {
502
649
  const pending = this.store.state.pending[0];
503
- if (pending?.event !== 'user-questions/request')
650
+ if (pending?.kind !== 'question')
504
651
  throw new Error('No pending question');
505
- await this.reply(pending, { kind: 'rejected', error: {
652
+ await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'rejected', error: {
506
653
  name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
507
- } });
508
- this.interactions.delete(string(pending.eventId));
654
+ } }));
655
+ this.interactions.delete(pending.eventId);
509
656
  this.store.update({});
510
657
  }
511
658
  /** Add a page before the retained window using its fixed opening cut.
@@ -537,84 +684,6 @@ export class SessionController {
537
684
  * @returns The recalled prompt, or the unsent draft.
538
685
  */
539
686
  recall(direction, current) { return this.prompts.move(direction, current); }
540
- /** Composer draft, caret and parked draft, as the selected session holds them. */
541
- get composer() { return this.info.composer; }
542
- /** Replace the composer text and caret, publishing only when either actually changed. */
543
- setComposer(draft, cursor = draft.length) {
544
- const composer = this.info.composer;
545
- if (composer.draft === draft && composer.cursor === cursor)
546
- return;
547
- composer.draft = draft;
548
- composer.cursor = cursor;
549
- this.store.update({});
550
- }
551
- /** Move the caret without changing the text. */
552
- setComposerCursor(cursor) {
553
- const composer = this.info.composer;
554
- if (composer.cursor === cursor)
555
- return;
556
- composer.cursor = cursor;
557
- this.store.update({});
558
- }
559
- /** Move a non-empty draft aside while a dialog owns the keyboard. */
560
- parkComposer() {
561
- const composer = this.info.composer;
562
- if (composer.draft === '')
563
- return;
564
- composer.parked = composer.draft;
565
- composer.draft = '';
566
- composer.cursor = 0;
567
- this.store.update({});
568
- }
569
- /** Give a parked draft back once no dialog needs the keyboard. */
570
- restoreComposer() {
571
- const composer = this.info.composer;
572
- if (composer.parked === '')
573
- return;
574
- composer.draft = composer.parked;
575
- composer.cursor = composer.parked.length;
576
- composer.parked = '';
577
- this.store.update({});
578
- }
579
- /** How the selected session's record is being read right now. */
580
- get view() { return this.info.view; }
581
- /** Show a detached history window, releasing the one it replaces.
582
- * @param window - Record to display, or undefined to return to the live transcript.
583
- */
584
- setViewWindow(window) {
585
- if (this.info.view.window === window)
586
- return;
587
- this.info.closeWindow();
588
- this.info.view.window = window;
589
- this.store.update({});
590
- }
591
- /** Move the reader's position inside the displayed record.
592
- * @param scroll - Rows scrolled back from the live end.
593
- */
594
- setScroll(scroll) {
595
- if (this.info.view.scroll === scroll)
596
- return;
597
- this.info.view.scroll = scroll;
598
- this.store.update({});
599
- }
600
- /** Replace the set of expanded reasoning blocks, keyed by message sequence.
601
- * @param folds - Sequences to expand beyond the default fold.
602
- */
603
- setFolds(folds) {
604
- if (this.info.view.folds === folds)
605
- return;
606
- this.info.view.folds = folds;
607
- this.store.update({});
608
- }
609
- /** Set the fold mode of the live attempt's completed reasoning.
610
- * @param reasoning - `row` to fold, `full` to keep the streamed text.
611
- */
612
- setLiveReasoning(reasoning) {
613
- if (this.info.view.liveReasoning === reasoning)
614
- return;
615
- this.info.view.liveReasoning = reasoning;
616
- this.store.update({});
617
- }
618
687
  /** Local answer state for the selected session's pending waterfalls. */
619
688
  get interaction() { return this.info.interaction; }
620
689
  /** Replace the partly collected answers, keyed by the waterfall event id.
@@ -642,73 +711,6 @@ export class SessionController {
642
711
  this.info.interaction.approval = approval;
643
712
  this.store.update({});
644
713
  }
645
- /** Composer-adjacent `@` reference menu state. */
646
- get reference() { return this.info.reference; }
647
- /** Highlight one row of the open reference menu.
648
- * @param index - Row index into the current matches.
649
- */
650
- setReferenceIndex(index) {
651
- if (this.info.reference.index === index)
652
- return;
653
- this.info.reference.index = index;
654
- this.store.update({});
655
- }
656
- /** Remember the draft that dismissed the reference menu, so it does not reopen while it stands.
657
- * @param draft - Composer text at dismissal, or undefined to allow the menu again.
658
- */
659
- setReferenceDismissed(draft) {
660
- if (this.info.reference.dismissed === draft)
661
- return;
662
- this.info.reference.dismissed = draft;
663
- this.store.update({});
664
- }
665
- /** Panels the selected session has open. */
666
- get panels() { return this.info.panels; }
667
- /** Show or hide the reasoning panel.
668
- * @param open - Whether `/think` is open.
669
- */
670
- openThoughts(open) {
671
- if (this.info.panels.thoughts === open)
672
- return;
673
- this.info.panels.thoughts = open;
674
- this.store.update({});
675
- }
676
- /** Show or hide the pending-input panel.
677
- * @param open - Whether `/queue` is open.
678
- */
679
- openQueue(open) {
680
- if (this.info.panels.queue === open)
681
- return;
682
- this.info.panels.queue = open;
683
- this.store.update({});
684
- }
685
- /** Show the model dialog at one step, or close it.
686
- * @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
687
- */
688
- setModelPanel(model) {
689
- if (this.info.panels.model === model)
690
- return;
691
- this.info.panels.model = model;
692
- this.store.update({});
693
- }
694
- /** Show the history or content-search dialog, or close it.
695
- * @param history - Query, content-search mode and matches; undefined closes the dialog.
696
- */
697
- setHistoryPanel(history) {
698
- if (this.info.panels.history === history)
699
- return;
700
- this.info.panels.history = history;
701
- this.store.update({});
702
- }
703
- /** Show the host session-search results, or close them.
704
- * @param search - Query, results and truncation flag; undefined closes the dialog.
705
- */
706
- setSearchPanel(search) {
707
- if (this.info.panels.search === search)
708
- return;
709
- this.info.panels.search = search;
710
- this.store.update({});
711
- }
712
714
  /** Remember a locally submitted command, which never becomes a durable session record. */
713
715
  recordRecall(value) { this.prompts.record(value); }
714
716
  /** Leave recall navigation because the composer was edited or replaced. */
@@ -956,7 +958,7 @@ export class SessionController {
956
958
  * @returns Number of removed records.
957
959
  */
958
960
  reclaimHistory() {
959
- if (this.info.view.pinned || !this.store.state.online)
961
+ if (this.historyPinned || !this.store.state.online)
960
962
  return 0;
961
963
  const removed = this.info.record.trimHistory(this.historyLimits);
962
964
  if (removed)
@@ -976,7 +978,7 @@ export class SessionController {
976
978
  return this.store.state.sessionId;
977
979
  }
978
980
  /** Answer one retained waterfall through the connection's event-result endpoint. */
979
- async reply(frame, outcome) {
980
- await this.connection.reply(frame, outcome);
981
+ async reply(eventId, outcome) {
982
+ await this.connection.reply(eventId, outcome);
981
983
  }
982
984
  }