local-operator-ui 0.33.16 → 0.34.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 (87) hide show
  1. package/out/main/index.js +539 -90
  2. package/out/preload/index.js +2115 -6
  3. package/out/renderer/assets/{_basePickBy-CGsf5s5y.js → _basePickBy-9TBYQa7o.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-C43UnLjk.js → _baseUniq-LWPq37Nv.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-BIwam1AY.js → agent-details-page-w0feeNbR.js} +2 -2
  6. package/out/renderer/assets/{agent-hub-page-01a3MLYP.js → agent-hub-page-MpNGaBsQ.js} +2 -2
  7. package/out/renderer/assets/{agents-page-CRnd_ZL5.js → agents-page-CS_TizSE.js} +3 -3
  8. package/out/renderer/assets/{arc-M0pLW_S9.js → arc-Cl2fdKKj.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-BSBbZOP-.js → architectureDiagram-IEHRJDOE-BjnbTm2l.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-B927zHwX.js → blockDiagram-JOT3LUYC-pkJso5xt.js} +1 -1
  11. package/out/renderer/assets/{book-open-BOhsj37a.js → book-open-NI0D1Wqb.js} +1 -1
  12. package/out/renderer/assets/browser-page-CXJ90U-n.js +1 -0
  13. package/out/renderer/assets/{browser-webauthn-prompt-k8f91jC5.js → browser-webauthn-prompt-B_cRbltg.js} +1 -1
  14. package/out/renderer/assets/{c4Diagram-VJAJSXHY-Db4tt7Tq.js → c4Diagram-VJAJSXHY-CsiT1ln1.js} +1 -1
  15. package/out/renderer/assets/channel-CjpL-D3q.js +1 -0
  16. package/out/renderer/assets/{chunk-4BMEZGHF-Esvq7q-I.js → chunk-4BMEZGHF-BCo1pqHD.js} +1 -1
  17. package/out/renderer/assets/{chunk-A2AXSNBT-YDyKUqYa.js → chunk-A2AXSNBT-DG75mkPI.js} +1 -1
  18. package/out/renderer/assets/{chunk-AEK57VVT-D7xfN31Q.js → chunk-AEK57VVT-nm3o7842.js} +1 -1
  19. package/out/renderer/assets/{chunk-D6G4REZN-B7GPJrWB.js → chunk-D6G4REZN-DwdG2uCi.js} +1 -1
  20. package/out/renderer/assets/{chunk-RZ5BOZE2-OrSabd2d.js → chunk-RZ5BOZE2-DhfAnKjd.js} +1 -1
  21. package/out/renderer/assets/{chunk-XZIHB7SX-Ls62ZuT-.js → chunk-XZIHB7SX-CpBM6cFt.js} +1 -1
  22. package/out/renderer/assets/classDiagram-GIVACNV2-BD5hmHYM.js +1 -0
  23. package/out/renderer/assets/classDiagram-v2-COTLJTTW-BD5hmHYM.js +1 -0
  24. package/out/renderer/assets/clone-Ba8sZt4_.js +1 -0
  25. package/out/renderer/assets/{console-mirror-D5I24PC-.js → console-mirror-CBdCHzj8.js} +1 -1
  26. package/out/renderer/assets/{consoleCapture-BkjkGzZU.js → consoleCapture-Bud3UbKT.js} +1 -1
  27. package/out/renderer/assets/{conversation-input-sync-CvO1w7en.js → conversation-input-sync-gc-8lTMK.js} +243 -239
  28. package/out/renderer/assets/{dagre-OKDRZEBW-BF8NMghr.js → dagre-OKDRZEBW-BOkEddRs.js} +1 -1
  29. package/out/renderer/assets/{diagram-SSKATNLV-DYPlCY6D.js → diagram-SSKATNLV-CQn_39Sz.js} +1 -1
  30. package/out/renderer/assets/{diagram-VNBRO52H-htbXA7Fo.js → diagram-VNBRO52H-DroiF3Bk.js} +1 -1
  31. package/out/renderer/assets/{erDiagram-Q7BY3M3F-4Mm_pKd-.js → erDiagram-Q7BY3M3F-CWW8Quok.js} +1 -1
  32. package/out/renderer/assets/{error-boundary-CYrSujeA.js → error-boundary-AUcef_lG.js} +1 -1
  33. package/out/renderer/assets/{flowDiagram-4HSFHLVR-DDzUlC1i.js → flowDiagram-4HSFHLVR-BwQjXnik.js} +1 -1
  34. package/out/renderer/assets/{ganttDiagram-APWFNJXF-4vH_i6M9.js → ganttDiagram-APWFNJXF-B1QNh_bE.js} +1 -1
  35. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-PTmOOixv.js → gitGraphDiagram-7IBYFJ6S-Wj10byRK.js} +1 -1
  36. package/out/renderer/assets/{graph-016lbgzw.js → graph-CmV1xTJR.js} +1 -1
  37. package/out/renderer/assets/index-BeS_HPbS.js +807 -0
  38. package/out/renderer/assets/{index-5rWUB2pk.js → index-BivhAAGx.js} +1 -1
  39. package/out/renderer/assets/{index-I0rfR2G9.js → index-QtFRh-C-.js} +1 -1
  40. package/out/renderer/assets/index-mRyzMIoR.css +1 -0
  41. package/out/renderer/assets/{infoDiagram-PH2N3AL5-BUH-2kNx.js → infoDiagram-PH2N3AL5-CZTZ4QP3.js} +1 -1
  42. package/out/renderer/assets/{installer-DzJgD7dC.js → installer-DeUwkvHe.js} +1 -1
  43. package/out/renderer/assets/{journeyDiagram-U35MCT3I-B0GPzUDZ.js → journeyDiagram-U35MCT3I-BXorYXyE.js} +1 -1
  44. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-DPMTM-PB.js → kanban-definition-NDS4AKOZ-BNfLQEdM.js} +1 -1
  45. package/out/renderer/assets/{layout-CZCsyyxh.js → layout-BgGtCNb_.js} +1 -1
  46. package/out/renderer/assets/{legacy-agents-page-BrIHqr1U.js → legacy-agents-page-DyNtw5de.js} +4 -4
  47. package/out/renderer/assets/{mermaid.core-DIr_Z2eU.js → mermaid.core-C5N5tFD-.js} +5 -5
  48. package/out/renderer/assets/{mesh-page-CY9vZlLm.js → mesh-page-nKes20vh.js} +1 -1
  49. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-Dk9hiZoP.js → mindmap-definition-ALO5MXBD-DsqW-F-E.js} +1 -1
  50. package/out/renderer/assets/mini-CYMmaq4M.js +1 -0
  51. package/out/renderer/assets/{org-surface-gate-vsaHuMo7.js → org-surface-gate-CM0x2fAa.js} +1 -1
  52. package/out/renderer/assets/{page-header-DWAQV8z7.js → page-header-BMHnL0kj.js} +1 -1
  53. package/out/renderer/assets/{pieDiagram-IB7DONF6-CT-KDbbB.js → pieDiagram-IB7DONF6-C9uKRUck.js} +1 -1
  54. package/out/renderer/assets/{projects-page-B-Uqww5s.js → projects-page-BsWuzpbw.js} +1 -1
  55. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CbuvxmHL.js → quadrantDiagram-7GDLP6J5-xZZ3WcYT.js} +1 -1
  56. package/out/renderer/assets/{radar-MK3ICKWK-DWgcYgI1.js → radar-MK3ICKWK-08z-9TMb.js} +1 -1
  57. package/out/renderer/assets/{radient-auth-buttons-BwMgaUi2.js → radient-auth-buttons-CzmYII93.js} +1 -1
  58. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-gL2w5pv1.js → requirementDiagram-KVF5MWMF-CA8yDZr7.js} +1 -1
  59. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-COy-DHKN.js → sankeyDiagram-QLVOVGJD-C_S67_wP.js} +1 -1
  60. package/out/renderer/assets/{schedules-page-WYM7voi8.js → schedules-page-CTlQizzj.js} +19 -24
  61. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-D8fMPudr.js → sequenceDiagram-X6HHIX6F-BgeEbPQZ.js} +1 -1
  62. package/out/renderer/assets/{settings-page-73bm6YFk.js → settings-page-JBT67VrP.js} +7 -7
  63. package/out/renderer/assets/{settings-section-CfFkk5yW.js → settings-section-BzPM14cn.js} +1 -1
  64. package/out/renderer/assets/{stateDiagram-DGXRK772-DJv925EE.js → stateDiagram-DGXRK772-CZKqMaPx.js} +1 -1
  65. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-ClyI23kk.js +1 -0
  66. package/out/renderer/assets/{timeline-definition-BDJGKUSR-D4Mikzrt.js → timeline-definition-BDJGKUSR-o4J6Cn1Y.js} +1 -1
  67. package/out/renderer/assets/ui-preferences-store-Bg2HUvBc.js +1 -0
  68. package/out/renderer/assets/{use-download-agent-mutation-EYt16YWD.js → use-download-agent-mutation-DoSIu0x-.js} +2 -2
  69. package/out/renderer/assets/{use-memberships-query-CTlFzQjW.js → use-memberships-query-BIiDf0qJ.js} +1 -1
  70. package/out/renderer/assets/{use-session-snapshot-JyEJO1f9.js → use-session-snapshot-Ctn7Q-U9.js} +1 -1
  71. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-7I7MMjXI.js → xychartDiagram-VJFVF3MP-QsBQWdLs.js} +1 -1
  72. package/out/renderer/console-capture.html +5 -5
  73. package/out/renderer/index.html +7 -7
  74. package/out/renderer/installer.html +4 -4
  75. package/out/renderer/mini.html +7 -7
  76. package/package.json +3 -2
  77. package/out/renderer/assets/browser-page-Dk6im9oj.js +0 -1
  78. package/out/renderer/assets/channel-CILm-Wsr.js +0 -1
  79. package/out/renderer/assets/classDiagram-GIVACNV2-BjqKgY46.js +0 -1
  80. package/out/renderer/assets/classDiagram-v2-COTLJTTW-BjqKgY46.js +0 -1
  81. package/out/renderer/assets/clone-Dj2cZSoa.js +0 -1
  82. package/out/renderer/assets/index-CTM-knBb.css +0 -1
  83. package/out/renderer/assets/index-DWsjHqzk.js +0 -802
  84. package/out/renderer/assets/mini-BGlRDs7P.js +0 -1
  85. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-BL621Pie.js +0 -1
  86. package/out/renderer/assets/ui-preferences-store-DrvWjHl4.js +0 -1
  87. /package/out/renderer/assets/{index-nSWgbpx8.js → index-CrBT28zA.js} +0 -0
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  const electron = require("electron");
3
+ const zod = require("zod");
3
4
  const electronAPI = {
4
5
  ipcRenderer: {
5
6
  send(channel, ...args) {
@@ -86,6 +87,2093 @@ function readTelemetryArgument(argv) {
86
87
  const BACKEND_STATUS_CHANNEL = "backend-status";
87
88
  const BACKEND_STATUS_EVENT = "backend-status-changed";
88
89
  const BACKEND_RECONNECT_CHANNEL = "backend-reconnect";
90
+ const id = zod.z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
91
+ const settingKey = zod.z.string().min(1).max(256).regex(/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/);
92
+ const secret = zod.z.string().min(1).max(32768);
93
+ const sessionIdPattern = /^[a-f0-9]{12}$/;
94
+ const sessionId = zod.z.string().regex(sessionIdPattern);
95
+ const skillCwd = zod.z.string().min(1).max(4096);
96
+ const mcpServerName = zod.z.string().regex(/^[A-Za-z0-9_.:-]{1,100}$/);
97
+ const mcpSecretReference = zod.z.string().regex(/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/);
98
+ const mcpOperationId = zod.z.string().regex(/^[a-f0-9]{32}$/);
99
+ const mcpCatalogCwdPattern = /^(\/|[A-Za-z]:[\\/])/;
100
+ const mcpCatalogCwd = zod.z.string().min(1).max(4096).regex(mcpCatalogCwdPattern);
101
+ const SUBSCRIPTION_ID_PATTERN = /^[a-f0-9]{32}$/;
102
+ const requestId = zod.z.string().regex(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/);
103
+ const meshId = zod.z.string().regex(/^[A-Za-z0-9_-]{1,128}$/);
104
+ const approvalId = zod.z.string().regex(/^ap_[0-9a-hjkmnp-tv-z]{1,64}$/);
105
+ const sessionImage = zod.z.object({
106
+ data_b64: zod.z.string().min(1).max(1e6),
107
+ mime_type: zod.z.enum(["image/png", "image/jpeg", "image/gif", "image/webp"])
108
+ }).strict();
109
+ const DESKTOP_MESSAGE_MAX_CHARS = 2e5;
110
+ const DESKTOP_MESSAGE_MAX_IMAGES = 8;
111
+ const SESSION_SEARCH_MAX_CHARS = 256;
112
+ const CHECKPOINT_WARM_MAX_IDS = 16;
113
+ const THREAD_FIND_MAX_CHARS = 256;
114
+ const THREAD_FIND_MAX_LIMIT = 200;
115
+ const DESKTOP_SYSTEM_PROMPT_MAX_CHARS = 1e6;
116
+ const profileName = zod.z.string().min(1).max(128).refine(
117
+ (name) => name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && [...name].every(
118
+ (character) => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127
119
+ )
120
+ );
121
+ const target = zod.z.object({ kind: zod.z.enum(["agent", "team"]), name: profileName }).strict();
122
+ const modelSelection = zod.z.object({
123
+ provider: zod.z.string().min(1).max(128),
124
+ model_id: zod.z.string().min(1).max(512),
125
+ reasoning_effort: zod.z.string().min(1).max(64).nullable()
126
+ }).strict();
127
+ const profileFields = zod.z.object({
128
+ kind: zod.z.enum(["role", "specialist"]).optional(),
129
+ description: zod.z.string().max(8e3).optional(),
130
+ instructions: zod.z.string().max(8e3).optional(),
131
+ tools: zod.z.array(zod.z.string().min(1).max(128)).max(256).optional(),
132
+ effort: zod.z.string().max(64).optional(),
133
+ delegate: zod.z.boolean().optional(),
134
+ /*
135
+ * The action class (`reactive` | `proactive`), the field the Class control
136
+ * writes. An ENUM rather than a free string, because the route takes a
137
+ * `Literal` and a spelling this schema let through would be a 422 the
138
+ * caller could not tell from a bug — the same reasoning `scheduleUnit`
139
+ * above states. Optional like every other field here: an omitted key means
140
+ * "leave the class alone" on an update, which is what lets the control send
141
+ * the class and nothing else.
142
+ */
143
+ action_class: zod.z.enum(["reactive", "proactive"]).optional()
144
+ }).strict();
145
+ const teamFields = zod.z.object({
146
+ name: zod.z.string().min(1).max(64).optional(),
147
+ description: zod.z.string().max(8e3).optional(),
148
+ manager: profileName.optional(),
149
+ members: zod.z.array(
150
+ zod.z.object({
151
+ role: profileName,
152
+ count: zod.z.number().int().min(1).max(16),
153
+ kind: zod.z.enum(["agent", "team"])
154
+ }).strict()
155
+ ).max(128).optional(),
156
+ instructions: zod.z.string().max(8e3).optional(),
157
+ project: zod.z.string().max(8e3).optional()
158
+ }).strict();
159
+ const chains = zod.z.record(zod.z.array(zod.z.string().max(1024)).max(100));
160
+ const scheduleUnit = zod.z.enum(["minutes", "hours", "days"]);
161
+ function isAddressableVariableKey(key) {
162
+ return !/^\.+$/.test(key);
163
+ }
164
+ const variableKey = zod.z.string().min(1).max(128).refine(
165
+ (key) => [...key].every(
166
+ (character) => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127 && character !== "/" && character !== "\\"
167
+ )
168
+ ).refine(isAddressableVariableKey);
169
+ const variableType = zod.z.enum(["str", "int", "float", "bool", "list", "dict"]);
170
+ const variableValue = zod.z.string().max(16384);
171
+ const WAKE_MESSAGE_MAX_CHARS = 2e3;
172
+ const wakeMessage = zod.z.string().min(1).max(WAKE_MESSAGE_MAX_CHARS);
173
+ const wakeId = zod.z.string().min(1).max(64);
174
+ const monitorId = zod.z.string().min(1).max(64);
175
+ const scheduleWrite = zod.z.object({
176
+ is_active: zod.z.boolean().nullish(),
177
+ one_time: zod.z.boolean().nullish(),
178
+ start_time_utc: zod.z.string().max(64).nullish(),
179
+ end_time_utc: zod.z.string().max(64).nullish()
180
+ }).strict();
181
+ const configUpdate = zod.z.object({
182
+ conversation_length: zod.z.number().int().optional(),
183
+ detail_length: zod.z.number().int().optional(),
184
+ max_learnings_history: zod.z.number().int().optional(),
185
+ hosting: zod.z.string().max(256).optional(),
186
+ model_name: zod.z.string().max(1024).optional(),
187
+ auto_save_conversation: zod.z.boolean().optional()
188
+ }).strict();
189
+ const PUBLICATION_NAME_MAX_CHARS = 128;
190
+ const PUBLICATION_DESCRIPTION_MAX_CHARS = 2e3;
191
+ const PUBLICATION_INSTRUCTIONS_MAX_CHARS = 8e3;
192
+ const PUBLICATION_TOOL_MAX_CHARS = 64;
193
+ const PUBLICATION_TOOLS_MAX_ITEMS = 64;
194
+ const publicationCharCount = (value) => [...value].length;
195
+ const isPublicationWhiteSpace = (point) => point >= 9 && point <= 13 || point >= 28 && point <= 32 || point === 133 || point === 160 || point === 5760 || point >= 8192 && point <= 8202 || point === 8232 || point === 8233 || point === 8239 || point === 8287 || point === 12288;
196
+ const isPublicationControlWhitespace = (point) => point === 9 || point === 10 || point === 11 || point === 12 || point === 13 || point === 133;
197
+ const isPublicationControl = (point) => point <= 31 || point >= 127 && point <= 159;
198
+ const isPublicationFormat = (point) => /\p{Cf}/u.test(String.fromCodePoint(point));
199
+ const isPublicationBidiOverride = (point) => point >= 8234 && point <= 8238 || point >= 8294 && point <= 8297;
200
+ const publicationHasCodePoint = (value, predicate) => {
201
+ for (const character of value) {
202
+ if (predicate(character.codePointAt(0) ?? 0)) return true;
203
+ }
204
+ return false;
205
+ };
206
+ const publicationTrim = (value) => {
207
+ const characters = [...value];
208
+ let start = 0;
209
+ let end = characters.length;
210
+ while (start < end && isPublicationWhiteSpace(characters[start].codePointAt(0) ?? 0))
211
+ start++;
212
+ while (end > start && isPublicationWhiteSpace(characters[end - 1].codePointAt(0) ?? 0))
213
+ end--;
214
+ return characters.slice(start, end).join("");
215
+ };
216
+ function publicationNameRule(name) {
217
+ const trimmed = publicationTrim(name);
218
+ if (!trimmed) return "must not be empty";
219
+ if (publicationCharCount(trimmed) > PUBLICATION_NAME_MAX_CHARS)
220
+ return `must be at most ${PUBLICATION_NAME_MAX_CHARS} characters`;
221
+ if (/[/\\:]/.test(trimmed)) return 'must not contain "/", "\\" or ":"';
222
+ if (publicationHasCodePoint(trimmed, isPublicationBidiOverride))
223
+ return "must not contain Unicode bidirectional override characters";
224
+ if (publicationHasCodePoint(trimmed, isPublicationControlWhitespace))
225
+ return "must not contain whitespace";
226
+ if (publicationHasCodePoint(trimmed, isPublicationControl))
227
+ return "must not contain control characters";
228
+ if (publicationHasCodePoint(trimmed, isPublicationFormat))
229
+ return "must not contain invisible Unicode formatting characters";
230
+ const characters = [...trimmed];
231
+ if ("-.".includes(characters[0]) || "-.".includes(characters[characters.length - 1]))
232
+ return 'must not begin or end with "-" or "."';
233
+ return null;
234
+ }
235
+ function publicationDescriptionRule(description) {
236
+ if (!description.trim()) return "must not be empty";
237
+ if (publicationCharCount(description) > PUBLICATION_DESCRIPTION_MAX_CHARS)
238
+ return `must be at most ${PUBLICATION_DESCRIPTION_MAX_CHARS} characters`;
239
+ return null;
240
+ }
241
+ function publicationInstructionsRule(instructions) {
242
+ if (!instructions.trim()) return "must not be empty";
243
+ if (publicationCharCount(instructions) > PUBLICATION_INSTRUCTIONS_MAX_CHARS)
244
+ return `must be at most ${PUBLICATION_INSTRUCTIONS_MAX_CHARS} characters`;
245
+ return null;
246
+ }
247
+ function publicationToolsRule(tools) {
248
+ if (tools.length > PUBLICATION_TOOLS_MAX_ITEMS)
249
+ return `must hold at most ${PUBLICATION_TOOLS_MAX_ITEMS} items`;
250
+ for (const tool of tools) {
251
+ if (!tool.trim() || publicationCharCount(tool) > PUBLICATION_TOOL_MAX_CHARS)
252
+ return `must hold items of 1 to ${PUBLICATION_TOOL_MAX_CHARS} characters`;
253
+ }
254
+ return null;
255
+ }
256
+ const isPublishableName = (name) => publicationNameRule(name) === null;
257
+ const publicationName = zod.z.string().refine(isPublishableName, {
258
+ message: "That is not a name the hub can publish."
259
+ });
260
+ const publicationInstructions = zod.z.string().refine(
261
+ (instructions) => publicationInstructionsRule(instructions) === null,
262
+ { message: "That instruction body cannot be published." }
263
+ );
264
+ const publicationDescription = zod.z.string().refine((description) => publicationDescriptionRule(description) === null, {
265
+ message: "That description cannot be published."
266
+ });
267
+ const publicationTools = zod.z.array(zod.z.string()).refine((tools) => publicationToolsRule(tools) === null, {
268
+ message: "That tool list cannot be published."
269
+ });
270
+ const publicationDocument = zod.z.object({
271
+ name: publicationName,
272
+ description: publicationDescription,
273
+ instructions: publicationInstructions,
274
+ kind: zod.z.enum(["role", "specialist"]),
275
+ when_to_use: zod.z.string(),
276
+ tools: publicationTools,
277
+ effort: zod.z.string(),
278
+ delegate: zod.z.boolean(),
279
+ version: zod.z.string(),
280
+ categories: zod.z.array(zod.z.string()),
281
+ tags: zod.z.array(zod.z.string())
282
+ }).partial().strict();
283
+ const catalogueScopeKind = zod.z.enum(["team", "agent"]);
284
+ const catalogueScopeName = zod.z.string().min(1).max(64);
285
+ const catalogueCursor = zod.z.string().min(1).max(256);
286
+ const PROJECT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
287
+ const projectName = zod.z.string().regex(PROJECT_NAME_PATTERN, {
288
+ message: "Letters, digits, dot, underscore and dash; no spaces."
289
+ });
290
+ const PROJECT_STATUSES = [
291
+ "planning",
292
+ "active",
293
+ "qa",
294
+ "validation",
295
+ "done",
296
+ "paused",
297
+ "archived"
298
+ ];
299
+ const projectStatus = zod.z.enum(PROJECT_STATUSES);
300
+ const projectDate = zod.z.string().regex(/^$|^\d{4}-\d{2}-\d{2}$/, "Dates are YYYY-MM-DD, or empty to clear.");
301
+ const projectTags = zod.z.array(zod.z.string().min(1).max(24)).max(8);
302
+ const projectKey = zod.z.string().min(1).max(64);
303
+ const PROJECT_PROGRESS_MAX_CHARS = 1e3;
304
+ const PROJECT_DESCRIPTION_MAX_CHARS = 240;
305
+ const PROJECT_MILESTONE_NAME_MAX_CHARS = 80;
306
+ const PROJECTS_SEARCH_MAX_CHARS = 256;
307
+ const PROJECTS_SEARCH_MAX_LIMIT = 200;
308
+ const hubItemKind = zod.z.enum(["agent", "team"]);
309
+ const desktopRequestUnion = zod.z.discriminatedUnion("op", [
310
+ zod.z.object({ op: zod.z.literal("capabilities") }).strict(),
311
+ /*
312
+ * THE MACHINE'S RUNTIME ROSTER, read for the straggler census a completed
313
+ * server update reports (`GET /v1/desktop/runtimes`). A GET with a fixed
314
+ * shape, so the whole request is the op.
315
+ *
316
+ * It reads the INVENTORY (`probe=false` in the path composer): the census
317
+ * asks every row what build it booted with, and the route's per-row loopback
318
+ * connects answer a different question ("did it answer?") that nothing here
319
+ * acts on. Skipping them also keeps the response inside the control budget
320
+ * without depending on the probe pool's own deadline logic.
321
+ *
322
+ * THE CONTROL BUDGET IS THE RIGHT ONE, and it is a bound rather than a hope:
323
+ * the route's own ceiling is the two external reads in front of the
324
+ * composition (5 s process table + 3 s socket table, stated in `roster.py`),
325
+ * and `probe=false` skips the per-row connects entirely - so the worst case
326
+ * is ~8 s, well under the 20 s control deadline. It is NOT on the long-read
327
+ * list: nothing here fans out to a provider or walks a ledger.
328
+ */
329
+ zod.z.object({ op: zod.z.literal("runtimes.list") }).strict(),
330
+ zod.z.object({ op: zod.z.literal("profiles.list") }).strict(),
331
+ zod.z.object({ op: zod.z.literal("profiles.get"), name: profileName }).strict(),
332
+ zod.z.object({ op: zod.z.literal("profiles.install"), name: profileName, requestId }).strict(),
333
+ zod.z.object({
334
+ op: zod.z.literal("profiles.create"),
335
+ name: profileName,
336
+ requestId,
337
+ fields: profileFields.extend({
338
+ description: zod.z.string().max(8e3),
339
+ instructions: zod.z.string().min(1).max(8e3)
340
+ })
341
+ }).strict(),
342
+ zod.z.object({
343
+ op: zod.z.literal("profiles.update"),
344
+ name: profileName,
345
+ requestId,
346
+ fields: profileFields
347
+ }).strict(),
348
+ /*
349
+ * THE HUB AUTO-UPDATE PLANE (backend `server/routes/desktop_hub.py`, gated by
350
+ * the `hub_updates` capability). `hub.updates` is a STORE READ on the backend
351
+ * (no network, O(items)), which is what makes it safe to poll from the
352
+ * sidebar; the five mutations are the ones that touch the hub or write a
353
+ * local definition, and each carries a `requestId` so a lost response is
354
+ * replayed by the server's receipts rather than re-run.
355
+ *
356
+ * `kind` is closed to the two definition families the hub carries, and `name`
357
+ * is the profile/team NAME because names are the runtime's attachment keys.
358
+ * `prefer` is per-item only: the backend refuses it on apply-all, since a
359
+ * conflict decision cannot be made for a set of items at once, so the
360
+ * apply-all schema does not offer it.
361
+ */
362
+ zod.z.object({ op: zod.z.literal("hub.updates") }).strict(),
363
+ zod.z.object({
364
+ op: zod.z.literal("hub.check"),
365
+ requestId,
366
+ kind: hubItemKind.optional(),
367
+ name: profileName.optional()
368
+ }).strict(),
369
+ zod.z.object({
370
+ op: zod.z.literal("hub.apply"),
371
+ requestId,
372
+ kind: hubItemKind,
373
+ name: profileName,
374
+ prefer: zod.z.enum(["local", "remote"]).optional(),
375
+ acknowledgeUnknownBaseline: zod.z.boolean().optional(),
376
+ dryRun: zod.z.boolean().optional()
377
+ }).strict(),
378
+ zod.z.object({
379
+ op: zod.z.literal("hub.applyAll"),
380
+ requestId,
381
+ kind: hubItemKind.optional()
382
+ }).strict(),
383
+ zod.z.object({
384
+ op: zod.z.literal("hub.retry"),
385
+ requestId,
386
+ kind: hubItemKind,
387
+ name: profileName
388
+ }).strict(),
389
+ zod.z.object({ op: zod.z.literal("teams.list") }).strict(),
390
+ zod.z.object({ op: zod.z.literal("teams.get"), name: profileName }).strict(),
391
+ zod.z.object({
392
+ op: zod.z.literal("teams.create"),
393
+ requestId,
394
+ fields: teamFields.extend({ name: zod.z.string().min(1).max(64) })
395
+ }).strict(),
396
+ zod.z.object({
397
+ op: zod.z.literal("teams.update"),
398
+ name: profileName,
399
+ requestId,
400
+ fields: teamFields
401
+ }).strict(),
402
+ zod.z.object({
403
+ op: zod.z.literal("sessions.list"),
404
+ limit: zod.z.number().int().min(1).max(500).optional(),
405
+ /*
406
+ * Whether ARCHIVED conversations belong in the answer.
407
+ *
408
+ * ABSENT MEANS `false`, and that default is a compatibility promise rather
409
+ * than a preference: a client that predates archiving sends no such field
410
+ * and must keep the list it always had rather than acquiring rows it has no
411
+ * way to mark, filter or restore. This app sends `true` and partitions the
412
+ * archived rows out of every default list itself (see `fetchSessions` in the
413
+ * canonical sessions store and `visibleRows` in `features/chat/chat-archived`),
414
+ * which is what lets ONE fetch serve both the hidden list and the two
415
+ * questions a list that hides them cannot answer: the open conversation's own
416
+ * archived state, and an unarchive control on a row found by search.
417
+ */
418
+ include_archived: zod.z.boolean().optional(),
419
+ /*
420
+ * Whether conversations OTHER devices hold belong in the answer.
421
+ *
422
+ * ABSENT MEANS `false`, the same compatibility promise `include_archived`
423
+ * makes and for the same reason: the app's own sidebar fetch has always
424
+ * meant "this device's catalogue", and a client that predates the mesh must
425
+ * keep reading exactly that. The Mesh tab is the ONE surface that asks for
426
+ * the federated list, because "which conversation is on which device" is its
427
+ * question and it cannot answer it from `session_count` alone.
428
+ *
429
+ * THE COST IS NOT ZERO, which is why only that surface asks: the backend's
430
+ * peer projection dials each peer's relay under a 12 s fan-out budget and is
431
+ * TTL-cached at 20 s (`network/relay.py`, `session/peer_rows.py`), while a
432
+ * machine in no network short-circuits to no call at all — so the flag costs
433
+ * a paired device one cached fan-out per cadence, and an unpaired one
434
+ * nothing. The sidebar's two-second poll must never carry it.
435
+ */
436
+ include_peers: zod.z.boolean().optional(),
437
+ /*
438
+ * The four parameters that make the catalogue PAGEABLE, and the switch that
439
+ * makes the daemon count it.
440
+ *
441
+ * ALL FOUR ARE OPTIONAL AND DEFAULTED, which is the whole compatibility
442
+ * promise: a request that sends none of them is byte-identical to the one
443
+ * this app sent before they existed, and an older daemon is therefore fully
444
+ * supported. They are gated on the `session_catalogue_page` capability rather
445
+ * than on a `session_catalogue` version bump, for the reason that map's own
446
+ * register states (an EXISTING surface must keep working against a backend
447
+ * that lacks the new one): FastAPI silently ignores unknown query parameters,
448
+ * so an un-gated client asking for `scope_kind=team&scope_name=lopdev` would
449
+ * receive the UNSCOPED page and draw other teams' rows under that team, and an
450
+ * un-gated `cursor` would receive page one again and duplicate it. The client
451
+ * has to be able to ask whether the daemon understands these, and the
452
+ * capability map is how this codebase asks.
453
+ *
454
+ * They travel as ONE contract revision rather than three: counts without the
455
+ * scope could not be rendered consistently with that scope's paged rows.
456
+ */
457
+ scope_kind: catalogueScopeKind.optional(),
458
+ scope_name: catalogueScopeName.optional(),
459
+ cursor: catalogueCursor.optional(),
460
+ with_counts: zod.z.boolean().optional()
461
+ }).strict(),
462
+ /*
463
+ * Acknowledge the one-time delegated-cleanup notice:
464
+ * `POST /v1/desktop/delegated-cleanup-notice/ack`.
465
+ *
466
+ * The notice rides `sessions.list` UNTIL this write lands: the GET is a
467
+ * non-consuming peek (every list answer keeps carrying the field while the
468
+ * store is unacknowledged, which is what lets a second window see the same
469
+ * notice), and this op is the one thing that flips `notice_acknowledged` on
470
+ * disk. The route is idempotent - acknowledging an acknowledged store writes
471
+ * what is already there - so, like `sessions.archive`, it needs no
472
+ * `requestId`: a retry cannot flip anything back.
473
+ */
474
+ zod.z.object({ op: zod.z.literal("delegated_cleanup_notice.ack") }).strict(),
475
+ zod.z.object({
476
+ // Content search over the store: name, id, exact conversation body, and
477
+ // a bounded soft tier. `q` is capped at the backend's own 256-character
478
+ // bound so an over-long query is refused here, by name, rather than by
479
+ // the backend's generic "invalid fields" 422.
480
+ op: zod.z.literal("sessions.search"),
481
+ // `.min(1)`: an EMPTY query is not a search. The backend answers one by
482
+ // listing the whole store (documented there, and used by the phone's web
483
+ // client), but this surface has that list already — its box is a filter
484
+ // over the catalogue — so an empty `q` here would ask the server to send
485
+ // back everything the client is holding. Refusing it by name is how the
486
+ // closed vocabulary stays closed: no caller can send a request whose
487
+ // answer it would have to discard.
488
+ q: zod.z.string().min(1).max(SESSION_SEARCH_MAX_CHARS),
489
+ limit: zod.z.number().int().min(1).max(500).optional(),
490
+ /*
491
+ * Whether the SCAN admits archived conversations, absent meaning `false`
492
+ * (the same compatibility promise `sessions.list` states).
493
+ *
494
+ * The sidebar's "Include archived" control is the only writer, and the flag is
495
+ * in the query's cache key on this side because the two answers to one query
496
+ * are different questions: without that, toggling the control off would serve
497
+ * the answer that carries the archived hits and the rows would linger - the
498
+ * stale-row failure `chat-search.test.mjs` pins.
499
+ */
500
+ include_archived: zod.z.boolean().optional()
501
+ }).strict(),
502
+ /*
503
+ * Archive or unarchive ONE conversation: `POST /v1/desktop/sessions/{id}/archive`.
504
+ *
505
+ * DESIRED STATE ON THE WIRE (`archived: true|false`), never a bare toggle, for
506
+ * the reason the pin op states beside its own field: over HTTP a toggle is not
507
+ * idempotent, and a request retried after a dropped response would flip the
508
+ * conversation back. Idempotent by construction here rather than by a receipt:
509
+ * re-archiving an archived conversation writes what is already there, so the op
510
+ * carries no `requestId` (the same at-most-once trade `sessions.warm` makes for
511
+ * a call that already is).
512
+ *
513
+ * Archiving is RECOVERABLE and therefore never confirmed: it hides the
514
+ * conversation from the default lists and from search, and unarchiving restores
515
+ * it. Deliberately NOT a `MESSAGE_OPS` member (see `desktopRequestByteBudget`):
516
+ * a boolean and a 12-char id are not prose.
517
+ */
518
+ zod.z.object({
519
+ op: zod.z.literal("sessions.archive"),
520
+ sessionId,
521
+ archived: zod.z.boolean()
522
+ }).strict(),
523
+ /*
524
+ * Delete ONE conversation PERMANENTLY: `DELETE /v1/desktop/sessions/{id}`.
525
+ *
526
+ * `confirmed: true` is required and is not a receipt: it is the wire's own echo
527
+ * of the user's answer to a danger dialog, so a caller that has not asked cannot
528
+ * express this request at all (a missing field is a 422 here, by name, rather
529
+ * than a delete nobody confirmed). The route refuses the delete of a session
530
+ * that is LIVE with a 409 and a sentence naming the guard - the one refusal this
531
+ * surface renders inside the dialog that asked, which is why the op needs no
532
+ * failure vocabulary of its own.
533
+ *
534
+ * It removes exactly the addressed conversation and NOT its subagent children;
535
+ * a surface that has children to mention says so in its own copy rather than
536
+ * implying a wider blast radius. No `requestId`: the delete is not retried by
537
+ * this client, and a retry of a delete cannot be owed an answer - the second
538
+ * call's honest answer is 404 and the outcome the user asked for either way.
539
+ */
540
+ zod.z.object({
541
+ op: zod.z.literal("sessions.delete"),
542
+ sessionId,
543
+ confirmed: zod.z.literal(true)
544
+ }).strict(),
545
+ zod.z.object({
546
+ op: zod.z.literal("sessions.create"),
547
+ requestId,
548
+ /*
549
+ * OPTIONAL ONLY FOR A `purpose` CREATE, and the pair is enforced below by
550
+ * the union's own `superRefine` rather than by two union members: a
551
+ * `discriminatedUnion` accepts one member per `op` value, and a `.refine`
552
+ * on a member would make it a `ZodEffects` the union refuses (the same
553
+ * constraint `agent.publish`'s pairing rule states).
554
+ *
555
+ * WHY A CONFIGURATION RUN OMITS IT AT ALL. `cwd` exists so a conversation
556
+ * has a folder to work in, and this app has always refused an empty one
557
+ * precisely because it would resolve to "a directory the user never named".
558
+ * A configuration run edits this device's registries and holds no file
559
+ * tools at all, so its cwd is not authority — and the renderer cannot name
560
+ * one honestly: it would have to invent a path it cannot verify exists.
561
+ * The backend resolves it (to the config directory) when `purpose` is set.
562
+ */
563
+ cwd: zod.z.string().min(1).max(4096).optional(),
564
+ target: target.optional(),
565
+ /*
566
+ * WHAT KIND OF SESSION THIS IS, when it is not a conversation the operator
567
+ * asked for: `agents-config` starts a supervised configuration run (the
568
+ * Agents page's composer), which the backend stamps as a hidden origin and
569
+ * admits through the desktop door for watch/events/messages/interrupt
570
+ * without ever listing it as one of the operator's conversations.
571
+ *
572
+ * A LITERAL, so a typo is a compile error on this side and a 422 on a
573
+ * backend that does not know the value. It is capability-gated before it is
574
+ * ever sent (`agents_config`), because a backend older than this field
575
+ * validates the create body with `extra="forbid"` and would report a
576
+ * malformed request for a request the app deliberately made.
577
+ */
578
+ purpose: zod.z.literal("agents-config").optional(),
579
+ /*
580
+ * OMITTED when the user never picked anything, so the body is the one
581
+ * this op sent before the draft's chips could open: making them
582
+ * actionable is strictly additive, and a `null` here would be a
583
+ * different request for every caller that never asked.
584
+ */
585
+ model: modelSelection.optional(),
586
+ /*
587
+ * The id a `sessions.draft` mint handed the pane, when it has one: the
588
+ * create then adopts that id (and the runtime already engaged for it)
589
+ * instead of minting a fresh session id. OMITTED when the pane never
590
+ * minted — an older backend, a draft the user sent before the first
591
+ * keystroke's mint answered, or a set of fields that changed since the
592
+ * mint (see the store's drop rule) — so the body is byte-for-byte the
593
+ * request this op sent before the draft could be warmed, and a backend
594
+ * that cannot resolve the id mints fresh rather than failing the send.
595
+ */
596
+ draftId: sessionId.optional(),
597
+ /*
598
+ * The DEVICE to create the conversation on (`features.peers`): the mesh route's
599
+ * own field, sent only when the user actually picked one, so the body of an
600
+ * ordinary create is unchanged. Omitted means this device.
601
+ *
602
+ * THE DIRECTORY RULE THAT COMES WITH IT, which is why the app must not offer this
603
+ * choice without saying so: a remote create carries an explicit `cwd` and an EMPTY
604
+ * one resolves to that device's home rather than to this project, so a pane with a
605
+ * peer destination names the directory it will use.
606
+ *
607
+ * `--yolo` and friends are NOT here because the peer's own route refuses them: a
608
+ * remote create that let this machine run tools unattended on another one is the
609
+ * thing the route declines to express.
610
+ */
611
+ peer: meshId.optional()
612
+ }).strict(),
613
+ /*
614
+ * What a NEW conversation's readings would be, without creating anything.
615
+ *
616
+ * The composer's status strip needs a model, an effort ladder and a window
617
+ * BEFORE a session exists, and every honest way to get them was rejected: a
618
+ * `sessions.create` on pane open writes a directory and a marker, so every
619
+ * abandoned new-chat pane would leave a visible empty row in the sidebar,
620
+ * and composing it in the renderer from `config.get` + `models.catalogue`
621
+ * moves model RESOLUTION (Python policy) into the app and reports nothing
622
+ * when the pair is absent from the catalogue. This op runs the backend's own
623
+ * cold resolution and returns the same canonical projection a cold session
624
+ * publishes, so the strip keeps one arithmetic path and the draft cannot
625
+ * disagree with the session the first send creates.
626
+ *
627
+ * Body and response mirror `sessions.create` deliberately: same `cwd`, same
628
+ * optional `target`, same `model` and same 422 for an unresolvable profile —
629
+ * the pane is asking the question it will ask for real on the first send, so
630
+ * the two bodies are derived from one selection. The response is a
631
+ * `CanonicalFrontendSync` — the wire shape `sessions.watch` streams — whose
632
+ * `snapshot.session_id` is EMPTY, because there is no session. The renderer
633
+ * passes it to the strip and never into the canonical sessions store.
634
+ */
635
+ zod.z.object({
636
+ op: zod.z.literal("sessions.preview"),
637
+ requestId,
638
+ cwd: zod.z.string().min(1).max(4096),
639
+ target: target.optional(),
640
+ /* Present only when the pane's chips were used: the preview then answers
641
+ the reading the CHOSEN model gives, which is the ladder and window the
642
+ first turn will actually get. */
643
+ model: modelSelection.optional()
644
+ }).strict(),
645
+ /*
646
+ * Mint the id a NEW chat's runtime is warmed and then born on, from the
647
+ * pane's first keystroke.
648
+ *
649
+ * WHY THE OP EXISTS. A draft pane has no session to address, so there is
650
+ * nothing to warm: `sessions.warm` needs an id, and the multi-second engage
651
+ * the first send pays is exactly what the draft cannot pre-empt without one.
652
+ * The mint allocates that id and registers it with the daemon (fast, no
653
+ * engage), and the id then unlocks the same three doors a session pane uses
654
+ * — `events`, `watch` (the lease) and `warm` — through a deliberately narrow
655
+ * allow-list on the backend. `sessions.create` adopts the id on send, so the
656
+ * conversation the user lands in IS the one that was warmed; a daemon that
657
+ * has never seen the id (restart, expiry, eviction) mints fresh and the send
658
+ * works exactly as it did before this op existed.
659
+ *
660
+ * THE MINT ENGAGES NOTHING, and that is the lifetime design rather than an
661
+ * omission: the pane's own subscription and watch lease hold the bridge that
662
+ * keeps a warm alive, exactly as they do for a session, so abandoning the
663
+ * pane cancels an in-flight warm through the same `_detach` and a runtime
664
+ * nobody holds is reaped by the residency drain. There is no second, warmer-
665
+ * owned lifetime to get wrong.
666
+ *
667
+ * `requestId` IS A RECEIPT KEY, unlike `sessions.preview`'s token: a mint is
668
+ * fired once per pane, and a retry — a fast second keystroke, a lost
669
+ * response — must replay the SAME id, because two ids for one pane would
670
+ * warm two runtimes and leave a registry entry nobody can ever consume.
671
+ *
672
+ * The body is `sessions.create`'s first half, deliberately: same `cwd` bounds,
673
+ * same optional `target`, same optional `model` and the same 422 for an
674
+ * unresolvable profile. The pane is asking the question it will ask for real
675
+ * on the first send, so both derive from one selection and cannot disagree.
676
+ *
677
+ * Deliberately NOT a `MESSAGE_OPS` member (see `desktopRequestByteBudget`):
678
+ * a path and two optional short ids are not prose, so the mint costs the
679
+ * control budget — which is what lets a KEYSTROKE issue it.
680
+ */
681
+ zod.z.object({
682
+ op: zod.z.literal("sessions.draft"),
683
+ requestId,
684
+ cwd: zod.z.string().min(1).max(4096),
685
+ target: target.optional(),
686
+ model: modelSelection.optional()
687
+ }).strict(),
688
+ zod.z.object({ op: zod.z.literal("sessions.get"), sessionId }).strict(),
689
+ zod.z.object({
690
+ op: zod.z.literal("sessions.history"),
691
+ sessionId,
692
+ beforeId: id.optional(),
693
+ limit: zod.z.number().int().min(1).max(500).optional()
694
+ }).strict(),
695
+ /*
696
+ * The transcript checkpoint rail's manifest (design D1/D9): every material
697
+ * checkpoint of one LOCAL conversation — the reader's own messages and each
698
+ * completed turn — with the seq-proportional ordinal the rail places a tick
699
+ * by, and a completion's optional generated name.
700
+ *
701
+ * The answer is deliberately cheap and never blocks on a scan: a cold or
702
+ * stale index answers `state: "building"` while a refresh runs in the
703
+ * background (the backend measures 22 s for this machine's 272 MB journal),
704
+ * so the rail's first paint is immediate and the hook polls while anything
705
+ * it asked for is still pending. A remote/peer conversation answers
706
+ * `state: "unsupported"` — a fact about where the bytes are, not a failure
707
+ * — and the rail hides, the same degradation as an empty manifest.
708
+ */
709
+ zod.z.object({ op: zod.z.literal("sessions.checkpoints"), sessionId }).strict(),
710
+ /*
711
+ * Buy names for checkpoints (design D2/D9): idempotent, bounded, and never
712
+ * blocking on the model call itself — the backend schedules one
713
+ * `complete_once` per turn (15 s budget, single attempt, a 10-minute
714
+ * cooldown after a failure) and answers accepted/pending immediately.
715
+ *
716
+ * `ids` is the hover gesture's arm (a bounded set of explicit checkpoint
717
+ * ids); omitting it is the rail-open arm, where the backend selects the
718
+ * most recent checkpoints missing names under its own default. Both fields
719
+ * are optional, and an older backend that predates the op answers the same
720
+ * 404/422 a missing route always does — the hook treats any failure as "no
721
+ * rail here", never as a user-facing error.
722
+ */
723
+ zod.z.object({
724
+ op: zod.z.literal("sessions.checkpoints.warm"),
725
+ sessionId,
726
+ ids: zod.z.array(id).max(CHECKPOINT_WARM_MAX_IDS).optional(),
727
+ limit: zod.z.number().int().min(1).max(CHECKPOINT_WARM_MAX_IDS).optional()
728
+ }).strict(),
729
+ /*
730
+ * In-thread find (D9): messages of ONE conversation matching `q`, best
731
+ * first, served from the per-session transcript index.
732
+ *
733
+ * A READ like `history` and `checkpoints` beside it. The answer's `state` is
734
+ * the checkpoint manifest's own ladder: a cold or stale index answers
735
+ * `building` inside the first-paint budget (with hits ranked from the
736
+ * previous scan marked `partial`) while the background refresh runs, so the
737
+ * overlay's first paint is immediate; `unsupported` is a peer conversation
738
+ * whose journal is not on this device; `error` is a failed refresh inside
739
+ * its cooldown. The overlay renders both tiers (`exact`/`soft`) and treats
740
+ * `ranges` as snippet-relative — empty on a soft hit, which has no literal
741
+ * occurrence of the query by construction.
742
+ */
743
+ zod.z.object({
744
+ op: zod.z.literal("sessions.find"),
745
+ sessionId,
746
+ // `.min(1)`: an empty query is not a search. The overlay's client never
747
+ // sends one — it answers an empty query locally, without a request — so
748
+ // refusing it by name keeps the vocabulary closed rather than paying a
749
+ // round trip for an answer the box already knows.
750
+ q: zod.z.string().min(1).max(THREAD_FIND_MAX_CHARS),
751
+ limit: zod.z.number().int().min(1).max(THREAD_FIND_MAX_LIMIT).optional()
752
+ }).strict(),
753
+ /*
754
+ * One child's durable transcript, for the run panel's reader
755
+ * (`docs/run-sidebar.md` § 10.1, § 10.3).
756
+ *
757
+ * Both ids are the same `^[a-f0-9]{12}$` the whole desktop surface already
758
+ * validates on, and NEITHER is a path: the child directory is resolved by the
759
+ * route from the id and from the parent's own roster, so the renderer cannot
760
+ * name a directory at all. That containment is the route's, not the caller's —
761
+ * a schema that accepted a path here would be the whole boundary.
762
+ *
763
+ * `beforeId` is an entry id (max 128 chars, the `id` shape) rather than an
764
+ * offset, matching `sessions.history`: file compaction replaces the JSONL
765
+ * atomically, so offsets become lies while ids stay meaningful.
766
+ */
767
+ zod.z.object({
768
+ op: zod.z.literal("subagents.transcript"),
769
+ sessionId,
770
+ childId: sessionId,
771
+ beforeId: id.optional(),
772
+ limit: zod.z.number().int().min(1).max(500).optional()
773
+ }).strict(),
774
+ zod.z.object({
775
+ op: zod.z.literal("sessions.message"),
776
+ sessionId,
777
+ requestId,
778
+ text: zod.z.string().max(DESKTOP_MESSAGE_MAX_CHARS),
779
+ images: zod.z.array(sessionImage).max(DESKTOP_MESSAGE_MAX_IMAGES).optional(),
780
+ mode: zod.z.enum(["prompt", "steer"]).optional(),
781
+ /*
782
+ * HOW THE MESSAGE WAS PRODUCED (arch §4.2), and the harness gate is what
783
+ * keeps this `optional`: `features.input_mode`. Absent means a legacy
784
+ * body (and is what every older build sends), so an older harness's own
785
+ * `.strict()` schema never sees the key at all.
786
+ *
787
+ * `inputPath` is RESERVED (§4.2a): the route cascade owns its
788
+ * vocabulary and no caller sets it yet; it is accepted here - bounded -
789
+ * so the first caller that does does not also need a contract change.
790
+ */
791
+ inputMode: zod.z.enum(["typed", "dictated", "mixed"]).optional(),
792
+ inputPath: zod.z.string().max(1024).optional()
793
+ }).strict(),
794
+ zod.z.object({
795
+ op: zod.z.literal("sessions.command"),
796
+ sessionId,
797
+ requestId,
798
+ command: zod.z.string().regex(/^\/?[A-Za-z]+$/).max(64),
799
+ args: zod.z.string().max(DESKTOP_MESSAGE_MAX_CHARS).optional(),
800
+ images: zod.z.array(sessionImage).max(DESKTOP_MESSAGE_MAX_IMAGES).optional()
801
+ }).strict(),
802
+ /*
803
+ * Point a LIVE session at another working directory (the desktop's `/move`).
804
+ *
805
+ * Its own op rather than an argument to `sessions.command`, and the reason is
806
+ * where the work happens: the move is executed in the SERVER process against
807
+ * the session's viewer (`POST
808
+ * /v1/desktop/sessions/{id}/working-directory`), while the command endpoint
809
+ * answers a `NativeAction | OwnerCommandResult` - the union's other member is
810
+ * the RUNTIME's own slash answer, and a move produces neither. `/move`
811
+ * therefore stays a presentation request in the backend's command catalogue
812
+ * (`desktop_destination="session.move"`): a bare `/move` opens the picker,
813
+ * and the argument form and the chip both call this op instead. The shape is
814
+ * `sessions.warm`'s: a lifecycle operation on the session's runtime, with its
815
+ * own receipt.
816
+ *
817
+ * Deliberately NOT a `MESSAGE_OPS` member (see `desktopRequestByteBudget`): a
818
+ * path is not prose, and claiming image-sized room for a 4 KB field would
819
+ * spend the message budget on nothing. `cwd` carries the same bound as
820
+ * `sessions.create`/`sessions.preview` because it reaches the same route
821
+ * model, which refuses anything else.
822
+ */
823
+ zod.z.object({
824
+ op: zod.z.literal("sessions.move"),
825
+ sessionId,
826
+ requestId,
827
+ cwd: zod.z.string().min(1).max(4096)
828
+ }).strict(),
829
+ zod.z.object({
830
+ op: zod.z.literal("sessions.answer"),
831
+ sessionId,
832
+ /**
833
+ * The owner epoch, for the two GATE shapes. Optional here because a queued
834
+ * ask has no epoch to give — `ask_id` is the other selector and the checks
835
+ * below require exactly one of the pair.
836
+ */
837
+ epoch: id.optional(),
838
+ requestId: id.optional(),
839
+ value: zod.z.string().max(32768).optional(),
840
+ approved: zod.z.boolean().optional(),
841
+ questionIndex: zod.z.number().int().min(0).optional(),
842
+ /**
843
+ * The queued ask's id (`a-3f9c`). Its presence selects the THIRD body shape —
844
+ * `{ask_id, answers}` or `{ask_id, decline}` — and, per the backend's own
845
+ * contract, makes `epoch` irrelevant rather than optional-but-checked: an ask
846
+ * outlives the owner that queued it, so requiring an epoch would refuse
847
+ * exactly the case the feature exists for (a cold session whose asks are
848
+ * still open). The single-winner rule that the epoch check used to provide
849
+ * now lives on the ask log, where the first `answered` event wins.
850
+ */
851
+ askId: id.optional(),
852
+ /**
853
+ * The WHOLE ask's answers, keyed by question id, each a list because a
854
+ * question may be multi-select.
855
+ *
856
+ * One atomic body rather than a per-question stream: the blocking path
857
+ * answered one question at a time over the wire, and a client that died
858
+ * part-way left an ask half-settled — the exact state the atomic submit
859
+ * exists to make unrepresentable.
860
+ */
861
+ answers: zod.z.record(zod.z.string(), zod.z.array(zod.z.string().max(32768))).optional(),
862
+ /** "No answer — decide yourself", the explicit form of today's Esc. */
863
+ decline: zod.z.boolean().optional(),
864
+ /**
865
+ * CHANGE a recorded answer instead of submitting a first one (design §10,
866
+ * #1936).
867
+ *
868
+ * A modifier on the SAME `{ask_id, answers}` body, and it has to be sent
869
+ * explicitly because the intent is not recoverable from the values: equal
870
+ * values are not a retry marker and different values are not a revision
871
+ * (§10 — "no layer may infer a revision by comparing values"). The backend
872
+ * accepts it only while the answer is still UNDELIVERED and refuses it with
873
+ * its own sentence once the response row exists; this client does not
874
+ * pre-judge that, it renders the owner's refusal.
875
+ */
876
+ revise: zod.z.boolean().optional()
877
+ }).strict(),
878
+ zod.z.object({
879
+ op: zod.z.literal("sessions.seen"),
880
+ sessionId,
881
+ completionToken: zod.z.string().uuid()
882
+ }).strict(),
883
+ /*
884
+ * Bulk acknowledgement: mark many completions read in ONE user gesture
885
+ * (`POST /v1/desktop/attention/seen`).
886
+ *
887
+ * Its OWN op rather than a loop over `sessions.seen`, for two reasons that are
888
+ * both about what the receipt MEANS. The backend writes the whole batch in one
889
+ * `BEGIN IMMEDIATE`, so an observer sees the pre-batch or the post-batch receipt
890
+ * set and never a prefix; N round trips would expose a partial batch to every
891
+ * poller, and a failure half way would leave the user unable to tell which
892
+ * marks went. And the items are TOKEN-bound on purpose: the batch names exactly
893
+ * the completions the client has RENDERED, so a completion published between
894
+ * the render and the click is not in it and stays unread. A watermark sweep
895
+ * ("acknowledge everything now") would clear exactly that result instead, which
896
+ * is the hazard the per-session receipt exists to prevent — so this op never
897
+ * carries a time, a count or a "all" flag, only the pairs the client observed.
898
+ *
899
+ * `items` is 1..500: 500 is the catalogue's own maximum page, so every row the
900
+ * sidebar can hold is sendable in one call and `markAllRead` never has to
901
+ * chunk (chunking would break "one gesture, one request").
902
+ */
903
+ zod.z.object({
904
+ op: zod.z.literal("attention.seen"),
905
+ items: zod.z.array(
906
+ zod.z.object({
907
+ sessionId,
908
+ // A REAL uuid, like `sessions.seen`: a token that is not one cannot
909
+ // name a completion, so it is refused here rather than round-tripped
910
+ // to answer `unknown` for an item the client should not have sent.
911
+ completionToken: zod.z.string().uuid()
912
+ }).strict()
913
+ ).min(1).max(500)
914
+ }).strict(),
915
+ // Cross-surface delivery claim, NOT a read receipt. `claim_delivery`
916
+ // serialises the observers that can see one completion (a TUI, this app) so
917
+ // exactly one of them toasts it. It deliberately never advances the read
918
+ // watermark: routing a notification must not clear the sidebar's unseen mark
919
+ // for a session the user never opened, which is why this is its own op and
920
+ // not a reuse of `sessions.seen`.
921
+ zod.z.object({
922
+ op: zod.z.literal("sessions.notified"),
923
+ sessionId,
924
+ completionToken: zod.z.string().uuid()
925
+ }).strict(),
926
+ /*
927
+ * Pin or unpin a conversation, in the SHARED pin store the terminal's `f10`
928
+ * writes (`local_operator/tui/sidebar_pins.py`), so a conversation pinned in
929
+ * the TUI is pinned in this app and the other way round.
930
+ *
931
+ * DESIRED STATE ON THE WIRE (`pinned: true|false`), never a bare toggle, and
932
+ * this is the decision the op exists to carry. The TUI's verb is a toggle
933
+ * because it is a keypress; over HTTP a toggle is not idempotent - a request
934
+ * retried after a dropped response flips the pin back, and the user reports
935
+ * "the pin keeps un-pinning itself" in the one feature whose whole value is
936
+ * reliability. The route makes the call idempotent BY CONSTRUCTION instead of
937
+ * putting it on the receipt ladder: re-pinning an already-pinned conversation
938
+ * is a no-op that does not reorder, and unpinning an unpinned one writes
939
+ * nothing. That is also why there is no `requestId` here - at-most-once is
940
+ * bought only for calls that ADMIT WORK (`sessions.warm` carries no receipt
941
+ * for the same reason), and a call that is already idempotent needs none.
942
+ *
943
+ * Deliberately NOT a `MESSAGE_OPS` member (see `desktopRequestByteBudget`):
944
+ * a boolean and a 12-char id are not prose, so this costs the control budget.
945
+ */
946
+ zod.z.object({
947
+ op: zod.z.literal("sessions.pin"),
948
+ sessionId,
949
+ pinned: zod.z.boolean()
950
+ }).strict(),
951
+ zod.z.object({
952
+ op: zod.z.literal("sessions.watch"),
953
+ sessionId,
954
+ subscriptionId: zod.z.string().regex(SUBSCRIPTION_ID_PATTERN),
955
+ visible: zod.z.boolean(),
956
+ canNotify: zod.z.boolean()
957
+ }).strict(),
958
+ // Speculative runtime engage. Carries no payload BY DESIGN: it admits no
959
+ // work, so it is not receipt-keyed and not a `MESSAGE_OPS` member - see
960
+ // `desktopRequestByteBudget`. Firing it costs the control budget and a
961
+ // 2-byte body, which is what lets the renderer issue it from a keystroke.
962
+ zod.z.object({ op: zod.z.literal("sessions.warm"), sessionId }).strict(),
963
+ /*
964
+ * Stop the session's CURRENT TURN and the work under it, leaving the session
965
+ * and its process alive: the composer's Stop control and the Escape that is
966
+ * its accelerator.
967
+ *
968
+ * Its OWN op rather than an argument to `sessions.command`, because the
969
+ * command endpoint answers a presentation form: `/stop` is a catalogue entry
970
+ * whose `native_action` asks the client to open the session-stop picker, so a
971
+ * body of `{command: "stop"}` there answers 200, changes nothing, and reads as
972
+ * a stop that worked. That is the reported defect this op exists to remove.
973
+ *
974
+ * NOT `sessions.stop` either, and the two are one letter apart on purpose of
975
+ * naming rather than of meaning: `sessions.stop` is the KILL SWITCH (deny the
976
+ * pending gates, dispose the runtime, release the writer lease, unpublish,
977
+ * exit) reached from the `/stop` picker, and it is the rung ABOVE this one. A
978
+ * client that could not interrupt a turn can still stop a session; a backend
979
+ * that can do the second must not be told it can do the first, which is why
980
+ * the capability key is new rather than a `lifecycle` bump.
981
+ *
982
+ * `requestId` is the route's receipt key: "make the current turn stop" is
983
+ * idempotent and creates or destroys nothing, so the same id replayed with the
984
+ * same body answers the first receipt rather than interrupting twice.
985
+ *
986
+ * Deliberately NOT a `MESSAGE_OPS` member (see `desktopRequestByteBudget`): a
987
+ * uuid is not prose, so this costs the control budget.
988
+ */
989
+ zod.z.object({ op: zod.z.literal("sessions.interrupt"), sessionId, requestId }).strict(),
990
+ /*
991
+ * A session's code memory: the names the session's own cells have left in its
992
+ * eval namespace.
993
+ *
994
+ * Session-addressed, because the runtime is what holds a namespace and the
995
+ * kernel it lives in is keyed by session id. The legacy
996
+ * `/v1/agents/{id}/execution-variables` route answered through the agent
997
+ * registry instead, which resolves agent-directory UUIDs - so a canonical
998
+ * session id could only ever 404 there, and the panel that taught "code
999
+ * memory" never loaded once.
1000
+ */
1001
+ zod.z.object({ op: zod.z.literal("sessions.variables.list"), sessionId }).strict(),
1002
+ zod.z.object({
1003
+ op: zod.z.literal("sessions.variables.create"),
1004
+ sessionId,
1005
+ key: variableKey,
1006
+ value: variableValue,
1007
+ type: variableType
1008
+ }).strict(),
1009
+ zod.z.object({
1010
+ op: zod.z.literal("sessions.variables.update"),
1011
+ sessionId,
1012
+ key: variableKey,
1013
+ value: variableValue,
1014
+ type: variableType
1015
+ }).strict(),
1016
+ zod.z.object({
1017
+ op: zod.z.literal("sessions.variables.delete"),
1018
+ sessionId,
1019
+ key: variableKey
1020
+ }).strict(),
1021
+ // Machine-wide desktop presence. NOT a watch lease and deliberately not
1022
+ // shaped like one: it names no session, because the fact it carries is
1023
+ // "somebody is at this machine's screen and this app can raise a banner",
1024
+ // which is a property of the app rather than of any conversation. The
1025
+ // backend reads it to decide whether a BACKGROUND completion is worth a
1026
+ // banner here at all (a session runtime with no surface to present on
1027
+ // otherwise toasts on its own host, where nobody may be).
1028
+ //
1029
+ // `subscription_id` is the LIVE feed subscription the presence belongs to,
1030
+ // not a per-session watch subscription: the server holds the lease against
1031
+ // that socket, so a dropped feed revokes the claim without waiting for a
1032
+ // heartbeat to go stale. That is the whole reason presence is a route on the
1033
+ // feed rather than a local file - a desktop paired to a backend on another
1034
+ // host cannot write to that host's disk.
1035
+ zod.z.object({
1036
+ op: zod.z.literal("sessions.presence"),
1037
+ subscriptionId: zod.z.string().regex(/^[a-f0-9]{1,64}$/),
1038
+ canNotify: zod.z.boolean(),
1039
+ /*
1040
+ * WHY THE CLAIM CARRIES MORE THAN "a desktop is connected".
1041
+ *
1042
+ * The backend's delivery lease answers two separate questions from
1043
+ * this one beat, and a claim that names neither is a claim that
1044
+ * delivers NOTHING:
1045
+ *
1046
+ * - `can_notify_kinds` is what `delivers(kind)` reads. Empty (the
1047
+ * default for a client that forgot) means rung 2 is never eligible
1048
+ * for any completion, so rung 4 raises the runtime's own banner —
1049
+ * earlier, and its claim advances the read watermark, so the
1050
+ * clickable banner this app exists to raise never composes.
1051
+ * The backend's own test for this is
1052
+ * `test_a_claim_with_no_kinds_claims_nothing`: an app that does not
1053
+ * advertise must not win the rung.
1054
+ * - `window` (with `session_id`) is what `attended` reads. Without it
1055
+ * this app counts as watching NOTHING, so a completion in the
1056
+ * conversation on screen raises a banner — the inverse of rung 1,
1057
+ * and a regression against the per-session flag it replaces.
1058
+ *
1059
+ * `window` is sent even when there is no window: a windowless app
1060
+ * (macOS, alive in the dock) can still raise a banner but cannot be
1061
+ * displaying anything, which is why `session_id` must be "" there
1062
+ * rather than the last conversation the closed window held.
1063
+ */
1064
+ canNotifyKinds: zod.z.array(zod.z.enum(["complete", "error"])).max(4),
1065
+ sessionId: zod.z.string().regex(/^([a-f0-9]{12})?$/),
1066
+ window: zod.z.object({
1067
+ exists: zod.z.boolean(),
1068
+ focused: zod.z.boolean(),
1069
+ visible: zod.z.boolean(),
1070
+ minimized: zod.z.boolean()
1071
+ }).strict()
1072
+ }).strict(),
1073
+ // The legacy surface, reached through the same authenticated vocabulary as
1074
+ // everything else. These routes are gated in managed mode (agent inventory,
1075
+ // cwd paths, job history and conversation content are the same tenant's data
1076
+ // as the control plane), so a bare renderer fetch would 401 against exactly
1077
+ // the backend this app starts.
1078
+ zod.z.object({
1079
+ op: zod.z.literal("legacy.models"),
1080
+ provider: zod.z.string().max(128).optional(),
1081
+ // Mirrors ModelSortKey / ModelSortDirection in models-api.ts. Enumerated
1082
+ // rather than free text so the renderer cannot smuggle a query fragment.
1083
+ sort: zod.z.enum(["id", "name", "provider", "recommended"]).optional(),
1084
+ direction: zod.z.enum(["ascending", "descending"]).optional()
1085
+ }).strict(),
1086
+ zod.z.object({ op: zod.z.literal("legacy.models.providers") }).strict(),
1087
+ // Reachability is checked only when the user asks for it: a probe is a real
1088
+ // network round trip, and nothing behind first paint may make one.
1089
+ zod.z.object({ op: zod.z.literal("auth.probe"), provider: id }).strict(),
1090
+ zod.z.object({ op: zod.z.literal("legacy.agent.upload"), agentId: id }).strict(),
1091
+ /*
1092
+ * Publication, beside the legacy zip upload it replaces for this surface.
1093
+ *
1094
+ * `legacy.agent.upload` stays exactly as it is — it is how an agent published
1095
+ * under the old standard is still updated, and an older desktop build still
1096
+ * pushes through it — but the app's own publish action moves onto the document
1097
+ * ops below, which carry the instruction set instead of a zip of the agent
1098
+ * directory.
1099
+ */
1100
+ zod.z.object({
1101
+ op: zod.z.literal("agent.publish"),
1102
+ agentId: id,
1103
+ document: publicationDocument.optional(),
1104
+ /*
1105
+ * The publication TARGET (§4.4/§4.7). Both travel together or neither does,
1106
+ * and the rule is a `superRefine` on the whole UNION (below) rather than a
1107
+ * `.refine` here — a refined member becomes a `ZodEffects`, which cannot be a
1108
+ * member of a discriminated union; measured, it does not compile.
1109
+ *
1110
+ * WHY THE COMMENT CHANGED (security review round 1, S-1). It used to say a
1111
+ * half-specified target was "refused there rather than silently published to
1112
+ * the public hub". The local server's route does refuse one, but
1113
+ * `desktopEndpoint` dropped an unpaired half before the request was composed,
1114
+ * so what reached the server was indistinguishable from a deliberate public
1115
+ * publication: the documented guarantee held at neither boundary, and it
1116
+ * failed OPEN on a privacy-relevant target. Measured at the previous head:
1117
+ * each half parsed on its own and `desktopEndpoint({visibility: "org"})`
1118
+ * composed a plain `/publish` with no query at all.
1119
+ *
1120
+ * Absent means the public hub, which is exactly what every caller did before
1121
+ * this field existed — so the rule is "one half is an error", not "absent is
1122
+ * an error".
1123
+ */
1124
+ visibility: zod.z.literal("org").optional(),
1125
+ tenantId: id.optional()
1126
+ }).strict(),
1127
+ zod.z.object({
1128
+ op: zod.z.literal("agent.republish"),
1129
+ agentId: id,
1130
+ // The HUB listing to update. Required rather than inferred: the local
1131
+ // registry keeps no link to the listing a row was published as, so a
1132
+ // republish that did not name one would have to guess which public row to
1133
+ // overwrite — and guessing here overwrites somebody's listing.
1134
+ hubAgentId: id,
1135
+ document: publicationDocument.optional(),
1136
+ /** The publication target, on the same terms as `agent.publish` above. */
1137
+ visibility: zod.z.literal("org").optional(),
1138
+ tenantId: id.optional()
1139
+ }).strict(),
1140
+ /*
1141
+ * Pulling a published organization team into this machine's local registry
1142
+ * (§4.5/§8.4's "list + pull action").
1143
+ *
1144
+ * The id is the HUB document's id, not a local row's: the pull addresses what
1145
+ * was published, and the local copy gets its own fresh id (the local server's
1146
+ * `GET /v1/teams/pull/{team_id}` reconstructs it, renaming on a local id
1147
+ * clash through the registry's own convention). `tenantId` is OPTIONAL and is
1148
+ * the caller's statement of which organization owns the document, not part of
1149
+ * the address: §4.5's pull path is org-agnostic by id, the local server
1150
+ * verifies the claim and refuses a document owned by another tenant, and the
1151
+ * credential that reads it is the one the local server already holds.
1152
+ */
1153
+ zod.z.object({
1154
+ op: zod.z.literal("team.pull"),
1155
+ teamId: id,
1156
+ /*
1157
+ * The caller's statement of which organization owns the document. The local
1158
+ * server verifies it and refuses a document owned by another tenant rather
1159
+ * than storing it under the wrong expectation, so sending it is a stronger
1160
+ * read where the caller knows the org — and omitting it is still legal
1161
+ * (§4.5's pull path is org-agnostic by id).
1162
+ */
1163
+ tenantId: id.optional()
1164
+ }).strict(),
1165
+ zod.z.object({
1166
+ op: zod.z.literal("agent.nameAvailability"),
1167
+ // Deliberately looser than `publicationName`: this op asks whether a name
1168
+ // is publishable, and a name that breaks the rules is a question the
1169
+ // dialog does not ask (it shows the rule locally instead). The route is
1170
+ // public, so the only thing this bound has to keep out is a query the
1171
+ // backend can make no sense of.
1172
+ name: zod.z.string().min(1).max(PUBLICATION_NAME_MAX_CHARS)
1173
+ }).strict(),
1174
+ zod.z.object({
1175
+ op: zod.z.literal("legacy.agents.list"),
1176
+ page: zod.z.number().int().min(1).optional(),
1177
+ perPage: zod.z.number().int().min(1).max(500).optional(),
1178
+ name: zod.z.string().max(256).optional(),
1179
+ sort: zod.z.string().max(64).optional(),
1180
+ direction: zod.z.enum(["asc", "desc"]).optional()
1181
+ }).strict(),
1182
+ zod.z.object({ op: zod.z.literal("legacy.agent.get"), agentId: id }).strict(),
1183
+ zod.z.object({
1184
+ op: zod.z.literal("legacy.agent.history"),
1185
+ agentId: id,
1186
+ page: zod.z.number().int().min(1).optional(),
1187
+ perPage: zod.z.number().int().min(1).max(500).optional()
1188
+ }).strict(),
1189
+ zod.z.object({
1190
+ op: zod.z.literal("legacy.jobs.list"),
1191
+ agentId: id.optional(),
1192
+ status: zod.z.string().max(64).optional()
1193
+ }).strict(),
1194
+ zod.z.object({ op: zod.z.literal("legacy.job.get"), jobId: id }).strict(),
1195
+ // The schedules surface. Gated in managed mode not because a schedule is
1196
+ // sensitive to READ but because both writes hand their `prompt` to the
1197
+ // scheduler, which later runs it as the user's own agent -- so a bare
1198
+ // renderer fetch would 401 against exactly the backend this app starts.
1199
+ zod.z.object({
1200
+ op: zod.z.literal("legacy.schedules.list"),
1201
+ page: zod.z.number().int().min(1).optional(),
1202
+ perPage: zod.z.number().int().min(1).max(100).optional()
1203
+ }).strict(),
1204
+ zod.z.object({
1205
+ op: zod.z.literal("legacy.agent.schedules.list"),
1206
+ agentId: id,
1207
+ page: zod.z.number().int().min(1).optional(),
1208
+ perPage: zod.z.number().int().min(1).max(100).optional()
1209
+ }).strict(),
1210
+ zod.z.object({
1211
+ op: zod.z.literal("legacy.agent.schedule.create"),
1212
+ agentId: id,
1213
+ // Passed through as the request body. Declared field-by-field rather
1214
+ // than as a passthrough object so the renderer cannot smuggle keys the
1215
+ // backend model would silently accept.
1216
+ schedule: scheduleWrite.extend({
1217
+ prompt: zod.z.string().max(64e3),
1218
+ interval: zod.z.number().int().min(1),
1219
+ unit: scheduleUnit
1220
+ })
1221
+ }).strict(),
1222
+ zod.z.object({ op: zod.z.literal("legacy.schedule.get"), scheduleId: id }).strict(),
1223
+ zod.z.object({
1224
+ op: zod.z.literal("legacy.schedule.edit"),
1225
+ scheduleId: id,
1226
+ // Every field optional: the backend applies `exclude_unset`, so sending
1227
+ // a key the user did not touch would overwrite it with a default.
1228
+ schedule: scheduleWrite.extend({
1229
+ prompt: zod.z.string().max(64e3).nullish(),
1230
+ interval: zod.z.number().int().min(1).nullish(),
1231
+ unit: scheduleUnit.nullish()
1232
+ })
1233
+ }).strict(),
1234
+ zod.z.object({ op: zod.z.literal("legacy.schedule.remove"), scheduleId: id }).strict(),
1235
+ // The remaining gated legacy writes and reads the renderer still issued as
1236
+ // bare same-origin `fetch`. `apiConfig.baseUrl` points AT THE BACKEND, not
1237
+ // through the main-process relay, so in managed mode these went out with no
1238
+ // bearer and 401'd -- agent creation among them, which is the app's most
1239
+ // basic action (review round 3, Q7).
1240
+ zod.z.object({
1241
+ op: zod.z.literal("legacy.agent.create"),
1242
+ // The body is forwarded whole: the backend model owns which fields
1243
+ // exist, and enumerating ~20 optional agent fields here would be a
1244
+ // second schema to keep in step with it. `strict()` on the wrapper
1245
+ // still stops any key outside `agent` from riding along.
1246
+ agent: zod.z.record(zod.z.unknown())
1247
+ }).strict(),
1248
+ zod.z.object({
1249
+ op: zod.z.literal("legacy.agent.update"),
1250
+ agentId: id,
1251
+ update: zod.z.record(zod.z.unknown())
1252
+ }).strict(),
1253
+ zod.z.object({ op: zod.z.literal("legacy.agent.delete"), agentId: id }).strict(),
1254
+ zod.z.object({ op: zod.z.literal("legacy.agent.conversation.clear"), agentId: id }).strict(),
1255
+ zod.z.object({ op: zod.z.literal("legacy.agent.systemPrompt.get"), agentId: id }).strict(),
1256
+ zod.z.object({
1257
+ op: zod.z.literal("legacy.agent.systemPrompt.update"),
1258
+ agentId: id,
1259
+ systemPrompt: zod.z.string().max(DESKTOP_SYSTEM_PROMPT_MAX_CHARS)
1260
+ }).strict(),
1261
+ zod.z.object({ op: zod.z.literal("legacy.agent.download"), agentId: id }).strict(),
1262
+ zod.z.object({ op: zod.z.literal("legacy.job.cancel"), jobId: id }).strict(),
1263
+ zod.z.object({ op: zod.z.literal("commands.list") }).strict(),
1264
+ zod.z.object({
1265
+ op: zod.z.literal("commands.entities"),
1266
+ sessionId,
1267
+ command: id,
1268
+ name: zod.z.string().max(128).optional()
1269
+ }).strict(),
1270
+ zod.z.object({
1271
+ op: zod.z.literal("models.catalogue"),
1272
+ live: zod.z.boolean().optional(),
1273
+ /*
1274
+ * Which view of the catalogue to answer with: `usable` (the rows this
1275
+ * machine can run, plus the session's current model) or `all`. Absent
1276
+ * means `all`, which is also what a backend that predates the
1277
+ * parameter does with it — the picker stays correct either way, because
1278
+ * a compile-time-correct request against an old backend is filtered
1279
+ * client-side by `scopeCatalogue`.
1280
+ */
1281
+ scope: zod.z.enum(["usable", "all"]).optional()
1282
+ }).strict(),
1283
+ zod.z.object({
1284
+ op: zod.z.literal("usage.get"),
1285
+ provider: id.optional(),
1286
+ live: zod.z.boolean().optional(),
1287
+ refresh: zod.z.boolean().optional()
1288
+ }).strict(),
1289
+ zod.z.object({
1290
+ op: zod.z.literal("analytics.get"),
1291
+ sessionId: sessionId.optional(),
1292
+ sinceMs: zod.z.number().int().nonnegative().optional(),
1293
+ untilMs: zod.z.number().int().nonnegative().optional(),
1294
+ days: zod.z.number().int().min(1).max(366).optional()
1295
+ }).strict(),
1296
+ /*
1297
+ * The per-model rate table's own read, and a SEPARATE op on purpose.
1298
+ *
1299
+ * Its rows come from a grouped scan of the RAW LEDGER rather than from the
1300
+ * rollup `analytics.get` reads, which is what makes it cover the operator's
1301
+ * whole existing history rather than only the days since the rollup shipped
1302
+ * — and is also what makes it cost seconds on a large ledger. Riding
1303
+ * `analytics.get` would add that scan to every analytics panel load,
1304
+ * including the ones that never scroll to the table, so it is fetched on its
1305
+ * own and its wait is bounded and stated on its own section.
1306
+ *
1307
+ * Same arguments as `analytics.get`, same `.strict()` door, same window
1308
+ * bounds: the two ops are windowed by the same `since_ms`/`until_ms`, which
1309
+ * is the one property that lets a reader hold a row here against the
1310
+ * headline Total above it.
1311
+ */
1312
+ zod.z.object({
1313
+ op: zod.z.literal("analytics.models"),
1314
+ sessionId: sessionId.optional(),
1315
+ sinceMs: zod.z.number().int().nonnegative().optional(),
1316
+ untilMs: zod.z.number().int().nonnegative().optional(),
1317
+ days: zod.z.number().int().min(1).max(366).optional()
1318
+ }).strict(),
1319
+ /*
1320
+ * The two diagnostics reads. They ride their own capability key
1321
+ * (`diagnostics`) rather than the catalogue one, because `/analytics` and
1322
+ * `/failovers` must keep working against a backend that lacks these routes.
1323
+ */
1324
+ zod.z.object({ op: zod.z.literal("info.get") }).strict(),
1325
+ zod.z.object({
1326
+ op: zod.z.literal("sessions.report"),
1327
+ sessionId,
1328
+ /*
1329
+ * 0..50, matching the route's own clamp. The client always sends it:
1330
+ * the number of rows the panel draws is a design decision made here, and
1331
+ * inheriting the route's default would let the two drift apart.
1332
+ */
1333
+ recentLimit: zod.z.number().int().min(0).max(50).optional()
1334
+ }).strict(),
1335
+ zod.z.object({
1336
+ op: zod.z.literal("skills.list"),
1337
+ /*
1338
+ * The sessionless arm's pair, both optional, `cwd` wins when both are sent
1339
+ * (the explicit folder beats the implied one; the route's docstring owns
1340
+ * the rule). `cwd` given → the folder's own discovery roots (identical to
1341
+ * the session created there); `session_id` given → the session's cwd
1342
+ * (compat, for the `/skills` panel and released clients); NEITHER → the
1343
+ * daemon's home roots, explicitly — never the daemon's process cwd.
1344
+ */
1345
+ sessionId: sessionId.optional(),
1346
+ cwd: skillCwd.optional(),
1347
+ name: id.optional()
1348
+ }).strict(),
1349
+ zod.z.object({ op: zod.z.literal("sessions.failovers"), sessionId }).strict(),
1350
+ zod.z.object({
1351
+ op: zod.z.literal("sessions.credential"),
1352
+ sessionId,
1353
+ action: zod.z.enum(["list", "store", "forget"]),
1354
+ key: settingKey.optional(),
1355
+ value: secret.optional(),
1356
+ confirmed: zod.z.boolean().optional()
1357
+ }).strict(),
1358
+ zod.z.object({
1359
+ op: zod.z.literal("sessions.fork"),
1360
+ sessionId,
1361
+ requestId,
1362
+ message: zod.z.string().max(2e5).optional(),
1363
+ /*
1364
+ * WHERE THE COPY STOPS. `next_safe` is the historical form and the default
1365
+ * (the whole committed conversation, waiting for a turn boundary);
1366
+ * `at_entry` cuts through `entryId` and needs no boundary, because a named
1367
+ * point is already committed.
1368
+ *
1369
+ * `entryId` is bounded rather than patterned for the route's reason -
1370
+ * entry ids are minted in more than one shape and existence in the
1371
+ * CONVERSATION is what decides whether one is real (the route answers an
1372
+ * unknown or foreign id with a refusal, never a fork of the wrong
1373
+ * history) - so this client validates the shape it can and lets the
1374
+ * backend adjudicate the rest.
1375
+ *
1376
+ * BOTH ARE OPTIONAL AND ABSENT IS THE OLD CALL, key for key: a typed
1377
+ * `/fork` and the palette's fork send neither, and the body builder below
1378
+ * omits `entry_id` entirely rather than sending it as null.
1379
+ */
1380
+ boundary: zod.z.enum(["next_safe", "at_entry"]).optional(),
1381
+ entryId: zod.z.string().max(128).optional()
1382
+ }).strict(),
1383
+ zod.z.object({
1384
+ op: zod.z.literal("sessions.stop"),
1385
+ requestId,
1386
+ targets: zod.z.array(sessionId).min(1).max(100),
1387
+ confirmed: zod.z.literal(true)
1388
+ }).strict(),
1389
+ zod.z.object({
1390
+ op: zod.z.literal("sessions.aside"),
1391
+ sessionId,
1392
+ requestId,
1393
+ text: zod.z.string().min(1).max(32768),
1394
+ asideId: requestId.optional(),
1395
+ /*
1396
+ * THE SUBSCRIPTION THAT WANTS THE ANSWER'S CHUNKS, named by the viewer
1397
+ * that is asking.
1398
+ *
1399
+ * `aside_delta` is published on the session's stream, and the stream is
1400
+ * read by every attached viewer of a session - so the owner has to be
1401
+ * told WHICH of them asked, or an off-record answer is broadcast to
1402
+ * windows that never asked the question. The same id the `open` frame
1403
+ * hands the renderer (`payload.subscription_id`), which is also what
1404
+ * `sessions.watch` leases it with, so the two cannot disagree about
1405
+ * which subscription a viewer is.
1406
+ *
1407
+ * OPTIONAL, and that is the backward-compatibility half: an owner that
1408
+ * predates the routing sends no `aside_delta` at all, and a viewer that
1409
+ * has no subscription yet (the stream has not opened) still gets the
1410
+ * settled answer from the POST's response.
1411
+ */
1412
+ subscriptionId: zod.z.string().regex(SUBSCRIPTION_ID_PATTERN).optional()
1413
+ }).strict(),
1414
+ zod.z.object({
1415
+ op: zod.z.literal("sessions.adopt"),
1416
+ sessionId,
1417
+ requestId,
1418
+ asideId: requestId,
1419
+ confirmed: zod.z.literal(true)
1420
+ }).strict(),
1421
+ zod.z.object({
1422
+ op: zod.z.literal("sessions.aside.get"),
1423
+ sessionId,
1424
+ asideId: requestId
1425
+ }).strict(),
1426
+ zod.z.object({
1427
+ op: zod.z.literal("sessions.aside.close"),
1428
+ sessionId,
1429
+ asideId: requestId
1430
+ }).strict(),
1431
+ /*
1432
+ * The wake surface: the Schedules page's machine-wide read and its writes.
1433
+ *
1434
+ * Deliberately NOT an extension of `sessions.*`. The session list is
1435
+ * paginated by recency (`limit ≤ 500`), so a session armed once and never
1436
+ * opened falls off it - and the page whose whole job is "every session that
1437
+ * has wakes" would then list fewer sessions than exist. `wakes.list` reads
1438
+ * the wake index instead, which is one small JSON file per wake-carrying
1439
+ * session and is complete at any store size.
1440
+ *
1441
+ * The four ops sit behind the same router-level desktop bearer as their
1442
+ * `sessions.*` siblings, and every one of them does its filesystem work off
1443
+ * the event loop, so a listing over a cold store cannot stall the desktop
1444
+ * plane's other readers.
1445
+ */
1446
+ zod.z.object({
1447
+ op: zod.z.literal("wakes.list"),
1448
+ /* Bounded like `sessions.list`: the field exists so a pathological
1449
+ store degrades visibly (through `truncated`) rather than silently. */
1450
+ limit: zod.z.number().int().min(1).max(500).optional(),
1451
+ /* Dormant rows are the ones whose session was stopped, which the page
1452
+ SHOWS ("parked") rather than hides: stopping a conversation parks its
1453
+ wakes, and a management surface that dropped them would report a
1454
+ scheduled task as gone when it is only parked. */
1455
+ includeDormant: zod.z.boolean().optional()
1456
+ }).strict(),
1457
+ zod.z.object({
1458
+ op: zod.z.literal("wakes.create"),
1459
+ requestId,
1460
+ /*
1461
+ * The session half of the request is EXACTLY ONE of two shapes: name an
1462
+ * existing `sessionId`, or name a `cwd` (plus an optional `target`) and
1463
+ * have the backend create the conversation and arm the wake in one
1464
+ * call. The exclusivity cannot be stated with a `.refine()` on this
1465
+ * object - that turns the member into a `ZodEffects`, which a
1466
+ * discriminated union cannot take (see `mcp.credentials.store`, which
1467
+ * records the same trap) - so the API client enforces it on the way out
1468
+ * with `wakeCreateBody`, and the backend refuses the malformed shape
1469
+ * with a 422 rather than a 500.
1470
+ */
1471
+ sessionId: sessionId.optional(),
1472
+ cwd: zod.z.string().min(1).max(4096).optional(),
1473
+ target: target.optional(),
1474
+ message: wakeMessage,
1475
+ /*
1476
+ * Timing, in the same spellings the wake tool and `lop wake create`
1477
+ * take, parsed by the backend's own `parse_wake_duration` /
1478
+ * `parse_wake_at`: a relative duration (`30m`), an ISO instant or the
1479
+ * next `HH:MM`. The dialog's presets are `in`/`at` pairs so the common
1480
+ * path never touches a calendar, and `Pick a time…` sends the ISO
1481
+ * instant the picker yields.
1482
+ */
1483
+ in: zod.z.string().min(1).max(64).optional(),
1484
+ at: zod.z.string().min(1).max(64).optional(),
1485
+ every: zod.z.string().min(1).max(64).optional(),
1486
+ until: zod.z.string().min(1).max(64).optional(),
1487
+ limit: zod.z.number().int().min(1).optional(),
1488
+ /*
1489
+ * The conversation's own title, when a caller wants to override the one
1490
+ * derived from the prompt.
1491
+ *
1492
+ * Carried because the interface declares it, and never set by this
1493
+ * page: the prompt IS the name here (the backend writes it into the
1494
+ * session's stored-title sidecar, which `resume.session_name` consults
1495
+ * first), which is what keeps the create flow free of a "name" field
1496
+ * nobody would fill in.
1497
+ */
1498
+ title: zod.z.string().min(1).max(200).optional()
1499
+ }).strict(),
1500
+ zod.z.object({
1501
+ op: zod.z.literal("wakes.edit"),
1502
+ sessionId,
1503
+ wakeId,
1504
+ /* Every field optional, and the body is sent with the absent ones
1505
+ OMITTED rather than nulled: the backend applies the update against
1506
+ the schedule it holds, so a `null` here would be a request to clear
1507
+ a bound the user did not touch. */
1508
+ message: wakeMessage.optional(),
1509
+ in: zod.z.string().min(1).max(64).optional(),
1510
+ at: zod.z.string().min(1).max(64).optional(),
1511
+ every: zod.z.string().min(1).max(64).optional(),
1512
+ until: zod.z.string().min(1).max(64).optional(),
1513
+ limit: zod.z.number().int().min(1).optional()
1514
+ }).strict(),
1515
+ zod.z.object({ op: zod.z.literal("wakes.remove"), sessionId, wakeId }).strict(),
1516
+ /*
1517
+ * The monitor surface's one write that has a UI: cancelling a standing watch
1518
+ * (`DELETE /v1/desktop/monitors/{session_id}/{monitor_id}`). The sibling
1519
+ * routes - the machine-wide listing and the arm - are deliberately not
1520
+ * mirrored yet: the pane and the composer's chip read the SESSION's own
1521
+ * `frontend.monitors` field (the design's §12 row states the desktop contract
1522
+ * as "the `monitors` field + command routes"), so a listing op would have no
1523
+ * reader, and the arm op waits for the form that will use it.
1524
+ */
1525
+ zod.z.object({
1526
+ op: zod.z.literal("monitors.cancel"),
1527
+ sessionId,
1528
+ monitorId
1529
+ }).strict(),
1530
+ zod.z.object({ op: zod.z.literal("mcp.list"), sessionId }).strict(),
1531
+ zod.z.object({
1532
+ op: zod.z.literal("mcp.credentials.store"),
1533
+ sessionId,
1534
+ name: zod.z.string().min(1).max(256),
1535
+ values: zod.z.record(zod.z.string().min(1).max(128), zod.z.string().min(1).max(32768)).refine(
1536
+ (secrets) => Object.keys(secrets).length <= 32 && Object.values(secrets).reduce(
1537
+ (total, value) => total + value.length,
1538
+ 0
1539
+ ) <= 65536,
1540
+ // Mirrors the owner's own bound (`local_operator/mcp/credentials.py`),
1541
+ // so an oversized paste is refused as a sentence rather than
1542
+ // serialized into a request the control budget rejects as an
1543
+ // opaque 413.
1544
+ {
1545
+ message: "Too many secret values, or too much secret text, for one MCP credential write."
1546
+ }
1547
+ ),
1548
+ confirmedReplace: zod.z.array(zod.z.string().min(1).max(128)).max(32)
1549
+ }).strict(),
1550
+ zod.z.object({
1551
+ op: zod.z.literal("mcp.control"),
1552
+ sessionId,
1553
+ control: zod.z.object({
1554
+ action: zod.z.enum([
1555
+ "list",
1556
+ "add",
1557
+ "remove",
1558
+ "reload",
1559
+ "connect",
1560
+ "probe",
1561
+ "disconnect",
1562
+ "login",
1563
+ "logout",
1564
+ "reauth",
1565
+ "status",
1566
+ "cancel"
1567
+ ]),
1568
+ name: mcpServerName.optional(),
1569
+ scope: zod.z.enum(["global", "project"]).optional(),
1570
+ command: zod.z.string().min(1).max(4096).optional(),
1571
+ args: zod.z.array(zod.z.string().max(8192)).max(128).optional(),
1572
+ env: zod.z.record(mcpSecretReference).optional(),
1573
+ url: zod.z.string().max(4096).optional(),
1574
+ headers: zod.z.record(mcpSecretReference).optional(),
1575
+ oauth: zod.z.boolean().optional(),
1576
+ confirmed: zod.z.boolean().optional(),
1577
+ operation_id: mcpOperationId.optional()
1578
+ }).strict()
1579
+ }).strict(),
1580
+ /*
1581
+ * The SESSIONLESS MCP catalog (`GET|POST /v1/desktop/mcp`), gated on the
1582
+ * `mcp_catalog` capability. Settings > Integrations reads and writes MCP
1583
+ * CONFIGURATION through these, so it no longer needs a running conversation -
1584
+ * the session route above booted a whole runtime (and so needed a model
1585
+ * provider) just to write a JSON file (UX walk U5). The session ops stay: the
1586
+ * run panel is a live per-runtime view, and `connect`/`disconnect`/`reload`
1587
+ * are about a runtime's live connection, so they are not accepted here.
1588
+ *
1589
+ * `cwd` is optional (the backend defaults it to the user's home, the desktop's
1590
+ * own default) and must be absolute: the backend 422s anything else, and the
1591
+ * schema refuses it first so a relative path never reaches the wire.
1592
+ */
1593
+ zod.z.object({
1594
+ op: zod.z.literal("mcp.catalog"),
1595
+ cwd: mcpCatalogCwd.optional(),
1596
+ sessionId: sessionId.optional()
1597
+ }).strict(),
1598
+ zod.z.object({
1599
+ op: zod.z.literal("mcp.catalog.control"),
1600
+ cwd: mcpCatalogCwd.optional(),
1601
+ control: zod.z.object({
1602
+ action: zod.z.enum([
1603
+ "add",
1604
+ "remove",
1605
+ "test",
1606
+ "login",
1607
+ "reauth",
1608
+ "logout",
1609
+ "status",
1610
+ "cancel"
1611
+ ]),
1612
+ name: mcpServerName.optional(),
1613
+ scope: zod.z.enum(["global", "project"]).optional(),
1614
+ command: zod.z.string().min(1).max(4096).optional(),
1615
+ args: zod.z.array(zod.z.string().max(8192)).max(128).optional(),
1616
+ env: zod.z.record(mcpSecretReference).optional(),
1617
+ url: zod.z.string().max(4096).optional(),
1618
+ headers: zod.z.record(mcpSecretReference).optional(),
1619
+ confirmed: zod.z.boolean().optional(),
1620
+ operation_id: mcpOperationId.optional()
1621
+ }).strict()
1622
+ }).strict(),
1623
+ zod.z.object({
1624
+ op: zod.z.literal("mcp.catalog.credentials"),
1625
+ /*
1626
+ * WHICH HEADER OR ENV NAME THE KEY BELONGS TO (backend #1511
1627
+ * `aa927158a`). A server with no `${ID}` reference has nothing for the
1628
+ * catalog's own `set_key` to fill, so the credential write names the
1629
+ * header itself and the backend adds `headers[header] = "${ID}"` to the
1630
+ * defining file. Refused (`invalid_target`) for a header the transport
1631
+ * owns, one already set, a malformed name, or an invalid id - and
1632
+ * refused with NOTHING written.
1633
+ */
1634
+ header: zod.z.string().min(1).max(128).optional(),
1635
+ cwd: mcpCatalogCwd.optional(),
1636
+ name: mcpServerName,
1637
+ values: zod.z.record(zod.z.string().min(1).max(128), zod.z.string().min(1).max(32768)).refine(
1638
+ (secrets) => Object.keys(secrets).length <= 32 && Object.values(secrets).reduce(
1639
+ (total, value) => total + value.length,
1640
+ 0
1641
+ ) <= 65536,
1642
+ {
1643
+ message: "Too many secret values, or too much secret text, for one MCP credential write."
1644
+ }
1645
+ ),
1646
+ confirmedReplace: zod.z.array(zod.z.string().min(1).max(128)).max(32)
1647
+ }).strict(),
1648
+ zod.z.object({
1649
+ op: zod.z.literal("radient.request"),
1650
+ control: zod.z.object({
1651
+ operation: zod.z.enum([
1652
+ "account",
1653
+ "prices",
1654
+ "credits",
1655
+ "usage",
1656
+ "provision",
1657
+ "application.create",
1658
+ "agents.list",
1659
+ "agents.get",
1660
+ "agents.create",
1661
+ "agents.update",
1662
+ "agents.delete",
1663
+ "agents.like",
1664
+ "agents.unlike",
1665
+ "agents.liked",
1666
+ "agents.like_count",
1667
+ "agents.favourite",
1668
+ "agents.unfavourite",
1669
+ "agents.favourited",
1670
+ "agents.favourite_count",
1671
+ "agents.download_count",
1672
+ /*
1673
+ * The viewer's own like/favourite state for a whole page of agents. The
1674
+ * closed vocabulary has to name it here as well as in
1675
+ * `shared/api/radient/proxy.ts`, because this schema is what validates
1676
+ * the request the renderer actually sends.
1677
+ */
1678
+ "agents.statuses",
1679
+ "comments.list",
1680
+ "comments.create",
1681
+ "comments.update",
1682
+ "comments.delete",
1683
+ "account.agents",
1684
+ /*
1685
+ * The organization operations (design §4.7). They are named here as well as
1686
+ * in `shared/api/radient/proxy.ts` because this schema is what validates the
1687
+ * request the renderer actually sends: an op in one list and not the other
1688
+ * is a request that never leaves the renderer. `team_id` is the published
1689
+ * team document an `org_team.get` pull names.
1690
+ */
1691
+ "memberships.list",
1692
+ "org_agents.list",
1693
+ "org_team.get",
1694
+ "org_teams.list"
1695
+ ]),
1696
+ request_id: requestId.optional(),
1697
+ tenant_id: id.optional(),
1698
+ account_id: id.optional(),
1699
+ agent_id: id.optional(),
1700
+ comment_id: id.optional(),
1701
+ team_id: id.optional(),
1702
+ query: zod.z.record(zod.z.union([zod.z.string().max(1024), zod.z.number().int()])).optional(),
1703
+ payload: zod.z.record(zod.z.unknown()).optional(),
1704
+ confirmed: zod.z.boolean().optional()
1705
+ }).strict()
1706
+ }).strict(),
1707
+ zod.z.object({ op: zod.z.literal("providers.list") }).strict(),
1708
+ zod.z.object({ op: zod.z.literal("accounts.list") }).strict(),
1709
+ zod.z.object({
1710
+ op: zod.z.literal("accounts.remove"),
1711
+ accountId: zod.z.number().int().positive(),
1712
+ confirmed: zod.z.literal(true)
1713
+ }).strict(),
1714
+ zod.z.object({ op: zod.z.literal("auth.start"), provider: id }).strict(),
1715
+ zod.z.object({ op: zod.z.literal("auth.status"), id }).strict(),
1716
+ zod.z.object({ op: zod.z.literal("auth.input"), id, promptId: id, value: secret }).strict(),
1717
+ zod.z.object({ op: zod.z.literal("auth.cancel"), id }).strict(),
1718
+ zod.z.object({ op: zod.z.literal("auth.key"), provider: id, value: secret }).strict(),
1719
+ zod.z.object({ op: zod.z.literal("auth.logout"), provider: id }).strict(),
1720
+ zod.z.object({ op: zod.z.literal("settings.list") }).strict(),
1721
+ zod.z.object({
1722
+ op: zod.z.literal("settings.edit"),
1723
+ key: settingKey,
1724
+ value: zod.z.unknown().refine((value) => value !== void 0, "A setting value is required."),
1725
+ base: chains.optional()
1726
+ }).strict(),
1727
+ zod.z.object({ op: zod.z.literal("settings.reset"), key: settingKey }).strict(),
1728
+ /*
1729
+ * THE VOICING SURFACE's read (`features.tts`), and the twin of the STT
1730
+ * cascade report: every rung in order with its availability and the reason a
1731
+ * reader would be shown, plus the surface's own `servable` bit. It is a GET
1732
+ * with no parameters - the daemon resolves the cascade against THIS
1733
+ * machine's stored credentials - so a client cannot scope it to anything and
1734
+ * has nothing to send.
1735
+ *
1736
+ * Gated on its own capability key rather than on `settings`: the two are
1737
+ * different contracts on different release trains (a backend can serve the
1738
+ * registry and predate voicing), and a surface that fired this read at such a
1739
+ * backend would render a 404 as a failure of the user's own account.
1740
+ */
1741
+ zod.z.object({ op: zod.z.literal("tts.paths") }).strict(),
1742
+ zod.z.object({ op: zod.z.literal("config.get") }).strict(),
1743
+ zod.z.object({ op: zod.z.literal("config.update"), value: configUpdate }).strict(),
1744
+ zod.z.object({ op: zod.z.literal("instructions.get") }).strict(),
1745
+ zod.z.object({
1746
+ op: zod.z.literal("instructions.update"),
1747
+ content: zod.z.string().max(64e3)
1748
+ }).strict(),
1749
+ zod.z.object({ op: zod.z.literal("credentials.list") }).strict(),
1750
+ zod.z.object({
1751
+ op: zod.z.literal("credentials.update"),
1752
+ key: settingKey,
1753
+ value: secret
1754
+ }).strict(),
1755
+ /*
1756
+ * THE MESH READS (`features.peers`).
1757
+ *
1758
+ * Both reach a peer only THROUGH this app's one backend: the renderer never dials
1759
+ * a peer, never learns an address to dial and holds no mesh credential, because a
1760
+ * UI that could would have to re-implement the relay's authorisation model in
1761
+ * JavaScript.
1762
+ *
1763
+ * THE MUTATING MESH OPS LANDED WITH THE SURFACES THAT USE THEM (slice 2: the drag
1764
+ * layer, the invite action, the member list), which is the rule this block stated
1765
+ * while they were still absent: a request schema entry with no caller is a
1766
+ * capability this app advertises but cannot exercise.
1767
+ *
1768
+ * `features.session_transfer` gates the TRANSFER and only it (`capabilities.py`):
1769
+ * a backend can host a network, mint invites and remove members without being able
1770
+ * to move a conversation, and the surfaces that gate on the wrong key draw a
1771
+ * control that 404s. The three ops below therefore sit behind different keys —
1772
+ * `networks.invite`/`networks.member.remove` behind `features.peers` (they are
1773
+ * routes the mesh itself introduced), `sessions.transfer` behind
1774
+ * `features.session_transfer`.
1775
+ *
1776
+ * WHAT TRAVELS, AND WHAT DOES NOT. `to` is the destination device id or the
1777
+ * literal `"local"` (a RECALL), because that is the route's own shape: one route,
1778
+ * two protocols, and the direction is decided by which of the two ends is asking
1779
+ * — see `guide://network`. `request_id` is minted by the CALLER and is what makes
1780
+ * a retry replay a recorded outcome instead of starting a second move for a
1781
+ * request that may still be running, so it is sent on every drop rather than kept
1782
+ * for a retry path this surface does not have.
1783
+ */
1784
+ zod.z.object({ op: zod.z.literal("peers.list") }).strict(),
1785
+ zod.z.object({ op: zod.z.literal("networks.list") }).strict(),
1786
+ /*
1787
+ * Mint an invite. `role` is the joined device's own role in the network and
1788
+ * `device` BINDS the token to one device id, so a token intercepted on its way to
1789
+ * another machine cannot be redeemed by a third one. The token itself NEVER
1790
+ * crosses this API (the receipt carries a path, and it is written where the
1791
+ * renderer cannot read it) — which is why the answer is a receipt and not a
1792
+ * secret.
1793
+ */
1794
+ zod.z.object({
1795
+ op: zod.z.literal("networks.invite"),
1796
+ networkId: meshId,
1797
+ role: zod.z.enum(["read", "drive", "admin"]),
1798
+ deviceId: meshId.optional()
1799
+ }).strict(),
1800
+ /*
1801
+ * Revoke a membership. `confirm` is the NETWORK'S NAME, typed by the user, and
1802
+ * the route compares it exactly: this is the one mesh act that changes other
1803
+ * devices' state (every peer is rekeyed and the removed device is locked out on
1804
+ * its next handshake), so the request must carry what the user was shown rather
1805
+ * than a bool a stray retry could also send.
1806
+ */
1807
+ zod.z.object({
1808
+ op: zod.z.literal("networks.member.remove"),
1809
+ networkId: meshId,
1810
+ deviceId: meshId,
1811
+ confirm: zod.z.string().min(1).max(256)
1812
+ }).strict(),
1813
+ /*
1814
+ * Ask a device to take a conversation, or ask THIS device to take one back.
1815
+ *
1816
+ * THE DIRECTION IS THE PROTOCOL'S, not a UI preference: there is no push verb, so
1817
+ * a drop on a peer is this device asking that peer to PULL (`to: <device_id>`),
1818
+ * and a drop on this device is a recall (`to: "local"`). `keep` is the reversible
1819
+ * half — it mints a new id at the destination and leaves the source running —
1820
+ * while a move deletes the source's copy once the handoff commits, which is why
1821
+ * `source_retired = (mode == "move")` on the receipt.
1822
+ *
1823
+ * `wait_s` is a CEILING ON WAITING INSIDE THE REQUEST, not a promise: the route
1824
+ * returns as soon as it has a definite outcome, and a `busy` source refuses
1825
+ * rather than being interrupted. The desktop's own deadline for this op is
1826
+ * derived from the route's published bound rather than from the 20 s control
1827
+ * budget (see `moveClientBoundMs`) — the defect this avoids is a client that gives
1828
+ * up first and reports its own timeout for a move the backend was about to answer.
1829
+ */
1830
+ zod.z.object({
1831
+ op: zod.z.literal("sessions.transfer"),
1832
+ sessionId,
1833
+ to: zod.z.union([zod.z.literal("local"), meshId]),
1834
+ keep: zod.z.boolean().optional(),
1835
+ waitS: zod.z.number().min(0).max(300).optional(),
1836
+ requestId: requestId.optional()
1837
+ }).strict(),
1838
+ /*
1839
+ * THE ONBOARDING APPROVALS (`features.approvals`), the third mesh-adjacent
1840
+ * family: one durable, signed record per remote-onboarding request, kept
1841
+ * DEVICE-LOCALLY under `<config>/network/approvals/` (remote-onboarding
1842
+ * design §2.3 — the badge must answer on a machine whose relay is down, which
1843
+ * is why the record is a flat file rather than something behind the relay).
1844
+ *
1845
+ * THE LIST IS THE BADGE READ and it dials nothing: every row comes off this
1846
+ * machine's own directory, so a rail-mounted interval costs one local scan
1847
+ * rather than a peer fan-out — the reason it is the ONE mesh-family read a
1848
+ * sidebar badge may poll (see `mesh-approvals.ts` for the cadence argument).
1849
+ * Each row is the frozen §3.5 shape — `what` (the scope block), the
1850
+ * where-block (`device` for `device_onboard`, `machine` for `local_authority`;
1851
+ * the KIND is which key is present), `requested_by` and the expiry — and the
1852
+ * surfaces do their own wording, so the wire stays the record's own
1853
+ * vocabulary and a new scope does not need a wire change.
1854
+ *
1855
+ * THE DECISIONS TAKE NO BODY, deliberately (`routes/desktop_approvals.py`:
1856
+ * "the two decision routes take NO body at all — approving is the gesture").
1857
+ * `approve` runs the SAME presence-gated signing call the CLI's `approve`
1858
+ * verb runs, so its latency includes a human's — see
1859
+ * `APPROVAL_APPROVE_DEADLINE_MS` for why this op alone carries its own
1860
+ * budget. `deny` never signs: it is write-once and settles in the safe
1861
+ * direction.
1862
+ */
1863
+ zod.z.object({ op: zod.z.literal("approvals.list") }).strict(),
1864
+ zod.z.object({ op: zod.z.literal("approvals.approve"), approvalId }).strict(),
1865
+ zod.z.object({ op: zod.z.literal("approvals.deny"), approvalId }).strict(),
1866
+ /*
1867
+ * THE FLEET ASKS READ (`GET /v1/desktop/asks`), the cross-session companion of
1868
+ * the per-session queue the canonical frame carries. A GET with no parameters
1869
+ * at all: the route answers from the DERIVED ask index under the config dir,
1870
+ * so it needs no session id and no `cwd` — the rows name their own conversation
1871
+ * (`session_id`, `cwd` are the frozen `PendingAsk` shape plus those two keys).
1872
+ *
1873
+ * IT IS A SEPARATE OP FROM THE SESSION QUEUE, deliberately. The session's asks
1874
+ * arrive on its own canonical stream (the frontend state's `asks`), which is
1875
+ * scoped to the conversation being watched and is therefore silent about every
1876
+ * other conversation by construction; "what is waiting across the whole app" is
1877
+ * a question no per-session frame can answer, which is why the drawer's other
1878
+ * scope needs its own read rather than a widened filter on that one.
1879
+ *
1880
+ * THE ANSWER IS A LIST OF RAW ROWS rather than a modelled shape, matching the
1881
+ * route's own reasoning: the rows ARE the frozen `PendingAsk` wire shape, and
1882
+ * re-declaring their fields here would be a third copy of §4 to keep in step.
1883
+ */
1884
+ zod.z.object({ op: zod.z.literal("asks.list") }).strict(),
1885
+ /*
1886
+ * The Projects surface (`/v1/desktop/projects*`), APPENDED to the union
1887
+ * rather than inserted beside the other catalogue ops: the backend serves
1888
+ * these routes from its own release, and an older daemon that has never
1889
+ * heard of the op is a backend this app must be able to gate against —
1890
+ * which it does through the `projects` capability key, not through this
1891
+ * schema (a request this client refuses to build is not a negotiation).
1892
+ *
1893
+ * One op per route; the wipe and the milestone routes are the two shapes
1894
+ * that do not fit the plain CRUD, and both exist because the backend
1895
+ * declared them separately (a milestone is add-or-update-by-name, and a
1896
+ * removal is a DELETE with the name in the path). `projects.update` carries
1897
+ * its editable fields as a NESTED `fields` object for the same reason the
1898
+ * profile ops do: the fields differ per surface, and a flat op would put
1899
+ * every future field at the top level of a union member.
1900
+ */
1901
+ zod.z.object({ op: zod.z.literal("projects.list") }).strict(),
1902
+ zod.z.object({ op: zod.z.literal("projects.get"), key: projectKey }).strict(),
1903
+ zod.z.object({
1904
+ op: zod.z.literal("projects.create"),
1905
+ name: projectName,
1906
+ description: zod.z.string().max(PROJECT_DESCRIPTION_MAX_CHARS).optional(),
1907
+ status: projectStatus.optional(),
1908
+ tags: projectTags.optional()
1909
+ }).strict(),
1910
+ zod.z.object({
1911
+ op: zod.z.literal("projects.update"),
1912
+ key: projectKey,
1913
+ /*
1914
+ * Only the keys the caller includes travel; an omitted key leaves the
1915
+ * field alone, and `""` clears a date, the progress snippet or an
1916
+ * attribution (the route forwards `model_fields_set`, and this client
1917
+ * mirrors it). The three attribution/title steps are bounded at 80 —
1918
+ * the store's `ATTRIBUTION_MAX`/`TITLE_MAX` — so a client cannot build
1919
+ * a body the route would refuse for length alone.
1920
+ */
1921
+ fields: zod.z.object({
1922
+ name: projectName.optional(),
1923
+ title: zod.z.string().max(80).optional(),
1924
+ owner: zod.z.string().max(80).optional(),
1925
+ team: zod.z.string().max(80).optional(),
1926
+ description: zod.z.string().max(PROJECT_DESCRIPTION_MAX_CHARS).optional(),
1927
+ status: projectStatus.optional(),
1928
+ progress: zod.z.string().max(PROJECT_PROGRESS_MAX_CHARS).optional(),
1929
+ tags: projectTags.optional(),
1930
+ start_date: projectDate.optional(),
1931
+ target_date: projectDate.optional(),
1932
+ completed_at: projectDate.optional(),
1933
+ estimate: zod.z.number().positive().max(1e3).optional(),
1934
+ estimate_unit: zod.z.enum(["points", "days"]).optional()
1935
+ }).strict(),
1936
+ /*
1937
+ * `force_done` is a flag about THIS CALL, not a field of the row, so it
1938
+ * sits at the op's top level and never inside `fields` (the strict
1939
+ * `fields` object above rejects it there). It is the daemon's deliberate
1940
+ * escape from the done-gate: close the project with milestones still
1941
+ * open. Sent ONLY when true (see the mapper) because a daemon that
1942
+ * predates the `projects_force_done` capability 422s an unknown body key
1943
+ * - the renderer gates the offer on that key, and an absent flag keeps
1944
+ * every other PATCH byte-identical to what shipped before.
1945
+ */
1946
+ force_done: zod.z.boolean().optional()
1947
+ }).strict(),
1948
+ zod.z.object({
1949
+ op: zod.z.literal("projects.delete"),
1950
+ key: projectKey,
1951
+ confirmed_name: projectName
1952
+ }).strict(),
1953
+ zod.z.object({
1954
+ op: zod.z.literal("projects.link"),
1955
+ key: projectKey,
1956
+ sessionId
1957
+ }).strict(),
1958
+ zod.z.object({
1959
+ op: zod.z.literal("projects.unlink"),
1960
+ key: projectKey,
1961
+ sessionId
1962
+ }).strict(),
1963
+ zod.z.object({
1964
+ op: zod.z.literal("projects.milestone"),
1965
+ key: projectKey,
1966
+ name: zod.z.string().min(1).max(PROJECT_MILESTONE_NAME_MAX_CHARS),
1967
+ /** `""` clears it; omitted leaves it alone. */
1968
+ targetDate: projectDate.optional(),
1969
+ /** `true` stamps today, `false` clears; omitted leaves it alone. */
1970
+ completed: zod.z.boolean().optional()
1971
+ }).strict(),
1972
+ zod.z.object({
1973
+ op: zod.z.literal("projects.milestone.remove"),
1974
+ key: projectKey,
1975
+ name: zod.z.string().min(1).max(PROJECT_MILESTONE_NAME_MAX_CHARS)
1976
+ }).strict(),
1977
+ /*
1978
+ * The check-in fan-out (`POST /v1/desktop/projects/{project}/request-update`):
1979
+ * ask every linked session to post a progress update. APPENDED to the union
1980
+ * like its siblings, and gated by its OWN capability key
1981
+ * (`features.projects_request_update`) rather than a bump of `projects`: the
1982
+ * tab renders perfectly well against a backend that cannot ask its sessions
1983
+ * for anything, so the version would hide a working surface behind an update
1984
+ * it does not need (the `session_search` rule above). The key is the row's
1985
+ * address (id or name) - the same shape every other projects op takes.
1986
+ */
1987
+ zod.z.object({
1988
+ op: zod.z.literal("projects.request_update"),
1989
+ key: projectKey
1990
+ }).strict(),
1991
+ /*
1992
+ * THE DERIVED SEARCH INDEX (`GET /v1/desktop/projects/search`). APPENDED like
1993
+ * its siblings, and gated by a `projects` VERSION BUMP (1 -> 2) rather than a
1994
+ * key of its own: the route ranks over the same store the listing reads and is
1995
+ * additive by construction — every version-1 call keeps its exact behaviour —
1996
+ * so the version is what lets a client ask for the new read without a second
1997
+ * negotiation. The backend's own register states the same split
1998
+ * (`routes/capabilities.py`: "a client gates ONLY the two new calls on
1999
+ * ``>= 2``").
2000
+ *
2001
+ * `.min(1)`: an EMPTY query is not a search. The route answers one with the
2002
+ * listing's own order truncated to `limit`, but this surface already holds
2003
+ * that list — its box is a filter over the catalogue — so an empty `q` would
2004
+ * ask the server to send back everything the client is holding, which is the
2005
+ * refusal `sessions.search` above states in the same words.
2006
+ */
2007
+ zod.z.object({
2008
+ op: zod.z.literal("projects.search"),
2009
+ q: zod.z.string().min(1).max(PROJECTS_SEARCH_MAX_CHARS),
2010
+ limit: zod.z.number().int().min(1).max(PROJECTS_SEARCH_MAX_LIMIT).optional()
2011
+ }).strict(),
2012
+ /*
2013
+ * AIDA'S CONTROL PLANE: one read and one control op on the same route
2014
+ * (`/v1/desktop/aida`), because the rail's row and the composer's `/aida`
2015
+ * need the SAME state and a second spelling of it would be a second answer
2016
+ * about her one long session (`design.md` § 4 freezes the route).
2017
+ *
2018
+ * The feature is gated by its OWN capability key (`features.aida`), never a
2019
+ * bump of `commands`: a renderer that does not read it keeps working against
2020
+ * this backend, and this renderer must not call the route while the key is
2021
+ * absent or 0 (§ 3.4's version skew).
2022
+ *
2023
+ * Deliberately NOT a `MESSAGE_OPS` member (see `desktopRequestByteBudget`):
2024
+ * an enum word and a receipt are not prose, so this costs the control budget.
2025
+ */
2026
+ zod.z.object({ op: zod.z.literal("aida.status") }).strict(),
2027
+ zod.z.object({
2028
+ op: zod.z.literal("aida.control"),
2029
+ /*
2030
+ * The route's own op vocabulary, held to it here: a word the backend does
2031
+ * not serve must fail at this boundary rather than travel as a 422 the
2032
+ * user reads as a defect of their press.
2033
+ */
2034
+ action: zod.z.enum(["open", "pause", "resume", "greet", "status"])
2035
+ }).strict(),
2036
+ /*
2037
+ * THE CODE REQUEST LEDGER (the per-session PR/MR list, and its refresh).
2038
+ *
2039
+ * BOTH ROUTES SIT UNDER THE SESSION because the LEDGER is the session's: the
2040
+ * rows are derived from THIS conversation's transcript, so a read that did not
2041
+ * name the session could only be some other session's list or a fleet scan
2042
+ * nobody asked for. The pair follows the monitors/projects routes' pattern
2043
+ * (`Depends(require_desktop)` behind the desktop bearer) and the refresh is a
2044
+ * POST because it mutates the cache's TTL state; a GET with a side effect is
2045
+ * the shape the monitors routes already refused.
2046
+ *
2047
+ * `keys` narrows the refresh to named rows and `force` bypasses the TTL but
2048
+ * NOT a host's rate-limit window (`cooling` in the list answer says which);
2049
+ * both are optional because "refresh what is dirty" and "per the TTL" are the
2050
+ * route's defaults, which is what the pane's untouched Refresh press wants.
2051
+ */
2052
+ zod.z.object({ op: zod.z.literal("code_requests.list"), sessionId }).strict(),
2053
+ zod.z.object({
2054
+ op: zod.z.literal("code_requests.refresh"),
2055
+ sessionId,
2056
+ /*
2057
+ * A key is one row's `key` from the list answer; the bound mirrors the
2058
+ * fetch routes' own ceilings rather than inventing one here, and the
2059
+ * cap keeps a mis-read list from being re-sent whole.
2060
+ */
2061
+ keys: zod.z.array(zod.z.string().min(1).max(512)).max(256).optional(),
2062
+ force: zod.z.boolean().optional()
2063
+ }).strict()
2064
+ ]);
2065
+ desktopRequestUnion.superRefine(
2066
+ (request, ctx) => {
2067
+ if (request.op === "sessions.answer") {
2068
+ if (request.askId !== void 0) {
2069
+ if (!request.askId)
2070
+ ctx.addIssue({
2071
+ code: zod.z.ZodIssueCode.custom,
2072
+ message: "ask_id must be a non-empty string",
2073
+ path: ["askId"]
2074
+ });
2075
+ if (request.decline !== true && !request.answers)
2076
+ ctx.addIssue({
2077
+ code: zod.z.ZodIssueCode.custom,
2078
+ message: "A queued-ask answer needs answers or decline",
2079
+ path: ["answers"]
2080
+ });
2081
+ else if (request.decline === true && request.answers !== void 0)
2082
+ ctx.addIssue({
2083
+ code: zod.z.ZodIssueCode.custom,
2084
+ message: "Supply either answers or decline",
2085
+ path: ["decline"]
2086
+ });
2087
+ else if (request.revise === true && request.decline === true)
2088
+ ctx.addIssue({
2089
+ code: zod.z.ZodIssueCode.custom,
2090
+ message: "Supply either decline or revise",
2091
+ path: ["revise"]
2092
+ });
2093
+ else if (request.answers !== void 0 && Object.keys(request.answers).length === 0)
2094
+ ctx.addIssue({
2095
+ code: zod.z.ZodIssueCode.custom,
2096
+ message: "answers must name at least one question",
2097
+ path: ["answers"]
2098
+ });
2099
+ return;
2100
+ }
2101
+ if (!request.epoch)
2102
+ ctx.addIssue({
2103
+ code: zod.z.ZodIssueCode.custom,
2104
+ message: "An epoch is required to answer a gate",
2105
+ path: ["epoch"]
2106
+ });
2107
+ if (!request.requestId)
2108
+ ctx.addIssue({
2109
+ code: zod.z.ZodIssueCode.custom,
2110
+ message: "A request id is required to answer a gate",
2111
+ path: ["requestId"]
2112
+ });
2113
+ if (request.revise === true)
2114
+ ctx.addIssue({
2115
+ code: zod.z.ZodIssueCode.custom,
2116
+ message: "revise applies to a queued ask",
2117
+ path: ["revise"]
2118
+ });
2119
+ return;
2120
+ }
2121
+ if (request.op === "sessions.create" && request.purpose === void 0) {
2122
+ if (request.cwd === void 0) {
2123
+ ctx.addIssue({
2124
+ code: zod.z.ZodIssueCode.custom,
2125
+ message: "A conversation needs a working directory: only a configuration run (`purpose`) may omit `cwd`.",
2126
+ path: ["cwd"]
2127
+ });
2128
+ }
2129
+ return;
2130
+ }
2131
+ if (request.op === "sessions.create" && request.purpose !== void 0) {
2132
+ for (const field of ["cwd", "target", "model", "draftId"]) {
2133
+ if (request[field] === void 0) continue;
2134
+ ctx.addIssue({
2135
+ code: zod.z.ZodIssueCode.custom,
2136
+ message: `A configuration run resolves its own \`${field}\`: send \`purpose\` alone.`,
2137
+ path: [field]
2138
+ });
2139
+ }
2140
+ return;
2141
+ }
2142
+ if (request.op !== "agent.publish" && request.op !== "agent.republish") {
2143
+ return;
2144
+ }
2145
+ if (Boolean(request.visibility === "org") !== Boolean(request.tenantId)) {
2146
+ ctx.addIssue({
2147
+ code: zod.z.ZodIssueCode.custom,
2148
+ message: 'A publication target needs both `visibility: "org"` and `tenantId`, or neither: one half would publish to the public hub.'
2149
+ });
2150
+ }
2151
+ }
2152
+ );
2153
+ const LEDGER_READ_OPS = /* @__PURE__ */ new Set([
2154
+ "analytics.get",
2155
+ /*
2156
+ * The worst case of the same shape, and the reason the set is spelled by
2157
+ * shape rather than by measured cost: `/analytics/models` is a grouped scan
2158
+ * of the raw ledger with no covering index, measured in SECONDS on a 1.95 M
2159
+ * row ledger where `analytics.get` measures 12-40 s cold. On the control
2160
+ * budget it would be abandoned mid-read on nearly every open, so it is here
2161
+ * beside the op it is a slower sibling of.
2162
+ */
2163
+ "analytics.models",
2164
+ "sessions.report"
2165
+ ]);
2166
+ const PROVIDER_READ_OPS = /* @__PURE__ */ new Set(["usage.get"]);
2167
+ const MESH_READ_OPS = /* @__PURE__ */ new Set([
2168
+ "networks.list",
2169
+ "peers.list"
2170
+ ]);
2171
+ /* @__PURE__ */ new Set([
2172
+ ...LEDGER_READ_OPS,
2173
+ ...PROVIDER_READ_OPS,
2174
+ ...MESH_READ_OPS
2175
+ ]);
2176
+ const EXTERNAL_OPEN_REFUSED_CHANNEL = "external-open-refused";
89
2177
  const DESKTOP_STREAM_DETAIL = {
90
2178
  /** The IPC subscribe itself rejected before a stream existed. */
91
2179
  openFailed: "The event stream could not be opened."
@@ -139,8 +2227,8 @@ function readOpenSessionArgv(argv) {
139
2227
  return found !== null && SESSION_ID.test(found) ? found : null;
140
2228
  }
141
2229
  function readLaunchTarget(argv) {
142
- const sessionId = readOpenSessionArgv(argv);
143
- if (sessionId) return { kind: "session", sessionId };
2230
+ const sessionId2 = readOpenSessionArgv(argv);
2231
+ if (sessionId2) return { kind: "session", sessionId: sessionId2 };
144
2232
  if (argv.includes(OPEN_CATALOGUE_FLAG)) return { kind: "catalogue" };
145
2233
  return { kind: "restore" };
146
2234
  }
@@ -477,7 +2565,28 @@ const api = {
477
2565
  * directory the user typed a `/` into.
478
2566
  */
479
2567
  listDirectory: (dir, cwd) => electron.ipcRenderer.invoke("list-directory", dir, cwd),
2568
+ /**
2569
+ * Hand a URL to the OS, through the main process's vetted door. The outcome
2570
+ * is the door's verdict (round-2 R-4): the toolbar's Open shows a refusal
2571
+ * rather than resolving quietly.
2572
+ */
480
2573
  openExternal: (url) => electron.ipcRenderer.invoke("open-external", url),
2574
+ /**
2575
+ * A refused external open, pushed to the window whose content asked. The
2576
+ * `openExternal` call answers its caller directly; this is the half a
2577
+ * markdown anchor's click has (round-2 R-4): it left through the main
2578
+ * process's `window.open` door, so main is where the refusal is known and
2579
+ * the renderer is where it must be shown.
2580
+ */
2581
+ onExternalOpenRefused: (callback) => {
2582
+ const handler = (_event, payload) => {
2583
+ if (payload && typeof payload.reason === "string") callback(payload);
2584
+ };
2585
+ electron.ipcRenderer.on(EXTERNAL_OPEN_REFUSED_CHANNEL, handler);
2586
+ return () => {
2587
+ electron.ipcRenderer.removeListener(EXTERNAL_OPEN_REFUSED_CHANNEL, handler);
2588
+ };
2589
+ },
481
2590
  showItemInFolder: (filePath) => electron.ipcRenderer.invoke("show-item-in-folder", filePath),
482
2591
  // System information
483
2592
  systemInfo: {
@@ -768,7 +2877,7 @@ const api = {
768
2877
  /** `sessionId` attributes the tab to the conversation it was opened from
769
2878
  * (design R1); `null` is a tab that belongs to no conversation, which is what
770
2879
  * the route and a draft pane open. Main validates it rather than trusting it. */
771
- newTab: (sessionId) => electron.ipcRenderer.invoke("browser-new-tab", sessionId ?? null),
2880
+ newTab: (sessionId2) => electron.ipcRenderer.invoke("browser-new-tab", sessionId2 ?? null),
772
2881
  closeTab: (tabId) => electron.ipcRenderer.invoke("browser-close-tab", tabId),
773
2882
  /** A bulk close: `{ mode: "ids", tabIds }` for tabs the user could see, or
774
2883
  * `{ mode: "conversation", sessionId }` which MAIN resolves at execution time
@@ -782,7 +2891,7 @@ const api = {
782
2891
  history: (direction) => electron.ipcRenderer.invoke("browser-history", direction),
783
2892
  setContentRect: (rect) => electron.ipcRenderer.invoke("browser-set-content-rect", rect),
784
2893
  setViewVisible: (visible) => electron.ipcRenderer.invoke("browser-set-visible", visible),
785
- handOver: (tabId, sessionId) => electron.ipcRenderer.invoke("browser-hand-over", tabId, sessionId),
2894
+ handOver: (tabId, sessionId2) => electron.ipcRenderer.invoke("browser-hand-over", tabId, sessionId2),
786
2895
  revokeHandOver: (tabId) => electron.ipcRenderer.invoke("browser-revoke-hand-over", tabId),
787
2896
  respondToConsent: (entryId, decision) => electron.ipcRenderer.invoke("browser-consent-respond", entryId, decision),
788
2897
  revokeApproval: (origin) => electron.ipcRenderer.invoke("browser-revoke-approval", origin),
@@ -797,7 +2906,7 @@ const api = {
797
2906
  * request id, so this carries no authority of its own — it is the user's
798
2907
  * choice travelling one way.
799
2908
  */
800
- respondToWebauthn: (requestId, credentialId) => electron.ipcRenderer.invoke("browser-webauthn-respond", requestId, credentialId),
2909
+ respondToWebauthn: (requestId2, credentialId) => electron.ipcRenderer.invoke("browser-webauthn-respond", requestId2, credentialId),
801
2910
  /** A `navigator.credentials.get()` matched more than one passkey, and the
802
2911
  * user has to be asked which one to use. Shapes are validated here the way
803
2912
  * every other inbound payload is: main is trusted, but a malformed payload
@@ -907,7 +3016,7 @@ const api = {
907
3016
  * the wrong direction, which is why this namespace exists at all.
908
3017
  */
909
3018
  console: {
910
- state: (sessionId) => electron.ipcRenderer.invoke("console-state", sessionId ?? null),
3019
+ state: (sessionId2) => electron.ipcRenderer.invoke("console-state", sessionId2 ?? null),
911
3020
  /** Create a surface the USER asked for. Main sets the origin to `user`; the
912
3021
  * renderer cannot name it, because provenance is not a renderer's to claim. */
913
3022
  createSurface: (request) => electron.ipcRenderer.invoke("console-create-surface", request),