@yadsh/dsh-qa-surface 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +162 -14
  3. package/SPEC-dsh-qa-surface.md +600 -96
  4. package/capability-policy.json +5 -0
  5. package/compatibility.json +7 -1
  6. package/cordis.patch.yml +11 -0
  7. package/docs/ARCHITECTURE.md +49 -7
  8. package/docs/COMPATIBILITY.md +5 -4
  9. package/docs/CONFIGURATION.md +96 -2
  10. package/lib/attestation.js +10 -0
  11. package/lib/attestation.js.map +1 -0
  12. package/lib/client/QaConfigController.js +111 -4
  13. package/lib/client/QaConfigController.js.map +1 -1
  14. package/lib/client/QaSessionController.js +501 -119
  15. package/lib/client/QaSessionController.js.map +1 -1
  16. package/lib/client/QaSurface.js +137 -20
  17. package/lib/client/QaSurface.js.map +1 -1
  18. package/lib/client/QaTranscriptAdapter.js +534 -45
  19. package/lib/client/QaTranscriptAdapter.js.map +1 -1
  20. package/lib/client/attestation.js +60 -0
  21. package/lib/client/attestation.js.map +1 -0
  22. package/lib/client/chat-index.js +86 -0
  23. package/lib/client/chat-index.js.map +1 -0
  24. package/lib/client/components/Markdown.js +75 -5
  25. package/lib/client/components/Markdown.js.map +1 -1
  26. package/lib/client/components/QaAgentsDrawer.js +40 -0
  27. package/lib/client/components/QaAgentsDrawer.js.map +1 -0
  28. package/lib/client/components/QaComposer.js +132 -15
  29. package/lib/client/components/QaComposer.js.map +1 -1
  30. package/lib/client/components/QaMessage.js +184 -10
  31. package/lib/client/components/QaMessage.js.map +1 -1
  32. package/lib/client/components/QaSidebar.js +124 -0
  33. package/lib/client/components/QaSidebar.js.map +1 -0
  34. package/lib/client/components/QaSourcesDrawer.js +46 -0
  35. package/lib/client/components/QaSourcesDrawer.js.map +1 -0
  36. package/lib/client/components/QaWorkGroup.js +135 -0
  37. package/lib/client/components/QaWorkGroup.js.map +1 -0
  38. package/lib/client/components/VariantSwitcher.js +29 -0
  39. package/lib/client/components/VariantSwitcher.js.map +1 -0
  40. package/lib/client/components/format.js +48 -0
  41. package/lib/client/components/format.js.map +1 -0
  42. package/lib/client/index.js +77 -34
  43. package/lib/client/index.js.map +1 -1
  44. package/lib/client/styles.js +308 -43
  45. package/lib/client/styles.js.map +1 -1
  46. package/lib/client/types.js +13 -0
  47. package/lib/client/types.js.map +1 -0
  48. package/lib/client/wait-for.js +49 -0
  49. package/lib/client/wait-for.js.map +1 -0
  50. package/lib/client.js +8228 -383
  51. package/lib/client.js.map +1 -1
  52. package/lib/config.js +61 -9
  53. package/lib/config.js.map +1 -1
  54. package/lib/index.js +166 -70
  55. package/lib/index.js.map +1 -1
  56. package/lib/lockdown-policy.js +20 -0
  57. package/lib/lockdown-policy.js.map +1 -0
  58. package/lib/resolve-config.js +122 -10
  59. package/lib/resolve-config.js.map +1 -1
  60. package/lib/secure-session.js +170 -0
  61. package/lib/secure-session.js.map +1 -0
  62. package/lib/typert.host.d.ts +3 -0
  63. package/lib/typert.host.js +191 -0
  64. package/lib/typert.remote-client.d.ts +24 -0
  65. package/lib/typert.remote-client.js +129 -0
  66. package/lib/types/attestation.d.ts +17 -0
  67. package/lib/types/client/QaConfigController.d.ts +34 -2
  68. package/lib/types/client/QaSessionController.d.ts +106 -19
  69. package/lib/types/client/QaSurface.d.ts +5 -4
  70. package/lib/types/client/QaTranscriptAdapter.d.ts +17 -2
  71. package/lib/types/client/attestation.d.ts +25 -0
  72. package/lib/types/client/chat-index.d.ts +28 -0
  73. package/lib/types/client/components/Markdown.d.ts +7 -3
  74. package/lib/types/client/components/QaAgentsDrawer.d.ts +25 -0
  75. package/lib/types/client/components/QaComposer.d.ts +12 -2
  76. package/lib/types/client/components/QaMessage.d.ts +14 -1
  77. package/lib/types/client/components/QaSidebar.d.ts +41 -0
  78. package/lib/types/client/components/QaSourcesDrawer.d.ts +8 -0
  79. package/lib/types/client/components/QaWorkGroup.d.ts +19 -0
  80. package/lib/types/client/components/VariantSwitcher.d.ts +19 -0
  81. package/lib/types/client/components/format.d.ts +9 -0
  82. package/lib/types/client/index.d.ts +1 -5
  83. package/lib/types/client/types.d.ts +29 -0
  84. package/lib/types/client/wait-for.d.ts +11 -0
  85. package/lib/types/index.d.ts +22 -6
  86. package/lib/types/lockdown-policy.d.ts +18 -0
  87. package/lib/types/secure-session.d.ts +22 -0
  88. package/lib/types/types.d.ts +148 -1
  89. package/package.json +29 -3
@@ -1,80 +1,59 @@
1
- import { projectTranscript } from "./QaTranscriptAdapter.js";
2
- const EMPTY_STATE = Object.freeze({
3
- phase: "idle",
4
- sessionId: null,
5
- messages: Object.freeze([]),
6
- error: null,
7
- canSend: false,
8
- canStop: false,
9
- });
10
- function waitFor(source, predicate, timeoutMs) {
11
- const current = source.getSnapshot();
12
- if (predicate(current))
13
- return Promise.resolve(current);
14
- return new Promise((resolve, reject) => {
15
- let settled = false;
16
- const timeout = {};
17
- let unsubscribe = () => undefined;
18
- const finish = (value) => {
19
- if (settled)
20
- return;
21
- settled = true;
22
- if (timeout.id !== undefined)
23
- clearTimeout(timeout.id);
24
- unsubscribe();
25
- resolve(value);
26
- };
27
- unsubscribe = source.subscribe(() => {
28
- const next = source.getSnapshot();
29
- if (predicate(next))
30
- finish(next);
31
- });
32
- // Tolerate observable implementations that notify synchronously while a
33
- // subscriber is being installed, and close that subscription afterward.
34
- if (settled) {
35
- unsubscribe();
36
- return;
37
- }
38
- const afterSubscribe = source.getSnapshot();
39
- if (predicate(afterSubscribe)) {
40
- finish(afterSubscribe);
41
- return;
42
- }
43
- timeout.id = setTimeout(() => {
44
- if (settled)
45
- return;
46
- settled = true;
47
- unsubscribe();
48
- reject(new Error("Timed out waiting for the DSH runtime."));
49
- }, timeoutMs);
50
- });
1
+ function bytesToBase64(bytes) {
2
+ let binary = "";
3
+ for (let index = 0; index < bytes.length; index += 0x8000) {
4
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
5
+ }
6
+ return btoa(binary);
51
7
  }
52
- /** The only module that couples QA behavior to DSH Session/client APIs. */
8
+ import { QaPolicyAttestationError, attestationHint, attestationReasonOf, proofMatchesConfig, } from "./attestation.js";
9
+ import { QaChatIndex } from "./chat-index.js";
10
+ import { QA_SESSION_IDLE_STATE } from "./types.js";
11
+ import { waitFor } from "./wait-for.js";
12
+ import { projectSources } from "./QaTranscriptAdapter.js";
13
+ import { projectTranscript, QA_REGENERATE_MARKER, } from "./QaTranscriptAdapter.js";
14
+ const CONFIGURATION_ERROR = "Настройки помощника недоступны.";
15
+ /**
16
+ * The only module that couples QA behavior to DSH Session/client APIs. It owns
17
+ * the chat lifecycle state machine; the observable wait, browser-local chat
18
+ * bookkeeping and policy-attestation details live in their own modules.
19
+ */
53
20
  export class QaSessionController {
54
21
  listeners = new Set();
55
22
  sessions;
56
23
  api;
57
24
  connection;
58
25
  config;
59
- storage;
26
+ secureSessionRemote;
27
+ chats;
60
28
  timeoutMs;
61
- state = EMPTY_STATE;
29
+ streamIntervalMs;
30
+ state = QA_SESSION_IDLE_STATE;
62
31
  session;
63
32
  unsubscribeSession;
64
33
  unsubscribeConnection;
65
34
  ensuring;
35
+ materializing;
66
36
  operationError = null;
67
37
  admissionPending = false;
38
+ policyReady = false;
39
+ drafting = false;
40
+ streamFlushTimer;
41
+ /** The chat session to return to when a subagent view closes. */
42
+ chatSessionId = null;
43
+ viewingSubagent = null;
68
44
  connectedOnce;
69
45
  disposed = false;
70
46
  generation = 0;
47
+ chatsRevision = 0;
71
48
  constructor(options) {
72
49
  this.sessions = options.sessions;
73
50
  this.api = options.api;
74
51
  this.connection = options.connection;
75
52
  this.config = options.config;
76
- this.storage = options.storage;
53
+ this.secureSessionRemote = options.secureSession;
54
+ this.chats = new QaChatIndex(options.storage, `${options.config.session.storageKey}:v1:${options.config.route.path}`);
77
55
  this.timeoutMs = options.timeoutMs ?? 15_000;
56
+ this.streamIntervalMs = options.streamIntervalMs ?? 66;
78
57
  this.connectedOnce = this.connection.getSnapshot() !== undefined;
79
58
  this.unsubscribeConnection = this.connection.subscribe(() => {
80
59
  if (this.connection.getSnapshot() !== undefined)
@@ -97,25 +76,48 @@ export class QaSessionController {
97
76
  this.ensuring = operation;
98
77
  return operation;
99
78
  }
100
- async send(text) {
79
+ async send(text, images = []) {
101
80
  const prompt = text.trim();
102
- if (prompt === "" || this.session === undefined || !this.state.canSend) {
81
+ if ((prompt === "" && images.length === 0) || !this.state.canSend) {
103
82
  return false;
104
83
  }
105
84
  if (prompt.startsWith("/")) {
106
- this.operationError =
107
- "Slash commands are not available in this assistant view.";
85
+ this.operationError = "Команды со слешем недоступны в режиме помощника.";
108
86
  this.publish();
109
87
  return false;
110
88
  }
89
+ if (this.session === undefined) {
90
+ // A draft chat materializes its session only now: nothing was created
91
+ // when the user pressed "New chat", so the first prompt pays for it.
92
+ if (!this.drafting || this.materializing !== undefined)
93
+ return false;
94
+ if (!(await this.materializeDraft()) || this.session === undefined) {
95
+ return false;
96
+ }
97
+ }
111
98
  this.operationError = null;
99
+ let attested = await this.attestPolicy();
100
+ if (!attested) {
101
+ attested = await this.recoverForSend();
102
+ if (!attested)
103
+ return false;
104
+ }
112
105
  this.admissionPending = true;
113
106
  this.publish();
114
107
  try {
115
- const result = await this.session.prompt([{ type: "text", text: prompt }], "queue");
108
+ const content = [
109
+ ...(prompt === "" ? [] : [{ type: "text", text: prompt }]),
110
+ ...images.map((image) => ({
111
+ type: "image",
112
+ mediaType: image.mediaType,
113
+ data: image.data,
114
+ name: image.name,
115
+ })),
116
+ ];
117
+ const result = await this.session.prompt(content, "queue");
116
118
  if (!result.ok) {
117
119
  this.admissionPending = false;
118
- this.operationError = "Your message could not be sent.";
120
+ this.operationError = "Не удалось отправить сообщение.";
119
121
  this.publish();
120
122
  return false;
121
123
  }
@@ -125,27 +127,45 @@ export class QaSessionController {
125
127
  catch (error) {
126
128
  this.admissionPending = false;
127
129
  console.error("dsh-qa-surface: prompt failed", error);
128
- this.operationError = "Your message could not be sent.";
130
+ this.operationError = "Не удалось отправить сообщение.";
129
131
  this.publish();
130
132
  return false;
131
133
  }
132
134
  }
135
+ /**
136
+ * Ask for a fresh variant of the previous answer. The session has no
137
+ * truncation seam, so this sends the hidden regeneration instruction as an
138
+ * ordinary prompt: the model produces a follow-up turn, the projection
139
+ * hides the marker message, and the consecutive turns read as variants of
140
+ * the original question (switched in the surface).
141
+ */
142
+ async regenerate() {
143
+ return this.send(QA_REGENERATE_MARKER);
144
+ }
133
145
  async stop() {
134
146
  if (this.session === undefined || !this.state.canStop)
135
147
  return;
136
148
  try {
137
149
  const result = await this.session.cancel();
138
150
  if (!result.ok)
139
- this.operationError = "The response could not be stopped.";
151
+ this.operationError = "Не удалось остановить ответ.";
140
152
  }
141
153
  catch (error) {
142
154
  console.error("dsh-qa-surface: stop failed", error);
143
- this.operationError = "The response could not be stopped.";
155
+ this.operationError = "Не удалось остановить ответ.";
144
156
  }
145
157
  this.publish();
146
158
  }
147
- async reset() {
148
- if (this.config.session.policy === "fixed")
159
+ /**
160
+ * Enter the "new chat" draft: show an empty composer without creating any
161
+ * session. Nothing appears in the chat list and no Host session is spent
162
+ * until the first prompt is actually sent, which materializes the session
163
+ * lazily ({@link materializeDraft}). Fixed-policy deployments cannot draft.
164
+ */
165
+ async startDraft() {
166
+ if (this.disposed ||
167
+ this.config.session.policy === "fixed" ||
168
+ (this.config.lockdown.enabled && !this.config.lockdown.allowSessionReset))
149
169
  return;
150
170
  const previous = this.session;
151
171
  if (previous?.getSnapshot().running === true) {
@@ -153,44 +173,226 @@ export class QaSessionController {
153
173
  await previous.cancel();
154
174
  }
155
175
  catch (error) {
156
- console.error("dsh-qa-surface: stop before reset failed", error);
176
+ console.error("dsh-qa-surface: stop before draft failed", error);
157
177
  }
158
178
  }
179
+ this.drafting = true;
180
+ this.unbind();
181
+ this.operationError = null;
182
+ this.admissionPending = false;
183
+ this.policyReady = false;
184
+ this.state = {
185
+ ...QA_SESSION_IDLE_STATE,
186
+ chatsRevision: this.chatsRevision,
187
+ };
188
+ this.publish();
189
+ }
190
+ /**
191
+ * Turn the current draft into a real attested session. Runs inside the
192
+ * send flow; concurrent sends share one materialization. A refusal here is
193
+ * reported at once — a fresh session's first attestation is the
194
+ * deployment's only chance to learn the reason.
195
+ */
196
+ materializeDraft() {
197
+ if (this.materializing !== undefined)
198
+ return this.materializing;
159
199
  const operation = ++this.generation;
200
+ const attempt = (async () => {
201
+ try {
202
+ await this.waitForConnection();
203
+ const id = await this.createSession();
204
+ if (this.disposed || operation !== this.generation)
205
+ return false;
206
+ await this.bind(id);
207
+ if (this.disposed || operation !== this.generation)
208
+ return false;
209
+ this.drafting = false;
210
+ if (this.config.session.policy === "browser-persistent") {
211
+ this.chats.saveActive(id);
212
+ }
213
+ return true;
214
+ }
215
+ catch (error) {
216
+ this.fail(this.operationError === CONFIGURATION_ERROR
217
+ ? this.operationError
218
+ : "Не удалось начать чат.", error);
219
+ return false;
220
+ }
221
+ })();
222
+ this.materializing = attempt;
223
+ this.publish();
224
+ // `attempt` never rejects: every failure path is caught and reports false.
225
+ void attempt.then(() => {
226
+ if (this.materializing === attempt)
227
+ this.materializing = undefined;
228
+ });
229
+ return attempt;
230
+ }
231
+ /**
232
+ * Open one of this browser's indexed chats. The id must be present in the
233
+ * host session list and pass policy attestation; an unknown id is forgotten
234
+ * from the index. Fixed-policy deployments cannot switch.
235
+ */
236
+ async switchTo(sessionId) {
237
+ if (this.disposed || this.config.session.policy === "fixed")
238
+ return;
239
+ if (this.session !== undefined &&
240
+ String(this.session.sessionId) === sessionId)
241
+ return;
242
+ const operation = ++this.generation;
243
+ this.drafting = false;
244
+ this.viewingSubagent = null;
160
245
  this.unbind();
161
246
  this.operationError = null;
162
247
  this.admissionPending = false;
163
- this.state = { ...EMPTY_STATE, phase: "creating" };
248
+ this.policyReady = false;
249
+ this.state = {
250
+ ...QA_SESSION_IDLE_STATE,
251
+ chatsRevision: this.chatsRevision,
252
+ phase: "creating",
253
+ };
164
254
  this.emit();
165
255
  try {
166
256
  await this.waitForConnection();
167
- const id = await this.createSession();
257
+ const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
168
258
  if (this.disposed || operation !== this.generation)
169
259
  return;
170
- this.persist(id);
171
- await this.bind(id);
260
+ if (!Object.hasOwn(list.byId, sessionId)) {
261
+ this.forgetChat(sessionId);
262
+ throw new Error("Этот чат больше недоступен.");
263
+ }
264
+ await this.bind(sessionId);
265
+ if (this.disposed || operation !== this.generation)
266
+ return;
267
+ this.chats.saveActive(sessionId);
268
+ }
269
+ catch (error) {
270
+ this.fail(this.operationError === CONFIGURATION_ERROR
271
+ ? this.operationError
272
+ : "Не удалось открыть этот чат.", error);
273
+ }
274
+ }
275
+ /**
276
+ * Watch a subagent's transcript live. The binding is read-only by
277
+ * construction: no attestation runs (attestation admits sending, and the
278
+ * composer stays disabled here), the chat index is untouched, and
279
+ * {@link closeSubagent} returns to the chat the panel was opened from.
280
+ */
281
+ async viewSubagent(id, title) {
282
+ if (this.disposed)
283
+ return;
284
+ if (this.viewingSubagent !== null &&
285
+ this.viewingSubagent.id === id &&
286
+ this.session !== undefined) {
287
+ return;
288
+ }
289
+ const operation = ++this.generation;
290
+ this.drafting = false;
291
+ this.viewingSubagent = { id, title };
292
+ this.unbind();
293
+ this.operationError = null;
294
+ this.admissionPending = false;
295
+ this.policyReady = false;
296
+ this.state = {
297
+ ...QA_SESSION_IDLE_STATE,
298
+ chatsRevision: this.chatsRevision,
299
+ viewingSubagent: this.viewingSubagent,
300
+ phase: "creating",
301
+ };
302
+ this.emit();
303
+ try {
304
+ await this.waitForConnection();
305
+ await this.bind(id, { attest: false, track: false, report: false });
172
306
  }
173
307
  catch (error) {
174
- this.fail("Unable to start a new chat.", error);
308
+ this.viewingSubagent = null;
309
+ this.fail("Не удалось открыть транскрипт субагента.", error);
310
+ }
311
+ if (this.disposed || operation !== this.generation)
312
+ return;
313
+ }
314
+ /** Leave the subagent transcript and re-open the chat it was launched from. */
315
+ async closeSubagent() {
316
+ const chat = this.chatSessionId;
317
+ if (chat === null)
318
+ return;
319
+ await this.switchTo(chat);
320
+ }
321
+ /** This browser's indexed chat ids, most recently used first. */
322
+ chatIds() {
323
+ return this.chats.chatIds();
324
+ }
325
+ /** The bound session id, or null while no chat is bound. */
326
+ activeSessionId() {
327
+ return this.session === undefined ? null : String(this.session.sessionId);
328
+ }
329
+ /**
330
+ * Resolve one durable image attachment of the bound session into a
331
+ * browser-usable URL. The caller caches; failures surface as broken views.
332
+ */
333
+ async readImage(attachmentId) {
334
+ if (this.session === undefined)
335
+ throw new Error("no bound session");
336
+ const result = await this.session.readAttachment(attachmentId);
337
+ if (!result.ok) {
338
+ throw new Error(`${result.error.code}: ${result.error.message}`);
175
339
  }
340
+ const { attachment, data } = result.value;
341
+ if (typeof URL.createObjectURL !== "function") {
342
+ return `data:${attachment.mediaType};base64,${bytesToBase64(data)}`;
343
+ }
344
+ return URL.createObjectURL(new Blob([Uint8Array.from(data).buffer], {
345
+ type: attachment.mediaType,
346
+ }));
347
+ }
348
+ /**
349
+ * Remove one chat from this browser's index. Deleting the chat that is
350
+ * currently open also forgets the persisted id and starts a fresh attested
351
+ * session (when the deployment allows session resets); the Host-side
352
+ * session itself is not touched — no host deletion seam exists.
353
+ */
354
+ async deleteChat(id) {
355
+ this.forgetChat(id);
356
+ this.chatsRevision += 1;
357
+ if (this.activeSessionId() === id &&
358
+ this.config.session.policy !== "fixed") {
359
+ // No replacement session is created: deleting the open chat falls back
360
+ // to a draft, and the next prompt materializes a fresh one. The stale
361
+ // persisted id is dropped so a reload cannot resurrect the deleted chat.
362
+ this.chats.clearActive();
363
+ await this.startDraft();
364
+ return;
365
+ }
366
+ this.publish();
367
+ }
368
+ /** Drop one chat id from this browser's index. */
369
+ forgetChat(sessionId) {
370
+ this.chats.forgetChat(sessionId);
176
371
  }
177
372
  dispose() {
178
373
  if (this.disposed)
179
374
  return;
180
375
  this.disposed = true;
181
376
  this.generation += 1;
377
+ this.clearStreamFlush();
182
378
  this.unsubscribeConnection();
183
379
  this.unbind();
184
380
  this.listeners.clear();
185
381
  }
186
382
  async ensureSessionNow() {
187
383
  const operation = ++this.generation;
188
- this.state = { ...EMPTY_STATE, phase: "creating" };
384
+ this.drafting = false;
385
+ this.state = {
386
+ ...QA_SESSION_IDLE_STATE,
387
+ chatsRevision: this.chatsRevision,
388
+ phase: "creating",
389
+ };
189
390
  this.emit();
190
391
  try {
191
392
  await this.waitForConnection();
192
393
  const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
193
394
  let id = null;
395
+ let restored = false;
194
396
  if (this.config.session.policy === "fixed") {
195
397
  id = this.config.session.fixedSessionId;
196
398
  if (id === null || !Object.hasOwn(list.byId, id)) {
@@ -198,40 +400,133 @@ export class QaSessionController {
198
400
  }
199
401
  }
200
402
  else if (this.config.session.policy === "browser-persistent") {
201
- const stored = this.readPersisted();
403
+ const stored = this.chats.activeId();
202
404
  if (stored !== null && Object.hasOwn(list.byId, stored)) {
203
405
  id = stored;
406
+ restored = true;
204
407
  }
205
408
  else if (stored !== null) {
206
- this.clearPersisted();
409
+ this.chats.clearActive();
207
410
  }
208
411
  }
209
412
  if (id === null) {
210
413
  id = await this.createSession();
211
- if (this.config.session.policy === "browser-persistent")
212
- this.persist(id);
213
414
  }
214
415
  if (this.disposed || operation !== this.generation)
215
416
  return;
216
- await this.bind(id);
417
+ // A restored id's first bind stays quiet: the recovery path below may
418
+ // replace it. A freshly created session must report a refusal at once —
419
+ // it is the deployment's only chance to learn the reason.
420
+ try {
421
+ await this.bind(id, { report: !restored });
422
+ }
423
+ catch (error) {
424
+ if (!restored ||
425
+ this.config.session.policy !== "browser-persistent" ||
426
+ !(error instanceof QaPolicyAttestationError)) {
427
+ throw error;
428
+ }
429
+ // A stored id is only a hint. If Host refuses that session under the
430
+ // current QA policy (for example after the configured preset changes),
431
+ // forget it and bootstrap one fresh, policy-attested session instead.
432
+ this.unbind();
433
+ this.operationError = null;
434
+ this.chats.clearActive();
435
+ id = await this.createSession();
436
+ if (this.disposed || operation !== this.generation)
437
+ return;
438
+ await this.bind(id);
439
+ }
440
+ if (this.disposed || operation !== this.generation)
441
+ return;
442
+ if (this.config.session.policy === "browser-persistent") {
443
+ this.chats.saveActive(id);
444
+ }
445
+ }
446
+ catch (error) {
447
+ this.fail(this.operationError === CONFIGURATION_ERROR
448
+ ? this.operationError
449
+ : "Не удалось начать чат.", error);
450
+ }
451
+ }
452
+ /**
453
+ * Recovery ladder after a refused send-time attestation. The prompt was
454
+ * never admitted, so nothing here can duplicate it. Step 1 re-opens the
455
+ * session: a Host that idled the agent out dismantles its tool view, and
456
+ * re-opening re-materializes it. Step 2 — when that refused session is
457
+ * still blank (it never received the prompt, so there is nothing to lose) —
458
+ * starts a fresh attested session instead. A session with history is left
459
+ * alone: recreating it would silently drop the conversation.
460
+ */
461
+ async recoverForSend() {
462
+ const id = this.session === undefined ? null : String(this.session.sessionId);
463
+ if (id === null ||
464
+ this.disposed ||
465
+ this.config.session.policy === "fixed") {
466
+ return false;
467
+ }
468
+ const operation = ++this.generation;
469
+ this.unbind();
470
+ this.admissionPending = false;
471
+ this.policyReady = false;
472
+ this.state = { ...this.state, phase: "creating" };
473
+ this.emit();
474
+ try {
475
+ await this.waitForConnection();
476
+ try {
477
+ await this.bind(id);
478
+ if (this.disposed || operation !== this.generation)
479
+ return false;
480
+ if (this.policyReady)
481
+ return true;
482
+ }
483
+ catch (error) {
484
+ if (!(error instanceof QaPolicyAttestationError))
485
+ throw error;
486
+ if (this.disposed || operation !== this.generation)
487
+ return false;
488
+ }
489
+ if (this.session !== undefined && !this.session.getSnapshot().blank) {
490
+ this.fail(CONFIGURATION_ERROR, new QaPolicyAttestationError());
491
+ return false;
492
+ }
493
+ this.unbind();
494
+ this.chats.clearActive();
495
+ const created = await this.createSession();
496
+ if (this.disposed || operation !== this.generation)
497
+ return false;
498
+ await this.bind(created);
499
+ if (this.disposed || operation !== this.generation)
500
+ return false;
501
+ this.chats.saveActive(created);
502
+ return this.policyReady;
217
503
  }
218
504
  catch (error) {
219
- this.fail("Unable to start a chat.", error);
505
+ this.fail(this.operationError === CONFIGURATION_ERROR
506
+ ? this.operationError
507
+ : "Не удалось открыть этот чат.", error);
508
+ return false;
220
509
  }
221
510
  }
222
511
  async createSession() {
223
- const response = await this.api.create({
224
- ...(this.config.session.workspaceId === null
225
- ? {}
226
- : { workspaceId: this.config.session.workspaceId }),
227
- ...(this.config.session.agentPreset === null
228
- ? {}
229
- : { agentPreset: this.config.session.agentPreset }),
512
+ const created = await this.sessions.create({
513
+ ...(this.config.session.workspaceId !== null
514
+ ? { workspaceId: this.config.session.workspaceId }
515
+ : this.config.session.cwd !== null
516
+ ? { cwd: this.config.session.cwd }
517
+ : {}),
230
518
  });
231
- if (!response.result.ok)
232
- throw new Error(response.result.error.code);
233
- const id = String(response.result.value.sessionId);
234
- await waitFor(this.sessions.list, (snapshot) => Object.hasOwn(snapshot.byId, id), this.timeoutMs);
519
+ const id = String(created);
520
+ if (this.config.session.agentPreset !== null) {
521
+ const selectedPreset = await this.api.selectAgentPreset({
522
+ sessionId: id,
523
+ agentPreset: this.config.session.agentPreset,
524
+ });
525
+ if (!selectedPreset.result.ok) {
526
+ throw new Error(selectedPreset.result.error.code);
527
+ }
528
+ this.sessions.noteAgentPreset(id, this.config.session.agentPreset);
529
+ }
235
530
  if (this.config.session.provider !== null &&
236
531
  this.config.session.model !== null) {
237
532
  const selected = await this.api.selectModel({
@@ -247,7 +542,8 @@ export class QaSessionController {
247
542
  }
248
543
  return id;
249
544
  }
250
- async bind(id) {
545
+ async bind(id, options = {}) {
546
+ const { report = true, attest = true, track = true } = options;
251
547
  this.sessions.open(id);
252
548
  let binding = this.sessions.binding(id);
253
549
  if (binding === undefined) {
@@ -258,26 +554,88 @@ export class QaSessionController {
258
554
  throw new Error("Session binding is unavailable.");
259
555
  this.unbind();
260
556
  this.session = binding.session;
557
+ this.policyReady = false;
261
558
  this.unsubscribeSession = binding.session.subscribe(() => {
262
559
  this.admissionPending = false;
263
560
  this.operationError = null;
264
- this.publish();
561
+ this.publishSessionUpdate();
265
562
  });
563
+ await waitFor(binding.session, (snapshot) => snapshot.openState === "open" || snapshot.openState === "error", this.timeoutMs);
564
+ if (binding.session.getSnapshot().openState !== "open") {
565
+ throw new Error("Session binding could not be opened.");
566
+ }
567
+ if (attest && !(await this.attestPolicy(report))) {
568
+ throw new QaPolicyAttestationError();
569
+ }
570
+ if (track) {
571
+ this.chatSessionId = String(this.session.sessionId);
572
+ this.viewingSubagent = null;
573
+ this.chats.addChat(String(this.session.sessionId));
574
+ }
266
575
  this.publish();
267
576
  }
268
577
  unbind() {
578
+ this.clearStreamFlush();
269
579
  this.unsubscribeSession?.();
270
580
  this.unsubscribeSession = undefined;
271
581
  this.session = undefined;
272
582
  this.admissionPending = false;
583
+ this.policyReady = false;
273
584
  }
274
585
  waitForConnection() {
275
586
  return waitFor(this.connection, (description) => description !== undefined, this.timeoutMs);
276
587
  }
588
+ /**
589
+ * Project one session notification. A running turn's frames arrive at
590
+ * animation-frame cadence, so re-projections are spaced at least
591
+ * {@link streamIntervalMs} apart: the first frame of a window projects at
592
+ * once and further frames are absorbed (the memoized render path replays
593
+ * nothing, so absorbing a frame only defers it). Everything outside a
594
+ * running turn - phase flips, errors, turn completion - projects
595
+ * immediately.
596
+ */
597
+ publishSessionUpdate() {
598
+ if (this.streamIntervalMs <= 0 ||
599
+ this.session?.getSnapshot().running !== true) {
600
+ this.clearStreamFlush();
601
+ this.publish();
602
+ return;
603
+ }
604
+ if (this.streamFlushTimer !== undefined)
605
+ return;
606
+ this.publish();
607
+ this.streamFlushTimer = setTimeout(() => {
608
+ this.streamFlushTimer = undefined;
609
+ }, this.streamIntervalMs);
610
+ }
611
+ clearStreamFlush() {
612
+ if (this.streamFlushTimer === undefined)
613
+ return;
614
+ clearTimeout(this.streamFlushTimer);
615
+ this.streamFlushTimer = undefined;
616
+ }
277
617
  publish() {
278
618
  if (this.disposed)
279
619
  return;
280
620
  const connected = this.connection.getSnapshot() !== undefined;
621
+ if (this.drafting && this.session === undefined) {
622
+ // Draft state: an empty writable composer without a bound session. The
623
+ // session list is untouched — nothing exists until the first send.
624
+ const materializing = this.materializing !== undefined;
625
+ this.state = {
626
+ ...QA_SESSION_IDLE_STATE,
627
+ phase: materializing
628
+ ? "creating"
629
+ : !connected && this.connectedOnce
630
+ ? "reconnecting"
631
+ : "idle",
632
+ error: this.operationError,
633
+ canSend: connected && !materializing,
634
+ chatsRevision: this.chatsRevision,
635
+ };
636
+ this.emit();
637
+ return;
638
+ }
281
639
  if (this.session === undefined) {
282
640
  this.state = {
283
641
  ...this.state,
@@ -291,9 +649,9 @@ export class QaSessionController {
291
649
  const blocked = snapshot.pending.length > 0;
292
650
  const error = this.operationError ??
293
651
  (blocked
294
- ? "This request requires an interaction that is not available in this assistant view."
652
+ ? "Для этого запроса нужно действие, недоступное в режиме помощника."
295
653
  : snapshot.removed || snapshot.openState === "error"
296
- ? "This chat is no longer available."
654
+ ? "Этот чат больше недоступен."
297
655
  : null);
298
656
  const phase = !connected
299
657
  ? "reconnecting"
@@ -311,51 +669,75 @@ export class QaSessionController {
311
669
  sessionId: String(this.session.sessionId),
312
670
  messages: projectTranscript(snapshot, {
313
671
  showToolActivity: this.config.ui.showToolActivity,
672
+ showReasoning: this.config.ui.showReasoning,
314
673
  }),
315
674
  error,
316
- canSend: connected && phase === "ready",
675
+ canSend: connected && phase === "ready" && this.policyReady,
317
676
  canStop: connected && snapshot.running && this.config.ui.showStop && !blocked,
677
+ chatsRevision: this.chatsRevision,
678
+ sources: projectSources(snapshot),
679
+ viewingSubagent: this.viewingSubagent,
318
680
  };
319
681
  this.emit();
320
682
  }
321
683
  fail(message, error) {
322
- console.error("dsh-qa-surface: session operation failed", error);
684
+ // Policy attestation refusals already logged their precise reason; a
685
+ // second stack trace for the wrapper error is only console noise.
686
+ if (!(error instanceof QaPolicyAttestationError)) {
687
+ console.error("dsh-qa-surface: session operation failed", error);
688
+ }
323
689
  if (this.disposed)
324
690
  return;
325
691
  this.operationError = message;
326
692
  this.state = {
327
- ...EMPTY_STATE,
693
+ ...QA_SESSION_IDLE_STATE,
328
694
  phase: "error",
329
695
  error: message,
696
+ chatsRevision: this.chatsRevision,
330
697
  };
331
698
  this.emit();
332
699
  }
333
- storageKey() {
334
- return `${this.config.session.storageKey}:v1:${this.config.route.path}:session`;
335
- }
336
- readPersisted() {
337
- try {
338
- const value = this.storage?.getItem(this.storageKey())?.trim() ?? "";
339
- return value === "" ? null : value;
340
- }
341
- catch {
342
- return null;
343
- }
344
- }
345
- persist(id) {
346
- try {
347
- this.storage?.setItem(this.storageKey(), id);
348
- }
349
- catch {
350
- // A denied localStorage write must not prevent a real DSH session.
700
+ async attestPolicy(reportFailure = true) {
701
+ if (this.session === undefined)
702
+ return false;
703
+ if (!this.config.lockdown.enabled) {
704
+ this.policyReady = true;
705
+ return true;
351
706
  }
352
- }
353
- clearPersisted() {
707
+ this.policyReady = false;
708
+ this.publish();
709
+ // One precise console diagnostic per refusal: the coarse Host reason code
710
+ // plus an operator hint. The QA-facing message stays generic by design.
711
+ const reject = (reason) => {
712
+ if (reportFailure) {
713
+ console.error(`dsh-qa-surface: policy attestation failed (reason: ${reason ?? "unknown"}). ${attestationHint(reason)}`);
714
+ }
715
+ this.policyReady = false;
716
+ this.operationError = CONFIGURATION_ERROR;
717
+ this.publish();
718
+ return false;
719
+ };
354
720
  try {
355
- this.storage?.removeItem(this.storageKey());
721
+ const result = await this.secureSessionRemote(String(this.session.sessionId));
722
+ if (!result.ok) {
723
+ return reject(attestationReasonOf(result.error));
724
+ }
725
+ if (!proofMatchesConfig(result.value, this.config.lockdown, String(this.session.sessionId))) {
726
+ return reject("proof-mismatch");
727
+ }
728
+ this.policyReady = true;
729
+ this.operationError = null;
730
+ this.publish();
731
+ return true;
356
732
  }
357
- catch {
358
- // A denied localStorage removal is harmless; the id is revalidated later.
733
+ catch (error) {
734
+ if (reportFailure) {
735
+ console.error("dsh-qa-surface: policy attestation request failed", error);
736
+ }
737
+ this.policyReady = false;
738
+ this.operationError = CONFIGURATION_ERROR;
739
+ this.publish();
740
+ return false;
359
741
  }
360
742
  }
361
743
  emit() {