@yadsh/dsh-qa-surface 0.2.0 → 0.3.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 (161) hide show
  1. package/CHANGELOG.md +84 -1
  2. package/README.md +87 -12
  3. package/compatibility.json +11 -3
  4. package/docs/ARCHITECTURE.md +1 -1
  5. package/docs/COMPATIBILITY.md +1 -1
  6. package/docs/CONFIGURATION.md +88 -1
  7. package/docs/SOURCES-MIGRATION.md +28 -0
  8. package/docs/SOURCES-PROVENANCE-AUDIT.md +88 -0
  9. package/docs/SPEC-dsh-qa-surface-accounts.md +219 -0
  10. package/docs/SPEC-dsh-qa-surface-sources-provenance.md +1692 -0
  11. package/lib/accounts/index.js +2 -0
  12. package/lib/accounts/index.js.map +1 -0
  13. package/lib/accounts/store.js +478 -0
  14. package/lib/accounts/store.js.map +1 -0
  15. package/lib/attestation.js.map +1 -1
  16. package/lib/cli.js +143 -0
  17. package/lib/cli.js.map +1 -0
  18. package/lib/client/QaAccountsController.js +329 -0
  19. package/lib/client/QaAccountsController.js.map +1 -0
  20. package/lib/client/QaSessionController.js +203 -127
  21. package/lib/client/QaSessionController.js.map +1 -1
  22. package/lib/client/QaSurface.js +234 -72
  23. package/lib/client/QaSurface.js.map +1 -1
  24. package/lib/client/QaTranscriptAdapter.js +27 -241
  25. package/lib/client/QaTranscriptAdapter.js.map +1 -1
  26. package/lib/client/attestation.js +6 -0
  27. package/lib/client/attestation.js.map +1 -1
  28. package/lib/client/components/Markdown.js +54 -13
  29. package/lib/client/components/Markdown.js.map +1 -1
  30. package/lib/client/components/QaAuthGate.js +40 -0
  31. package/lib/client/components/QaAuthGate.js.map +1 -0
  32. package/lib/client/components/QaChangelog.js +112 -0
  33. package/lib/client/components/QaChangelog.js.map +1 -0
  34. package/lib/client/components/QaMessage.js +47 -17
  35. package/lib/client/components/QaMessage.js.map +1 -1
  36. package/lib/client/components/QaSidebar.js +63 -20
  37. package/lib/client/components/QaSidebar.js.map +1 -1
  38. package/lib/client/components/QaSourcesDrawer.js +113 -32
  39. package/lib/client/components/QaSourcesDrawer.js.map +1 -1
  40. package/lib/client/components/QaTurnRail.js +281 -0
  41. package/lib/client/components/QaTurnRail.js.map +1 -0
  42. package/lib/client/components/QaWelcomeNotice.js +125 -0
  43. package/lib/client/components/QaWelcomeNotice.js.map +1 -0
  44. package/lib/client/components/QaWidthHandle.js +142 -0
  45. package/lib/client/components/QaWidthHandle.js.map +1 -0
  46. package/lib/client/components/source-refs.js +78 -0
  47. package/lib/client/components/source-refs.js.map +1 -0
  48. package/lib/client/create-session.js +51 -0
  49. package/lib/client/create-session.js.map +1 -0
  50. package/lib/client/index.js +100 -12
  51. package/lib/client/index.js.map +1 -1
  52. package/lib/client/project-session-state.js +78 -0
  53. package/lib/client/project-session-state.js.map +1 -0
  54. package/lib/client/session-admission.js +38 -0
  55. package/lib/client/session-admission.js.map +1 -0
  56. package/lib/client/session-assets.js +52 -0
  57. package/lib/client/session-assets.js.map +1 -0
  58. package/lib/client/session-sources.js +61 -0
  59. package/lib/client/session-sources.js.map +1 -0
  60. package/lib/client/settlement.js +24 -0
  61. package/lib/client/settlement.js.map +1 -0
  62. package/lib/client/styles.js +151 -9
  63. package/lib/client/styles.js.map +1 -1
  64. package/lib/client/tool-presentation.js +102 -0
  65. package/lib/client/tool-presentation.js.map +1 -0
  66. package/lib/client/turn-sources.js +98 -0
  67. package/lib/client/turn-sources.js.map +1 -0
  68. package/lib/client/types.js +2 -0
  69. package/lib/client/types.js.map +1 -1
  70. package/lib/client/use-session-ui-state.js +68 -0
  71. package/lib/client/use-session-ui-state.js.map +1 -0
  72. package/lib/client/use-transcript-view.js +44 -0
  73. package/lib/client/use-transcript-view.js.map +1 -0
  74. package/lib/client.js +4649 -861
  75. package/lib/client.js.map +1 -1
  76. package/lib/config.js +218 -102
  77. package/lib/config.js.map +1 -1
  78. package/lib/entry-redirect.js +44 -0
  79. package/lib/entry-redirect.js.map +1 -0
  80. package/lib/host-route.js +25 -1
  81. package/lib/host-route.js.map +1 -1
  82. package/lib/index.js +209 -23
  83. package/lib/index.js.map +1 -1
  84. package/lib/launch-token.js +42 -0
  85. package/lib/launch-token.js.map +1 -0
  86. package/lib/provenance/collector.js +40 -0
  87. package/lib/provenance/collector.js.map +1 -0
  88. package/lib/provenance/connectors.js +147 -0
  89. package/lib/provenance/connectors.js.map +1 -0
  90. package/lib/provenance/dedupe.js +89 -0
  91. package/lib/provenance/dedupe.js.map +1 -0
  92. package/lib/provenance/extractors.js +340 -0
  93. package/lib/provenance/extractors.js.map +1 -0
  94. package/lib/provenance/file-preview.js +39 -0
  95. package/lib/provenance/file-preview.js.map +1 -0
  96. package/lib/provenance/host-store.js +387 -0
  97. package/lib/provenance/host-store.js.map +1 -0
  98. package/lib/provenance/index.js +9 -0
  99. package/lib/provenance/index.js.map +1 -0
  100. package/lib/provenance/normalize.js +128 -0
  101. package/lib/provenance/normalize.js.map +1 -0
  102. package/lib/provenance/reported.js +53 -0
  103. package/lib/provenance/reported.js.map +1 -0
  104. package/lib/provenance/types.js +2 -0
  105. package/lib/provenance/types.js.map +1 -0
  106. package/lib/resolve-config.js +161 -0
  107. package/lib/resolve-config.js.map +1 -1
  108. package/lib/secure-session.js +50 -8
  109. package/lib/secure-session.js.map +1 -1
  110. package/lib/typert.host.js +599 -6
  111. package/lib/typert.remote-client.d.ts +19 -3
  112. package/lib/typert.remote-client.js +471 -3
  113. package/lib/types/accounts/index.d.ts +2 -0
  114. package/lib/types/accounts/store.d.ts +121 -0
  115. package/lib/types/attestation.d.ts +1 -1
  116. package/lib/types/cli.d.ts +9 -0
  117. package/lib/types/client/QaAccountsController.d.ts +95 -0
  118. package/lib/types/client/QaConfigController.d.ts +1 -1
  119. package/lib/types/client/QaSessionController.d.ts +46 -7
  120. package/lib/types/client/QaSurface.d.ts +8 -3
  121. package/lib/types/client/QaTranscriptAdapter.d.ts +5 -10
  122. package/lib/types/client/components/Markdown.d.ts +11 -1
  123. package/lib/types/client/components/QaAgentsDrawer.d.ts +1 -1
  124. package/lib/types/client/components/QaAuthGate.d.ts +15 -0
  125. package/lib/types/client/components/QaChangelog.d.ts +21 -0
  126. package/lib/types/client/components/QaMessage.d.ts +10 -3
  127. package/lib/types/client/components/QaSidebar.d.ts +30 -2
  128. package/lib/types/client/components/QaSourcesDrawer.d.ts +15 -3
  129. package/lib/types/client/components/QaTurnRail.d.ts +59 -0
  130. package/lib/types/client/components/QaWelcomeNotice.d.ts +27 -0
  131. package/lib/types/client/components/QaWidthHandle.d.ts +36 -0
  132. package/lib/types/client/components/source-refs.d.ts +16 -0
  133. package/lib/types/client/create-session.d.ts +16 -0
  134. package/lib/types/client/index.d.ts +1 -0
  135. package/lib/types/client/project-session-state.d.ts +30 -0
  136. package/lib/types/client/session-admission.d.ts +23 -0
  137. package/lib/types/client/session-assets.d.ts +20 -0
  138. package/lib/types/client/session-sources.d.ts +28 -0
  139. package/lib/types/client/settlement.d.ts +6 -0
  140. package/lib/types/client/tool-presentation.d.ts +6 -0
  141. package/lib/types/client/turn-sources.d.ts +14 -0
  142. package/lib/types/client/types.d.ts +73 -9
  143. package/lib/types/client/use-session-ui-state.d.ts +35 -0
  144. package/lib/types/client/use-transcript-view.d.ts +20 -0
  145. package/lib/types/entry-redirect.d.ts +20 -0
  146. package/lib/types/host-route.d.ts +14 -1
  147. package/lib/types/index.d.ts +47 -3
  148. package/lib/types/launch-token.d.ts +21 -0
  149. package/lib/types/provenance/collector.d.ts +26 -0
  150. package/lib/types/provenance/connectors.d.ts +11 -0
  151. package/lib/types/provenance/dedupe.d.ts +6 -0
  152. package/lib/types/provenance/extractors.d.ts +14 -0
  153. package/lib/types/provenance/file-preview.d.ts +10 -0
  154. package/lib/types/provenance/host-store.d.ts +54 -0
  155. package/lib/types/provenance/index.d.ts +10 -0
  156. package/lib/types/provenance/normalize.d.ts +13 -0
  157. package/lib/types/provenance/reported.d.ts +5 -0
  158. package/lib/types/provenance/types.d.ts +78 -0
  159. package/lib/types/secure-session.d.ts +11 -2
  160. package/lib/types/types.d.ts +167 -11
  161. package/package.json +58 -41
@@ -5,12 +5,17 @@ function bytesToBase64(bytes) {
5
5
  }
6
6
  return btoa(binary);
7
7
  }
8
- import { QaPolicyAttestationError, attestationHint, attestationReasonOf, proofMatchesConfig, } from "./attestation.js";
8
+ import { QaPolicyAttestationError } from "./attestation.js";
9
9
  import { QaChatIndex } from "./chat-index.js";
10
+ import { SessionAssetRepository } from "./session-assets.js";
11
+ import { createQaSession } from "./create-session.js";
12
+ import { attestQaPolicy } from "./session-admission.js";
13
+ import { QaHostSourceBridge } from "./session-sources.js";
10
14
  import { QA_SESSION_IDLE_STATE } from "./types.js";
11
15
  import { waitFor } from "./wait-for.js";
12
- import { projectSources } from "./QaTranscriptAdapter.js";
13
- import { projectTranscript, QA_REGENERATE_MARKER, } from "./QaTranscriptAdapter.js";
16
+ import { QA_REGENERATE_MARKER } from "./QaTranscriptAdapter.js";
17
+ import { projectBoundSessionState } from "./project-session-state.js";
18
+ import { projectTurnSources } from "./turn-sources.js";
14
19
  const CONFIGURATION_ERROR = "Настройки помощника недоступны.";
15
20
  /**
16
21
  * The only module that couples QA behavior to DSH Session/client APIs. It owns
@@ -21,15 +26,22 @@ export class QaSessionController {
21
26
  listeners = new Set();
22
27
  sessions;
23
28
  api;
29
+ conversation;
24
30
  connection;
25
31
  config;
26
32
  secureSessionRemote;
33
+ sourceApi;
27
34
  chats;
35
+ accounts;
36
+ /** Per-chat attachment URL cache; blob URLs die with the chat binding. */
37
+ assets = new SessionAssetRepository();
28
38
  timeoutMs;
29
39
  streamIntervalMs;
30
40
  state = QA_SESSION_IDLE_STATE;
31
41
  session;
42
+ conversationBinding;
32
43
  unsubscribeSession;
44
+ unsubscribeChat;
33
45
  unsubscribeConnection;
34
46
  ensuring;
35
47
  materializing;
@@ -45,13 +57,27 @@ export class QaSessionController {
45
57
  disposed = false;
46
58
  generation = 0;
47
59
  chatsRevision = 0;
60
+ /** Host-side provenance of the bound chat, merged into the projection. */
61
+ hostSources;
48
62
  constructor(options) {
49
63
  this.sessions = options.sessions;
50
64
  this.api = options.api;
65
+ this.conversation = options.conversation;
51
66
  this.connection = options.connection;
52
67
  this.config = options.config;
53
68
  this.secureSessionRemote = options.secureSession;
69
+ this.sourceApi =
70
+ options.sourceApi ??
71
+ {
72
+ sources: async () => ({ ok: true, value: [] }),
73
+ readSourceFile: async () => ({
74
+ ok: false,
75
+ error: "unavailable",
76
+ }),
77
+ };
54
78
  this.chats = new QaChatIndex(options.storage, `${options.config.session.storageKey}:v1:${options.config.route.path}`);
79
+ this.accounts = options.accounts;
80
+ this.hostSources = new QaHostSourceBridge(this.sourceApi, () => this.accounts?.token() ?? "");
55
81
  this.timeoutMs = options.timeoutMs ?? 15_000;
56
82
  this.streamIntervalMs = options.streamIntervalMs ?? 66;
57
83
  this.connectedOnce = this.connection.getSnapshot() !== undefined;
@@ -96,12 +122,27 @@ export class QaSessionController {
96
122
  }
97
123
  }
98
124
  this.operationError = null;
99
- let attested = await this.attestPolicy();
100
- if (!attested) {
101
- attested = await this.recoverForSend();
102
- if (!attested)
125
+ const step = await this.attestPolicy();
126
+ if (step.kind === "stale") {
127
+ // The user moved to another chat while the proof was pending; that
128
+ // binding attests through its own flow and must not receive a prompt
129
+ // meant for the old one.
130
+ return false;
131
+ }
132
+ let attestedId = step.kind === "ok" ? step.sessionId : null;
133
+ if (attestedId === null) {
134
+ attestedId = await this.recoverForSend();
135
+ if (attestedId === null)
103
136
  return false;
104
137
  }
138
+ // Nothing can interleave between the last await and here, so matching the
139
+ // attested id proves the prompt rides exactly the session it proved.
140
+ const target = this.session;
141
+ if (this.disposed ||
142
+ target === undefined ||
143
+ String(target.sessionId) !== attestedId) {
144
+ return false;
145
+ }
105
146
  this.admissionPending = true;
106
147
  this.publish();
107
148
  try {
@@ -114,7 +155,9 @@ export class QaSessionController {
114
155
  name: image.name,
115
156
  })),
116
157
  ];
117
- const result = await this.session.prompt(content, "queue");
158
+ const result = await target.prompt(content, "queue");
159
+ if (this.session !== target)
160
+ return false;
118
161
  if (!result.ok) {
119
162
  this.admissionPending = false;
120
163
  this.operationError = "Не удалось отправить сообщение.";
@@ -127,8 +170,10 @@ export class QaSessionController {
127
170
  catch (error) {
128
171
  this.admissionPending = false;
129
172
  console.error("dsh-qa-surface: prompt failed", error);
130
- this.operationError = "Не удалось отправить сообщение.";
131
- this.publish();
173
+ if (this.session === target) {
174
+ this.operationError = "Не удалось отправить сообщение.";
175
+ this.publish();
176
+ }
132
177
  return false;
133
178
  }
134
179
  }
@@ -143,15 +188,20 @@ export class QaSessionController {
143
188
  return this.send(QA_REGENERATE_MARKER);
144
189
  }
145
190
  async stop() {
146
- if (this.session === undefined || !this.state.canStop)
191
+ const target = this.session;
192
+ if (target === undefined || !this.state.canStop)
147
193
  return;
148
194
  try {
149
- const result = await this.session.cancel();
195
+ const result = await target.cancel();
196
+ if (this.session !== target)
197
+ return;
150
198
  if (!result.ok)
151
199
  this.operationError = "Не удалось остановить ответ.";
152
200
  }
153
201
  catch (error) {
154
202
  console.error("dsh-qa-surface: stop failed", error);
203
+ if (this.session !== target)
204
+ return;
155
205
  this.operationError = "Не удалось остановить ответ.";
156
206
  }
157
207
  this.publish();
@@ -200,7 +250,11 @@ export class QaSessionController {
200
250
  const attempt = (async () => {
201
251
  try {
202
252
  await this.waitForConnection();
203
- const id = await this.createSession();
253
+ const id = await createQaSession({
254
+ sessions: this.sessions,
255
+ api: this.api,
256
+ config: this.config,
257
+ });
204
258
  if (this.disposed || operation !== this.generation)
205
259
  return false;
206
260
  await this.bind(id);
@@ -213,6 +267,8 @@ export class QaSessionController {
213
267
  return true;
214
268
  }
215
269
  catch (error) {
270
+ if (this.disposed || operation !== this.generation)
271
+ return false;
216
272
  this.fail(this.operationError === CONFIGURATION_ERROR
217
273
  ? this.operationError
218
274
  : "Не удалось начать чат.", error);
@@ -267,6 +323,9 @@ export class QaSessionController {
267
323
  this.chats.saveActive(sessionId);
268
324
  }
269
325
  catch (error) {
326
+ // A newer operation superseded this switch: its own outcome governs.
327
+ if (this.disposed || operation !== this.generation)
328
+ return;
270
329
  this.fail(this.operationError === CONFIGURATION_ERROR
271
330
  ? this.operationError
272
331
  : "Не удалось открыть этот чат.", error);
@@ -305,6 +364,9 @@ export class QaSessionController {
305
364
  await this.bind(id, { attest: false, track: false, report: false });
306
365
  }
307
366
  catch (error) {
367
+ // A newer operation superseded this view request: leave its state alone.
368
+ if (this.disposed || operation !== this.generation)
369
+ return;
308
370
  this.viewingSubagent = null;
309
371
  this.fail("Не удалось открыть транскрипт субагента.", error);
310
372
  }
@@ -318,9 +380,14 @@ export class QaSessionController {
318
380
  return;
319
381
  await this.switchTo(chat);
320
382
  }
321
- /** This browser's indexed chat ids, most recently used first. */
383
+ /** This browser's chat ids: the owned list when accounts are on, else the
384
+ * local index. Both intersect the host session list at projection time. */
322
385
  chatIds() {
323
- return this.chats.chatIds();
386
+ const owned = this.accounts?.ownedIds() ?? [];
387
+ if (owned.length === 0)
388
+ return this.chats.chatIds();
389
+ const local = this.chats.chatIds();
390
+ return [...new Set([...owned, ...local])];
324
391
  }
325
392
  /** The bound session id, or null while no chat is bound. */
326
393
  activeSessionId() {
@@ -328,12 +395,21 @@ export class QaSessionController {
328
395
  }
329
396
  /**
330
397
  * Resolve one durable image attachment of the bound session into a
331
- * browser-usable URL. The caller caches; failures surface as broken views.
398
+ * browser-usable URL. Resolutions are cached per chat in the asset
399
+ * repository (attachment ids repeat across chats) and revoked when the chat
400
+ * is unbound; failures surface as broken views and retry on demand.
332
401
  */
333
402
  async readImage(attachmentId) {
334
403
  if (this.session === undefined)
335
404
  throw new Error("no bound session");
336
- const result = await this.session.readAttachment(attachmentId);
405
+ const sessionId = String(this.session.sessionId);
406
+ return this.assets.resolve(sessionId, attachmentId, (id) => this.readAttachmentUrl(id));
407
+ }
408
+ async readAttachmentUrl(attachmentId) {
409
+ const session = this.session;
410
+ if (session === undefined)
411
+ throw new Error("no bound session");
412
+ const result = await session.readAttachment(attachmentId);
337
413
  if (!result.ok) {
338
414
  throw new Error(`${result.error.code}: ${result.error.message}`);
339
415
  }
@@ -377,6 +453,7 @@ export class QaSessionController {
377
453
  this.clearStreamFlush();
378
454
  this.unsubscribeConnection();
379
455
  this.unbind();
456
+ this.assets.dispose();
380
457
  this.listeners.clear();
381
458
  }
382
459
  async ensureSessionNow() {
@@ -410,7 +487,11 @@ export class QaSessionController {
410
487
  }
411
488
  }
412
489
  if (id === null) {
413
- id = await this.createSession();
490
+ id = await createQaSession({
491
+ sessions: this.sessions,
492
+ api: this.api,
493
+ config: this.config,
494
+ });
414
495
  }
415
496
  if (this.disposed || operation !== this.generation)
416
497
  return;
@@ -421,6 +502,10 @@ export class QaSessionController {
421
502
  await this.bind(id, { report: !restored });
422
503
  }
423
504
  catch (error) {
505
+ // A stale restore must not start its recovery ladder against a newer
506
+ // operation; rethrow for the generation-guarded outer catch.
507
+ if (this.disposed || operation !== this.generation)
508
+ throw error;
424
509
  if (!restored ||
425
510
  this.config.session.policy !== "browser-persistent" ||
426
511
  !(error instanceof QaPolicyAttestationError)) {
@@ -432,7 +517,11 @@ export class QaSessionController {
432
517
  this.unbind();
433
518
  this.operationError = null;
434
519
  this.chats.clearActive();
435
- id = await this.createSession();
520
+ id = await createQaSession({
521
+ sessions: this.sessions,
522
+ api: this.api,
523
+ config: this.config,
524
+ });
436
525
  if (this.disposed || operation !== this.generation)
437
526
  return;
438
527
  await this.bind(id);
@@ -444,6 +533,9 @@ export class QaSessionController {
444
533
  }
445
534
  }
446
535
  catch (error) {
536
+ // A newer operation superseded this bootstrap: its own outcome governs.
537
+ if (this.disposed || operation !== this.generation)
538
+ return;
447
539
  this.fail(this.operationError === CONFIGURATION_ERROR
448
540
  ? this.operationError
449
541
  : "Не удалось начать чат.", error);
@@ -456,14 +548,15 @@ export class QaSessionController {
456
548
  * re-opening re-materializes it. Step 2 — when that refused session is
457
549
  * still blank (it never received the prompt, so there is nothing to lose) —
458
550
  * starts a fresh attested session instead. A session with history is left
459
- * alone: recreating it would silently drop the conversation.
551
+ * alone: recreating it would silently drop the conversation. Returns the
552
+ * attested session id, or null when nothing was attested.
460
553
  */
461
554
  async recoverForSend() {
462
555
  const id = this.session === undefined ? null : String(this.session.sessionId);
463
556
  if (id === null ||
464
557
  this.disposed ||
465
558
  this.config.session.policy === "fixed") {
466
- return false;
559
+ return null;
467
560
  }
468
561
  const operation = ++this.generation;
469
562
  this.unbind();
@@ -476,72 +569,44 @@ export class QaSessionController {
476
569
  try {
477
570
  await this.bind(id);
478
571
  if (this.disposed || operation !== this.generation)
479
- return false;
572
+ return null;
480
573
  if (this.policyReady)
481
- return true;
574
+ return id;
482
575
  }
483
576
  catch (error) {
484
577
  if (!(error instanceof QaPolicyAttestationError))
485
578
  throw error;
486
579
  if (this.disposed || operation !== this.generation)
487
- return false;
580
+ return null;
488
581
  }
489
582
  if (this.session !== undefined && !this.session.getSnapshot().blank) {
490
583
  this.fail(CONFIGURATION_ERROR, new QaPolicyAttestationError());
491
- return false;
584
+ return null;
492
585
  }
493
586
  this.unbind();
494
587
  this.chats.clearActive();
495
- const created = await this.createSession();
588
+ const created = await createQaSession({
589
+ sessions: this.sessions,
590
+ api: this.api,
591
+ config: this.config,
592
+ });
496
593
  if (this.disposed || operation !== this.generation)
497
- return false;
594
+ return null;
498
595
  await this.bind(created);
499
596
  if (this.disposed || operation !== this.generation)
500
- return false;
597
+ return null;
501
598
  this.chats.saveActive(created);
502
- return this.policyReady;
599
+ return this.policyReady ? created : null;
503
600
  }
504
601
  catch (error) {
602
+ if (this.disposed || operation !== this.generation)
603
+ return null;
505
604
  this.fail(this.operationError === CONFIGURATION_ERROR
506
605
  ? this.operationError
507
606
  : "Не удалось открыть этот чат.", error);
508
- return false;
607
+ return null;
509
608
  }
510
609
  }
511
- async createSession() {
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
- : {}),
518
- });
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
- }
530
- if (this.config.session.provider !== null &&
531
- this.config.session.model !== null) {
532
- const selected = await this.api.selectModel({
533
- sessionId: id,
534
- provider: this.config.session.provider,
535
- model: this.config.session.model,
536
- ...(this.config.session.reasoningEffort === null
537
- ? {}
538
- : { reasoningEffort: this.config.session.reasoningEffort }),
539
- });
540
- if (!selected.result.ok)
541
- throw new Error(selected.result.error.code);
542
- }
543
- return id;
544
- }
545
610
  async bind(id, options = {}) {
546
611
  const { report = true, attest = true, track = true } = options;
547
612
  this.sessions.open(id);
@@ -554,23 +619,34 @@ export class QaSessionController {
554
619
  throw new Error("Session binding is unavailable.");
555
620
  this.unbind();
556
621
  this.session = binding.session;
622
+ this.conversationBinding = this.conversation.binding(id);
557
623
  this.policyReady = false;
558
624
  this.unsubscribeSession = binding.session.subscribe(() => {
559
625
  this.admissionPending = false;
560
626
  this.operationError = null;
561
627
  this.publishSessionUpdate();
562
628
  });
629
+ // Subscribing the Chat target activates it, so the assembled transcript
630
+ // (nodes, partials, running calls) materializes for the projection.
631
+ this.unsubscribeChat = this.conversationBinding
632
+ .target("chat")
633
+ .subscribe(() => {
634
+ this.publishSessionUpdate();
635
+ });
563
636
  await waitFor(binding.session, (snapshot) => snapshot.openState === "open" || snapshot.openState === "error", this.timeoutMs);
564
637
  if (binding.session.getSnapshot().openState !== "open") {
565
638
  throw new Error("Session binding could not be opened.");
566
639
  }
567
- if (attest && !(await this.attestPolicy(report))) {
568
- throw new QaPolicyAttestationError();
640
+ if (attest) {
641
+ const step = await this.attestPolicy(report);
642
+ if (step.kind !== "ok")
643
+ throw new QaPolicyAttestationError();
569
644
  }
570
645
  if (track) {
571
646
  this.chatSessionId = String(this.session.sessionId);
572
647
  this.viewingSubagent = null;
573
648
  this.chats.addChat(String(this.session.sessionId));
649
+ this.accounts?.onSessionCreated(String(this.session.sessionId));
574
650
  }
575
651
  this.publish();
576
652
  }
@@ -578,7 +654,16 @@ export class QaSessionController {
578
654
  this.clearStreamFlush();
579
655
  this.unsubscribeSession?.();
580
656
  this.unsubscribeSession = undefined;
657
+ this.unsubscribeChat?.();
658
+ this.unsubscribeChat = undefined;
659
+ this.conversationBinding = undefined;
660
+ if (this.session !== undefined) {
661
+ // Leaving the chat retires its attachment URLs; a later re-bind
662
+ // re-resolves them from the durable store.
663
+ this.assets.release(String(this.session.sessionId));
664
+ }
581
665
  this.session = undefined;
666
+ this.hostSources.reset();
582
667
  this.admissionPending = false;
583
668
  this.policyReady = false;
584
669
  }
@@ -646,38 +731,29 @@ export class QaSessionController {
646
731
  return;
647
732
  }
648
733
  const snapshot = this.session.getSnapshot();
649
- const blocked = snapshot.pending.length > 0;
650
- const error = this.operationError ??
651
- (blocked
652
- ? "Для этого запроса нужно действие, недоступное в режиме помощника."
653
- : snapshot.removed || snapshot.openState === "error"
654
- ? "Этот чат больше недоступен."
655
- : null);
656
- const phase = !connected
657
- ? "reconnecting"
658
- : blocked
659
- ? "blocked"
660
- : snapshot.removed || snapshot.openState === "error"
661
- ? "error"
662
- : snapshot.openState !== "open"
663
- ? "creating"
664
- : snapshot.running || this.admissionPending
665
- ? "running"
666
- : "ready";
667
- this.state = {
668
- phase,
669
- sessionId: String(this.session.sessionId),
670
- messages: projectTranscript(snapshot, {
671
- showToolActivity: this.config.ui.showToolActivity,
672
- showReasoning: this.config.ui.showReasoning,
673
- }),
674
- error,
675
- canSend: connected && phase === "ready" && this.policyReady,
676
- canStop: connected && snapshot.running && this.config.ui.showStop && !blocked,
734
+ const sessionId = String(this.session.sessionId);
735
+ const conversationSnapshot = this.conversationBinding?.snapshot.getSnapshot();
736
+ const projectedSourceBundles = projectTurnSources(conversationSnapshot, sessionId, this.config.session.cwd ?? undefined);
737
+ const sourceBundles = this.hostSources.merge(projectedSourceBundles);
738
+ if (!snapshot.running) {
739
+ void this.hostSources.refresh(sessionId, () => this.publish());
740
+ }
741
+ this.state = projectBoundSessionState({
742
+ connected,
743
+ sessionId,
744
+ sessionSnapshot: snapshot,
745
+ conversationSnapshot,
746
+ sourceBundles,
747
+ // Ownership is chat-level: every user message of a foreign chat
748
+ // carries its owner's name when an admin reads it.
749
+ author: this.accounts?.messageAuthorOf(sessionId),
750
+ operationError: this.operationError,
751
+ policyReady: this.policyReady,
752
+ admissionPending: this.admissionPending,
677
753
  chatsRevision: this.chatsRevision,
678
- sources: projectSources(snapshot),
679
754
  viewingSubagent: this.viewingSubagent,
680
- };
755
+ config: this.config,
756
+ });
681
757
  this.emit();
682
758
  }
683
759
  fail(message, error) {
@@ -697,48 +773,48 @@ export class QaSessionController {
697
773
  };
698
774
  this.emit();
699
775
  }
776
+ /**
777
+ * Attest the currently bound session. `ok` carries the session id the proof
778
+ * was issued for; `stale` means the binding changed while the request was
779
+ * in flight, so nothing was written — the newer binding manages its own
780
+ * attestation; `refused` is a genuine admission refusal of this binding.
781
+ */
700
782
  async attestPolicy(reportFailure = true) {
701
- if (this.session === undefined)
702
- return false;
783
+ const session = this.session;
784
+ if (session === undefined)
785
+ return { kind: "refused" };
786
+ const sessionId = String(session.sessionId);
703
787
  if (!this.config.lockdown.enabled) {
704
788
  this.policyReady = true;
705
- return true;
789
+ return { kind: "ok", sessionId };
706
790
  }
707
791
  this.policyReady = false;
708
792
  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
- };
720
- try {
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
- }
793
+ const outcome = await attestQaPolicy({
794
+ secureSession: this.secureSessionRemote,
795
+ token: this.accounts?.token() ?? "",
796
+ sessionId,
797
+ lockdown: this.config.lockdown,
798
+ report: reportFailure,
799
+ });
800
+ if (this.session !== session)
801
+ return { kind: "stale" };
802
+ if (outcome.ok) {
728
803
  this.policyReady = true;
729
804
  this.operationError = null;
730
805
  this.publish();
731
- return true;
806
+ return { kind: "ok", sessionId };
732
807
  }
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;
808
+ if (outcome.authRequired) {
809
+ // The identity expired or was rotated: back to the gate instead of
810
+ // the generic configuration error.
811
+ this.accounts?.onAuthRequired();
812
+ return { kind: "refused" };
741
813
  }
814
+ this.policyReady = false;
815
+ this.operationError = CONFIGURATION_ERROR;
816
+ this.publish();
817
+ return { kind: "refused" };
742
818
  }
743
819
  emit() {
744
820
  for (const listener of this.listeners)