@intentic/sandbox-contract 1.126.0 → 1.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/agent-catalog.d.ts.map +1 -1
  2. package/dist/contracts/activity.contract.d.ts +4 -4
  3. package/dist/contracts/activity.contract.d.ts.map +1 -1
  4. package/dist/contracts/agent.contract.d.ts +283 -252
  5. package/dist/contracts/agent.contract.d.ts.map +1 -1
  6. package/dist/contracts/agent.contract.js +4 -3
  7. package/dist/contracts/agent.contract.js.map +1 -1
  8. package/dist/contracts/agents.contract.d.ts +15 -13
  9. package/dist/contracts/agents.contract.d.ts.map +1 -1
  10. package/dist/contracts/automations.contract.d.ts +3 -3
  11. package/dist/contracts/automations.contract.d.ts.map +1 -1
  12. package/dist/contracts/capabilities.contract.d.ts +21 -16
  13. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  14. package/dist/contracts/claude.contract.d.ts.map +1 -1
  15. package/dist/contracts/codex.contract.d.ts.map +1 -1
  16. package/dist/contracts/drafts.contract.d.ts +6 -6
  17. package/dist/contracts/drafts.contract.d.ts.map +1 -1
  18. package/dist/contracts/extensions.contract.d.ts +4 -4
  19. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  20. package/dist/contracts/git.contract.d.ts +6 -4
  21. package/dist/contracts/git.contract.d.ts.map +1 -1
  22. package/dist/contracts/grok.contract.d.ts.map +1 -1
  23. package/dist/contracts/history.contract.d.ts +3 -3
  24. package/dist/contracts/history.contract.d.ts.map +1 -1
  25. package/dist/contracts/intentic.contract.d.ts.map +1 -1
  26. package/dist/contracts/inventory.contract.d.ts +20 -20
  27. package/dist/contracts/inventory.contract.d.ts.map +1 -1
  28. package/dist/contracts/logs.contract.d.ts.map +1 -1
  29. package/dist/contracts/panels.contract.d.ts +1 -1
  30. package/dist/contracts/panels.contract.d.ts.map +1 -1
  31. package/dist/contracts/ports.contract.d.ts +1 -1
  32. package/dist/contracts/ports.contract.d.ts.map +1 -1
  33. package/dist/contracts/secrets.contract.d.ts +4 -4
  34. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  35. package/dist/contracts/sessions.contract.d.ts +1 -1
  36. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  37. package/dist/contracts/settings.contract.d.ts.map +1 -1
  38. package/dist/contracts/system.contract.d.ts +26 -26
  39. package/dist/contracts/system.contract.d.ts.map +1 -1
  40. package/dist/contracts/translator.contract.d.ts.map +1 -1
  41. package/dist/contracts/workspace.contract.d.ts +21 -21
  42. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  43. package/dist/effects.d.ts +1 -1
  44. package/dist/effects.d.ts.map +1 -1
  45. package/dist/effects.js +2 -0
  46. package/dist/effects.js.map +1 -1
  47. package/dist/events.d.ts +213 -29
  48. package/dist/events.d.ts.map +1 -1
  49. package/dist/events.js +5 -0
  50. package/dist/events.js.map +1 -1
  51. package/dist/hostnames.d.ts.map +1 -1
  52. package/dist/index.d.ts +417 -377
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/model-metadata.d.ts.map +1 -1
  55. package/dist/schemas.d.ts +221 -212
  56. package/dist/schemas.d.ts.map +1 -1
  57. package/dist/schemas.js +13 -6
  58. package/dist/schemas.js.map +1 -1
  59. package/dist/sse.d.ts.map +1 -1
  60. package/dist/tunnel-ids.d.ts.map +1 -1
  61. package/package.json +8 -6
  62. package/src/agent-catalog.ts +62 -0
  63. package/src/contracts/activity.contract.ts +9 -0
  64. package/src/contracts/agent.contract.ts +17 -0
  65. package/src/contracts/agents.contract.ts +28 -0
  66. package/src/contracts/automations.contract.ts +23 -0
  67. package/src/contracts/capabilities.contract.ts +31 -0
  68. package/src/contracts/claude.contract.ts +22 -0
  69. package/src/contracts/codex.contract.ts +23 -0
  70. package/src/contracts/drafts.contract.ts +11 -0
  71. package/src/contracts/extensions.contract.ts +28 -0
  72. package/src/contracts/git.contract.ts +64 -0
  73. package/src/contracts/grok.contract.ts +16 -0
  74. package/src/contracts/history.contract.ts +12 -0
  75. package/src/contracts/intentic.contract.ts +17 -0
  76. package/src/contracts/inventory.contract.ts +11 -0
  77. package/src/contracts/logs.contract.ts +9 -0
  78. package/src/contracts/panels.contract.ts +12 -0
  79. package/src/contracts/ports.contract.ts +11 -0
  80. package/src/contracts/secrets.contract.ts +15 -0
  81. package/src/contracts/sessions.contract.ts +14 -0
  82. package/src/contracts/settings.contract.ts +11 -0
  83. package/src/contracts/system.contract.ts +37 -0
  84. package/src/contracts/translator.contract.ts +20 -0
  85. package/src/contracts/workspace.contract.ts +74 -0
  86. package/src/effects.test.ts +132 -0
  87. package/src/effects.ts +138 -0
  88. package/src/events.ts +277 -0
  89. package/src/hostnames.test.ts +44 -0
  90. package/src/hostnames.ts +97 -0
  91. package/src/index.ts +84 -0
  92. package/src/model-metadata.ts +30 -0
  93. package/src/schemas.ts +1407 -0
  94. package/src/sse.ts +43 -0
  95. package/src/terminal-protocol.ts +16 -0
  96. package/src/tunnel-ids.ts +19 -0
package/src/schemas.ts ADDED
@@ -0,0 +1,1407 @@
1
+ import { ExtensionManifestSchema } from "@intentic/extension-api";
2
+ import { z } from "zod";
3
+
4
+ // All request/response wire schemas for the sandbox daemon. Inputs that carry a `{param}` in their route path
5
+ // (repo / id / name) merge the path param into the same flat object — oRPC fills the path placeholder from the
6
+ // matching key and routes the rest to the body (POST/PUT) or query (GET).
7
+
8
+ // ---- shared ----
9
+
10
+ // Success ack for routes that only report completion (push / disconnect / self-host register). A turn paused on
11
+ // a plan/question that no longer exists, or a missing repo/path, is an ORPCError thrown by the handler instead.
12
+ export const OkSchema = z.object({ ok: z.literal(true) });
13
+
14
+ // Which repo a git route targets: "root" (the /work workspace repo) or a repo id — the repo's root-relative
15
+ // dir, which may be nested ("clients/foo"; URL-encoded in the path param). Kept as a bare string on the wire
16
+ // (not an enum) so an unknown repo is a handler-thrown NOT_FOUND — matching the daemon's prior 404 — rather
17
+ // than an input-validation rejection.
18
+ export const RepoParamSchema = z.object({ repo: z.string() });
19
+
20
+ // ---- agent ----
21
+
22
+ export const SessionTranscriptMessageSchema = z.object({ role: z.enum(["user", "assistant"]), text: z.string() });
23
+ export type SessionTranscriptMessage = z.infer<typeof SessionTranscriptMessageSchema>;
24
+
25
+ // The agent runtimes the daemon can serve — the vocabulary every surface that picks an agent shares (chat
26
+ // turns, automations). The three NATIVE providers have dedicated adapters (and their ids are reserved); any
27
+ // other value is the id of an installed `agent`-kind capability served over ACP (Agent Client Protocol).
28
+ // Kept as a bare string on the wire (not an enum) so an unknown id is a clean error frame from the agent
29
+ // route — the same bet RepoParamSchema makes — and adding an ACP agent needs no contract change.
30
+ export const NATIVE_PROVIDERS = ["claude", "codex", "grok"] as const;
31
+ export type NativeProvider = (typeof NATIVE_PROVIDERS)[number];
32
+ export const AgentProviderSchema = z.string().min(1);
33
+ export type AgentProvider = z.infer<typeof AgentProviderSchema>;
34
+
35
+ // The harness (agentic loop) a turn runs on, orthogonal to the provider. See AgentTurnSchema.harness.
36
+ export const AgentHarnessSchema = z.enum(["native", "claude-code"]);
37
+ export type AgentHarness = z.infer<typeof AgentHarnessSchema>;
38
+
39
+ // What the user is looking at in the editor, attached to a turn only when they explicitly opt in (the
40
+ // composer chip — off by default). The daemon folds it into the prompt as a context note, so deictic
41
+ // prompts ("fix this") resolve without an @-mention. Selection is bounded — it's context, not an upload.
42
+ export const EditorContextSchema = z.object({
43
+ // Workspace-relative path of the file open in the editor.
44
+ file: z.string().min(1),
45
+ // 1-based line range of the selection; absent when the whole file is the context.
46
+ startLine: z.number().int().min(1).optional(),
47
+ endLine: z.number().int().min(1).optional(),
48
+ // The selected text itself, truncated client-side to the cap.
49
+ selection: z.string().max(20_000).optional(),
50
+ });
51
+ export type EditorContext = z.infer<typeof EditorContextSchema>;
52
+
53
+ // The client-minted stable conversation identity. Constrained because it lands in branch names (agent/<id>)
54
+ // and filesystem paths — the regex is the injection guard. Shared by the turn input and the attach input.
55
+ const ConversationIdSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/);
56
+
57
+ export const AgentTurnSchema = z
58
+ .object({
59
+ prompt: z.string(),
60
+ // The client's display title for the conversation — seeds a FRESH registry entry (so a renamed draft's
61
+ // first turn keeps its user-chosen title); an existing entry's title always wins.
62
+ title: z.string().max(80).optional(),
63
+ // Workspace-relative paths of files the user attached, already uploaded via /workspace/upload
64
+ // (the browser puts them under .intentic/attachments/<uuid>/<name>). The daemon hands them to the
65
+ // provider: Claude reads them from disk via its Read tool; Codex gets images as native inputs.
66
+ attachments: z.array(z.string().min(1)).max(20).optional(),
67
+ // Which provider (model + account) serves the turn; absent = claude. A sessionId only resumes on the
68
+ // provider that minted it (Claude Code sessions vs Codex threads vs Grok/OpenCode sessions are separate
69
+ // stores) — a mid-conversation provider/account/harness switch sends `history` instead of resuming.
70
+ agent: AgentProviderSchema.optional(),
71
+ // Which harness (agentic loop) runs the turn, orthogonal to the provider above. Absent = "native": each
72
+ // provider on its own runtime (Claude Code SDK / Codex CLI / opencode) with its subscription OAuth.
73
+ // "claude-code" forces the Claude Code Agent SDK loop for ANY provider — codex/grok then drive their model
74
+ // through the sandbox's bundled Anthropic↔OpenAI translator, which needs that provider's API key (its
75
+ // subscription OAuth can't reach a gateway). For the claude provider the two are identical.
76
+ harness: AgentHarnessSchema.optional(),
77
+ // Which connected account of that provider serves the turn; absent = the provider's first account.
78
+ account: z.string().optional(),
79
+ sessionId: z.string().optional(),
80
+ // The client-minted stable conversation identity (survives provider/account/harness switches, which
81
+ // retire sessions). Keys the fleet registry entry, the conversation's worktree, and the turn run.
82
+ conversationId: ConversationIdSchema.optional(),
83
+ // When true, the turn runs in the conversation's isolated git worktree (created lazily on first use)
84
+ // instead of the shared /work tree — the parallel-agents mode. Requires conversationId.
85
+ isolated: z.boolean().optional(),
86
+ // The client-held transcript of a conversation that just switched provider/account: seeds the FIRST
87
+ // turn of the replacement session. The daemon folds it into the prompt as one role-attributed context
88
+ // preamble for every runtime. Mutually exclusive with sessionId — a resumed session has its context.
89
+ history: z.array(SessionTranscriptMessageSchema).max(200).optional(),
90
+ // The browser sends the chosen model per turn; the provider token is the sandbox's own stored credential.
91
+ model: z.string().optional(),
92
+ // When true, run the always-plan flow (propose → approve → execute). Reasoning controls are optional.
93
+ plan: z.boolean().optional(),
94
+ effort: z.string().optional(),
95
+ thinking: z.boolean().optional(),
96
+ // The opt-in editor context chip: what the user is looking at, folded into the prompt daemon-side.
97
+ editorContext: EditorContextSchema.optional(),
98
+ })
99
+ // An attachment-only send (no text) is legal; an entirely empty turn is not.
100
+ .refine((turn) => turn.prompt.trim().length > 0 || (turn.attachments?.length ?? 0) > 0, {
101
+ message: "prompt or attachments required",
102
+ })
103
+ .refine((turn) => turn.sessionId === undefined || turn.history === undefined, {
104
+ message: "history and sessionId are mutually exclusive",
105
+ })
106
+ .refine((turn) => turn.isolated !== true || turn.conversationId !== undefined, {
107
+ message: "isolated requires conversationId",
108
+ });
109
+ export type AgentTurn = z.infer<typeof AgentTurnSchema>;
110
+
111
+ // POST /agent's ack: the daemon-minted id of the detached turn run it started. The turn executes daemon-side
112
+ // regardless of any client connection; every window — the initiator included — renders it via /agent/attach.
113
+ export const StartedTurnSchema = z.object({ run: z.string() });
114
+ export type StartedTurn = z.infer<typeof StartedTurnSchema>;
115
+
116
+ // Attach to a conversation's turn run (live, or finished within the retention window). `run`+`after` is the
117
+ // resume cursor of a client whose stream dropped: frames after `after` replay when `run` still names the
118
+ // current run; a mismatch (a newer turn started meanwhile) replays that run from its first frame instead.
119
+ export const AttachTurnSchema = z.object({
120
+ conversationId: ConversationIdSchema,
121
+ run: z.string().optional(),
122
+ after: z.number().int().min(0).optional(),
123
+ });
124
+ export type AttachTurn = z.infer<typeof AttachTurnSchema>;
125
+
126
+ // ---- agents: the parallel-conversation fleet ----
127
+ // A "fleet agent" is a conversation with a registry entry — every isolated conversation, keyed by its
128
+ // conversationId. Isolated ones own a git worktree (branch agent/<id> in every workspace repo); the fleet
129
+ // surface shows all of them with live status/activity/cost so the user can drive N agents in parallel.
130
+
131
+ // idle/running/awaiting are the turn lifecycle (awaiting = paused on a plan approval or question); landed /
132
+ // conflict are outcomes of the land flow; error is a terminal turn failure surfaced on the card.
133
+ export const AgentStatusSchema = z.enum(["idle", "running", "awaiting", "landed", "conflict", "error"]);
134
+ export type AgentStatus = z.infer<typeof AgentStatusSchema>;
135
+ // The card's live activity snippet: the last tool the agent used (with its target) and the in-progress todo.
136
+ export const AgentActivitySchema = z.object({
137
+ tool: z.string().optional(),
138
+ target: z.string().optional(),
139
+ todo: z.string().optional(),
140
+ });
141
+ export type AgentActivity = z.infer<typeof AgentActivitySchema>;
142
+ // Which "needs you" flags are raised — the fleet badge aggregates these across all agents.
143
+ export const AgentAttentionSchema = z.object({ plan: z.boolean(), question: z.boolean(), conflict: z.boolean() });
144
+ export type AgentAttention = z.infer<typeof AgentAttentionSchema>;
145
+ export const AgentSummarySchema = z.object({
146
+ // The conversationId.
147
+ id: z.string(),
148
+ sessionId: z.string().optional(),
149
+ // First prompt, sanitized to one bounded line.
150
+ title: z.string().optional(),
151
+ status: AgentStatusSchema,
152
+ provider: AgentProviderSchema,
153
+ harness: AgentHarnessSchema,
154
+ model: z.string().optional(),
155
+ account: z.string().optional(),
156
+ // The worktree branch (agent/<id>); absent for a non-isolated (main-tree) conversation.
157
+ branch: z.string().optional(),
158
+ // The ROOT repo's short base sha — the checkout moment's display identity. Per-repo bases stay
159
+ // daemon-internal (agents.diff already reports against them).
160
+ base: z.string().optional(),
161
+ costUsd: z.number().optional(),
162
+ inputTokens: z.number().optional(),
163
+ outputTokens: z.number().optional(),
164
+ contextTokens: z.number().optional(),
165
+ contextWindow: z.number().optional(),
166
+ activity: AgentActivitySchema.optional(),
167
+ // Present while a turn runs: its start, ms since epoch.
168
+ startedAt: z.number().optional(),
169
+ updatedAt: z.number(),
170
+ attention: AgentAttentionSchema,
171
+ // Completed turns and lifetime tool calls — the card's msgs/tools counters.
172
+ turns: z.number().optional(),
173
+ toolUses: z.number().optional(),
174
+ // The agent's cumulative output (base → branch tip across every repo), refreshed on each land —
175
+ // the card's "12 files · +412 −96" readout. Independent of what has landed.
176
+ diff: z.object({ files: z.number(), insertions: z.number(), deletions: z.number() }).optional(),
177
+ });
178
+ export type AgentSummary = z.infer<typeof AgentSummarySchema>;
179
+ export const AgentsListSchema = z.object({ agents: z.array(AgentSummarySchema) });
180
+ export const AgentIdSchema = z.object({ id: z.string().min(1) });
181
+ // rename's input: the user-chosen display title (bounded like sanitizeTitle's cap).
182
+ export const AgentRenameSchema = z.object({ id: z.string().min(1), title: z.string().trim().min(1).max(80) });
183
+ export const AgentFileDiffQuerySchema = z.object({ id: z.string().min(1), repo: z.string().min(1), path: z.string().min(1) });
184
+ // land's outcome: per-repo conflicts (dirty-main overlaps or merge conflicts); landed only when every repo
185
+ // with changes merged cleanly. Conflicted repos keep their worktree state — nothing is lost.
186
+ export const LandResultSchema = z.object({
187
+ landed: z.boolean(),
188
+ conflicts: z.array(z.object({ repo: z.string(), paths: z.array(z.string()) })).optional(),
189
+ });
190
+ export type LandResult = z.infer<typeof LandResultSchema>;
191
+
192
+ // ---- routed-provider subscriptions ----
193
+
194
+ // The providers whose model can run UNDER the Claude Code harness through the bundled translator (CLIProxyAPI),
195
+ // which holds their SUBSCRIPTION OAuth and re-serves it behind an Anthropic endpoint. The `claude` provider is
196
+ // absent — native Anthropic OAuth serves it directly, without the translator.
197
+ export const KeyedProviderSchema = z.enum(["codex", "grok"]);
198
+ export type KeyedProvider = z.infer<typeof KeyedProviderSchema>;
199
+
200
+ // Which routed-provider subscriptions are connected in the translator (per provider). Drives the
201
+ // "connected / connect subscription" state in Sandbox ▸ Agent.
202
+ export const TranslatorAccountsSchema = z.object({ codex: z.boolean(), grok: z.boolean() });
203
+ export type TranslatorAccounts = z.infer<typeof TranslatorAccountsSchema>;
204
+
205
+ // Side-channel bodies: the UI posts these to resolve a turn paused on a plan approval / question.
206
+ export const DecisionSchema = z.object({ decisionId: z.string().min(1), approve: z.boolean(), feedback: z.string().optional() });
207
+ export const AnswerSchema = z.object({
208
+ requestId: z.string().min(1),
209
+ answers: z.record(z.string(), z.array(z.string())).optional(),
210
+ cancelled: z.boolean().optional(),
211
+ });
212
+ // Steering: a user message delivered INTO the running turn (injected between tool calls, Claude Code style),
213
+ // keyed by the conversation whose turn is in flight. NOT_FOUND when no steerable turn is running — the client
214
+ // then falls back to a fresh send.
215
+ export const SteerSchema = z.object({ conversationId: z.string().min(1), text: z.string().min(1).max(20_000) });
216
+ // True cancel for the conversation's in-flight turn — aborts the agent daemon-side, unlike closing the
217
+ // /agent fetch (which sends no cancel frame).
218
+ export const StopTurnSchema = z.object({ conversationId: z.string().min(1) });
219
+
220
+ // ---- provider oauth ----
221
+ // Claude uses the PKCE authorize-URL + paste-back handshake (start → exchange). Codex uses OpenAI's device-code
222
+ // flow (start → poll): the browser signs in at verificationUri and enters userCode; the daemon polls until done.
223
+ // A sandbox can hold several accounts per provider side by side: `id` is the daemon-minted store key, `label`
224
+ // the user's display name (auto-filled from the sign-in identity where the token carries one). Tokens never
225
+ // ride this shape — connection status is existence in the list.
226
+
227
+ export const OauthAccountSchema = z.object({
228
+ id: z.string(),
229
+ label: z.string(),
230
+ scope: z.string().optional(),
231
+ connectedAt: z.number(), // epoch ms
232
+ // Set only when the account's stored credential can no longer be refreshed (revoked/expired refresh token)
233
+ // — the user must reconnect. Absent ⇒ healthy or not-yet-probed; `detail` carries the reason for the UI.
234
+ // Provider-agnostic; only Codex probes it today (Claude refreshes on-demand, Grok's tokens are OpenCode's).
235
+ needsReauth: z.boolean().optional(),
236
+ detail: z.string().optional(),
237
+ });
238
+ export type OauthAccount = z.infer<typeof OauthAccountSchema>;
239
+ export const OauthAccountListSchema = z.object({ accounts: z.array(OauthAccountSchema) });
240
+ // Address one account of a provider (disconnect, and the turn's `account`).
241
+ export const AccountIdSchema = z.object({ id: z.string().min(1) });
242
+ // The completing calls carry the user-chosen label (blank ⇒ the daemon derives one from the sign-in identity
243
+ // or a provider default).
244
+ export const OauthExchangeSchema = z.object({
245
+ code: z.string().min(1),
246
+ verifier: z.string().min(1),
247
+ state: z.string().min(1),
248
+ label: z.string().optional(),
249
+ });
250
+ export const AuthorizeChallengeSchema = z.object({ authorizeUrl: z.string(), verifier: z.string(), state: z.string() });
251
+ export const CodexDeviceStartSchema = z.object({
252
+ userCode: z.string(),
253
+ deviceAuthId: z.string(),
254
+ interval: z.number(),
255
+ verificationUri: z.string(),
256
+ });
257
+ export const CodexDevicePollSchema = z.object({ deviceAuthId: z.string().min(1), userCode: z.string().min(1), label: z.string().optional() });
258
+ // The poll answer: `pending` while the user is still signing in; the created account once done.
259
+ export const CodexPollResultSchema = z.object({ pending: z.boolean(), account: OauthAccountSchema.optional() });
260
+ // xAI Grok (via OpenCode) uses subscription OAuth via the headless device-code method. `start` returns the
261
+ // `url` the user opens (xAI's verification_uri_complete, which pre-fills the code) and `code` — the same
262
+ // one-time code, surfaced so the card matches x.ai exactly. There is no paste-back: OpenCode polls to
263
+ // completion and the UI polls `/grok/accounts`.
264
+ // ponytail: OpenCode holds one xAI auth per data dir, so Grok stays single-account — the list is 0 or 1. Per
265
+ // account would need an OpenCode server per data dir; add when there's demand.
266
+ // A device-code login start: the verification URL + the one-time code the user enters there. Shared by the
267
+ // native Grok flow (via OpenCode) and the routed-provider subscription connect (codex/grok via CLIProxyAPI).
268
+ export const DeviceStartSchema = z.object({ url: z.string(), code: z.string() });
269
+ // A provider's model catalog, resolved daemon-side from live discovery with a persisted last-known-good list and
270
+ // a seed floor (Grok via opencode.ts xaiModels, Codex via codex-models.ts, Claude via the Agent SDK's
271
+ // supportedModels) — never empty, so the picker is never blank. `label` is the humanized display name; `default`
272
+ // is the model a fresh chat on that provider seeds (always present). Shared by /grok/models, /codex/models,
273
+ // /claude/models. `efforts` is the reasoning-effort tiers the model accepts (Claude reports them per model);
274
+ // empty ⇒ the client's default tiers.
275
+ export const ModelSchema = z.object({ id: z.string(), label: z.string(), efforts: z.array(z.string()).optional() });
276
+ export const ModelsSchema = z.object({ models: z.array(ModelSchema), default: z.string() });
277
+
278
+ // ---- sessions ----
279
+
280
+ export const SessionIdParamSchema = z.object({ id: z.string() });
281
+ export const SessionSummarySchema = z.object({ id: z.string(), title: z.string(), updatedAt: z.number() });
282
+ export const SessionsListSchema = z.object({ sessions: z.array(SessionSummarySchema) });
283
+ export const SessionTranscriptSchema = z.object({ messages: z.array(SessionTranscriptMessageSchema) });
284
+
285
+ // ---- settings: per-sandbox agent settings (.intentic/settings.json) ----
286
+ // Small user-owned config the /settings routes edit and streamAgent reads — all opt-in booleans the owner
287
+ // toggles in the UI (so each can be A/B benchmarked):
288
+ // stableSystemPrompt — keeps the system prompt byte-stable across turns (the delegation note rides the user
289
+ // message instead of the preset `append`) so the provider prompt cache survives.
290
+ // skills — names of baked-tool skills to load into .claude/skills so the agent reaches for them
291
+ // (e.g. "lsp" — TS rename + diagnostics over the language service); a name absent ⇒ its
292
+ // skill file isn't written, so the agent doesn't reach for it. Data-driven: a new baked
293
+ // tool is one daemon-side registry entry, not a new settings field.
294
+ // hashlineEdits — swaps the native Read/Edit/Write for hash-anchored edits on the Claude path (stale-file
295
+ // guard + fewer output tokens); off ⇒ the native file tools.
296
+ // terseOutput — appends a concise-response steer to the end of the system prompt (a stable suffix, so it
297
+ // composes with stableSystemPrompt) to cut the model's OWN output tokens.
298
+ // iqSearch — loads the image-baked iq Claude Code plugin (skill + SessionStart nudge) so the agent
299
+ // prefers the iq CLI over grep/find/Glob; off ⇒ plugin not loaded, native search tools
300
+ // only. Opt-in (default off); the browser Search box uses iq regardless.
301
+ // outputCleaners — the Bash output-cleaner spec (agent-output-filter): "off" = filter disabled (default),
302
+ // "" = all cleaners on, else an iq-style allow-list / default-minus
303
+ // spec ("git,pnpm" = only those; "-cap" = all except). Threaded to the filter via env.
304
+ // outputHoldout — measurement control: a fraction [0,1] of Bash commands whose output bypasses cleaning
305
+ // (recorded raw as `heldOut`), so the savings report compares a real cleaned-vs-raw
306
+ // population instead of an estimate. 0 = no holdout (default).
307
+ // filterBackend — which cleaner runs the compression: "native" (agent-output-filter, default) or "rtk"
308
+ // (the rtk binary from its installed extension, rewritten at the PreToolUse hook) — an
309
+ // A/B backend switch, so native and rtk can be benchmarked head-to-head.
310
+ // The booleans default off, skills defaults [] (no skill loaded), outputCleaners defaults "off" (cleaning off),
311
+ // outputHoldout 0, filterBackend "native" — a fresh sandbox starts with cleaning and iq off until the owner enables them.
312
+
313
+ export const SandboxSettingsSchema = z.object({
314
+ stableSystemPrompt: z.boolean(),
315
+ skills: z.array(z.string()),
316
+ hashlineEdits: z.boolean(),
317
+ terseOutput: z.boolean(),
318
+ iqSearch: z.boolean(),
319
+ outputCleaners: z.string(),
320
+ outputHoldout: z.number().min(0).max(1),
321
+ filterBackend: z.enum(["native", "rtk"]),
322
+ });
323
+ export type SandboxSettings = z.infer<typeof SandboxSettingsSchema>;
324
+
325
+ // ---- output-cleaner savings report (rtk-`gain`-style) ----
326
+ // Aggregated from historyRoot/logs/filter-stats.jsonl (one row per agent Bash command). `perCleaner` attributes
327
+ // which cleaner ids fired across commands; `holdout` is the measured control (commands the holdout bypassed) vs
328
+ // the cleaned population — a real saved-% rather than an estimate; `gaps` are high-volume commands that matched
329
+ // no cleaner (the next handler to write). Empty/zeroed when no commands have run yet.
330
+ export const CleanerSavingsSchema = z.object({
331
+ commands: z.number(),
332
+ rawTokens: z.number(),
333
+ emittedTokens: z.number(),
334
+ savedPct: z.number(),
335
+ perCleaner: z.array(z.object({ id: z.string(), commands: z.number() })),
336
+ holdout: z.object({ cleaned: z.number(), heldOut: z.number(), measuredSavedPct: z.number().optional() }),
337
+ gaps: z.array(z.object({ command: z.string(), tokens: z.number() })),
338
+ });
339
+ export type CleanerSavings = z.infer<typeof CleanerSavingsSchema>;
340
+
341
+ // ---- intentic CLI ----
342
+
343
+ export const IntenticRunSchema = z.object({ args: z.array(z.string()) });
344
+
345
+ // ---- git ----
346
+
347
+ export const CommitSchema = RepoParamSchema.extend({
348
+ message: z.string().min(1),
349
+ // Repo-relative paths to commit; absent ⇒ commit everything changed in the repo.
350
+ paths: z.array(z.string().min(1)).max(500).optional(),
351
+ });
352
+ export const DiscardSchema = RepoParamSchema.extend({
353
+ // Repo-relative paths to discard; absent ⇒ discard every uncommitted change in the repo.
354
+ paths: z.array(z.string().min(1)).max(500).optional(),
355
+ });
356
+ export const PushSchema = RepoParamSchema.extend({ branch: z.string().min(1) });
357
+ export const GitFileQuerySchema = RepoParamSchema.extend({ path: z.string().min(1) });
358
+ export const GitFileWriteSchema = RepoParamSchema.extend({ path: z.string().min(1), content: z.string() });
359
+ export const GitFileDiffQuerySchema = RepoParamSchema.extend({ path: z.string().min(1) });
360
+ export const GitStatusSchema = z.object({ branch: z.string(), dirty: z.boolean(), files: z.array(z.string()) });
361
+ export const GitFilesSchema = z.object({ files: z.array(z.string()) });
362
+ export const GitFileSchema = z.object({ path: z.string(), content: z.string() });
363
+ export const CommitResultSchema = z.object({ committed: z.boolean() });
364
+ // One change to a file — an uncommitted working-tree change (status vs HEAD, untracked included), an agent
365
+ // worktree's delta vs its base, or a file in a commit. `additions`/`deletions` are the numstat line counts,
366
+ // undefined for a binary file (git reports "-"/"-") or an untracked file (no HEAD blob to diff against).
367
+ export const GitChangeSchema = z.object({
368
+ // Repo-relative path with forward slashes; for "renamed" the NEW path (`from` carries the old one).
369
+ path: z.string(),
370
+ status: z.enum(["added", "modified", "deleted", "renamed", "type-changed"]),
371
+ from: z.string().optional(),
372
+ additions: z.number().optional(),
373
+ deletions: z.number().optional(),
374
+ });
375
+ export type GitChange = z.infer<typeof GitChangeSchema>;
376
+ export const RepoChangesSchema = z.object({
377
+ // The {repo} param the per-repo git routes accept: "root" or a repo id (its root-relative dir).
378
+ repo: z.string(),
379
+ // Absent on an unborn HEAD (a repo initialized but never committed).
380
+ branch: z.string().optional(),
381
+ changes: z.array(GitChangeSchema),
382
+ });
383
+ export type RepoChanges = z.infer<typeof RepoChangesSchema>;
384
+ // The aggregated review set across every repo (root + every discovered repo); only repos with changes appear.
385
+ export const GitChangesSchema = z.object({ repos: z.array(RepoChangesSchema) });
386
+ export type GitChanges = z.infer<typeof GitChangesSchema>;
387
+
388
+ // ---- git history graph (the "Git Graph" view over a repo's real commits) ----
389
+ // A hex sha (full or git-abbreviated): the only shape the graph ever sends back, so the per-commit routes
390
+ // constrain to it rather than accepting an arbitrary git revision expression.
391
+ const ShaSchema = z.string().regex(/^[0-9a-f]{4,64}$/);
392
+ // One commit in the graph. `parents` (0 = root, 1 = normal, 2+ = merge) drive the lane layout, computed
393
+ // client-side. `refs` are the branch/tag decorations at this commit (tags keep their `tag: ` prefix; the bare
394
+ // "HEAD" marker is lifted into `head` instead). `at` is author time in ms since epoch; `short` is git's
395
+ // abbreviated sha; `body` is the message minus its subject line.
396
+ export const GitCommitSchema = z.object({
397
+ sha: z.string(),
398
+ short: z.string(),
399
+ parents: z.array(z.string()),
400
+ subject: z.string(),
401
+ body: z.string(),
402
+ author: z.string(),
403
+ email: z.string(),
404
+ at: z.number(),
405
+ refs: z.array(z.string()),
406
+ head: z.boolean(),
407
+ });
408
+ export type GitCommit = z.infer<typeof GitCommitSchema>;
409
+ // One repo's log: commits newest-first across ALL refs (branch topology is the point of a graph), plus the
410
+ // checked-out branch (absent on a detached HEAD or an unborn repo).
411
+ export const GitLogSchema = z.object({ repo: z.string(), branch: z.string().optional(), commits: z.array(GitCommitSchema) });
412
+ export type GitLog = z.infer<typeof GitLogSchema>;
413
+ export const GitLogQuerySchema = RepoParamSchema.extend({ limit: z.coerce.number().int().positive().max(2000).optional() });
414
+ // Every real git repo under /work as root-relative dir ids ("root" is implicit — the /work repo itself).
415
+ export const GitReposSchema = z.object({ repos: z.array(z.string()) });
416
+ export type GitRepos = z.infer<typeof GitReposSchema>;
417
+ export const GitCommitDiffQuerySchema = RepoParamSchema.extend({ sha: ShaSchema });
418
+ // A commit's changed files (vs its first parent; a root commit vs the empty tree) — the graph's detail tree
419
+ // renders these (line stats included) and reuses the diff UI on click. Just GitChanges: the line stats live on
420
+ // GitChange now, so working-tree and commit files share one shape.
421
+ export const GitCommitDiffSchema = z.object({ files: z.array(GitChangeSchema) });
422
+ export type GitCommitDiff = z.infer<typeof GitCommitDiffSchema>;
423
+ export const GitCommitFileDiffQuerySchema = RepoParamSchema.extend({ sha: ShaSchema, path: z.string().min(1) });
424
+ // Git write actions from the graph's commit context menu (VSCode "Git Graph" parity). Non-destructive: branch
425
+ // and tag just add a ref (HEAD + worktree untouched, no checkpoint). Sequence ops (revert / cherry-pick /
426
+ // merge / rebase / drop) add or replay commits and are auto-checkpointed daemon-side; a conflict aborts and
427
+ // reports `ok:false` (an expected outcome, not a throw). Checkout and reset move HEAD (reset --hard discards
428
+ // the worktree) — also auto-checkpointed. A `{repo, sha}` names the target commit for every commit-scoped
429
+ // action; a ref name (branch/tag) is validated structurally, git enforces the rest of ref-name legality.
430
+ const RefNameSchema = z
431
+ .string()
432
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/)
433
+ .max(200);
434
+ export const GitBranchCreateSchema = RepoParamSchema.extend({ sha: ShaSchema, name: RefNameSchema });
435
+ export const GitTagCreateSchema = RepoParamSchema.extend({ sha: ShaSchema, name: RefNameSchema });
436
+ export const GitCheckoutSchema = RepoParamSchema.extend({ ref: RefNameSchema });
437
+ export const GitResetSchema = RepoParamSchema.extend({ sha: ShaSchema, mode: z.enum(["soft", "mixed", "hard"]) });
438
+ export const GitCommitActionSchema = RepoParamSchema.extend({ sha: ShaSchema });
439
+ export const GitActionResultSchema = z.object({ ok: z.boolean(), reason: z.string().optional() });
440
+ export type GitActionResult = z.infer<typeof GitActionResultSchema>;
441
+
442
+ // ---- history: daemon-owned workspace snapshots (diff + restore) ----
443
+ // The daemon snapshots /work into bare git dirs on /history (outside the agent's reach). A "snapshot" groups
444
+ // one commit per scope (root + each nested repo) under a shared id. Only checkpoint triggers (turn / user /
445
+ // pre-restore / restore) are listed; "interval" captures are a hidden safety net that dissolves into the next
446
+ // visible checkpoint's diff.
447
+
448
+ export const SnapshotTriggerSchema = z.enum(["turn", "interval", "pre-restore", "restore", "user"]);
449
+ export type SnapshotTrigger = z.infer<typeof SnapshotTriggerSchema>;
450
+ export const SnapshotSchema = z.object({
451
+ id: z.string(),
452
+ // Committer time, ms since epoch.
453
+ at: z.number(),
454
+ trigger: SnapshotTriggerSchema,
455
+ // Human-readable checkpoint label — the turn's prompt for "turn" snapshots; absent otherwise.
456
+ label: z.string().optional(),
457
+ });
458
+ export type Snapshot = z.infer<typeof SnapshotSchema>;
459
+ export const SnapshotsListSchema = z.object({ snapshots: z.array(SnapshotSchema) });
460
+ export const SnapshotIdSchema = z.object({ id: z.string().min(1) });
461
+ export const SnapshotChangeSchema = z.object({
462
+ scope: z.string(),
463
+ // Scope-relative path with forward slashes.
464
+ path: z.string(),
465
+ status: z.enum(["added", "modified", "deleted", "type-changed"]),
466
+ });
467
+ export type SnapshotChange = z.infer<typeof SnapshotChangeSchema>;
468
+ export const SnapshotDiffSchema = z.object({ changes: z.array(SnapshotChangeSchema) });
469
+ export const SnapshotFileDiffQuerySchema = z.object({
470
+ id: z.string().min(1),
471
+ scope: z.string().min(1),
472
+ path: z.string().min(1),
473
+ });
474
+ // Both sides of a file diff — a snapshot vs its parent, or a working tree vs HEAD; an absent side means the
475
+ // file was added/deleted. Binary or oversized content is flagged instead of shipped.
476
+ export const FileDiffSchema = z.object({
477
+ before: z.string().optional(),
478
+ after: z.string().optional(),
479
+ binary: z.boolean().optional(),
480
+ truncated: z.boolean().optional(),
481
+ });
482
+ export type FileDiff = z.infer<typeof FileDiffSchema>;
483
+
484
+ // ---- workspace tree + files ----
485
+
486
+ // One node of the full /work filesystem tree the agent sees (untracked + generated files included), distinct
487
+ // from the git-tracked listing. `path` is root-relative with forward slashes so it feeds straight back to the
488
+ // file route. Recursive via zod's getter form (so the type is inferred, not hand-annotated).
489
+ export const WorkspaceTreeEntrySchema = z.object({
490
+ name: z.string(),
491
+ path: z.string(),
492
+ type: z.enum(["file", "dir"]),
493
+ size: z.number().optional(),
494
+ // Set on a dir whose child list was cut short by the entry cap — some of its items aren't in `children`.
495
+ truncated: z.boolean().optional(),
496
+ // Ignored-by-tooling (node_modules, .git, .gitignore'd paths, browser profiles): the client grays the row.
497
+ // An ignored DIR is listed without `children` — the client lazy-loads it via /workspace/children on expand.
498
+ ignored: z.boolean().optional(),
499
+ get children() {
500
+ return z.array(WorkspaceTreeEntrySchema).optional();
501
+ },
502
+ });
503
+ export type WorkspaceTreeEntry = z.infer<typeof WorkspaceTreeEntrySchema>;
504
+ export const WorkspaceTreeSchema = z.object({
505
+ root: z.string(),
506
+ tree: z.array(WorkspaceTreeEntrySchema),
507
+ // True when the root's own entries were cut by the entry cap (per-dir cuts are flagged on each dir entry).
508
+ truncated: z.boolean(),
509
+ });
510
+ export type WorkspaceTree = z.infer<typeof WorkspaceTreeSchema>;
511
+ // Lazy-load one directory's children — for an ignored dir the tree walk didn't descend into. Every returned entry
512
+ // is itself `ignored` (it lives under an ignored subtree); child dirs again carry no `children`, so they lazy-load
513
+ // on their own expand. `truncated` ⇒ the dir's child list was cut by the entry cap.
514
+ export const WorkspaceChildrenQuerySchema = z.object({ path: z.string().min(1) });
515
+ export const WorkspaceChildrenSchema = z.object({
516
+ entries: z.array(WorkspaceTreeEntrySchema),
517
+ truncated: z.boolean(),
518
+ });
519
+ export type WorkspaceChildren = z.infer<typeof WorkspaceChildrenSchema>;
520
+ export const WorkspaceFileQuerySchema = z.object({ path: z.string().min(1) });
521
+ export const WorkspaceFileSchema = z.object({ path: z.string(), content: z.string() });
522
+ // Direct file management over the /work tree (delete / new folder / rename+move / copy). Byte writes + the
523
+ // editor's text save go through the plain POST /workspace/upload route (a body doesn't fit oRPC), not here.
524
+ export const WorkspaceDirSchema = z.object({ path: z.string().min(1) });
525
+ export const WorkspaceMoveSchema = z.object({ from: z.string().min(1), to: z.string().min(1) });
526
+ // Deterministic (no-LLM) classification of the dropped workspace: each repo dir and loose file sorted into one
527
+ // coarse bucket. Read-only — the browser turns it into a proposed layout and applies the accepted moves via the
528
+ // existing /workspace/move route. `reason` records the winning signal (magic:<mime>, ext:<ext>,
529
+ // repository:<marker>, text-content, unknown) so the proposal is explainable.
530
+ export const WorkspaceBucketSchema = z.enum(["repositories", "documents", "media", "archives", "other"]);
531
+ export type WorkspaceBucket = z.infer<typeof WorkspaceBucketSchema>;
532
+ export const WorkspaceClassificationSchema = z.object({
533
+ classifications: z.array(z.object({ path: z.string(), bucket: WorkspaceBucketSchema, reason: z.string() })),
534
+ });
535
+ export type WorkspaceClassification = z.infer<typeof WorkspaceClassificationSchema>;
536
+ // ---- workspace search ----
537
+
538
+ // The workspace-search wire shape — shared by the daemon's /workspace/search route and the web client.
539
+ // (Implementation detail, not part of the contract: the daemon backs this route with a resident in-process iq
540
+ // engine; the engine is interchangeable behind this shape.) Groups are relevance-ranked (best first, never path
541
+ // order); each hit carries the match-reason tags the fused engines contributed. `start`/`end` are char offsets
542
+ // within `text` so clients highlight without re-finding the needle.
543
+ export const WorkspaceSearchQuerySchema = z.object({
544
+ query: z.string().min(2).max(512),
545
+ // Search verbs only — anchor/git verbs (outline, context, log, who, …) are CLI-only surface.
546
+ mode: z.enum(["q", "find", "files", "def", "refs", "sym", "ast", "ask"]).optional(),
547
+ includeIgnored: z.stringbool().optional(),
548
+ limit: z.coerce.number().int().positive().optional(),
549
+ after: z.string().optional(),
550
+ });
551
+ export const WorkspaceSearchTagSchema = z.object({
552
+ kind: z.enum(["def", "text", "sem", "bm25", "rerank", "path", "import", "call", "type", "write", "fuzzy", "heuristic"]),
553
+ score: z.number().optional(),
554
+ });
555
+ export type WorkspaceSearchTag = z.infer<typeof WorkspaceSearchTagSchema>;
556
+ export const WorkspaceSearchHitSchema = z.object({
557
+ line: z.number(),
558
+ text: z.string(),
559
+ start: z.number().optional(),
560
+ end: z.number().optional(),
561
+ tags: z.array(WorkspaceSearchTagSchema),
562
+ // Enclosing symbol ("createWidget (fn)") — parent-document context so the reader often needs no follow-up.
563
+ context: z.string().optional(),
564
+ });
565
+ export type WorkspaceSearchHit = z.infer<typeof WorkspaceSearchHitSchema>;
566
+ export const WorkspaceSearchGroupSchema = z.object({ path: z.string(), score: z.number(), hits: z.array(WorkspaceSearchHitSchema) });
567
+ export type WorkspaceSearchGroup = z.infer<typeof WorkspaceSearchGroupSchema>;
568
+ // `building` = index still filling (progress 0..1, e.g. embeddings pending); `stale` = revalidation was skipped
569
+ // (cursor replay). ageMs = time since the index last matched the disk state.
570
+ export const WorkspaceSearchFreshnessSchema = z.object({
571
+ state: z.enum(["fresh", "building", "stale"]),
572
+ ageMs: z.number().optional(),
573
+ progress: z.number().optional(),
574
+ });
575
+ export type WorkspaceSearchFreshness = z.infer<typeof WorkspaceSearchFreshnessSchema>;
576
+ export const WorkspaceSearchResultSchema = z.object({
577
+ mode: z.string(),
578
+ total: z.number(),
579
+ shown: z.number(),
580
+ groups: z.array(WorkspaceSearchGroupSchema),
581
+ freshness: WorkspaceSearchFreshnessSchema,
582
+ truncated: z.boolean(),
583
+ cursor: z.string().optional(),
584
+ hint: z.string().optional(),
585
+ // Code-graph neighbors of the top hits (definition anchors + ready-made follow-up commands).
586
+ related: z.array(z.string()).optional(),
587
+ // Run provenance for benchmarking: retrieval stages DISABLED this invocation (absent = full pipeline).
588
+ features: z.array(z.string()).optional(),
589
+ });
590
+ export type WorkspaceSearchResult = z.infer<typeof WorkspaceSearchResultSchema>;
591
+
592
+ // ---- workspace repos ----
593
+
594
+ // Every discovered repo's id (root-relative dir under /work), sorted — roles included.
595
+ export const ReposListSchema = z.object({ repos: z.array(z.string()) });
596
+ export const CloneRepoSchema = z.object({ name: z.string().min(1), cloneUrl: z.string().min(1), branch: z.string().optional() });
597
+ export const CloneResultSchema = z.object({ name: z.string(), path: z.string() });
598
+ // Per-repo result of a workspace sync (fetch + guarded fast-forward). `status` mirrors GitSyncResult plus the
599
+ // turn-orchestration outcomes skipped/error; behind/ahead/head/message are present per status (see RepoSyncOutcome).
600
+ export const RepoSyncSchema = z.object({
601
+ repo: z.string(),
602
+ status: z.enum(["updated", "current", "dirty", "diverged", "no-remote", "skipped", "error"]),
603
+ behind: z.number().optional(),
604
+ ahead: z.number().optional(),
605
+ head: z.string().optional(),
606
+ message: z.string().optional(),
607
+ });
608
+ export const WorkspaceSyncSchema = z.object({ repos: z.array(RepoSyncSchema) });
609
+ // Add one or more named app instances into an EXISTING monorepo. Each entry pairs a template key from the
610
+ // source repo's templates.json manifest (e.g. "api", "web", "landing") with a user-chosen instance name
611
+ // (e.g. "shop-api"); {repo} names the target monorepo.
612
+ export const AppInstanceInputSchema = z.object({
613
+ template: z.string().min(1),
614
+ name: z
615
+ .string()
616
+ .min(1)
617
+ .regex(/^[a-z][a-z0-9-]*$/),
618
+ });
619
+ export type AppInstanceInput = z.infer<typeof AppInstanceInputSchema>;
620
+ export const AddAppsSchema = z.object({
621
+ repo: z.string(),
622
+ apps: z.array(AppInstanceInputSchema).min(1),
623
+ });
624
+
625
+ // Run vitest for one or more repo-relative project dirs in a named one-shot tmux panel session
626
+ // (panel-<repo>--<session>), driven by the apps extension's Run-tests actions. `session` is a slug suffix
627
+ // (an app/package name as `<name>__test`, or `tests` for the library section); `dirs` are repo-relative
628
+ // package dirs, where "" targets the repo root.
629
+ export const RunTestsSchema = z.object({
630
+ repo: z.string(),
631
+ session: z.string(),
632
+ dirs: z.array(z.string()).min(1),
633
+ });
634
+
635
+ // One addable app type the configured source repo offers (from its templates.json), listed for the operator
636
+ // panel's Add-app picker: the manifest key + its label/description.
637
+ export const TemplateSummarySchema = z.object({ key: z.string(), label: z.string(), description: z.string() });
638
+ export type TemplateSummary = z.infer<typeof TemplateSummarySchema>;
639
+ export const TemplatesListSchema = z.object({ templates: z.array(TemplateSummarySchema) });
640
+ export type TemplatesList = z.infer<typeof TemplatesListSchema>;
641
+
642
+ // One app instance currently in a monorepo, with its own preview dev server + live status (started/stopped
643
+ // from the apps extension). `app` is the user-chosen instance name (the _apps/ dir); `template` is the
644
+ // manifest key it was created from (api/web/landing). previewUrl is
645
+ // https://preview-<repo>--<app>-<sandboxId>.<zone> (absent on loopback — no zone or no connect token).
646
+ export const RepoAppSchema = z.object({
647
+ app: z.string(),
648
+ template: z.string(),
649
+ previewUrl: z.string().optional(),
650
+ running: z.boolean(),
651
+ healthy: z.boolean(),
652
+ });
653
+ export type RepoApp = z.infer<typeof RepoAppSchema>;
654
+ export const AppsListSchema = z.object({ apps: z.array(RepoAppSchema) });
655
+ export type AppsList = z.infer<typeof AppsListSchema>;
656
+ // One workspace package in a pnpm monorepo, discovered from pnpm-workspace.yaml's packages globs. `dir` is the
657
+ // repo-relative package dir (e.g. "_apps/web"); `group` is its top-level dir segment (e.g. "_apps"), the
658
+ // dependencies view's coloring axis.
659
+ export const WorkspacePackageSchema = z.object({ name: z.string(), dir: z.string(), group: z.string() });
660
+ export type WorkspacePackage = z.infer<typeof WorkspacePackageSchema>;
661
+ export const WorkspaceDepTypeSchema = z.enum(["prod", "dev", "peer"]);
662
+ export type WorkspaceDepType = z.infer<typeof WorkspaceDepTypeSchema>;
663
+ // A workspace-internal dependency edge: `from` DEPENDS ON `to` (from's package.json lists to), typed by which
664
+ // dependency block declared it. Pure data — layout/direction is the client's concern.
665
+ export const WorkspaceDepEdgeSchema = z.object({ from: z.string(), to: z.string(), type: WorkspaceDepTypeSchema });
666
+ export type WorkspaceDepEdge = z.infer<typeof WorkspaceDepEdgeSchema>;
667
+ export const WorkspaceGraphSchema = z.object({ packages: z.array(WorkspacePackageSchema), edges: z.array(WorkspaceDepEdgeSchema) });
668
+ export type WorkspaceGraph = z.infer<typeof WorkspaceGraphSchema>;
669
+ // Path params for the per-repo apps routes: the monorepo name (validated in the handler like PanelRepoParam)
670
+ // and, for per-app preview control (start/stop), the app key (api/web/landing).
671
+ export const RepoAppsParamSchema = z.object({ repo: z.string() });
672
+ export const AppParamSchema = z.object({
673
+ repo: z.string(),
674
+ app: z
675
+ .string()
676
+ .min(1)
677
+ .regex(/^[a-z][a-z0-9-]*$/),
678
+ });
679
+
680
+ // ---- inventory: the i.have.* / i.want.service entries in deploy.config.ts's managed region ----
681
+ // The daemon renders/parses these; the browser edits them through the inventory routes. Moved here from the
682
+ // daemon's deploy-config.ts so the daemon and the browser validate against ONE schema (no cross-repo dupes).
683
+
684
+ export const InventoryProviderSchema = z.enum(["host", "cloudflare", "github", "gitlab", "stripe"]);
685
+ export type InventoryProvider = z.infer<typeof InventoryProviderSchema>;
686
+ export const ServiceKindSchema = z.enum(["signoz", "outline", "paperless", "openproject", "invoiceninja", "infisical"]);
687
+ export type ServiceKind = z.infer<typeof ServiceKindSchema>;
688
+ // Non-secret option values the user provides; secret options (sshKey, apiToken, apiKey) are emitted as env()
689
+ // references and never travel over the wire.
690
+ export const InventoryValuesSchema = z.record(z.string(), z.union([z.string(), z.number()]));
691
+ // `const <name>` binding in deploy.config.ts, so it must be a valid identifier.
692
+ const inventoryName = z
693
+ .string()
694
+ .min(1)
695
+ .max(60)
696
+ .regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/);
697
+ export const BackendEntrySchema = z.object({
698
+ kind: z.literal("backend"),
699
+ provider: InventoryProviderSchema,
700
+ name: z.string(),
701
+ values: InventoryValuesSchema,
702
+ });
703
+ export const ServiceEntrySchema = z.object({
704
+ kind: z.literal("service"),
705
+ service: ServiceKindSchema,
706
+ name: z.string(),
707
+ values: InventoryValuesSchema,
708
+ on: z.string(),
709
+ expose: z.string(),
710
+ });
711
+ // i.want.app — a deployable app built from source. Single production environment on `main`; `values.domain` is
712
+ // where it's exposed. Multi-env/teams/use wiring is hand-authored outside the managed region.
713
+ export const AppEntrySchema = z.object({
714
+ kind: z.literal("app"),
715
+ name: z.string(),
716
+ values: InventoryValuesSchema,
717
+ on: z.string(),
718
+ expose: z.string(),
719
+ });
720
+ export const InventoryEntrySchema = z.discriminatedUnion("kind", [BackendEntrySchema, ServiceEntrySchema, AppEntrySchema]);
721
+ export type InventoryEntry = z.infer<typeof InventoryEntrySchema>;
722
+ export const AddInventoryInputSchema = z.discriminatedUnion("kind", [
723
+ BackendEntrySchema.extend({ name: inventoryName }),
724
+ ServiceEntrySchema.extend({ name: inventoryName }),
725
+ AppEntrySchema.extend({ name: inventoryName }),
726
+ ]);
727
+ export type AddInventoryInput = z.infer<typeof AddInventoryInputSchema>;
728
+ export const InventoryNameParamSchema = z.object({ name: z.string() });
729
+ export const InventoryListSchema = z.object({ entries: z.array(InventoryEntrySchema) });
730
+
731
+ // A deploy-target host self-registering via the connect-host script's POST /enroll (connect-token auth). The SSH
732
+ // key (+ optional Cloudflare token) is written to desired-state/.env; the host (+ cf) is upserted into inventory.
733
+ export const EnrollHostInputSchema = z.object({
734
+ name: inventoryName,
735
+ user: z.string().min(1),
736
+ address: z.string().min(1),
737
+ port: z.coerce.number().default(22),
738
+ via: z.enum(["direct", "cloudflared"]).default("cloudflared"),
739
+ sshKey: z.string().min(1),
740
+ cfToken: z.string().optional(),
741
+ // The zone the connect script resolved alongside cfToken — recorded on the i.have.cloudflare entry so
742
+ // resolve validates against it (no re-discovery) and the Add-service dialog offers `<subdomain>.<zone>`.
743
+ cfZone: z.string().optional(),
744
+ });
745
+ export type EnrollHostInput = z.infer<typeof EnrollHostInputSchema>;
746
+
747
+ // ---- capabilities: the sandbox's unified capability manifest (.intentic/capabilities.json) ----
748
+ // Everything a user adds to a sandbox is a capability with an idempotent apply + a status check. The manifest is
749
+ // the source of truth for what's active; `mcp`-kind entries also feed the agent's MCP servers each turn. DevOps
750
+ // is the capability that scaffolds the intent/desired-state repos — until it's active the sandbox is empty.
751
+
752
+ export const CapabilityKindSchema = z.enum([
753
+ "devops",
754
+ "monorepo",
755
+ "mcp",
756
+ "service",
757
+ "integration",
758
+ "cli",
759
+ "plugin",
760
+ "extension",
761
+ "ssh",
762
+ "vpn",
763
+ "docker",
764
+ "browser",
765
+ "agent",
766
+ ]);
767
+ export type CapabilityKind = z.infer<typeof CapabilityKindSchema>;
768
+ export const CapabilityStateSchema = z.enum(["active", "pending", "error", "inactive"]);
769
+ export type CapabilityState = z.infer<typeof CapabilityStateSchema>;
770
+
771
+ // A manifest entry id (capabilities + automations) — also the `mcp__<id>__…` server name for mcp capabilities,
772
+ // so it's a safe identifier.
773
+ const entryId = z
774
+ .string()
775
+ .min(1)
776
+ .max(60)
777
+ .regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/);
778
+
779
+ // Per-kind config. Secrets (an mcp token) live here and are denylisted like tools.json.
780
+ export const McpConfigSchema = z.object({ url: z.string().url(), token: z.string().optional() });
781
+ export const ServiceConfigSchema = z.object({
782
+ service: ServiceKindSchema,
783
+ domain: z.string().min(1),
784
+ on: z.string().min(1),
785
+ expose: z.string().min(1),
786
+ });
787
+ // External-app credential injected into DEPLOYED apps (i.have.stripe → STRIPE_API_KEY from env). Agent-facing
788
+ // connectors are `cli` capabilities instead (see below), not integrations.
789
+ export const IntegrationConfigSchema = z.object({ provider: z.literal("stripe") });
790
+ // A `cli` capability gives the AGENT an authenticated command-line tool (not a deployed-app credential like
791
+ // `integration`): the credential + any non-secret URL are stored here and injected into the agent's env each
792
+ // turn (see cliEnvOf), and a .claude/skills/<id> cheatsheet teaches the agent to use it via curl. The provider
793
+ // data (fields, env, skill, image fragment) is DATA in an installed extension's `contributes.connectors`, not a
794
+ // per-provider schema arm — so the config is `provider` + arbitrary string fields, validated against the
795
+ // connector's declared fields at add-time (see the sandbox's connector-registry) rather than by this schema.
796
+ export const CliConfigSchema = z.object({ provider: z.string().min(1) }).catchall(z.string());
797
+ // A Claude Code plugin from a git repo. The daemon only owns the checkout; the Agent SDK's plugin loader reads
798
+ // its internals (skills/agents/hooks/commands/.mcp.json). `path` = subdirectory for plugins that live inside a
799
+ // marketplace/monorepo checkout. `token` = https auth for private repos (never echoed; becomes hasToken).
800
+ export const PluginConfigSchema = z.object({
801
+ url: z.string().url(),
802
+ // Branch / tag / commit sha to pin; absent = the default branch's HEAD.
803
+ ref: z.string().min(1).optional(),
804
+ path: z
805
+ .string()
806
+ .min(1)
807
+ .refine((value) => !value.split("/").includes(".."), { message: "path must stay inside the checkout" })
808
+ .optional(),
809
+ token: z.string().min(1).optional(),
810
+ });
811
+ // An intentic extension from a git repo (an intentic-extension.json checkout — UI bundle + agent contributions
812
+ // + processes). Unlike `plugin`, `ref` is a REQUIRED full commit sha: extension code runs trusted in the
813
+ // owner's browser, so the owner approves exactly the code that runs — pin by construction, updates are explicit
814
+ // re-adds at a new sha. `path`/`token` as in PluginConfigSchema.
815
+ export const ExtensionConfigSchema = z.object({
816
+ url: z.string().url(),
817
+ ref: z.string().regex(/^[0-9a-f]{40}$/, "ref must be a full 40-character commit sha"),
818
+ path: z
819
+ .string()
820
+ .min(1)
821
+ .refine((value) => !value.split("/").includes(".."), { message: "path must stay inside the checkout" })
822
+ .optional(),
823
+ token: z.string().min(1).optional(),
824
+ });
825
+ // A remote machine the AGENT can reach over SSH. One capability = one machine; the id is its ssh-config Host
826
+ // alias, so the agent runs `ssh <id> "…"`. The handler writes a per-machine config block + a 0600 key/password
827
+ // file under ~/.ssh (see the ssh handler), so — unlike `cli` — nothing is injected into the agent's env, and
828
+ // several machines never collide. Discriminated by auth so exactly one credential shape is required.
829
+ export const SshConfigSchema = z.discriminatedUnion("auth", [
830
+ z.object({
831
+ auth: z.literal("key"),
832
+ host: z.string().min(1),
833
+ port: z.coerce.number().default(22),
834
+ user: z.string().min(1),
835
+ privateKey: z.string().min(1),
836
+ }),
837
+ z.object({
838
+ auth: z.literal("password"),
839
+ host: z.string().min(1),
840
+ port: z.coerce.number().default(22),
841
+ user: z.string().min(1),
842
+ password: z.string().min(1),
843
+ }),
844
+ ]);
845
+ // A WireGuard tunnel the agent's traffic rides. `config` is the pasted .conf ([Interface] + [Peer]) — it holds
846
+ // the private key, so it's a secret like an mcp token. `enabled` is the on/off toggle (its default is set by
847
+ // the app repo's catalog entry): "on" ⇒ apply brings the tunnel up and the daemon restores it on boot; "off" ⇒
848
+ // the conf is stored but the tunnel stays down. The id doubles as the wg interface name, hence the union arm
849
+ // below caps it at Linux's 15-char IFNAMSIZ limit.
850
+ export const VpnConfigSchema = z.object({ config: z.string().min(1), enabled: z.enum(["on", "off"]).default("on") });
851
+ // A logged-in browser session the AGENT drives via Playwright MCP tools — for social platforms whose APIs can't
852
+ // cover "all the actions" (X reads are paywalled; X community-join and YouTube community-posts have no API). No
853
+ // secret in the manifest: the session lives in a persisted Chromium profile under .intentic/browser/<platform>,
854
+ // established once through the guided-login WebSocket (/system/browser-login). Chromium itself rides this kind's
855
+ // Dockerfile fragment, applied on an owner rebuild. One capability = one platform (the id doubles as the profile).
856
+ export const BrowserPlatformSchema = z.enum(["reddit", "x", "youtube"]);
857
+ export const BrowserConfigSchema = z.object({ platform: BrowserPlatformSchema });
858
+ // An ACP (Agent Client Protocol) agent served as a chat provider: the daemon spawns `command` as a long-lived
859
+ // subprocess speaking JSON-RPC over stdio, and the capability id becomes the provider id in the chat picker
860
+ // (see AgentProviderSchema). `command` is split on whitespace — no shell quoting. `env` is a pasted KEY=VALUE
861
+ // block (one per line); credentials ride here, so the whole block is the secret field (echoed as hasSecret) —
862
+ // the vpn-conf precedent. `loginCommand` is an interactive login the user completes in a visible terminal
863
+ // (device-code flows); the agent persists credentials in its own store inside the container. `name` is the
864
+ // picker's display label; absent = the id.
865
+ export const AcpAgentConfigSchema = z.object({
866
+ command: z.string().min(1),
867
+ name: z.string().min(1).optional(),
868
+ env: z.string().optional(),
869
+ loginCommand: z.string().min(1).optional(),
870
+ });
871
+ export type McpConfig = z.infer<typeof McpConfigSchema>;
872
+ export type ServiceConfig = z.infer<typeof ServiceConfigSchema>;
873
+ export type IntegrationConfig = z.infer<typeof IntegrationConfigSchema>;
874
+ export type CliConfig = z.infer<typeof CliConfigSchema>;
875
+ export type PluginConfig = z.infer<typeof PluginConfigSchema>;
876
+ export type ExtensionConfig = z.infer<typeof ExtensionConfigSchema>;
877
+ export type SshConfig = z.infer<typeof SshConfigSchema>;
878
+ export type VpnConfig = z.infer<typeof VpnConfigSchema>;
879
+ export type BrowserPlatform = z.infer<typeof BrowserPlatformSchema>;
880
+ export type BrowserConfig = z.infer<typeof BrowserConfigSchema>;
881
+ export type AcpAgentConfig = z.infer<typeof AcpAgentConfigSchema>;
882
+
883
+ export const CapabilitySchema = z.discriminatedUnion("kind", [
884
+ z.object({ id: entryId, kind: z.literal("devops"), config: z.object({}) }),
885
+ // A pnpm+turbo monorepo the user scaffolds as its own repo; the `id` is the repo name. No config — apps are
886
+ // added into it afterwards from its operator panel.
887
+ z.object({ id: entryId, kind: z.literal("monorepo"), config: z.object({}) }),
888
+ z.object({ id: entryId, kind: z.literal("mcp"), config: McpConfigSchema }),
889
+ z.object({ id: entryId, kind: z.literal("service"), config: ServiceConfigSchema }),
890
+ z.object({ id: entryId, kind: z.literal("integration"), config: IntegrationConfigSchema }),
891
+ z.object({ id: entryId, kind: z.literal("cli"), config: CliConfigSchema }),
892
+ z.object({ id: entryId, kind: z.literal("plugin"), config: PluginConfigSchema }),
893
+ z.object({ id: entryId, kind: z.literal("extension"), config: ExtensionConfigSchema }),
894
+ z.object({ id: entryId, kind: z.literal("ssh"), config: SshConfigSchema }),
895
+ z.object({ id: entryId.max(15), kind: z.literal("vpn"), config: VpnConfigSchema }),
896
+ // The in-sandbox Docker Engine (baked into the base image, dormant by default). No config: the capability's
897
+ // whole effect is its fragment's `--privileged` runtime directive + running dockerd. No remove — the engine's
898
+ // state (/var/lib/docker) and whatever runs on it make a silent de-privilege more destructive than useful.
899
+ z.object({ id: entryId, kind: z.literal("docker"), config: z.object({}) }),
900
+ z.object({ id: entryId, kind: z.literal("browser"), config: BrowserConfigSchema }),
901
+ z.object({ id: entryId, kind: z.literal("agent"), config: AcpAgentConfigSchema }),
902
+ ]);
903
+ export type Capability = z.infer<typeof CapabilitySchema>;
904
+
905
+ export const CapabilityStatusSchema = z.object({ state: CapabilityStateSchema, detail: z.string().optional() });
906
+ export type CapabilityStatus = z.infer<typeof CapabilityStatusSchema>;
907
+ // The list row: manifest entry + live status. Secrets are never returned (an mcp token becomes hasToken).
908
+ export const CapabilitySummarySchema = z.object({
909
+ id: z.string(),
910
+ kind: CapabilityKindSchema,
911
+ status: CapabilityStatusSchema,
912
+ config: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
913
+ });
914
+ export const CapabilitiesListSchema = z.object({ capabilities: z.array(CapabilitySummarySchema) });
915
+ export const CapabilityIdParamSchema = z.object({ id: z.string() });
916
+ // POST /capabilities/{id}/secret body: replace just the capability's secret field (its key is per-kind, see the
917
+ // sandbox's secretField) and re-run its idempotent apply — the /secrets page's edit path.
918
+ export const CapabilitySecretInputSchema = z.object({ id: z.string(), value: z.string().min(1) });
919
+ // POST /capabilities/{id}/login response: the interactive tmux session running the agent's loginCommand,
920
+ // which the web surfaces in the terminal panel for the user to complete the sign-in.
921
+ export const CapabilityLoginSchema = z.object({ session: z.string() });
922
+
923
+ // Browse a Claude Code plugin marketplace (a git repo with .claude-plugin/marketplace.json). POST so the
924
+ // optional token for a private marketplace never rides a URL or an access log.
925
+ export const MarketplaceRequestSchema = z.object({ url: z.string().url(), token: z.string().min(1).optional() });
926
+ // One marketplace entry; `install` is the entry's source resolved onto PluginConfig shape (url/ref/path), so
927
+ // picking an entry just pre-fills the plugin form. Absent = a source the daemon can't clone (e.g. npm).
928
+ export const MarketplacePluginSchema = z.object({
929
+ name: z.string(),
930
+ description: z.string().optional(),
931
+ version: z.string().optional(),
932
+ // "extension" marks an intentic-extension entry (installs as the `extension` capability, sha-pinned);
933
+ // absent/"plugin" = a Claude Code plugin. Claude Code ignores unknown marketplace fields, so one
934
+ // marketplace repo serves both consumers.
935
+ kind: z.enum(["plugin", "extension"]).optional(),
936
+ install: z.object({ url: z.string(), ref: z.string().optional(), path: z.string().optional() }).optional(),
937
+ });
938
+ export type MarketplacePlugin = z.infer<typeof MarketplacePluginSchema>;
939
+ export const MarketplaceSchema = z.object({ name: z.string(), plugins: z.array(MarketplacePluginSchema) });
940
+ export type Marketplace = z.infer<typeof MarketplaceSchema>;
941
+
942
+ // ---- extensions: installed extension-kind capabilities resolved to their manifests ----
943
+ // What the web extension host boots from: each row is an extension capability whose checkout still parses —
944
+ // the approved manifest (contribution declarations), and the checked-out commit (the code identity; the bundle
945
+ // route's ETag). A rotted checkout is skipped here; its capability row still shows status.
946
+ // The routing handle: a git-installed extension uses its capability entry id; an image-baked one has no
947
+ // capability entry and is addressed by the manifest-derived publisher.name — hence the dot in the pattern.
948
+ const extensionId = z
949
+ .string()
950
+ .min(1)
951
+ .max(121)
952
+ .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/);
953
+ export const ExtensionSummarySchema = z.object({
954
+ id: extensionId,
955
+ manifest: ExtensionManifestSchema,
956
+ commit: z.string(),
957
+ // Image-baked first-party extension (no git checkout, not removable) vs a git-installed capability — the
958
+ // web hides the uninstall affordance for baked ones.
959
+ builtin: z.boolean(),
960
+ });
961
+ export type ExtensionSummary = z.infer<typeof ExtensionSummarySchema>;
962
+ export const ExtensionsListSchema = z.object({ extensions: z.array(ExtensionSummarySchema) });
963
+ // The extension's contributes.settings values, persisted daemon-side (.intentic/extension-settings.json) keyed
964
+ // by the manifest-derived extension id — the checkout stays pristine, so a re-clone update never loses them.
965
+ // Secret-marked values are stripped from `settings`; `secretsSet` lists the secret keys that DO hold a value,
966
+ // so the UI renders "•••• (set)" without ever receiving the secret back.
967
+ export const ExtensionSettingsSchema = z.object({
968
+ settings: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
969
+ secretsSet: z.array(z.string()),
970
+ });
971
+ export type ExtensionSettings = z.infer<typeof ExtensionSettingsSchema>;
972
+ export const ExtensionSettingsInputSchema = z.object({
973
+ id: z.string(),
974
+ settings: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
975
+ });
976
+ // One declared background process (contributes.processes) — status/start/stop, addressed by the capability
977
+ // entry id + the manifest's process name. Undeclared names are NOT_FOUND, the manifest-honesty rule again.
978
+ export const ExtensionProcessParamSchema = z.object({ id: z.string(), name: z.string() });
979
+ export const ExtensionProcessStatusSchema = z.object({
980
+ name: z.string(),
981
+ running: z.boolean(),
982
+ port: z.number().optional(),
983
+ previewUrl: z.string().optional(),
984
+ });
985
+ export type ExtensionProcessStatus = z.infer<typeof ExtensionProcessStatusSchema>;
986
+
987
+ // ---- automations: scheduled agent wake-ups (.intentic/automations.json) ----
988
+ // An automation wakes the agent autonomously: the daemon's scheduler fires each enabled automation on its
989
+ // trigger, runs the optional guard command (a shell command in the workspace; non-zero exit skips the wake),
990
+ // then runs one agent turn with the prompt. The manifest is user config; run history is daemon-recorded.
991
+
992
+ // `schedule` fires on its cron; `event` fires when an external system POSTs /automations/{id}/fire?token=…
993
+ // (a plain Hono route — webhook bodies are arbitrary). The token is the webhook's own auth (senders can't do
994
+ // Google ID tokens): optional on input — the daemon generates one on upsert — and always present in stored and
995
+ // listed automations, so the owner's UI can render the copyable URL.
996
+ // `listener` fires from a realtime source's connection to the provider (an extension's gateway process holds
997
+ // it, e.g. Discord) — no cron, no token, never reachable via /fire. channelId narrows to one channel; absent ⇒
998
+ // every channel the bot can read. eventType narrows to one kind of event (a Discord message, a live voice
999
+ // utterance batch, or a finished voice transcript); absent ⇒ all event kinds the source emits. mentioned
1000
+ // narrows message events to those that @mention one of the workspace's bots or reply to a bot's message;
1001
+ // absent ⇒ all messages. `provider` and `eventType` are open strings — a realtime source is now extension-
1002
+ // declared (contributes.listener), so the daemon validates a listener trigger at upsert against `webchat` ∪ the
1003
+ // installed extensions' declared providers/eventTypes rather than a hardcoded enum here.
1004
+ // `webchat` is the exception: it has no gateway. An embeddable widget POSTs a visitor's message to
1005
+ // /webchat/<id>/message and the agent's reply streams back over SSE. Its address is the public automation id, so
1006
+ // allowedOrigins (the widget's embed sites) + a per-conversation rate limit are its abuse boundary — no secret
1007
+ // token can live in a browser.
1008
+ export const TriggerSchema = z.discriminatedUnion("kind", [
1009
+ z.object({ kind: z.literal("schedule"), cron: z.string().min(1) }),
1010
+ z.object({ kind: z.literal("event"), token: z.string().min(1).optional() }),
1011
+ z.object({
1012
+ kind: z.literal("listener"),
1013
+ provider: z.string().min(1),
1014
+ channelId: z.string().min(1).optional(),
1015
+ eventType: z.string().min(1).optional(),
1016
+ mentioned: z.boolean().optional(),
1017
+ // webchat only: the website origins allowed to POST to the widget endpoint. Absent/empty ⇒ none admitted.
1018
+ allowedOrigins: z.array(z.string()).optional(),
1019
+ }),
1020
+ ]);
1021
+ export type Trigger = z.infer<typeof TriggerSchema>;
1022
+
1023
+ export const AutomationSchema = z.object({
1024
+ id: entryId,
1025
+ trigger: TriggerSchema,
1026
+ // Shell command run in the workspace root before waking; exit 0 ⇒ wake, non-zero ⇒ the run is "skipped".
1027
+ guard: z.string().min(1).optional(),
1028
+ prompt: z.string().min(1),
1029
+ // Which provider adapter serves the wake; absent ⇒ claude. Same dispatch as a chat turn (AgentTurnSchema.agent).
1030
+ agent: AgentProviderSchema.optional(),
1031
+ // Which harness (agentic loop) runs the wake; absent ⇒ native. Same semantics as AgentTurnSchema.harness.
1032
+ harness: AgentHarnessSchema.optional(),
1033
+ // Which model the wake runs on (see agent-catalog.ts modelsFor); absent ⇒ the provider's default.
1034
+ model: z.string().optional(),
1035
+ // When true, a fire doesn't wake the agent — it's held in the approvals queue until the owner approves.
1036
+ requireApproval: z.boolean().optional(),
1037
+ enabled: z.boolean(),
1038
+ });
1039
+ export type Automation = z.infer<typeof AutomationSchema>;
1040
+
1041
+ // A wake held for owner approval (.intentic/approvals/<id>.json, one file per held wake). It snapshots the
1042
+ // trigger payload so an approved run replays exactly what fired, even across a daemon restart. The id is minted
1043
+ // by the daemon (an entryId-safe filename).
1044
+ export const AutomationApprovalSchema = z.object({
1045
+ id: entryId,
1046
+ automationId: z.string(),
1047
+ // The event/listener payload the wake would have carried; absent for schedule triggers.
1048
+ payload: z.string().optional(),
1049
+ createdAt: z.number(),
1050
+ });
1051
+ export type AutomationApproval = z.infer<typeof AutomationApprovalSchema>;
1052
+ export const AutomationApprovalsListSchema = z.object({ approvals: z.array(AutomationApprovalSchema) });
1053
+ export const AutomationApprovalIdParamSchema = z.object({ id: z.string() });
1054
+
1055
+ export const AutomationRunSchema = z.object({
1056
+ at: z.number(),
1057
+ // skipped = the guard said no; error = the guard passed but the agent turn surfaced an error.
1058
+ outcome: z.enum(["completed", "skipped", "error"]),
1059
+ detail: z.string().optional(),
1060
+ });
1061
+ export type AutomationRun = z.infer<typeof AutomationRunSchema>;
1062
+
1063
+ // The list row: the stored automation + its recent runs + the next scheduled fire (absent when disabled).
1064
+ export const AutomationSummarySchema = AutomationSchema.extend({
1065
+ runs: z.array(AutomationRunSchema),
1066
+ nextRun: z.number().optional(),
1067
+ });
1068
+ export type AutomationSummary = z.infer<typeof AutomationSummarySchema>;
1069
+ export const AutomationsListSchema = z.object({ automations: z.array(AutomationSummarySchema) });
1070
+ export const AutomationIdParamSchema = z.object({ id: z.string() });
1071
+
1072
+ // ---- drafts: agent-proposed posts awaiting owner approval (.intentic/drafts/<id>.json) ----
1073
+ // One JSON file per draft. The AGENT creates drafts with its normal file tools — it can't call daemon routes,
1074
+ // the same split as the environment proposal — while the daemon edits/deletes them on the owner's behalf, so
1075
+ // the two writers never share a file. The id IS the filename (entryId charset ⇒ path-safe); the body never
1076
+ // carries it. Posting is the agent's job too (there is no typed publish path): a "publish approved drafts"
1077
+ // automation wakes the agent for due drafts, which posts via the platform skills and flips the status.
1078
+
1079
+ export const DraftStatusSchema = z.enum(["proposed", "approved", "posting", "posted", "failed"]);
1080
+ export type DraftStatus = z.infer<typeof DraftStatusSchema>;
1081
+
1082
+ // The on-disk file body. proposed (agent) → approved (owner) → posting (publisher, set BEFORE acting so a dead
1083
+ // turn can't double-post) → posted | failed. Reject = delete the file; retry = re-approve a failed draft.
1084
+ export const DraftSchema = z.object({
1085
+ // Which skill posts it: "x" | "reddit" | "youtube" | "discord" | … — a bare string so new platforms need
1086
+ // no contract change; an unknown platform simply fails at posting time.
1087
+ platform: z.string().min(1),
1088
+ content: z.string().min(1),
1089
+ // Reddit posts / YouTube uploads need one.
1090
+ title: z.string().optional(),
1091
+ // Where on the platform: subreddit / Discord channel id / community.
1092
+ target: z.string().optional(),
1093
+ // Workspace-relative attachment paths, e.g. ".intentic/drafts/media/chart.png".
1094
+ media: z.array(z.string()).optional(),
1095
+ // Suggested post time (epoch ms, the at/nextRun convention). Optional — the agent may propose without a
1096
+ // date and the owner sets one at approval; an approved draft with no date posts as soon as it's picked up.
1097
+ scheduledAt: z.number().optional(),
1098
+ // Agent-written files only need platform + content; status defaults, the rest are optional, so a
1099
+ // well-formed proposal never lands in `invalid` just for omitting bookkeeping fields.
1100
+ status: DraftStatusSchema.default("proposed"),
1101
+ createdAt: z.number().optional(),
1102
+ postedAt: z.number().optional(),
1103
+ // Why posting failed; set with status "failed".
1104
+ error: z.string().optional(),
1105
+ });
1106
+ export type Draft = z.infer<typeof DraftSchema>;
1107
+
1108
+ // The list row / upsert input: the file body plus its filename id.
1109
+ export const DraftSummarySchema = DraftSchema.extend({ id: entryId });
1110
+ export type DraftSummary = z.infer<typeof DraftSummarySchema>;
1111
+ // `invalid` = filenames that failed to parse. Agent-written files are a trust boundary — without this a typo'd
1112
+ // draft would silently never post.
1113
+ export const DraftsListSchema = z.object({ drafts: z.array(DraftSummarySchema), invalid: z.array(z.string()) });
1114
+ // entryId, not a bare string: the id becomes a filename under .intentic/drafts/.
1115
+ export const DraftIdParamSchema = z.object({ id: entryId });
1116
+
1117
+ // ---- panels: per-repository dev servers + the content facts extensions detect on ----
1118
+ // Every discovered git repo under /work is one list row: its runnable-panel runtime status (a `dev` script at
1119
+ // operator/ or the repo root; the daemon runs it, auto-assigns a free port, and the preview proxy routes
1120
+ // preview-<panelKey>-<sandboxId>.<zone> to it) PLUS content facts — evidence the web app's extensions run their
1121
+ // detect() over, computed daemon-side in one pass so the browser never scans /work file-by-file.
1122
+
1123
+ export const PanelSummarySchema = z.object({
1124
+ // The repo id: its root-relative dir under /work (slashes become `--` in the preview subdomain label).
1125
+ repo: z.string(),
1126
+ // Whether the repo ships a runnable dev server (a package.json `dev` script at operator/ or the root).
1127
+ hasPanel: z.boolean(),
1128
+ running: z.boolean(),
1129
+ // A plain probe of the running panel's port; false when not running.
1130
+ healthy: z.boolean(),
1131
+ // The dev server's OS-assigned port; absent when not running.
1132
+ port: z.number().optional(),
1133
+ // https://preview-<repo>-<sandboxId>.<zone>; absent when the sandbox has no zone or connect token (loopback/tests).
1134
+ previewUrl: z.string().optional(),
1135
+ // The workspace role this repo dir occupies (the three fixed dirs); absent for extra clones.
1136
+ role: z.enum(["intent", "desired-state", "app"]).optional(),
1137
+ // Content facts: deploy.config.ts (the intent ledger's day-one marker), desired-state.json (present after
1138
+ // the first resolve), .intentic/ui/index.html (a sandboxed directory UI), pnpm-workspace.yaml +
1139
+ // turbo.json (a pnpm+turbo monorepo), and vitest evidence (a root vitest.config.ts, or "vitest" in the
1140
+ // root manifest / workspace catalog).
1141
+ deployConfig: z.boolean(),
1142
+ desiredState: z.boolean(),
1143
+ directoryUi: z.boolean(),
1144
+ monorepo: z.boolean(),
1145
+ vitest: z.boolean(),
1146
+ });
1147
+ export type PanelSummary = z.infer<typeof PanelSummarySchema>;
1148
+ export const PanelsListSchema = z.object({ panels: z.array(PanelSummarySchema) });
1149
+ export type PanelsList = z.infer<typeof PanelsListSchema>;
1150
+ // The {repo} path param on the start/stop/terminals routes (a bare string: unknown repo is a handler NOT_FOUND).
1151
+ export const PanelRepoParamSchema = z.object({ repo: z.string() });
1152
+
1153
+ // ---- ports: every listening TCP socket in the sandbox + explicit port forwarding ----
1154
+ // Anything run in a terminal (a turbo TUI fanning out dev servers, `python -m http.server`, an agent's ad-hoc
1155
+ // process) binds ports the daemon never assigned — the panel machinery can't see them. The /ports routes are
1156
+ // the generic complement: `list` reports the live listeners (procfs scan, on demand), `forward` makes one
1157
+ // reachable at port-<slot>-<sandboxId>.<zone> through the preview proxy. Forwarding is an explicit gesture —
1158
+ // previews are public, so nothing is exposed until the owner (or an agent acting for them) asks.
1159
+
1160
+ export const PortSummarySchema = z.object({
1161
+ port: z.number(),
1162
+ // The loopback address the listener actually answers at inside the sandbox — a `localhost` bind can land
1163
+ // on ::1 only (Vite). The preview proxy and the desktop mirror (Mutagen forward) both dial this.
1164
+ host: z.enum(["127.0.0.1", "::1"]),
1165
+ // Which bucket the Ports view files it under: `workspace` = user-run (dev servers in repos, terminal
1166
+ // processes, published container ports) — the previewable set; `system` = the sandbox's own machinery
1167
+ // (agent runtimes, translator, dockerd, sshd), listed for transparency but nobody previews it.
1168
+ kind: z.enum(["workspace", "system"]),
1169
+ // The owning process, resolved from procfs; absent when no /proc/*/fd entry matched the socket's inode.
1170
+ pid: z.number().optional(),
1171
+ // The process argv joined with spaces ("node /work/app/node_modules/.bin/vite").
1172
+ command: z.string().optional(),
1173
+ // The process working directory (how the UI attributes a port to a repo).
1174
+ cwd: z.string().optional(),
1175
+ forwarded: z.boolean(),
1176
+ // https://port-<slot>-<sandboxId>.<zone>; present only while forwarded AND the sandbox has a zone + id.
1177
+ previewUrl: z.string().optional(),
1178
+ });
1179
+ export type PortSummary = z.infer<typeof PortSummarySchema>;
1180
+ export const PortsListSchema = z.object({ ports: z.array(PortSummarySchema) });
1181
+ export type PortsList = z.infer<typeof PortsListSchema>;
1182
+
1183
+ export const PortParamSchema = z.object({ port: z.number().int().min(1).max(65535) });
1184
+ // `previewUrl` is absent on a loopback/no-tunnel sandbox — the slot is mapped, but no public hostname exists.
1185
+ export const PortForwardResultSchema = z.object({ previewUrl: z.string().optional() });
1186
+ export type PortForwardResult = z.infer<typeof PortForwardResultSchema>;
1187
+
1188
+ // ---- terminal ----
1189
+ // EVERY attachable tmux session in the sandbox — the web app's ONE global terminal panel (the interactive I/O
1190
+ // is the /system/terminal WebSocket, not oRPC): `shell` = a web-* session the user opened (numbered pill),
1191
+ // `panel` = a panel-* dev-server session (labeled by its panel key, started via Start; running:false =
1192
+ // untracked, e.g. a finished one-shot job's lingering shell), `agent` = an agent-* session the Claude agent's
1193
+ // Bash commands run in (live-watchable, AI-marked in the UI), `job` = a job-* session the daemon's terminal
1194
+ // runner executes user-triggered flows in (capability adds, infra check), `process` = a managed background
1195
+ // process riding a panel session (an extension's declared processes, dockerd) — surfaced in the panel's
1196
+ // background-processes popover with read-only log views, never as a killable tab; running is the actual
1197
+ // process (a lingering shell after a crash reads false). A process row that maps to an installed extension's
1198
+ // declared process carries extensionId+processName, the address for its /extensions start/stop routes. The
1199
+ // `{name}` kill-route param is a bare string validated in the handler (a bad name is a BAD_REQUEST) since the
1200
+ // same charset gates a `tmux kill-session -t` shell-out.
1201
+ export const TerminalSessionSchema = z.object({
1202
+ name: z.string(),
1203
+ label: z.string().optional(),
1204
+ kind: z.enum(["shell", "panel", "agent", "job", "process"]),
1205
+ running: z.boolean(),
1206
+ extensionId: z.string().optional(),
1207
+ processName: z.string().optional(),
1208
+ });
1209
+ export const TerminalsListSchema = z.object({ sessions: z.array(TerminalSessionSchema) });
1210
+ export type TerminalsList = z.infer<typeof TerminalsListSchema>;
1211
+ export const TerminalNameParamSchema = z.object({ name: z.string() });
1212
+
1213
+ // ---- environment: the overlay Dockerfile extending the sandbox image ----
1214
+ // The approved file is DAEMON-COMPOSED: pinned FROM + capability fragments + the owner-approved custom section.
1215
+ // The agent writes the proposal file (.intentic/environment.Dockerfile — custom-section content only, no FROM)
1216
+ // with its normal file tools; the owner approves it in the browser, which stores it as the custom file and
1217
+ // recomposes the approved artifact whose sha256 the rebuild executor pins. Status is derived, never stored:
1218
+ // applied = sha256(approved) === appliedHash; pending rebuild = approved present but hashes differ; proposed =
1219
+ // proposal present with a hash different from custom's.
1220
+
1221
+ const environmentFileSchema = z.object({ content: z.string(), hash: z.string() });
1222
+ export const EnvironmentSchema = z.object({
1223
+ proposal: environmentFileSchema.optional(),
1224
+ // The owner-approved agent-written custom section (.intentic/environment.custom.Dockerfile).
1225
+ custom: environmentFileSchema.optional(),
1226
+ approved: environmentFileSchema.optional(),
1227
+ // sha256 of the overlay the running container was built from (SANDBOX_ENVIRONMENT_HASH); absent = stock image.
1228
+ appliedHash: z.string().optional(),
1229
+ // config.sandbox.name — the UI derives the rebuild one-liner's slug from it.
1230
+ container: z.string().optional(),
1231
+ });
1232
+ export type Environment = z.infer<typeof EnvironmentSchema>;
1233
+ export const EnvironmentApproveSchema = z.object({ hash: z.string().min(1) });
1234
+
1235
+ // ---- secrets: user-supplied env-var secrets the daemon writes to desired-state/.env ----
1236
+ // The web posts a Cloudflare token / GitHub PAT / another-host SSH key straight to the sandbox daemon (never
1237
+ // through the platform); `apply` reloads .env each run so a new secret is picked up with NO restart. `list`
1238
+ // returns KEYS ONLY — the values never leave the sandbox; `reveal` is the one deliberate, owner-only exception.
1239
+ export const SecretSetSchema = z.object({
1240
+ key: z
1241
+ .string()
1242
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
1243
+ .max(128),
1244
+ value: z.string().min(1),
1245
+ });
1246
+ export const SecretKeysSchema = z.object({ keys: z.array(z.string()) });
1247
+ export const SecretKeyParamSchema = z.object({ key: z.string() });
1248
+ export const SecretRevealSchema = z.object({ value: z.string() });
1249
+
1250
+ // One entry per secret the sandbox knows about, across every store: intent env secrets and intentic-generated
1251
+ // passwords (from the desired-state repo), capability credentials, and AI-provider accounts. Values never ride
1252
+ // this shape — `revealable` says whether `reveal` can return one (everything but provider accounts).
1253
+ export const SecretInventoryEntrySchema = z.object({
1254
+ // Env-var key for env|generated; `<provider>:<accountId>` for provider entries; capability instance id
1255
+ // otherwise. Unique within the inventory — several accounts of one provider each get their own entry.
1256
+ key: z.string(),
1257
+ kind: z.enum(["env", "generated", "capability", "provider"]),
1258
+ // Display name for provider entries: "<ProviderName> · <accountLabel>". Absent on env/generated entries.
1259
+ label: z.string().optional(),
1260
+ status: z.enum(["missing", "set", "connected"]),
1261
+ // The artifact resources referencing this secret ({$secret} refs); [] for capability/provider entries.
1262
+ requiredBy: z.array(z.object({ resourceId: z.string(), type: z.string() })),
1263
+ // Human-readable provenance, e.g. "desired-state/.env" — the UI's "where does this live" line.
1264
+ storedAt: z.string(),
1265
+ revealable: z.boolean(),
1266
+ // Forgejo Actions replication state, present only after adopt on env|generated entries.
1267
+ ci: z.object({ synced: z.boolean(), pushedAt: z.string().optional() }).optional(),
1268
+ });
1269
+ export type SecretInventoryEntry = z.infer<typeof SecretInventoryEntrySchema>;
1270
+ export const SecretInventorySchema = z.object({ entries: z.array(SecretInventoryEntrySchema) });
1271
+
1272
+ // ---- system ----
1273
+
1274
+ // version: what this daemon runs (baked). latest/updateAvailable: the daemon compares its version to the
1275
+ // latest published `stable` release so the web can offer a non-blocking update (see system/version-check.ts).
1276
+ export const InfoSchema = z.object({
1277
+ name: z.string().optional(),
1278
+ image: z.string().optional(),
1279
+ version: z.string().optional(),
1280
+ latest: z.string().optional(),
1281
+ updateAvailable: z.boolean().optional(),
1282
+ });
1283
+
1284
+ // Intentic-provided host SSH tunnel: minting it needs intentic's PLATFORM Cloudflare account, so the daemon
1285
+ // can't do it directly — it relays to the platform authenticated by the connect token (the announce pattern).
1286
+ // The panel embeds the returned connector token + hostname in its connect-host one-liner.
1287
+ export const HostTunnelInputSchema = z.object({ hostName: z.string().min(1) });
1288
+ export const HostTunnelSchema = z.object({ hostname: z.string(), tunnelToken: z.string() });
1289
+
1290
+ // ---- activity: the activity audit log (historyRoot/activity.jsonl) ----
1291
+ // One provider-agnostic event per agent↔provider interaction, appended by the daemon only (never the agent —
1292
+ // the log lives under historyRoot, outside /work, so the agent can't read or rewrite its own trail). Discord
1293
+ // is the first source; other cli providers reuse the same shape.
1294
+
1295
+ export const ActivityEventSchema = z.object({
1296
+ id: z.string(),
1297
+ // Epoch ms; also the paging cursor.
1298
+ at: z.number(),
1299
+ // "discord", …; absent on provider-less system events (a cron automation.run).
1300
+ provider: z.string().optional(),
1301
+ // Which provider account handled the turn — the attribution key for per-account usage totals. Absent on
1302
+ // provider-less events and turns that ran on the provider's default account.
1303
+ account: z.string().optional(),
1304
+ direction: z.enum(["in", "out", "system"]),
1305
+ // in: message.received | voice_utterance.received | voice_transcript.received
1306
+ // out: message.send | reaction.add | messages.read | api.call (unclassified provider endpoint)
1307
+ // system: gateway.login_failed | dispatch.failed | voice.session_started | voice.session_ended | automation.run
1308
+ // | turn.started | turn.plan | turn.error | turn.completed (agent turn lifecycle; provider = claude/codex)
1309
+ type: z.string(),
1310
+ channelId: z.string().optional(),
1311
+ // Inbound author display name.
1312
+ author: z.string().optional(),
1313
+ // Full message text (inbound) or sent payload content (outbound).
1314
+ content: z.string().optional(),
1315
+ // Outbound HTTP method + endpoint path (tokens ride headers, never URLs).
1316
+ method: z.string().optional(),
1317
+ endpoint: z.string().optional(),
1318
+ // The agent turn that made/handled it — the join key between an inbound wake and its outbound calls.
1319
+ sessionId: z.string().optional(),
1320
+ automationIds: z.array(z.string()).optional(),
1321
+ outcome: z.enum(["ok", "error"]).optional(),
1322
+ error: z.string().optional(),
1323
+ // Source-specific detail: guildId, attachments, transcript path, participants…
1324
+ extra: z.record(z.string(), z.unknown()).optional(),
1325
+ });
1326
+ export type ActivityEvent = z.infer<typeof ActivityEventSchema>;
1327
+
1328
+ export const ActivityQuerySchema = z.object({
1329
+ provider: z.string().optional(),
1330
+ limit: z.coerce.number().min(1).max(500).default(100),
1331
+ // `at` cursor, exclusive — newest-first paging.
1332
+ before: z.coerce.number().optional(),
1333
+ });
1334
+ export type ActivityQuery = z.infer<typeof ActivityQuerySchema>;
1335
+ export const ActivityListSchema = z.object({ events: z.array(ActivityEventSchema) });
1336
+
1337
+ // Live connection health, probed per provider capability (not stored): gateway state from the client pool
1338
+ // (idle = the gateway is up but has no enabled listener automation to connect for — distinct from a
1339
+ // connection that should be up but isn't), lastError from the newest system-error event in the recent log.
1340
+ export const ActivityConnectionSchema = z.object({
1341
+ capabilityId: z.string(),
1342
+ provider: z.string(),
1343
+ gateway: z.enum(["ready", "connecting", "disconnected", "idle"]),
1344
+ lastError: z.string().optional(),
1345
+ });
1346
+ export const ActivityStatusSchema = z.object({
1347
+ connections: z.array(ActivityConnectionSchema),
1348
+ // The daemon's live voice session, when one is up.
1349
+ voice: z.object({ channelId: z.string(), channelName: z.string(), startedAt: z.number(), participants: z.array(z.string()) }).optional(),
1350
+ });
1351
+ export type ActivityStatus = z.infer<typeof ActivityStatusSchema>;
1352
+
1353
+ // ---- usage: per-account token/cost totals ----
1354
+ // Aggregated from the activity log's turn.completed events (their `usage` extra), grouped by provider+account.
1355
+ // Totals cover the retained log window (the log prunes to its most recent entries), not all-time.
1356
+ export const UsageAccountSchema = z.object({
1357
+ provider: z.string(),
1358
+ account: z.string(),
1359
+ turns: z.number(),
1360
+ inputTokens: z.number(),
1361
+ outputTokens: z.number(),
1362
+ cacheReadTokens: z.number(),
1363
+ cacheCreationTokens: z.number(),
1364
+ costUsd: z.number(),
1365
+ });
1366
+ export type UsageAccount = z.infer<typeof UsageAccountSchema>;
1367
+ export const UsageSummarySchema = z.object({ accounts: z.array(UsageAccountSchema) });
1368
+
1369
+ // ---- logs: daemon-owned debug logs (historyRoot/logs) ----
1370
+ // Terminal pipe-pane captures (terminals/), intentic CLI run logs (intentic-runs/), and the daemon's own pino
1371
+ // file (daemon.log) — written by the daemon/tmux only, under historyRoot so the agent can't rewrite them.
1372
+
1373
+ export const LogFileEntrySchema = z.object({
1374
+ // Path relative to the logs root, e.g. "terminals/web-1-%0.log" or "daemon.log".
1375
+ name: z.string(),
1376
+ sizeBytes: z.number(),
1377
+ // Epoch ms mtime.
1378
+ modifiedAt: z.number(),
1379
+ });
1380
+ export type LogFileEntry = z.infer<typeof LogFileEntrySchema>;
1381
+ export const LogsListSchema = z.object({ files: z.array(LogFileEntrySchema) });
1382
+
1383
+ // `name` rides the query (log names contain slashes, which don't fit a path segment); `bytes` is the tail
1384
+ // size — the newest bytes win when the file is larger.
1385
+ export const LogReadQuerySchema = z.object({
1386
+ name: z.string().min(1),
1387
+ bytes: z.coerce.number().min(1).max(1_048_576).default(65_536),
1388
+ });
1389
+ export const LogReadSchema = z.object({
1390
+ name: z.string(),
1391
+ sizeBytes: z.number(),
1392
+ // The tail text; truncated when the file holds more than the requested bytes.
1393
+ text: z.string(),
1394
+ truncated: z.boolean(),
1395
+ });
1396
+ export type LogRead = z.infer<typeof LogReadSchema>;
1397
+
1398
+ // A tab's self-report of what it is looking at, keyed by its /events connection's clientId. Full replace,
1399
+ // not a merge — an absent field means "cleared", so a tab leaving a file drops the path with the same report.
1400
+ export const PresenceReportSchema = z.object({
1401
+ clientId: z.string(),
1402
+ idle: z.boolean(),
1403
+ view: z.string().optional(),
1404
+ sessionId: z.string().optional(),
1405
+ path: z.string().optional(),
1406
+ });
1407
+ export type PresenceReport = z.infer<typeof PresenceReportSchema>;