local-operator-ui 0.33.16 → 0.33.17
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.
- package/out/main/index.js +490 -90
- package/out/preload/index.js +2087 -6
- package/out/renderer/assets/{_basePickBy-CGsf5s5y.js → _basePickBy-C6nvmA2O.js} +1 -1
- package/out/renderer/assets/{_baseUniq-C43UnLjk.js → _baseUniq-D-t5jxGe.js} +1 -1
- package/out/renderer/assets/{agent-details-page-BIwam1AY.js → agent-details-page-mE7vTnG6.js} +2 -2
- package/out/renderer/assets/{agent-hub-page-01a3MLYP.js → agent-hub-page-CaqTDoWq.js} +2 -2
- package/out/renderer/assets/{agents-page-CRnd_ZL5.js → agents-page-DA1i3841.js} +3 -3
- package/out/renderer/assets/{arc-M0pLW_S9.js → arc-DImPBqfb.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-BSBbZOP-.js → architectureDiagram-IEHRJDOE-f1d6V-bd.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-B927zHwX.js → blockDiagram-JOT3LUYC-CMcoLCyx.js} +1 -1
- package/out/renderer/assets/{book-open-BOhsj37a.js → book-open-BHF9CFNT.js} +1 -1
- package/out/renderer/assets/browser-page-s-y7nb7P.js +1 -0
- package/out/renderer/assets/{browser-webauthn-prompt-k8f91jC5.js → browser-webauthn-prompt-DTozoEE6.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-Db4tt7Tq.js → c4Diagram-VJAJSXHY-Ddsy3KqL.js} +1 -1
- package/out/renderer/assets/channel-JtmJZ6rG.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-Esvq7q-I.js → chunk-4BMEZGHF-CDjCGQ2m.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-YDyKUqYa.js → chunk-A2AXSNBT-Baqc6iAL.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-D7xfN31Q.js → chunk-AEK57VVT-CCyw5JVf.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-B7GPJrWB.js → chunk-D6G4REZN-EvFtaW-d.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-OrSabd2d.js → chunk-RZ5BOZE2-GTO1bXU_.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-Ls62ZuT-.js → chunk-XZIHB7SX-DoyGDc-_.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-BI50x_TJ.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-BI50x_TJ.js +1 -0
- package/out/renderer/assets/clone-XzOZppxb.js +1 -0
- package/out/renderer/assets/{console-mirror-D5I24PC-.js → console-mirror-a7yB-s_0.js} +1 -1
- package/out/renderer/assets/{consoleCapture-BkjkGzZU.js → consoleCapture-CesHMPtd.js} +1 -1
- package/out/renderer/assets/{conversation-input-sync-CvO1w7en.js → conversation-input-sync-B4PMPza6.js} +235 -236
- package/out/renderer/assets/{dagre-OKDRZEBW-BF8NMghr.js → dagre-OKDRZEBW-JuntHNlb.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-DYPlCY6D.js → diagram-SSKATNLV-DyRsGLiH.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-htbXA7Fo.js → diagram-VNBRO52H-B9gGTGdS.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-4Mm_pKd-.js → erDiagram-Q7BY3M3F-B26A6UsD.js} +1 -1
- package/out/renderer/assets/{error-boundary-CYrSujeA.js → error-boundary-D9cFOE8L.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-DDzUlC1i.js → flowDiagram-4HSFHLVR-CitlL0-h.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-4vH_i6M9.js → ganttDiagram-APWFNJXF-BHkObFqC.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-PTmOOixv.js → gitGraphDiagram-7IBYFJ6S-PmAB3iwh.js} +1 -1
- package/out/renderer/assets/{graph-016lbgzw.js → graph-kQDtoPNV.js} +1 -1
- package/out/renderer/assets/{index-DWsjHqzk.js → index-31_gb9wt.js} +153 -153
- package/out/renderer/assets/index-BDUcsMbE.css +1 -0
- package/out/renderer/assets/{index-5rWUB2pk.js → index-CoTpZA1x.js} +1 -1
- package/out/renderer/assets/{index-I0rfR2G9.js → index-DatO4ILn.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-BUH-2kNx.js → infoDiagram-PH2N3AL5-Bthl21hU.js} +1 -1
- package/out/renderer/assets/{installer-DzJgD7dC.js → installer-0DM8a2O5.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-B0GPzUDZ.js → journeyDiagram-U35MCT3I-CS2dhA53.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-DPMTM-PB.js → kanban-definition-NDS4AKOZ-BuYZJpFN.js} +1 -1
- package/out/renderer/assets/{layout-CZCsyyxh.js → layout-BDAgAb56.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-BrIHqr1U.js → legacy-agents-page-ByVaCmw5.js} +4 -4
- package/out/renderer/assets/{mermaid.core-DIr_Z2eU.js → mermaid.core-BNyEpqlY.js} +5 -5
- package/out/renderer/assets/{mesh-page-CY9vZlLm.js → mesh-page-Bpcc_Lcj.js} +1 -1
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-Dk9hiZoP.js → mindmap-definition-ALO5MXBD-Km_IXn-L.js} +1 -1
- package/out/renderer/assets/mini-CueIa1FR.js +1 -0
- package/out/renderer/assets/{org-surface-gate-vsaHuMo7.js → org-surface-gate-BNNGp-Zg.js} +1 -1
- package/out/renderer/assets/{page-header-DWAQV8z7.js → page-header-Bj15XDRT.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-CT-KDbbB.js → pieDiagram-IB7DONF6-CgQ2nspc.js} +1 -1
- package/out/renderer/assets/{projects-page-B-Uqww5s.js → projects-page-BiqCz3gt.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CbuvxmHL.js → quadrantDiagram-7GDLP6J5-C9VRMhAK.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-DWgcYgI1.js → radar-MK3ICKWK-Ca5oUXiG.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-BwMgaUi2.js → radient-auth-buttons-wx9BULzn.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-gL2w5pv1.js → requirementDiagram-KVF5MWMF-Dl3vif4l.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-COy-DHKN.js → sankeyDiagram-QLVOVGJD-Dp6MvwZ8.js} +1 -1
- package/out/renderer/assets/{schedules-page-WYM7voi8.js → schedules-page-BlByjsrj.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-D8fMPudr.js → sequenceDiagram-X6HHIX6F-DBC8EXEO.js} +1 -1
- package/out/renderer/assets/{settings-page-73bm6YFk.js → settings-page-BPjWIUIw.js} +7 -7
- package/out/renderer/assets/{settings-section-CfFkk5yW.js → settings-section-C9vsPtq9.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-DJv925EE.js → stateDiagram-DGXRK772-ZaVJUkT9.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-rmZg0oCb.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-D4Mikzrt.js → timeline-definition-BDJGKUSR-D-lVRJfh.js} +1 -1
- package/out/renderer/assets/{ui-preferences-store-DrvWjHl4.js → ui-preferences-store-CGuje1Kj.js} +1 -1
- package/out/renderer/assets/{use-download-agent-mutation-EYt16YWD.js → use-download-agent-mutation-DwLETtX-.js} +2 -2
- package/out/renderer/assets/{use-memberships-query-CTlFzQjW.js → use-memberships-query-BO0DbD7e.js} +1 -1
- package/out/renderer/assets/{use-session-snapshot-JyEJO1f9.js → use-session-snapshot-CrrB5aTS.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-7I7MMjXI.js → xychartDiagram-VJFVF3MP-Dfu_vxvq.js} +1 -1
- package/out/renderer/console-capture.html +5 -5
- package/out/renderer/index.html +7 -7
- package/out/renderer/installer.html +4 -4
- package/out/renderer/mini.html +7 -7
- package/package.json +3 -2
- package/out/renderer/assets/browser-page-Dk6im9oj.js +0 -1
- package/out/renderer/assets/channel-CILm-Wsr.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-BjqKgY46.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-BjqKgY46.js +0 -1
- package/out/renderer/assets/clone-Dj2cZSoa.js +0 -1
- package/out/renderer/assets/index-CTM-knBb.css +0 -1
- package/out/renderer/assets/mini-BGlRDs7P.js +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-BL621Pie.js +0 -1
- /package/out/renderer/assets/{index-nSWgbpx8.js → index-WYMv0ga-.js} +0 -0
package/out/preload/index.js
CHANGED
|
@@ -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,2065 @@ 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
|
+
desktopRequestUnion.superRefine(
|
|
2038
|
+
(request, ctx) => {
|
|
2039
|
+
if (request.op === "sessions.answer") {
|
|
2040
|
+
if (request.askId !== void 0) {
|
|
2041
|
+
if (!request.askId)
|
|
2042
|
+
ctx.addIssue({
|
|
2043
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2044
|
+
message: "ask_id must be a non-empty string",
|
|
2045
|
+
path: ["askId"]
|
|
2046
|
+
});
|
|
2047
|
+
if (request.decline !== true && !request.answers)
|
|
2048
|
+
ctx.addIssue({
|
|
2049
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2050
|
+
message: "A queued-ask answer needs answers or decline",
|
|
2051
|
+
path: ["answers"]
|
|
2052
|
+
});
|
|
2053
|
+
else if (request.decline === true && request.answers !== void 0)
|
|
2054
|
+
ctx.addIssue({
|
|
2055
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2056
|
+
message: "Supply either answers or decline",
|
|
2057
|
+
path: ["decline"]
|
|
2058
|
+
});
|
|
2059
|
+
else if (request.revise === true && request.decline === true)
|
|
2060
|
+
ctx.addIssue({
|
|
2061
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2062
|
+
message: "Supply either decline or revise",
|
|
2063
|
+
path: ["revise"]
|
|
2064
|
+
});
|
|
2065
|
+
else if (request.answers !== void 0 && Object.keys(request.answers).length === 0)
|
|
2066
|
+
ctx.addIssue({
|
|
2067
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2068
|
+
message: "answers must name at least one question",
|
|
2069
|
+
path: ["answers"]
|
|
2070
|
+
});
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
if (!request.epoch)
|
|
2074
|
+
ctx.addIssue({
|
|
2075
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2076
|
+
message: "An epoch is required to answer a gate",
|
|
2077
|
+
path: ["epoch"]
|
|
2078
|
+
});
|
|
2079
|
+
if (!request.requestId)
|
|
2080
|
+
ctx.addIssue({
|
|
2081
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2082
|
+
message: "A request id is required to answer a gate",
|
|
2083
|
+
path: ["requestId"]
|
|
2084
|
+
});
|
|
2085
|
+
if (request.revise === true)
|
|
2086
|
+
ctx.addIssue({
|
|
2087
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2088
|
+
message: "revise applies to a queued ask",
|
|
2089
|
+
path: ["revise"]
|
|
2090
|
+
});
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
if (request.op === "sessions.create" && request.purpose === void 0) {
|
|
2094
|
+
if (request.cwd === void 0) {
|
|
2095
|
+
ctx.addIssue({
|
|
2096
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2097
|
+
message: "A conversation needs a working directory: only a configuration run (`purpose`) may omit `cwd`.",
|
|
2098
|
+
path: ["cwd"]
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
if (request.op === "sessions.create" && request.purpose !== void 0) {
|
|
2104
|
+
for (const field of ["cwd", "target", "model", "draftId"]) {
|
|
2105
|
+
if (request[field] === void 0) continue;
|
|
2106
|
+
ctx.addIssue({
|
|
2107
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2108
|
+
message: `A configuration run resolves its own \`${field}\`: send \`purpose\` alone.`,
|
|
2109
|
+
path: [field]
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
if (request.op !== "agent.publish" && request.op !== "agent.republish") {
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
if (Boolean(request.visibility === "org") !== Boolean(request.tenantId)) {
|
|
2118
|
+
ctx.addIssue({
|
|
2119
|
+
code: zod.z.ZodIssueCode.custom,
|
|
2120
|
+
message: 'A publication target needs both `visibility: "org"` and `tenantId`, or neither: one half would publish to the public hub.'
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
);
|
|
2125
|
+
const LEDGER_READ_OPS = /* @__PURE__ */ new Set([
|
|
2126
|
+
"analytics.get",
|
|
2127
|
+
/*
|
|
2128
|
+
* The worst case of the same shape, and the reason the set is spelled by
|
|
2129
|
+
* shape rather than by measured cost: `/analytics/models` is a grouped scan
|
|
2130
|
+
* of the raw ledger with no covering index, measured in SECONDS on a 1.95 M
|
|
2131
|
+
* row ledger where `analytics.get` measures 12-40 s cold. On the control
|
|
2132
|
+
* budget it would be abandoned mid-read on nearly every open, so it is here
|
|
2133
|
+
* beside the op it is a slower sibling of.
|
|
2134
|
+
*/
|
|
2135
|
+
"analytics.models",
|
|
2136
|
+
"sessions.report"
|
|
2137
|
+
]);
|
|
2138
|
+
const PROVIDER_READ_OPS = /* @__PURE__ */ new Set(["usage.get"]);
|
|
2139
|
+
const MESH_READ_OPS = /* @__PURE__ */ new Set([
|
|
2140
|
+
"networks.list",
|
|
2141
|
+
"peers.list"
|
|
2142
|
+
]);
|
|
2143
|
+
/* @__PURE__ */ new Set([
|
|
2144
|
+
...LEDGER_READ_OPS,
|
|
2145
|
+
...PROVIDER_READ_OPS,
|
|
2146
|
+
...MESH_READ_OPS
|
|
2147
|
+
]);
|
|
2148
|
+
const EXTERNAL_OPEN_REFUSED_CHANNEL = "external-open-refused";
|
|
89
2149
|
const DESKTOP_STREAM_DETAIL = {
|
|
90
2150
|
/** The IPC subscribe itself rejected before a stream existed. */
|
|
91
2151
|
openFailed: "The event stream could not be opened."
|
|
@@ -139,8 +2199,8 @@ function readOpenSessionArgv(argv) {
|
|
|
139
2199
|
return found !== null && SESSION_ID.test(found) ? found : null;
|
|
140
2200
|
}
|
|
141
2201
|
function readLaunchTarget(argv) {
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
2202
|
+
const sessionId2 = readOpenSessionArgv(argv);
|
|
2203
|
+
if (sessionId2) return { kind: "session", sessionId: sessionId2 };
|
|
144
2204
|
if (argv.includes(OPEN_CATALOGUE_FLAG)) return { kind: "catalogue" };
|
|
145
2205
|
return { kind: "restore" };
|
|
146
2206
|
}
|
|
@@ -477,7 +2537,28 @@ const api = {
|
|
|
477
2537
|
* directory the user typed a `/` into.
|
|
478
2538
|
*/
|
|
479
2539
|
listDirectory: (dir, cwd) => electron.ipcRenderer.invoke("list-directory", dir, cwd),
|
|
2540
|
+
/**
|
|
2541
|
+
* Hand a URL to the OS, through the main process's vetted door. The outcome
|
|
2542
|
+
* is the door's verdict (round-2 R-4): the toolbar's Open shows a refusal
|
|
2543
|
+
* rather than resolving quietly.
|
|
2544
|
+
*/
|
|
480
2545
|
openExternal: (url) => electron.ipcRenderer.invoke("open-external", url),
|
|
2546
|
+
/**
|
|
2547
|
+
* A refused external open, pushed to the window whose content asked. The
|
|
2548
|
+
* `openExternal` call answers its caller directly; this is the half a
|
|
2549
|
+
* markdown anchor's click has (round-2 R-4): it left through the main
|
|
2550
|
+
* process's `window.open` door, so main is where the refusal is known and
|
|
2551
|
+
* the renderer is where it must be shown.
|
|
2552
|
+
*/
|
|
2553
|
+
onExternalOpenRefused: (callback) => {
|
|
2554
|
+
const handler = (_event, payload) => {
|
|
2555
|
+
if (payload && typeof payload.reason === "string") callback(payload);
|
|
2556
|
+
};
|
|
2557
|
+
electron.ipcRenderer.on(EXTERNAL_OPEN_REFUSED_CHANNEL, handler);
|
|
2558
|
+
return () => {
|
|
2559
|
+
electron.ipcRenderer.removeListener(EXTERNAL_OPEN_REFUSED_CHANNEL, handler);
|
|
2560
|
+
};
|
|
2561
|
+
},
|
|
481
2562
|
showItemInFolder: (filePath) => electron.ipcRenderer.invoke("show-item-in-folder", filePath),
|
|
482
2563
|
// System information
|
|
483
2564
|
systemInfo: {
|
|
@@ -768,7 +2849,7 @@ const api = {
|
|
|
768
2849
|
/** `sessionId` attributes the tab to the conversation it was opened from
|
|
769
2850
|
* (design R1); `null` is a tab that belongs to no conversation, which is what
|
|
770
2851
|
* the route and a draft pane open. Main validates it rather than trusting it. */
|
|
771
|
-
newTab: (
|
|
2852
|
+
newTab: (sessionId2) => electron.ipcRenderer.invoke("browser-new-tab", sessionId2 ?? null),
|
|
772
2853
|
closeTab: (tabId) => electron.ipcRenderer.invoke("browser-close-tab", tabId),
|
|
773
2854
|
/** A bulk close: `{ mode: "ids", tabIds }` for tabs the user could see, or
|
|
774
2855
|
* `{ mode: "conversation", sessionId }` which MAIN resolves at execution time
|
|
@@ -782,7 +2863,7 @@ const api = {
|
|
|
782
2863
|
history: (direction) => electron.ipcRenderer.invoke("browser-history", direction),
|
|
783
2864
|
setContentRect: (rect) => electron.ipcRenderer.invoke("browser-set-content-rect", rect),
|
|
784
2865
|
setViewVisible: (visible) => electron.ipcRenderer.invoke("browser-set-visible", visible),
|
|
785
|
-
handOver: (tabId,
|
|
2866
|
+
handOver: (tabId, sessionId2) => electron.ipcRenderer.invoke("browser-hand-over", tabId, sessionId2),
|
|
786
2867
|
revokeHandOver: (tabId) => electron.ipcRenderer.invoke("browser-revoke-hand-over", tabId),
|
|
787
2868
|
respondToConsent: (entryId, decision) => electron.ipcRenderer.invoke("browser-consent-respond", entryId, decision),
|
|
788
2869
|
revokeApproval: (origin) => electron.ipcRenderer.invoke("browser-revoke-approval", origin),
|
|
@@ -797,7 +2878,7 @@ const api = {
|
|
|
797
2878
|
* request id, so this carries no authority of its own — it is the user's
|
|
798
2879
|
* choice travelling one way.
|
|
799
2880
|
*/
|
|
800
|
-
respondToWebauthn: (
|
|
2881
|
+
respondToWebauthn: (requestId2, credentialId) => electron.ipcRenderer.invoke("browser-webauthn-respond", requestId2, credentialId),
|
|
801
2882
|
/** A `navigator.credentials.get()` matched more than one passkey, and the
|
|
802
2883
|
* user has to be asked which one to use. Shapes are validated here the way
|
|
803
2884
|
* every other inbound payload is: main is trusted, but a malformed payload
|
|
@@ -907,7 +2988,7 @@ const api = {
|
|
|
907
2988
|
* the wrong direction, which is why this namespace exists at all.
|
|
908
2989
|
*/
|
|
909
2990
|
console: {
|
|
910
|
-
state: (
|
|
2991
|
+
state: (sessionId2) => electron.ipcRenderer.invoke("console-state", sessionId2 ?? null),
|
|
911
2992
|
/** Create a surface the USER asked for. Main sets the origin to `user`; the
|
|
912
2993
|
* renderer cannot name it, because provenance is not a renderer's to claim. */
|
|
913
2994
|
createSurface: (request) => electron.ipcRenderer.invoke("console-create-surface", request),
|