@yadsh/dsh-qa-surface 0.7.4 → 0.9.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 (247) hide show
  1. package/README.md +169 -12
  2. package/compatibility.json +5 -0
  3. package/lib/access/capability-catalog.js +32 -3
  4. package/lib/access/capability-catalog.js.map +1 -1
  5. package/lib/access/model.js +24 -4
  6. package/lib/access/model.js.map +1 -1
  7. package/lib/access/role-repository.js +199 -91
  8. package/lib/access/role-repository.js.map +1 -1
  9. package/lib/access/service.js +223 -7
  10. package/lib/access/service.js.map +1 -1
  11. package/lib/account-remotes.js +2 -4
  12. package/lib/account-remotes.js.map +1 -1
  13. package/lib/accounts/database.js +447 -0
  14. package/lib/accounts/database.js.map +1 -0
  15. package/lib/accounts/errors.js.map +1 -1
  16. package/lib/accounts/file.js +27 -90
  17. package/lib/accounts/file.js.map +1 -1
  18. package/lib/accounts/store.js +224 -59
  19. package/lib/accounts/store.js.map +1 -1
  20. package/lib/admin/permissions.js +4 -0
  21. package/lib/admin/permissions.js.map +1 -1
  22. package/lib/admin/quality-store.js +270 -54
  23. package/lib/admin/quality-store.js.map +1 -1
  24. package/lib/admin/service.js +145 -7
  25. package/lib/admin/service.js.map +1 -1
  26. package/lib/admin/session-log.js +66 -5
  27. package/lib/admin/session-log.js.map +1 -1
  28. package/lib/cli.js +2 -1
  29. package/lib/cli.js.map +1 -1
  30. package/lib/client/QaSessionController.js +379 -39
  31. package/lib/client/QaSessionController.js.map +1 -1
  32. package/lib/client/QaSurface.js +94 -34
  33. package/lib/client/QaSurface.js.map +1 -1
  34. package/lib/client/QaTranscriptAdapter.js +40 -0
  35. package/lib/client/QaTranscriptAdapter.js.map +1 -1
  36. package/lib/client/admin/QaAdmin.js +26 -8
  37. package/lib/client/admin/QaAdmin.js.map +1 -1
  38. package/lib/client/admin/{format.js → copy.js} +1 -1
  39. package/lib/client/admin/copy.js.map +1 -0
  40. package/lib/client/admin/pages/Conversations.js +34 -8
  41. package/lib/client/admin/pages/Conversations.js.map +1 -1
  42. package/lib/client/admin/pages/Overview.js +1 -1
  43. package/lib/client/admin/pages/Overview.js.map +1 -1
  44. package/lib/client/admin/pages/Quality.js +1 -1
  45. package/lib/client/admin/pages/Quality.js.map +1 -1
  46. package/lib/client/admin/pages/Review.js +1 -1
  47. package/lib/client/admin/pages/Review.js.map +1 -1
  48. package/lib/client/admin/pages/Users.js +6 -2
  49. package/lib/client/admin/pages/Users.js.map +1 -1
  50. package/lib/client/admin/shared.js +10 -1
  51. package/lib/client/admin/shared.js.map +1 -1
  52. package/lib/client/admin/styles.js +2 -0
  53. package/lib/client/admin/styles.js.map +1 -1
  54. package/lib/client/audit/QaAuditDialog.js +74 -0
  55. package/lib/client/audit/QaAuditDialog.js.map +1 -0
  56. package/lib/client/audit/QaAuditRowBadge.js +17 -0
  57. package/lib/client/audit/QaAuditRowBadge.js.map +1 -0
  58. package/lib/client/audit/controller.js +39 -0
  59. package/lib/client/audit/controller.js.map +1 -0
  60. package/lib/client/audit/types.js +36 -0
  61. package/lib/client/audit/types.js.map +1 -0
  62. package/lib/client/audit/use-chat-audits.js +139 -0
  63. package/lib/client/audit/use-chat-audits.js.map +1 -0
  64. package/lib/client/components/Markdown.js +14 -206
  65. package/lib/client/components/Markdown.js.map +1 -1
  66. package/lib/client/components/QaChangelog.js +91 -0
  67. package/lib/client/components/QaChangelog.js.map +1 -1
  68. package/lib/client/components/QaComposer.js +63 -3
  69. package/lib/client/components/QaComposer.js.map +1 -1
  70. package/lib/client/components/QaHeader.js +1 -3
  71. package/lib/client/components/QaHeader.js.map +1 -1
  72. package/lib/client/components/QaMessage.js +25 -4
  73. package/lib/client/components/QaMessage.js.map +1 -1
  74. package/lib/client/components/QaQuestions.js +1 -1
  75. package/lib/client/components/QaQuestions.js.map +1 -1
  76. package/lib/client/components/QaSidebar.js +17 -3
  77. package/lib/client/components/QaSidebar.js.map +1 -1
  78. package/lib/client/components/QaSourcesPanel.js +3 -18
  79. package/lib/client/components/QaSourcesPanel.js.map +1 -1
  80. package/lib/client/components/QaWorkGroup.js +4 -8
  81. package/lib/client/components/QaWorkGroup.js.map +1 -1
  82. package/lib/client/components/source-icon.js +26 -0
  83. package/lib/client/components/source-icon.js.map +1 -0
  84. package/lib/client/components/thinking-phrases.js +2 -11
  85. package/lib/client/components/thinking-phrases.js.map +1 -1
  86. package/lib/client/components/use-now.js +21 -0
  87. package/lib/client/components/use-now.js.map +1 -0
  88. package/lib/client/index.js +33 -5
  89. package/lib/client/index.js.map +1 -1
  90. package/lib/client/lineage.js +18 -0
  91. package/lib/client/lineage.js.map +1 -0
  92. package/lib/client/markdown/CodeBlock.js +28 -0
  93. package/lib/client/markdown/CodeBlock.js.map +1 -0
  94. package/lib/client/markdown/blocks.js +342 -0
  95. package/lib/client/markdown/blocks.js.map +1 -0
  96. package/lib/client/markdown/highlight.js +1089 -0
  97. package/lib/client/markdown/highlight.js.map +1 -0
  98. package/lib/client/markdown/inline.js +377 -0
  99. package/lib/client/markdown/inline.js.map +1 -0
  100. package/lib/client/markdown/render.js +147 -0
  101. package/lib/client/markdown/render.js.map +1 -0
  102. package/lib/client/markdown/source-chip.js +55 -0
  103. package/lib/client/markdown/source-chip.js.map +1 -0
  104. package/lib/client/markdown/use-transient-flag.js +27 -0
  105. package/lib/client/markdown/use-transient-flag.js.map +1 -0
  106. package/lib/client/project-session-state.js +15 -2
  107. package/lib/client/project-session-state.js.map +1 -1
  108. package/lib/client/prompt-content.js +26 -0
  109. package/lib/client/prompt-content.js.map +1 -1
  110. package/lib/client/role/RoleSelector.js +11 -2
  111. package/lib/client/role/RoleSelector.js.map +1 -1
  112. package/lib/client/role/preview.js +123 -0
  113. package/lib/client/role/preview.js.map +1 -0
  114. package/lib/client/settings/card.js +2 -2
  115. package/lib/client/settings/card.js.map +1 -1
  116. package/lib/client/settings/sections/lockdown.js +1 -6
  117. package/lib/client/settings/sections/lockdown.js.map +1 -1
  118. package/lib/client/settings/sections/slash.js +57 -0
  119. package/lib/client/settings/sections/slash.js.map +1 -0
  120. package/lib/client/settings/sections/status.js +9 -1
  121. package/lib/client/settings/sections/status.js.map +1 -1
  122. package/lib/client/settings/sections.js +1 -0
  123. package/lib/client/settings/sections.js.map +1 -1
  124. package/lib/client/slash/SlashPalette.js +48 -0
  125. package/lib/client/slash/SlashPalette.js.map +1 -0
  126. package/lib/client/slash/copy.js +54 -0
  127. package/lib/client/slash/copy.js.map +1 -0
  128. package/lib/client/slash/slash-router.js +49 -0
  129. package/lib/client/slash/slash-router.js.map +1 -0
  130. package/lib/client/slash/useSlashNavigation.js +95 -0
  131. package/lib/client/slash/useSlashNavigation.js.map +1 -0
  132. package/lib/client/styles.js +124 -20
  133. package/lib/client/styles.js.map +1 -1
  134. package/lib/client/types.js +11 -0
  135. package/lib/client/types.js.map +1 -1
  136. package/lib/client.js +7615 -1567
  137. package/lib/client.js.map +1 -1
  138. package/lib/config-resolvers/accounts.js +15 -0
  139. package/lib/config-resolvers/accounts.js.map +1 -1
  140. package/lib/config-resolvers/basics.js +11 -2
  141. package/lib/config-resolvers/basics.js.map +1 -1
  142. package/lib/config-resolvers/defaults.js +30 -0
  143. package/lib/config-resolvers/defaults.js.map +1 -1
  144. package/lib/config-resolvers/lockdown.js +8 -2
  145. package/lib/config-resolvers/lockdown.js.map +1 -1
  146. package/lib/config-resolvers/slash-commands.js +60 -0
  147. package/lib/config-resolvers/slash-commands.js.map +1 -0
  148. package/lib/config-resolvers/sources.js +16 -0
  149. package/lib/config-resolvers/sources.js.map +1 -1
  150. package/lib/config.js +118 -2
  151. package/lib/config.js.map +1 -1
  152. package/lib/index.js +183 -11
  153. package/lib/index.js.map +1 -1
  154. package/lib/lockdown-policy.js +19 -0
  155. package/lib/lockdown-policy.js.map +1 -1
  156. package/lib/personal-skills/service.js +16 -3
  157. package/lib/personal-skills/service.js.map +1 -1
  158. package/lib/provenance/host-store.js +25 -4
  159. package/lib/provenance/host-store.js.map +1 -1
  160. package/lib/provenance/retention.js +25 -0
  161. package/lib/provenance/retention.js.map +1 -0
  162. package/lib/provenance/snapshot-store.js +290 -35
  163. package/lib/provenance/snapshot-store.js.map +1 -1
  164. package/lib/questions.js +98 -38
  165. package/lib/questions.js.map +1 -1
  166. package/lib/resolve-config.js +5 -0
  167. package/lib/resolve-config.js.map +1 -1
  168. package/lib/secure-session.js +139 -21
  169. package/lib/secure-session.js.map +1 -1
  170. package/lib/slash/catalog.js +77 -0
  171. package/lib/slash/catalog.js.map +1 -0
  172. package/lib/slash/matcher.js +100 -0
  173. package/lib/slash/matcher.js.map +1 -0
  174. package/lib/slash/parser.js +79 -0
  175. package/lib/slash/parser.js.map +1 -0
  176. package/lib/slash/policy.js +72 -0
  177. package/lib/slash/policy.js.map +1 -0
  178. package/lib/slash/remotes.js +272 -0
  179. package/lib/slash/remotes.js.map +1 -0
  180. package/lib/typert.host.js +348 -56
  181. package/lib/typert.remote-client.d.ts +7 -1
  182. package/lib/typert.remote-client.js +274 -51
  183. package/lib/types/access/capability-catalog.d.ts +28 -1
  184. package/lib/types/access/role-repository.d.ts +38 -7
  185. package/lib/types/access/service.d.ts +106 -3
  186. package/lib/types/account-remotes.d.ts +8 -4
  187. package/lib/types/accounts/database.d.ts +84 -0
  188. package/lib/types/accounts/errors.d.ts +7 -1
  189. package/lib/types/accounts/file.d.ts +17 -39
  190. package/lib/types/accounts/store.d.ts +110 -19
  191. package/lib/types/admin/quality-store.d.ts +38 -2
  192. package/lib/types/admin/service.d.ts +34 -2
  193. package/lib/types/admin/session-log.d.ts +65 -1
  194. package/lib/types/client/QaSessionController.d.ts +84 -2
  195. package/lib/types/client/QaSurface.d.ts +14 -1
  196. package/lib/types/client/admin/{format.d.ts → copy.d.ts} +1 -1
  197. package/lib/types/client/admin/pages/Conversations.d.ts +4 -0
  198. package/lib/types/client/audit/QaAuditDialog.d.ts +21 -0
  199. package/lib/types/client/audit/QaAuditRowBadge.d.ts +18 -0
  200. package/lib/types/client/audit/controller.d.ts +27 -0
  201. package/lib/types/client/audit/types.d.ts +72 -0
  202. package/lib/types/client/audit/use-chat-audits.d.ts +20 -0
  203. package/lib/types/client/components/Markdown.d.ts +7 -8
  204. package/lib/types/client/components/QaComposer.d.ts +12 -2
  205. package/lib/types/client/components/QaQuestions.d.ts +7 -0
  206. package/lib/types/client/components/QaSidebar.d.ts +11 -1
  207. package/lib/types/client/components/QaSourcesPanel.d.ts +1 -3
  208. package/lib/types/client/components/source-icon.d.ts +12 -0
  209. package/lib/types/client/components/use-now.d.ts +8 -0
  210. package/lib/types/client/lineage.d.ts +15 -0
  211. package/lib/types/client/markdown/CodeBlock.d.ts +14 -0
  212. package/lib/types/client/markdown/blocks.d.ts +74 -0
  213. package/lib/types/client/markdown/highlight.d.ts +29 -0
  214. package/lib/types/client/markdown/inline.d.ts +40 -0
  215. package/lib/types/client/markdown/render.d.ts +13 -0
  216. package/lib/types/client/markdown/source-chip.d.ts +32 -0
  217. package/lib/types/client/markdown/use-transient-flag.d.ts +12 -0
  218. package/lib/types/client/project-session-state.d.ts +3 -1
  219. package/lib/types/client/prompt-content.d.ts +9 -1
  220. package/lib/types/client/role/RoleSelector.d.ts +11 -1
  221. package/lib/types/client/role/preview.d.ts +109 -0
  222. package/lib/types/client/settings/sections/slash.d.ts +16 -0
  223. package/lib/types/client/settings/sections.d.ts +1 -0
  224. package/lib/types/client/slash/SlashPalette.d.ts +24 -0
  225. package/lib/types/client/slash/copy.d.ts +30 -0
  226. package/lib/types/client/slash/slash-router.d.ts +67 -0
  227. package/lib/types/client/slash/useSlashNavigation.d.ts +47 -0
  228. package/lib/types/client/styles.d.ts +9 -0
  229. package/lib/types/client/types.d.ts +16 -1
  230. package/lib/types/config-resolvers/slash-commands.d.ts +20 -0
  231. package/lib/types/index.d.ts +83 -2
  232. package/lib/types/lockdown-policy.d.ts +16 -0
  233. package/lib/types/personal-skills/service.d.ts +5 -0
  234. package/lib/types/provenance/host-store.d.ts +20 -3
  235. package/lib/types/provenance/retention.d.ts +46 -0
  236. package/lib/types/provenance/snapshot-store.d.ts +63 -7
  237. package/lib/types/questions.d.ts +13 -0
  238. package/lib/types/secure-session.d.ts +27 -1
  239. package/lib/types/slash/catalog.d.ts +56 -0
  240. package/lib/types/slash/matcher.d.ts +51 -0
  241. package/lib/types/slash/parser.d.ts +46 -0
  242. package/lib/types/slash/policy.d.ts +40 -0
  243. package/lib/types/slash/remotes.d.ts +35 -0
  244. package/lib/types/types.d.ts +254 -5
  245. package/package.json +9 -5
  246. package/scripts/attach-workspace-sessions.mjs +472 -0
  247. package/lib/client/admin/format.js.map +0 -1
@@ -1,10 +1,13 @@
1
1
  import { bytesToBase64 } from "./base64.js";
2
2
  import { qaStorageNamespace } from "../shared/session-key.js";
3
+ import { resolveSlashRoute } from "./slash/slash-router.js";
4
+ import { SLASH_DISABLED_COPY, slashAttachmentsCopy, slashExecuteFailureCopy, slashHostRefusalCopy, slashRefusalCopy, slashWithheldCopy, } from "./slash/copy.js";
3
5
  import { canOpenAsCompatibilityReadOnly, QaPolicyAttestationError, } from "./attestation.js";
4
6
  import { QaChatIndex } from "./chat-index.js";
7
+ import { isDelegatedSession } from "./lineage.js";
5
8
  import { SessionAssetRepository } from "./session-assets.js";
6
9
  import { createQaSession } from "./create-session.js";
7
- import { buildQaPromptContent, stageQaFiles } from "./prompt-content.js";
10
+ import { buildQaPromptContent, buildQaSlashAttachments, stageQaFiles, } from "./prompt-content.js";
8
11
  import { attestQaPolicy } from "./session-admission.js";
9
12
  import { QaHostSourceBridge } from "./session-sources.js";
10
13
  import { QaHostApprovalBridge } from "./approvals.js";
@@ -17,6 +20,13 @@ import { readableSubagentName } from "./settlement.js";
17
20
  import { projectBoundSessionState } from "./project-session-state.js";
18
21
  import { projectTurnSources } from "./turn-sources.js";
19
22
  const CONFIGURATION_ERROR = "Настройки помощника недоступны.";
23
+ /**
24
+ * How long a catalog answer stays fresh enough to skip a re-read when the
25
+ * palette opens. Short on purpose: the check is one round-trip and a stale
26
+ * palette shows a skill that was deleted or hides one that was just added.
27
+ * No polling loop — the palette opening is the only trigger.
28
+ */
29
+ const SLASH_CATALOG_STALE_MS = 3_000;
20
30
  /**
21
31
  * Spacing of the parked-request poll. A request is answered by a person, so a
22
32
  * second of latency is invisible; the poll only runs while a turn runs.
@@ -38,6 +48,8 @@ export class QaSessionController {
38
48
  sourceApi;
39
49
  chats;
40
50
  accounts;
51
+ /** Slash half of the Host namespace; absent on an older Host build. */
52
+ slashApi;
41
53
  /** Resolved per send: a page without the upload plugin has no receipts. */
42
54
  fileUpload;
43
55
  /** Per-chat attachment URL cache; blob URLs die with the chat binding. */
@@ -78,6 +90,25 @@ export class QaSessionController {
78
90
  hostQuestions;
79
91
  /** Set while a running turn is polled for parked requests. */
80
92
  pendingTimer;
93
+ /**
94
+ * Slash catalog of the bound chat. Keyed by session: it depends on the
95
+ * chat's cwd, its agent composition and its role, so it is dropped with the
96
+ * binding instead of being carried across chats.
97
+ */
98
+ slashSessionId = null;
99
+ slashEntries = [];
100
+ slashDenied = [];
101
+ slashSurface = "unavailable";
102
+ slashState = "idle";
103
+ slashError = null;
104
+ slashLoadedAt = 0;
105
+ slashLoading;
106
+ /** Last published view; its identity is the render signal. */
107
+ slashViewCache;
108
+ /** Bumped to re-open a palette the user dismissed; see `QaSlashView`. */
109
+ slashReopen = 0;
110
+ /** Chat, connection and admission state of the last one-shot probe. */
111
+ pendingProbeKey = "";
81
112
  constructor(options) {
82
113
  this.sessions = options.sessions;
83
114
  this.createSessionRemote = options.createSession;
@@ -96,6 +127,7 @@ export class QaSessionController {
96
127
  };
97
128
  this.chats = new QaChatIndex(options.storage, qaStorageNamespace(options.config));
98
129
  this.accounts = options.accounts;
130
+ this.slashApi = options.slashApi;
99
131
  this.selectedSubrole = options.initialSubrole ?? null;
100
132
  this.adminPreview = options.adminPreview === true;
101
133
  this.fileUpload = options.fileUpload ?? (() => undefined);
@@ -138,7 +170,52 @@ export class QaSessionController {
138
170
  this.adminPreview = adminPreview;
139
171
  await this.startDraft(true);
140
172
  }
141
- async send(text, attachments = []) {
173
+ /**
174
+ * The attested live session a submission may ride, or `undefined` when the
175
+ * caller must abort. Materializing a draft chat, attesting policy and
176
+ * recovering from a stale proof live here, so the prompt path and the
177
+ * command path cannot drift apart on what "ready to send" means.
178
+ */
179
+ async liveTarget() {
180
+ if (this.session === undefined) {
181
+ // A draft chat materializes its session only now: nothing was created
182
+ // when the user pressed "New chat", so the first submission pays for it.
183
+ if (!this.drafting || this.materializing !== undefined)
184
+ return undefined;
185
+ if (!(await this.materializeDraft()) || this.session === undefined) {
186
+ return undefined;
187
+ }
188
+ }
189
+ const step = await this.attestPolicy();
190
+ if (step.kind === "stale") {
191
+ // The user moved to another chat while the proof was pending; that
192
+ // binding attests through its own flow and must not receive a submission
193
+ // meant for the old one.
194
+ return undefined;
195
+ }
196
+ let attestedId = step.kind === "ok" ? step.sessionId : null;
197
+ if (attestedId === null) {
198
+ attestedId = await this.recoverForSend();
199
+ if (attestedId === null)
200
+ return undefined;
201
+ }
202
+ // Nothing can interleave between the last await and here, so matching the
203
+ // attested id proves the submission rides exactly the session it proved.
204
+ const target = this.session;
205
+ if (this.disposed ||
206
+ target === undefined ||
207
+ String(target.sessionId) !== attestedId) {
208
+ return undefined;
209
+ }
210
+ return target;
211
+ }
212
+ /**
213
+ * Route one composer draft. Ordinary text and a skill invocation both ride
214
+ * the native prompt path — a skill is a gesture inside the prompt, not a
215
+ * call this surface makes — while a human command goes to the Host's command
216
+ * runtime and never becomes a model message.
217
+ */
218
+ async send(text, attachments = [], pick = null) {
142
219
  const prompt = text.trim();
143
220
  // Images ride the prompt inline; files must be staged first, because the
144
221
  // wire carries a receipt rather than bytes.
@@ -146,8 +223,37 @@ export class QaSessionController {
146
223
  if ((prompt === "" && attachments.length === 0) || !this.state.canSend) {
147
224
  return false;
148
225
  }
149
- if (prompt.startsWith("/")) {
150
- this.operationError = "Команды со слешем недоступны в режиме помощника.";
226
+ const route = resolveSlashRoute({
227
+ text: prompt,
228
+ enabled: this.config.slashCommands.enabled,
229
+ catalogReady: this.slashCatalogReady(),
230
+ entries: this.slashEntries,
231
+ deniedSkills: this.slashDenied,
232
+ picked: pick,
233
+ });
234
+ if (route.kind === "command") {
235
+ return this.sendCommand(prompt, attachments, files, route.entry);
236
+ }
237
+ if (route.kind === "disabled") {
238
+ this.operationError = SLASH_DISABLED_COPY;
239
+ this.publish();
240
+ return false;
241
+ }
242
+ if (route.kind !== "prompt" && route.kind !== "skill") {
243
+ this.operationError = slashRefusalCopy(route);
244
+ if (route.kind === "unknown" || route.kind === "ambiguous") {
245
+ // Re-open the palette the user had to dismiss to reach this line: the
246
+ // refusal is only useful next to the choices it is talking about.
247
+ this.slashReopen += 1;
248
+ }
249
+ this.publish();
250
+ return false;
251
+ }
252
+ if (route.kind === "prompt" && (route.withheld?.length ?? 0) > 0) {
253
+ // The gesture is real and user-invocable here, and this deployment
254
+ // withholds it. Sending it would let the user believe it took effect —
255
+ // the native consumer refuses the injection, but only after the turn.
256
+ this.operationError = slashWithheldCopy(route.withheld ?? []);
151
257
  this.publish();
152
258
  return false;
153
259
  }
@@ -159,36 +265,9 @@ export class QaSessionController {
159
265
  let accepted = false;
160
266
  let target;
161
267
  try {
162
- if (this.session === undefined) {
163
- // A draft chat materializes its session only now: nothing was created
164
- // when the user pressed "New chat", so the first prompt pays for it.
165
- if (!this.drafting || this.materializing !== undefined)
166
- return false;
167
- if (!(await this.materializeDraft()) || this.session === undefined) {
168
- return false;
169
- }
170
- }
171
- const step = await this.attestPolicy();
172
- if (step.kind === "stale") {
173
- // The user moved to another chat while the proof was pending; that
174
- // binding attests through its own flow and must not receive a prompt
175
- // meant for the old one.
268
+ target = await this.liveTarget();
269
+ if (target === undefined)
176
270
  return false;
177
- }
178
- let attestedId = step.kind === "ok" ? step.sessionId : null;
179
- if (attestedId === null) {
180
- attestedId = await this.recoverForSend();
181
- if (attestedId === null)
182
- return false;
183
- }
184
- // Nothing can interleave between the last await and here, so matching the
185
- // attested id proves the prompt rides exactly the session it proved.
186
- target = this.session;
187
- if (this.disposed ||
188
- target === undefined ||
189
- String(target.sessionId) !== attestedId) {
190
- return false;
191
- }
192
271
  if (submission !== undefined && this.pendingSubmission === submission) {
193
272
  // Recovery may have replaced the original chat with a fresh session;
194
273
  // reconcile against the actual prompt target, not the abandoned one.
@@ -246,6 +325,198 @@ export class QaSessionController {
246
325
  }
247
326
  }
248
327
  }
328
+ /**
329
+ * Run one admitted human command. The Host answers it and writes the
330
+ * lifecycle into the session log; this method only decides what the composer
331
+ * does next, which is why nothing here creates a pending user row.
332
+ *
333
+ * A refusal keeps the draft and the attachments: the user asked for
334
+ * something the deployment does not allow, and retyping the line is a worse
335
+ * answer than leaving it in place to be edited.
336
+ */
337
+ async sendCommand(line, attachments, files, entry) {
338
+ if (this.slashApi === undefined) {
339
+ this.operationError = SLASH_DISABLED_COPY;
340
+ this.publish();
341
+ return false;
342
+ }
343
+ if (attachments.length > 0 && entry.acceptsAttachments !== true) {
344
+ // Checked here so nothing is staged and no Host round-trip is paid for a
345
+ // submission that cannot be admitted; the Host checks it again anyway.
346
+ this.operationError = slashAttachmentsCopy(entry);
347
+ this.publish();
348
+ return false;
349
+ }
350
+ let target;
351
+ try {
352
+ target = await this.liveTarget();
353
+ if (target === undefined)
354
+ return false;
355
+ let receipts = new Map();
356
+ if (files.length > 0) {
357
+ const outcome = await stageQaFiles(this.fileUpload(), target, files);
358
+ if (outcome.kind !== "ok") {
359
+ if (this.session === target) {
360
+ this.operationError =
361
+ outcome.kind === "unavailable"
362
+ ? "Вложения недоступны на этом сервере."
363
+ : "Не удалось приложить файл.";
364
+ this.publish();
365
+ }
366
+ return false;
367
+ }
368
+ receipts = outcome.receipts;
369
+ }
370
+ const result = await this.slashApi.execute(this.accounts?.token() ?? "", String(target.sessionId), line, buildQaSlashAttachments(attachments, receipts));
371
+ if (this.session !== target)
372
+ return false;
373
+ if (!result.ok) {
374
+ this.operationError = slashExecuteFailureCopy(entry);
375
+ this.publish();
376
+ return false;
377
+ }
378
+ if (result.value.kind === "refused") {
379
+ this.operationError = slashHostRefusalCopy(result.value);
380
+ this.publish();
381
+ return false;
382
+ }
383
+ this.operationError = null;
384
+ this.publish();
385
+ return true;
386
+ }
387
+ catch (error) {
388
+ console.error("dsh-qa-surface: slash command failed", error);
389
+ if (this.session === target) {
390
+ this.operationError = slashExecuteFailureCopy(entry);
391
+ this.publish();
392
+ }
393
+ return false;
394
+ }
395
+ }
396
+ /**
397
+ * Refresh the catalog of the bound chat. Called when a chat binds, and again
398
+ * when the palette opens on a catalog that has had time to go stale: the
399
+ * skill registry has no browser-facing change event, so a skill installed
400
+ * while the page is open is only discovered by asking again.
401
+ */
402
+ async refreshSlashCatalog(force = false) {
403
+ const session = this.session;
404
+ if (session === undefined || this.disposed)
405
+ return;
406
+ if (!this.config.slashCommands.enabled || this.compatibilityReadOnly) {
407
+ this.applySlashCatalog(String(session.sessionId), null);
408
+ return;
409
+ }
410
+ if (this.slashApi === undefined) {
411
+ this.applySlashCatalog(String(session.sessionId), undefined);
412
+ return;
413
+ }
414
+ if (this.slashLoading !== undefined)
415
+ return this.slashLoading;
416
+ if (!force && this.slashSessionId === String(session.sessionId)) {
417
+ if (Date.now() - this.slashLoadedAt < SLASH_CATALOG_STALE_MS)
418
+ return;
419
+ }
420
+ const sessionId = String(session.sessionId);
421
+ this.slashState = "loading";
422
+ this.publish();
423
+ const operation = (async () => {
424
+ try {
425
+ const result = await this.slashApi?.catalog(this.accounts?.token() ?? "", sessionId);
426
+ if (result === undefined)
427
+ return;
428
+ if (!result.ok) {
429
+ this.applySlashCatalog(sessionId, undefined);
430
+ return;
431
+ }
432
+ this.applySlashCatalog(sessionId, result.value);
433
+ }
434
+ catch (error) {
435
+ console.error("dsh-qa-surface: slash catalog failed", error);
436
+ this.applySlashCatalog(sessionId, undefined);
437
+ }
438
+ })().finally(() => {
439
+ if (this.slashLoading === operation)
440
+ this.slashLoading = undefined;
441
+ });
442
+ this.slashLoading = operation;
443
+ return operation;
444
+ }
445
+ /**
446
+ * Install one catalog answer. `null` means the deployment runs with the
447
+ * slash interface off; `undefined` means the answer never arrived. The
448
+ * difference matters: an unreachable catalog must not turn ordinary prompts
449
+ * into refusals.
450
+ */
451
+ applySlashCatalog(sessionId, catalog) {
452
+ if (this.session === undefined ||
453
+ String(this.session.sessionId) !== sessionId) {
454
+ return;
455
+ }
456
+ this.slashSessionId = sessionId;
457
+ this.slashLoadedAt = Date.now();
458
+ if (catalog === null) {
459
+ this.slashEntries = [];
460
+ this.slashDenied = [];
461
+ this.slashSurface = "unavailable";
462
+ this.slashState = "idle";
463
+ this.slashError = null;
464
+ this.publish();
465
+ return;
466
+ }
467
+ if (catalog === undefined) {
468
+ this.slashEntries = [];
469
+ this.slashDenied = [];
470
+ this.slashSurface = "unavailable";
471
+ this.slashState = "error";
472
+ this.slashError = "Не удалось загрузить команды";
473
+ this.publish();
474
+ return;
475
+ }
476
+ this.slashEntries = catalog.entries;
477
+ this.slashDenied = catalog.deniedSkills;
478
+ this.slashSurface = catalog.commandSurface;
479
+ this.slashState = "ready";
480
+ this.slashError = null;
481
+ this.publish();
482
+ }
483
+ /** Whether the catalog of the bound chat was read successfully. */
484
+ slashCatalogReady() {
485
+ return this.slashState === "ready" || this.slashState === "loading";
486
+ }
487
+ /** The slash view of the current state; assembled where the state is. */
488
+ slashView() {
489
+ const enabled = this.config.slashCommands.enabled &&
490
+ !this.compatibilityReadOnly &&
491
+ this.slashSessionId !== null &&
492
+ this.slashApi !== undefined;
493
+ // Identity is the render signal: this runs on every publish, including
494
+ // every stream frame of a running turn, and the composer is memoized on
495
+ // its props. A fresh object here would re-render the composer for each
496
+ // frame of an answer, which is exactly what its memo exists to prevent.
497
+ const cached = this.slashViewCache;
498
+ if (cached !== undefined &&
499
+ cached.enabled === enabled &&
500
+ cached.state === this.slashState &&
501
+ cached.entries === this.slashEntries &&
502
+ cached.commandSurface === this.slashSurface &&
503
+ cached.deniedSkills === this.slashDenied &&
504
+ cached.error === this.slashError &&
505
+ cached.reopen === this.slashReopen) {
506
+ return cached;
507
+ }
508
+ const view = {
509
+ enabled,
510
+ state: this.slashState,
511
+ entries: this.slashEntries,
512
+ commandSurface: this.slashSurface,
513
+ deniedSkills: this.slashDenied,
514
+ error: this.slashError,
515
+ reopen: this.slashReopen,
516
+ };
517
+ this.slashViewCache = view;
518
+ return view;
519
+ }
249
520
  /** Publish a browser-only copy before any network or Host admission awaits. */
250
521
  beginSubmission(text, attachments) {
251
522
  const submission = {
@@ -426,7 +697,15 @@ export class QaSessionController {
426
697
  const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
427
698
  if (this.disposed || operation !== this.generation)
428
699
  return;
429
- if (!Object.hasOwn(list.byId, sessionId)) {
700
+ // `hasOwn`, not a plain read: the index is browser storage, and a key
701
+ // like `__proto__` must not resolve to something inherited.
702
+ const summary = Object.hasOwn(list.byId, sessionId)
703
+ ? list.byId[sessionId]
704
+ : undefined;
705
+ // A delegated child is not a chat, even when this browser's index still
706
+ // names one: the entry is dropped the same way an unknown id is, so a
707
+ // subagent transcript can never be reopened as chat history.
708
+ if (summary === undefined || isDelegatedSession(summary)) {
430
709
  this.forgetChat(sessionId);
431
710
  throw new Error("Этот чат больше недоступен.");
432
711
  }
@@ -594,7 +873,16 @@ export class QaSessionController {
594
873
  }
595
874
  else if (this.config.session.policy === "browser-persistent") {
596
875
  const stored = this.chats.activeId();
597
- if (stored !== null && Object.hasOwn(list.byId, stored)) {
876
+ const summary = stored !== null && Object.hasOwn(list.byId, stored)
877
+ ? list.byId[stored]
878
+ : undefined;
879
+ // A stored id that is not a chat any more — a delegated child this
880
+ // browser once opened as one, a session the Host no longer lists —
881
+ // falls through to a fresh chat instead of restoring a transcript
882
+ // nobody can send into.
883
+ if (stored !== null &&
884
+ summary !== undefined &&
885
+ !isDelegatedSession(summary)) {
598
886
  id = stored;
599
887
  restored = true;
600
888
  existing = true;
@@ -785,6 +1073,9 @@ export class QaSessionController {
785
1073
  this.accounts?.onSessionCreated(String(this.session.sessionId));
786
1074
  }
787
1075
  this.publish();
1076
+ // Deliberately not awaited: the palette is a convenience, and a chat must
1077
+ // open at once whether or not the skill and command registries answer.
1078
+ void this.refreshSlashCatalog(true);
788
1079
  }
789
1080
  unbind() {
790
1081
  this.streamPublisher.clear();
@@ -803,9 +1094,21 @@ export class QaSessionController {
803
1094
  this.hostApprovals.reset();
804
1095
  this.hostQuestions.reset();
805
1096
  this.stopPendingPolling();
1097
+ // The next binding probes the Host again even for the same chat.
1098
+ this.pendingProbeKey = "";
806
1099
  this.admissionPending = false;
807
1100
  this.policyReady = false;
808
1101
  this.compatibilityReadOnly = false;
1102
+ // The catalog belongs to the chat that produced it: another chat has a
1103
+ // different cwd, composition and role, so carrying it across would offer
1104
+ // actions this chat may not have.
1105
+ this.slashSessionId = null;
1106
+ this.slashEntries = [];
1107
+ this.slashDenied = [];
1108
+ this.slashSurface = "unavailable";
1109
+ this.slashState = "idle";
1110
+ this.slashError = null;
1111
+ this.slashLoadedAt = 0;
809
1112
  }
810
1113
  waitForConnection() {
811
1114
  return waitFor(this.connection, (description) => description !== undefined, this.timeoutMs);
@@ -856,6 +1159,7 @@ export class QaSessionController {
856
1159
  if (!snapshot.running) {
857
1160
  void this.hostSources.refresh(sessionId, () => this.publish());
858
1161
  }
1162
+ this.probePending(sessionId, connected);
859
1163
  this.syncPendingPolling(snapshot.running === true && !this.compatibilityReadOnly);
860
1164
  const projectionInput = {
861
1165
  connected,
@@ -874,6 +1178,7 @@ export class QaSessionController {
874
1178
  admissionPending: this.admissionPending || this.pendingSubmission !== undefined,
875
1179
  chatsRevision: this.chatsRevision,
876
1180
  viewingSubagent: this.viewingSubagent,
1181
+ slash: this.slashView(),
877
1182
  config: this.config,
878
1183
  subagentNames: this.subagentNames(),
879
1184
  };
@@ -1023,6 +1328,12 @@ export class QaSessionController {
1023
1328
  * inside an open turn, and it is Host state: polling is how the page learns
1024
1329
  * about it, and how it reappears after a reload. One timer serves both seams,
1025
1330
  * and each is polled only where the deployment answers it.
1331
+ *
1332
+ * A request that is already on screen keeps the timer alive past the end of
1333
+ * its turn: the Host settles what the turn can no longer answer — an aborted
1334
+ * tool call, an agent that went idle — and this keeps reading until that
1335
+ * settlement reaches the page, so the form goes away instead of sitting
1336
+ * there answerable but dead.
1026
1337
  */
1027
1338
  syncPendingPolling(running) {
1028
1339
  const approvals = this.hostApprovals.available
@@ -1031,8 +1342,9 @@ export class QaSessionController {
1031
1342
  const questions = this.hostQuestions.available
1032
1343
  ? this.config.interaction.questions === "interactive"
1033
1344
  : false;
1034
- const wanted = running &&
1035
- (approvals || questions) &&
1345
+ const pending = this.hostApprovals.list().length > 0 ||
1346
+ this.hostQuestions.list().length > 0;
1347
+ const wanted = ((running && (approvals || questions)) || pending) &&
1036
1348
  // A subagent watched from the panel is a read-only view of a session the
1037
1349
  // chat owns: its own requests belong to the chat, not to this binding.
1038
1350
  this.viewingSubagent === null &&
@@ -1046,16 +1358,44 @@ export class QaSessionController {
1046
1358
  const sessionId = String(this.session.sessionId);
1047
1359
  const token = this.hostToken();
1048
1360
  const tick = () => {
1049
- if (approvals) {
1361
+ // A seam still on screen is re-read even where its config gate has since
1362
+ // closed: the Host is the authority on what it holds, and an empty answer
1363
+ // is what takes a settled request off the screen.
1364
+ if (approvals || this.hostApprovals.list().length > 0) {
1050
1365
  void this.hostApprovals.refresh(sessionId, token, () => this.publish());
1051
1366
  }
1052
- if (questions) {
1367
+ if (questions || this.hostQuestions.list().length > 0) {
1053
1368
  void this.hostQuestions.refresh(sessionId, token, () => this.publish());
1054
1369
  }
1055
1370
  };
1056
1371
  this.pendingTimer = setInterval(tick, PENDING_POLL_MS);
1057
1372
  tick();
1058
1373
  }
1374
+ /**
1375
+ * Read what the Host parks once per chat and connection, whether or not a
1376
+ * turn is running. A reload binds the chat before any turn reports itself
1377
+ * over the fresh stream, and a turn that is already waiting on a question
1378
+ * would otherwise sit behind an empty composer until the operator happened
1379
+ * to send something.
1380
+ */
1381
+ probePending(sessionId, connected) {
1382
+ const key = `${sessionId}:${connected ? "up" : "down"}:${String(this.policyReady)}`;
1383
+ // Attestation is what lets the Host answer about this chat at all, so the
1384
+ // read waits for it: the publish that follows a successful attestation
1385
+ // carries a new key and probes then.
1386
+ if (!connected || !this.policyReady || key === this.pendingProbeKey)
1387
+ return;
1388
+ this.pendingProbeKey = key;
1389
+ const token = this.hostToken();
1390
+ if (this.hostApprovals.available &&
1391
+ this.config.interaction.approvals === "interactive") {
1392
+ void this.hostApprovals.refresh(sessionId, token, () => this.publish());
1393
+ }
1394
+ if (this.hostQuestions.available &&
1395
+ this.config.interaction.questions === "interactive") {
1396
+ void this.hostQuestions.refresh(sessionId, token, () => this.publish());
1397
+ }
1398
+ }
1059
1399
  stopPendingPolling() {
1060
1400
  if (this.pendingTimer === undefined)
1061
1401
  return;