@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/effects.ts ADDED
@@ -0,0 +1,138 @@
1
+ import type { ConnectorContribution, ExtensionManifest } from "@intentic/extension-api";
2
+ import type { CapabilityKind } from "./schemas.js";
3
+
4
+ /* What adding a capability DOES to the sandbox, as data — the structured counterpart of the handlers' side
5
+ * effects (the sandbox's capabilities/handlers/*), rendered by the web as the "This will add to your sandbox"
6
+ * panel before an add and as per-instance effect strips after. Derived, not declared per card: config-dependent
7
+ * effects (a plugin's clone URL, the SQL card's engine-dependent client image) and connector/extension-declared
8
+ * ones (secret fields, image fragments, processes) are computed from the same contribution data the handlers
9
+ * consume, so there is no per-card effects list to drift. The streamed apply log stays the post-apply record;
10
+ * this is the pre-add disclosure. */
11
+
12
+ export type CapabilityEffect =
13
+ // Writes .claude/skills/<name>/SKILL.md, auto-loaded by the agent next turn. `name` is the instance id for
14
+ // cli/browser (per-instance skills), the fixed shared skill for ssh/vpn; absent while the instance is unnamed.
15
+ | { readonly kind: "skill"; readonly name?: string | undefined }
16
+ // Stores a credential in the sandbox. "agent-env": injected into the agent's environment each turn, never
17
+ // written to a file (cli). "disk": a 0600 file or a denylisted manifest field (ssh key, vpn conf, git token).
18
+ | { readonly kind: "secret"; readonly exposure: "agent-env" | "disk" }
19
+ // Git-clones a repo into .intentic/plugins|extensions/<id>. `url` absent until the form field is filled.
20
+ | { readonly kind: "clone"; readonly url?: string | undefined }
21
+ // Bakes a Dockerfile fragment into the sandbox image overlay — needs a one-time owner rebuild.
22
+ | { readonly kind: "image" }
23
+ // The baked fragment carries a privileged runtime directive: "net-admin" (vpn NET_ADMIN + tun) or
24
+ // "privileged" (docker — the full --privileged run its dockerd needs).
25
+ | { readonly kind: "runtime"; readonly level: "net-admin" | "privileged" }
26
+ // Runs long-lived background processes in the sandbox (an extension's declared processes).
27
+ | { readonly kind: "process"; readonly names: readonly string[] }
28
+ // Registers an mcp__<id>__ server the agent connects to next turn.
29
+ | { readonly kind: "mcp" }
30
+ // Scaffolds workspace repositories. Empty while a name-derived repo is still unnamed.
31
+ | { readonly kind: "scaffold"; readonly repos: readonly string[] }
32
+ // Writes a managed deploy.config.ts entry; `provisions` = also runs the infra apply job now (service).
33
+ | { readonly kind: "deploy"; readonly provisions: boolean }
34
+ // Extension code runs inside the app with the owner's session — the owner-only trust decision.
35
+ | { readonly kind: "trusted-code" }
36
+ // Keeps a logged-in Chromium profile under .intentic/browser/<platform> that the agent drives.
37
+ | { readonly kind: "profile"; readonly platform: string };
38
+
39
+ export interface CapabilityEffectInput {
40
+ readonly kind: CapabilityKind;
41
+ // The instance name — the skill name for cli/browser, the repo name for monorepo.
42
+ readonly id?: string | undefined;
43
+ // Live form values, or a CapabilitySummary's secret-stripped config echo (hasToken/hasSecret booleans).
44
+ readonly config: Record<string, string | number | boolean | undefined>;
45
+ // The cli provider's connector spec — the source of truth for its secret/fragment declarations.
46
+ readonly connector?: ConnectorContribution | undefined;
47
+ // An installed extension's manifest — resolves its process/image contributions (unknowable before install).
48
+ readonly manifest?: ExtensionManifest | undefined;
49
+ }
50
+
51
+ const filled = (value: string | number | boolean | undefined): boolean => typeof value === "string" && value.length > 0;
52
+ // A token either typed into the form (`token`) or echoed as present on an installed instance (`hasToken`).
53
+ const hasToken = (config: CapabilityEffectInput["config"]): boolean => filled(config["token"]) || config["hasToken"] === true;
54
+ const cloneUrl = (config: CapabilityEffectInput["config"]): string | undefined => (filled(config["url"]) ? String(config["url"]) : undefined);
55
+
56
+ export const capabilityEffects = (input: CapabilityEffectInput): readonly CapabilityEffect[] => {
57
+ switch (input.kind) {
58
+ case "devops":
59
+ return [{ kind: "scaffold", repos: ["intent", "desired-state"] }];
60
+ case "monorepo":
61
+ return [{ kind: "scaffold", repos: input.id === undefined || input.id.length === 0 ? [] : [input.id] }];
62
+ case "mcp": {
63
+ const effects: CapabilityEffect[] = [{ kind: "mcp" }];
64
+ if (hasToken(input.config)) {
65
+ effects.push({ kind: "secret", exposure: "disk" });
66
+ }
67
+ return effects;
68
+ }
69
+ case "service":
70
+ return [{ kind: "deploy", provisions: true }];
71
+ case "integration":
72
+ return [{ kind: "deploy", provisions: false }];
73
+ case "cli": {
74
+ // Without the connector spec (extensions query pending / sandbox unreachable) fall back to the
75
+ // echoed hasSecret — which the web also synthesizes from the card's own secret-marked fields, so
76
+ // the secret row never waits on /extensions. The image row does wait: a connector's fragment
77
+ // (postgres/mysql clients, discord's whisper) is spec data with no static counterpart on the card.
78
+ const effects: CapabilityEffect[] = [{ kind: "skill", name: input.id }];
79
+ const secret =
80
+ input.connector === undefined ? input.config["hasSecret"] === true : input.connector.fields.some((field) => field.secret === true);
81
+ if (secret) {
82
+ effects.push({ kind: "secret", exposure: "agent-env" });
83
+ }
84
+ if (input.connector?.fragment !== undefined) {
85
+ effects.push({ kind: "image" });
86
+ }
87
+ return effects;
88
+ }
89
+ case "plugin": {
90
+ const effects: CapabilityEffect[] = [{ kind: "clone", url: cloneUrl(input.config) }];
91
+ if (hasToken(input.config)) {
92
+ effects.push({ kind: "secret", exposure: "disk" });
93
+ }
94
+ return effects;
95
+ }
96
+ case "extension": {
97
+ const effects: CapabilityEffect[] = [{ kind: "trusted-code" }, { kind: "clone", url: cloneUrl(input.config) }];
98
+ if (hasToken(input.config)) {
99
+ effects.push({ kind: "secret", exposure: "disk" });
100
+ }
101
+ const contributes = input.manifest?.contributes;
102
+ if (contributes?.environment !== undefined) {
103
+ effects.push({ kind: "image" });
104
+ }
105
+ if (contributes?.processes !== undefined && contributes.processes.length > 0) {
106
+ effects.push({ kind: "process", names: contributes.processes.map((process) => process.name) });
107
+ }
108
+ return effects;
109
+ }
110
+ case "ssh":
111
+ return [
112
+ { kind: "secret", exposure: "disk" },
113
+ { kind: "skill", name: "ssh" },
114
+ ];
115
+ case "vpn":
116
+ return [{ kind: "secret", exposure: "disk" }, { kind: "skill", name: "vpn" }, { kind: "image" }, { kind: "runtime", level: "net-admin" }];
117
+ case "docker":
118
+ // The engine is baked into the base image — the "image" effect here is the overlay rebuild that
119
+ // applies the fragment's --privileged directive, not new tooling.
120
+ return [{ kind: "image" }, { kind: "runtime", level: "privileged" }, { kind: "process", names: ["dockerd"] }];
121
+ case "browser": {
122
+ const effects: CapabilityEffect[] = [{ kind: "skill", name: input.id }, { kind: "image" }];
123
+ const platform = input.config["platform"];
124
+ if (typeof platform === "string") {
125
+ effects.push({ kind: "profile", platform });
126
+ }
127
+ return effects;
128
+ }
129
+ case "agent": {
130
+ // The spawned ACP subprocess is the standing consequence; a pasted env block is a stored credential.
131
+ const effects: CapabilityEffect[] = [{ kind: "process", names: input.id === undefined || input.id.length === 0 ? [] : [input.id] }];
132
+ if (filled(input.config["env"]) || input.config["hasSecret"] === true) {
133
+ effects.push({ kind: "secret", exposure: "disk" });
134
+ }
135
+ return effects;
136
+ }
137
+ }
138
+ };
package/src/events.ts ADDED
@@ -0,0 +1,277 @@
1
+ import { z } from "zod";
2
+ import { AgentSummarySchema } from "./schemas.js";
3
+
4
+ // The wire shapes streamed from the daemon's event-iterator procedures. This is their canonical home: the
5
+ // daemon yields them and the browser client consumes them from the same schema, so the two can't drift (they
6
+ // used to be hand-duplicated across repos). Schemas, not bare types, because oRPC's `eventIterator(...)`
7
+ // validates each frame against them.
8
+
9
+ // One interactive question the agent asks via the `ask` tool (mirrors AskUserQuestion's input shape).
10
+ export const AskOptionSchema = z.object({
11
+ label: z.string(),
12
+ description: z.string(),
13
+ preview: z.string().optional(),
14
+ });
15
+ export type AskOption = z.infer<typeof AskOptionSchema>;
16
+
17
+ export const AskQuestionSchema = z.object({
18
+ question: z.string(),
19
+ header: z.string(),
20
+ multiSelect: z.boolean(),
21
+ options: z.array(AskOptionSchema),
22
+ });
23
+ export type AskQuestion = z.infer<typeof AskQuestionSchema>;
24
+
25
+ // One provider-advertised slash command (an ACP agent's available_commands entry). `hint` is the argument
26
+ // placeholder the popover shows after the name.
27
+ export const AgentCommandSchema = z.object({
28
+ name: z.string(),
29
+ description: z.string(),
30
+ hint: z.string().optional(),
31
+ });
32
+ export type AgentCommand = z.infer<typeof AgentCommandSchema>;
33
+
34
+ // One TodoWrite/Task checklist item, surfaced live so the UI shows the agent's plan-of-work (Claude Code style).
35
+ export const TodoItemSchema = z.object({
36
+ content: z.string(),
37
+ status: z.enum(["pending", "in_progress", "completed"]),
38
+ activeForm: z.string().optional(),
39
+ });
40
+ export type TodoItem = z.infer<typeof TodoItemSchema>;
41
+
42
+ // Claude subscription usage for a turn (the SDK's rate_limit_event): which window is active, how much of it is
43
+ // spent, and when it resets. Emitted on the stream at no token cost; only Claude turns report it. Its own
44
+ // schema so the browser can hold the latest snapshot as a plain value, separate from the `kind`-tagged frame.
45
+ export const RateLimitInfoSchema = z.object({
46
+ status: z.enum(["allowed", "allowed_warning", "rejected"]),
47
+ resetsAt: z.number().optional(), // epoch seconds
48
+ rateLimitType: z.string().optional(), // 'five_hour' | 'seven_day' | 'seven_day_opus' | ...
49
+ utilization: z.number().optional(), // 0-100, how much of the window is used
50
+ });
51
+ export type RateLimitInfo = z.infer<typeof RateLimitInfoSchema>;
52
+
53
+ // Context-window fill for a conversation: how many tokens the latest request sent vs the model's window, so
54
+ // the UI can warn as the chat nears auto-compaction. Per-conversation, unlike the account-wide usage above.
55
+ export const ContextUsageSchema = z.object({
56
+ tokens: z.number(), // full input of the latest request (input + cache read + cache creation)
57
+ contextWindow: z.number(), // the model's context window
58
+ });
59
+ export type ContextUsage = z.infer<typeof ContextUsageSchema>;
60
+
61
+ // ACP-aligned tool taxonomy (Agent Client Protocol's ToolKind, verbatim): what a tool call *does*, driving
62
+ // the card icon and follow-along behavior regardless of which backend named the tool.
63
+ export const ToolKindSchema = z.enum(["read", "edit", "delete", "move", "search", "execute", "think", "fetch", "other"]);
64
+ export type ToolKind = z.infer<typeof ToolKindSchema>;
65
+
66
+ export const ToolCallStatusSchema = z.enum(["pending", "in_progress", "completed", "failed"]);
67
+ export type ToolCallStatus = z.infer<typeof ToolCallStatusSchema>;
68
+
69
+ // A file a tool call touches. Workspace-root-relative, forward-slash (the tree/file route space) — adapters
70
+ // normalize from the turn's cwd. `line` is 1-based.
71
+ export const ToolCallLocationSchema = z.object({
72
+ path: z.string(),
73
+ line: z.number().optional(),
74
+ });
75
+ export type ToolCallLocation = z.infer<typeof ToolCallLocationSchema>;
76
+
77
+ // Structured tool output (ACP's ToolCallContent diff shape, verbatim). `diff` is hunk-level for Edit-style
78
+ // tools (old_string/new_string) and whole-file for Write; an absent oldText means a new file / unknown
79
+ // previous content. Sides are capped daemon-side; `truncated` marks a clipped side.
80
+ export const ToolCallContentSchema = z.discriminatedUnion("type", [
81
+ z.object({ type: z.literal("text"), text: z.string() }),
82
+ z.object({
83
+ type: z.literal("diff"),
84
+ path: z.string(),
85
+ oldText: z.string().optional(),
86
+ newText: z.string(),
87
+ truncated: z.boolean().optional(),
88
+ }),
89
+ ]);
90
+ export type ToolCallContent = z.infer<typeof ToolCallContentSchema>;
91
+
92
+ // One frame from an agent turn, relayed to the UI. `kind`-discriminated. The daemon normalizes the SDK's
93
+ // ~40 SDKMessage types down to this union: high-value block types get a dedicated frame
94
+ // (delta/thinking/tool_call/tool_call_update/todos/usage/rate_limit_info/context_usage/init/compact); any SDK message
95
+ // without a UI mapping is dropped. `plan`/`question` pause the turn until the user answers on a side channel.
96
+ // `parentToolUseId` tags frames produced inside a subagent (Task tool).
97
+ export const AgentEventSchema = z.discriminatedUnion("kind", [
98
+ z.object({ kind: z.literal("session"), sessionId: z.string() }),
99
+ // First frame of an isolated turn: the conversation's worktree identity — its branch (agent/<id>) and the
100
+ // ROOT repo's short base sha (the checkout moment). Emitted before any provider frames.
101
+ z.object({ kind: z.literal("worktree"), branch: z.string(), base: z.string() }),
102
+ // Emitted after a clean isolated turn whose delta auto-landed (or failed to): landed ⇒ the work is now
103
+ // UNCOMMITTED changes in the main tree (the Changes panel is the review); conflicts ⇒ it stayed safely in
104
+ // the worktree — the named paths collide with the user's own edits, "Land now" recovers after they resolve.
105
+ z.object({
106
+ kind: z.literal("landed"),
107
+ landed: z.boolean(),
108
+ conflicts: z.array(z.object({ repo: z.string(), paths: z.array(z.string()) })).optional(),
109
+ }),
110
+ // The SDK's init handshake; carries the model it actually resolved for the turn.
111
+ z.object({ kind: z.literal("init"), model: z.string() }),
112
+ // The pre-turn workspace snapshot's id (the attribution-fence "user" capture), emitted once before the
113
+ // provider stream so the client can offer "restore to before this message" on the turn's user bubble.
114
+ // Absent on isolated turns (they snapshot nothing) and when the tree was already clean at turn start.
115
+ z.object({ kind: z.literal("checkpoint"), id: z.string() }),
116
+ z.object({ kind: z.literal("delta"), text: z.string(), parentToolUseId: z.string().optional() }),
117
+ z.object({ kind: z.literal("thinking"), text: z.string(), parentToolUseId: z.string().optional() }),
118
+ // A tool call starting (or, for backends that only report completions, arriving whole). `content` carries
119
+ // structured output known at call time — an Edit's diff is derived from its input, no result needed.
120
+ z.object({
121
+ kind: z.literal("tool_call"),
122
+ id: z.string(),
123
+ name: z.string(),
124
+ category: ToolKindSchema,
125
+ status: ToolCallStatusSchema,
126
+ target: z.string().optional(),
127
+ locations: z.array(ToolCallLocationSchema).optional(),
128
+ content: z.array(ToolCallContentSchema).optional(),
129
+ parentToolUseId: z.string().optional(),
130
+ }),
131
+ // A later state of a tool call, correlated by `id`. N updates per call: status transitions and/or fresh
132
+ // content/locations — both REPLACE the prior value (snapshot semantics, not append); absent ⇒ unchanged.
133
+ z.object({
134
+ kind: z.literal("tool_call_update"),
135
+ id: z.string(),
136
+ status: ToolCallStatusSchema.optional(),
137
+ content: z.array(ToolCallContentSchema).optional(),
138
+ locations: z.array(ToolCallLocationSchema).optional(),
139
+ }),
140
+ // The agent just started running Bash in its live `agent-<id>` tmux session — the client surfaces that
141
+ // terminal in the global panel. One per turn (the session is reused across a turn's commands, incl. subagents').
142
+ z.object({ kind: z.literal("terminal"), session: z.string() }),
143
+ z.object({ kind: z.literal("todos"), items: z.array(TodoItemSchema) }),
144
+ // The provider's own slash commands (ACP available_commands_update), replaced whole each time — the
145
+ // composer's `/` popover lists them; invoking one is plain `/name …` prompt text (the ACP convention).
146
+ z.object({ kind: z.literal("commands"), items: z.array(AgentCommandSchema) }),
147
+ z.object({
148
+ kind: z.literal("usage"),
149
+ // The account that served this turn — the client attributes the totals to it (tagged by streamAgent).
150
+ account: z.string().optional(),
151
+ costUsd: z.number().optional(),
152
+ inputTokens: z.number().optional(),
153
+ outputTokens: z.number().optional(),
154
+ // Provider prompt-cache buckets for the turn: tokens served from cache (read) and written to cache
155
+ // (creation). Optional per provider — Codex reports only cached input (read); runtimes/turns that
156
+ // don't report a bucket omit it. Lets the client show cache hit rate = read / (read + input).
157
+ cacheReadTokens: z.number().optional(),
158
+ cacheCreationTokens: z.number().optional(),
159
+ durationMs: z.number().optional(),
160
+ numTurns: z.number().optional(),
161
+ }),
162
+ // account tags which Claude account the snapshot belongs to, so the client keys usageStatus by account.
163
+ RateLimitInfoSchema.extend({ kind: z.literal("rate_limit_info"), account: z.string().optional() }),
164
+ ContextUsageSchema.extend({ kind: z.literal("context_usage") }),
165
+ z.object({ kind: z.literal("compact"), trigger: z.string(), preTokens: z.number().optional(), postTokens: z.number().optional() }),
166
+ z.object({ kind: z.literal("plan"), decisionId: z.string(), text: z.string() }),
167
+ z.object({ kind: z.literal("question"), requestId: z.string(), questions: z.array(AskQuestionSchema) }),
168
+ // `code` is a machine-readable discriminator for errors the UI reacts to programmatically (dropping a
169
+ // dead session id so the next send self-heals). Absent on plain failures.
170
+ z.object({
171
+ kind: z.literal("error"),
172
+ message: z.string(),
173
+ code: z
174
+ .enum([
175
+ "session-not-found",
176
+ "rate_limit",
177
+ "codex-reauth",
178
+ "grok-model-invalid",
179
+ "codex-model-invalid",
180
+ "subscription-required",
181
+ "agent-busy",
182
+ ])
183
+ .optional(),
184
+ }),
185
+ z.object({ kind: z.literal("done") }),
186
+ ]);
187
+ export type AgentEvent = z.infer<typeof AgentEventSchema>;
188
+
189
+ // The /agent/attach stream: a head frame identifying the run, then its AgentEvents stamped with their 1-based
190
+ // seq (the client's resume cursor), then `end` when the run is over — every frame delivered, nothing more
191
+ // coming. A stream that closes WITHOUT `end` was dropped mid-run; the client re-attaches with `after` = the
192
+ // last seq it holds. The head's `prompt`/`startedAt` let a window that didn't initiate the turn (a reload, a
193
+ // second window, another device) synthesize the user bubble and the elapsed readout; its `seq` is the log
194
+ // length at attach time — the replay/live boundary.
195
+ export const AttachFrameSchema = z.discriminatedUnion("kind", [
196
+ z.object({ kind: z.literal("attached"), run: z.string(), prompt: z.string(), startedAt: z.number(), seq: z.number() }),
197
+ z.object({ kind: z.literal("frame"), seq: z.number(), event: AgentEventSchema }),
198
+ z.object({ kind: z.literal("end") }),
199
+ ]);
200
+ export type AttachFrame = z.infer<typeof AttachFrameSchema>;
201
+
202
+ // One parsed line from `intentic … --output ndjson` (engine events, provider `log`, the terminal `result`).
203
+ // Open-ended by design — the sandbox consumes the wire shape, not @intentic/engine's types — so a string
204
+ // `kind` plus arbitrary extra fields pass through. The apply-events tail (intentic.contract `applyEvents`) rides
205
+ // this same loose shape with three daemon/CLI-minted sentinel kinds alongside the engine ones: {kind:"start"}
206
+ // (first line, written when the run's file is reset), {kind:"exit",code} (last line, on the CLI process exit),
207
+ // and {kind:"heartbeat"} (interleaved by the tail while idle to keep the held-open stream alive).
208
+ export const IntenticLineSchema = z.looseObject({ kind: z.string() });
209
+ export type IntenticLine = z.infer<typeof IntenticLineSchema>;
210
+
211
+ // The daemon's liveness heartbeat frame: the browser holds the events stream open and trips a watchdog if the
212
+ // frames stop (the tunnel drops the proxied response when the origin dies).
213
+ export const HeartbeatSchema = z.object({ kind: z.literal("heartbeat") });
214
+ export type Heartbeat = z.infer<typeof HeartbeatSchema>;
215
+
216
+ // The stream's first frame: the workspace's stable identity, minted at the first boot of an empty /work. The
217
+ // browser remembers it per sandbox id and drops that sandbox's persisted query cache when it changes — a wiped
218
+ // and recreated workspace (cleanup.sh + reconnect keeps the same sandbox id) must not be painted from the
219
+ // previous workspace's cache.
220
+ export const HelloSchema = z.object({ kind: z.literal("hello"), workspaceId: z.string() });
221
+ export type Hello = z.infer<typeof HelloSchema>;
222
+
223
+ // The FULL discovered repo set (sorted root-relative ids), pushed whenever it changes — a clone, a scaffold,
224
+ // or a deleted repo re-frames it. The watcher descent-ignores .git, so no workspaceChanged path pattern can
225
+ // detect a repo appearing; the daemon diffs its own discovery instead. Snapshot-not-diff, last frame wins.
226
+ export const ReposChangedSchema = z.object({ kind: z.literal("reposChanged"), repos: z.array(z.string()) });
227
+ export type ReposChanged = z.infer<typeof ReposChangedSchema>;
228
+
229
+ // A batch of workspace paths that just changed on disk (created/edited/deleted), pushed on the same /events
230
+ // stream as the heartbeat so the browser refreshes the tree + any open file live — the agent edits files
231
+ // out-of-band (its own Write/Edit/Bash tools), so there's no HTTP mutation to hang an invalidate on. Paths are
232
+ // root-relative, forward-slash (the tree/file route space). An empty array means "something changed, refetch the
233
+ // tree" — a burst too large to enumerate, or a reconnect recovery where we don't know what was missed.
234
+ export const WorkspaceChangedSchema = z.object({ kind: z.literal("workspaceChanged"), paths: z.array(z.string()) });
235
+ export type WorkspaceChanged = z.infer<typeof WorkspaceChangedSchema>;
236
+
237
+ // One connected browser tab of a sandbox member. Identity fields come from the caller's verified Google ID
238
+ // token; activity fields from the tab's own /system/presence reports. No timestamps on the wire — an entry's
239
+ // lifetime IS its /events connection's lifetime, so there is nothing to age out or compare clocks over.
240
+ export const PresenceUserSchema = z.object({
241
+ // Per-CONNECTION id, minted by the browser for each /events attempt — never reused across reconnects.
242
+ clientId: z.string(),
243
+ email: z.string(),
244
+ name: z.string().optional(),
245
+ picture: z.string().optional(),
246
+ idle: z.boolean(),
247
+ // Route/view name the tab is on ("workspace", "automations", "ext:<id>/<key>", …).
248
+ view: z.string().optional(),
249
+ // The chat conversation the tab has active.
250
+ sessionId: z.string().optional(),
251
+ // The workspace file the tab has open (root-relative, forward-slash).
252
+ path: z.string().optional(),
253
+ });
254
+ export type PresenceUser = z.infer<typeof PresenceUserSchema>;
255
+
256
+ // The FULL roster of connected members, broadcast on every change — snapshots, not diffs, so a reconnecting
257
+ // browser is consistent from its first frame and ordering never matters (last frame wins).
258
+ export const PresenceSchema = z.object({ kind: z.literal("presence"), users: z.array(PresenceUserSchema) });
259
+ export type Presence = z.infer<typeof PresenceSchema>;
260
+
261
+ // The FULL fleet roster, broadcast on every registry change — same snapshot-not-diff contract as presence:
262
+ // a reconnecting browser is consistent from its first frame and last frame wins.
263
+ export const AgentsSchema = z.object({ kind: z.literal("agents"), agents: z.array(AgentSummarySchema) });
264
+ export type Agents = z.infer<typeof AgentsSchema>;
265
+
266
+ // The /events stream union: the hello identity frame, then liveness heartbeats interleaved with
267
+ // workspace-change batches, repo-set snapshots, and presence + fleet roster snapshots. oRPC validates every
268
+ // yielded frame against this, so all kinds must live here.
269
+ export const SystemEventSchema = z.discriminatedUnion("kind", [
270
+ HelloSchema,
271
+ HeartbeatSchema,
272
+ WorkspaceChangedSchema,
273
+ ReposChangedSchema,
274
+ PresenceSchema,
275
+ AgentsSchema,
276
+ ]);
277
+ export type SystemEvent = z.infer<typeof SystemEventSchema>;
@@ -0,0 +1,44 @@
1
+ import { expect, test } from "vitest";
2
+ import {
3
+ labelHostname,
4
+ panelFromHost,
5
+ portHostname,
6
+ portLabel,
7
+ portSlotFromHost,
8
+ portUrl,
9
+ previewHostname,
10
+ previewLabel,
11
+ previewUrl,
12
+ } from "./hostnames.js";
13
+
14
+ const ID = "abc123def456";
15
+
16
+ test("preview and port hostnames round-trip through their Host-header parsers", () => {
17
+ expect(panelFromHost(`${previewHostname("shop--web", ID, "example.com")}:443`, ID)).toBe("shop--web");
18
+ expect(portSlotFromHost(`${portHostname("a", ID, "example.com")}:443`, ID)).toBe("a");
19
+ // The id-less form (loopback tests / provider-deployed workspaces fronting the proxy themselves).
20
+ expect(panelFromHost("preview-app.example.com", undefined)).toBe("app");
21
+ expect(portSlotFromHost("port-a.example.com", undefined)).toBe("a");
22
+ });
23
+
24
+ test("the two schemes never bleed into each other, and stray/wrong-id hosts parse to nothing", () => {
25
+ expect(portSlotFromHost("preview-app-" + ID + ".example.com", ID)).toBeUndefined();
26
+ expect(panelFromHost("port-a-" + ID + ".example.com", ID)).toBeUndefined();
27
+ expect(panelFromHost("app.example.com", ID)).toBeUndefined();
28
+ expect(panelFromHost(`preview-app-000000000000.example.com`, ID)).toBeUndefined();
29
+ expect(portSlotFromHost(`port-a-000000000000.example.com`, ID)).toBeUndefined();
30
+ expect(panelFromHost(undefined, ID)).toBeUndefined();
31
+ });
32
+
33
+ test("labels are the mintable unit: hostname = <label>-<id>.<zone> for both schemes", () => {
34
+ expect(labelHostname(previewLabel("app"), ID, "example.com")).toBe(previewHostname("app", ID, "example.com"));
35
+ expect(labelHostname(portLabel("a"), ID, "example.com")).toBe(portHostname("a", ID, "example.com"));
36
+ });
37
+
38
+ test("preview/port URLs require both a zone and a sandbox id", () => {
39
+ expect(previewUrl("app", "example.com", ID)).toBe(`https://preview-app-${ID}.example.com`);
40
+ expect(portUrl("a", "example.com", ID)).toBe(`https://port-a-${ID}.example.com`);
41
+ expect(portUrl("a", undefined, ID)).toBeUndefined();
42
+ expect(portUrl("a", "", ID)).toBeUndefined();
43
+ expect(portUrl("a", "example.com", undefined)).toBeUndefined();
44
+ });
@@ -0,0 +1,97 @@
1
+ // The single source for the tunnel/preview hostname + ingress scheme, shared by the CLI, platform API, daemon,
2
+ // AND the browser. Pure string builders/parsers with NO node imports (unlike ./tunnel-ids, which needs
3
+ // node:crypto for the digest) — so the web bundle can import them and derive identical names. The caller supplies
4
+ // the 12-hex id (sandboxIdFromToken/hostSshIdFromToken in node; WebCrypto in the browser).
5
+ //
6
+ // All four apps MUST agree on these strings: a divergence resolves to NXDOMAIN that resolvers negative-cache for
7
+ // the zone's SOA TTL. This module is that agreement.
8
+
9
+ // The sandbox daemon's subdomain + hostname: `sandbox-<id>` / `sandbox-<id>.<zone>`.
10
+ export const sandboxSubdomain = (id: string): string => `sandbox-${id}`;
11
+ export const sandboxHostname = (id: string, zone: string): string => `${sandboxSubdomain(id)}.${zone}`;
12
+
13
+ // The container sshd hostname the desktop-sync (Mutagen) reaches over the sandbox tunnel: `ssh-<id>.<zone>`.
14
+ export const sshHostname = (id: string, zone: string): string => `ssh-${id}.${zone}`;
15
+
16
+ // A per-host SSH tunnel's Cloudflare tunnel NAME (its hostname reuses sshHostname with the host-ssh id).
17
+ export const hostSshTunnelName = (id: string): string => `host-ssh-${id}`;
18
+
19
+ // The proxied-CNAME target every tunnel points its DNS record at.
20
+ export const cfargotunnelCname = (tunnelId: string): string => `${tunnelId}.cfargotunnel.com`;
21
+
22
+ // The cloudflared ingress catch-all — must be the LAST rule.
23
+ export const CATCH_ALL = { service: "http_status:404" } as const;
24
+
25
+ // Preview scheme: `preview-<panel>-<sandboxId>.<zone>` — one DNS label (the free Universal SSL `*.<zone>` cert
26
+ // covers exactly one level), where <panel> is `<repo>` or `<repo>--<app>` and <sandboxId> pins the hostname to
27
+ // this sandbox (the shared intentic zone hosts many sandboxes; without the id two users' panels would collide).
28
+ // Port-forward scheme: `port-<slot>-<sandboxId>.<zone>` — the same shape with a `port-` prefix, where <slot>
29
+ // is one of the sandbox's fixed forward slots (see PORT_SLOTS), not the port number itself: slots keep the
30
+ // intentic-provided path's minted routes bounded and warm while dev servers churn ephemeral ports.
31
+ //
32
+ // A *label* is the first-DNS-label prefix before `-<sandboxId>` (`preview-<panel>` / `port-<slot>`) — the unit
33
+ // the platform's /sandbox/preview-route mints, so one endpoint serves both schemes.
34
+ export const previewLabel = (panel: string): string => `preview-${panel}`;
35
+ export const portLabel = (slot: string): string => `port-${slot}`;
36
+
37
+ // The fixed per-sandbox forward slots. Eight is deliberate: enough for a monorepo's worth of concurrent dev
38
+ // servers, and the hard cap on preview DNS records a sandbox can ever cost the shared intentic zone.
39
+ export const PORT_SLOTS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const;
40
+
41
+ // The hostname a label resolves to — what the platform's /sandbox/preview-route mints from the label alone.
42
+ export const labelHostname = (label: string, id: string, zone: string): string => `${label}-${id}.${zone}`;
43
+ export const previewHostname = (panel: string, id: string, zone: string): string => labelHostname(previewLabel(panel), id, zone);
44
+ export const portHostname = (slot: string, id: string, zone: string): string => labelHostname(portLabel(slot), id, zone);
45
+
46
+ // A panel's / forwarded port's preview URL — undefined unless the sandbox has both a zone and an id
47
+ // (headless/loopback sandboxes have neither and advertise no preview).
48
+ export const previewUrl = (panel: string, zone: string | undefined, sandboxId: string | undefined): string | undefined =>
49
+ zone !== undefined && zone !== "" && sandboxId !== undefined ? `https://${previewHostname(panel, sandboxId, zone)}` : undefined;
50
+ export const portUrl = (slot: string, zone: string | undefined, sandboxId: string | undefined): string | undefined =>
51
+ zone !== undefined && zone !== "" && sandboxId !== undefined ? `https://${portHostname(slot, sandboxId, zone)}` : undefined;
52
+
53
+ // The key after `<prefix>` from a request's Host header. The first DNS label must carry the prefix (the
54
+ // own-Cloudflare wildcard also catches stray subdomains → undefined → the caller's 404) and, when the sandbox
55
+ // has an id, the exact `-<sandboxId>` suffix — a fixed-length match, so keys containing `-` stay unambiguous.
56
+ // Without an id the bare label is the key (loopback tests and provider-deployed workspaces, which front the
57
+ // proxy themselves).
58
+ const keyFromHost = (prefix: string, hostHeader: string | undefined, sandboxId: string | undefined): string | undefined => {
59
+ const label = hostHeader?.split(":")[0]?.split(".")[0] ?? "";
60
+ if (!label.startsWith(prefix)) {
61
+ return undefined;
62
+ }
63
+ const key = label.slice(prefix.length);
64
+ if (sandboxId === undefined) {
65
+ return key === "" ? undefined : key;
66
+ }
67
+ const suffix = `-${sandboxId}`;
68
+ return key.length > suffix.length && key.endsWith(suffix) ? key.slice(0, -suffix.length) : undefined;
69
+ };
70
+
71
+ export const panelFromHost = (hostHeader: string | undefined, sandboxId: string | undefined): string | undefined =>
72
+ keyFromHost("preview-", hostHeader, sandboxId);
73
+ export const portSlotFromHost = (hostHeader: string | undefined, sandboxId: string | undefined): string | undefined =>
74
+ keyFromHost("port-", hostHeader, sandboxId);
75
+
76
+ // The Cloudflare zone from a sandbox public URL (https://sandbox-<id>.<zone> → <zone>): the hostname minus its
77
+ // first DNS label. undefined when the URL is unparsable OR the hostname has fewer than three labels (no zone
78
+ // suffix to strip — e.g. a 2-label host would otherwise yield a bare TLD). Accepts scheme-less input too, so it
79
+ // works whether the caller passes `https://…` (daemon/CLI) or a bare host. This is the single reconciled
80
+ // implementation of what used to be the daemon's `zoneFromPublicUrl` and the web's `zoneFromDaemonUrl`.
81
+ export const zoneFromUrl = (url: string | undefined): string | undefined => {
82
+ if (url === undefined || url === "") {
83
+ return undefined;
84
+ }
85
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : `https://${url}`;
86
+ let hostname: string;
87
+ try {
88
+ hostname = new URL(withScheme).hostname;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ const labels = hostname.split(".");
93
+ if (labels.length < 3) {
94
+ return undefined;
95
+ }
96
+ return labels.slice(1).join(".");
97
+ };
package/src/index.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { activityContract } from "./contracts/activity.contract.js";
2
+ import { agentContract } from "./contracts/agent.contract.js";
3
+ import { agentsContract } from "./contracts/agents.contract.js";
4
+ import { automationsContract } from "./contracts/automations.contract.js";
5
+ import { capabilitiesContract } from "./contracts/capabilities.contract.js";
6
+ import { claudeContract } from "./contracts/claude.contract.js";
7
+ import { codexContract } from "./contracts/codex.contract.js";
8
+ import { draftsContract } from "./contracts/drafts.contract.js";
9
+ import { extensionsContract } from "./contracts/extensions.contract.js";
10
+ import { gitContract } from "./contracts/git.contract.js";
11
+ import { grokContract } from "./contracts/grok.contract.js";
12
+ import { historyContract } from "./contracts/history.contract.js";
13
+ import { intenticContract } from "./contracts/intentic.contract.js";
14
+ import { inventoryContract } from "./contracts/inventory.contract.js";
15
+ import { logsContract } from "./contracts/logs.contract.js";
16
+ import { panelsContract } from "./contracts/panels.contract.js";
17
+ import { portsContract } from "./contracts/ports.contract.js";
18
+ import { secretsContract } from "./contracts/secrets.contract.js";
19
+ import { sessionsContract } from "./contracts/sessions.contract.js";
20
+ import { settingsContract } from "./contracts/settings.contract.js";
21
+ import { systemContract } from "./contracts/system.contract.js";
22
+ import { translatorContract } from "./contracts/translator.contract.js";
23
+ import { workspaceContract } from "./contracts/workspace.contract.js";
24
+
25
+ export { activityContract } from "./contracts/activity.contract.js";
26
+ export { agentContract } from "./contracts/agent.contract.js";
27
+ export { agentsContract } from "./contracts/agents.contract.js";
28
+ export { automationsContract } from "./contracts/automations.contract.js";
29
+ export { capabilitiesContract } from "./contracts/capabilities.contract.js";
30
+ export { claudeContract } from "./contracts/claude.contract.js";
31
+ export { codexContract } from "./contracts/codex.contract.js";
32
+ export { draftsContract } from "./contracts/drafts.contract.js";
33
+ export { extensionsContract } from "./contracts/extensions.contract.js";
34
+ export { gitContract } from "./contracts/git.contract.js";
35
+ export { grokContract } from "./contracts/grok.contract.js";
36
+ export { historyContract } from "./contracts/history.contract.js";
37
+ export { intenticContract } from "./contracts/intentic.contract.js";
38
+ export { inventoryContract } from "./contracts/inventory.contract.js";
39
+ export { logsContract } from "./contracts/logs.contract.js";
40
+ export { panelsContract } from "./contracts/panels.contract.js";
41
+ export { portsContract } from "./contracts/ports.contract.js";
42
+ export { secretsContract } from "./contracts/secrets.contract.js";
43
+ export { sessionsContract } from "./contracts/sessions.contract.js";
44
+ export { settingsContract } from "./contracts/settings.contract.js";
45
+ export { systemContract } from "./contracts/system.contract.js";
46
+ export { translatorContract } from "./contracts/translator.contract.js";
47
+ export { workspaceContract } from "./contracts/workspace.contract.js";
48
+ export * from "./effects.js";
49
+ export * from "./events.js";
50
+ export * from "./sse.js";
51
+ export * from "./agent-catalog.js";
52
+ export * from "./hostnames.js";
53
+ export * from "./model-metadata.js";
54
+ export * from "./schemas.js";
55
+ export * from "./terminal-protocol.js";
56
+
57
+ // The aggregated contract — implemented on the server by the per-domain route factories and consumed by the
58
+ // browser's typed oRPC client (ContractRouterClient<typeof sandboxContract>). The wire paths it declares are
59
+ // mounted at the sandbox root, so /health and /workspace/raw (plain Hono routes) sit alongside it.
60
+ export const sandboxContract = {
61
+ activity: activityContract,
62
+ agent: agentContract,
63
+ agents: agentsContract,
64
+ automations: automationsContract,
65
+ capabilities: capabilitiesContract,
66
+ claude: claudeContract,
67
+ codex: codexContract,
68
+ drafts: draftsContract,
69
+ extensions: extensionsContract,
70
+ sessions: sessionsContract,
71
+ settings: settingsContract,
72
+ intentic: intenticContract,
73
+ git: gitContract,
74
+ grok: grokContract,
75
+ history: historyContract,
76
+ workspace: workspaceContract,
77
+ inventory: inventoryContract,
78
+ logs: logsContract,
79
+ panels: panelsContract,
80
+ ports: portsContract,
81
+ secrets: secretsContract,
82
+ system: systemContract,
83
+ translator: translatorContract,
84
+ };