@spunto/build 0.24.0 → 0.26.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/build",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -93,7 +93,8 @@
93
93
  "import": "./src/agent-runners/index.ts"
94
94
  },
95
95
  "./agent-runners/claude.mjs": "./src/agent-runners/claude.mjs",
96
- "./agent-runners/codex.mjs": "./src/agent-runners/codex.mjs"
96
+ "./agent-runners/codex.mjs": "./src/agent-runners/codex.mjs",
97
+ "./agent-runners/mistral.mjs": "./src/agent-runners/mistral.mjs"
97
98
  },
98
99
  "files": [
99
100
  "src",
@@ -11,7 +11,12 @@
11
11
  * 2026-10-26. Each model comes back with `supportedEffortLevels`;
12
12
  * - Codex: `codex debug models` of `@openai/codex` 0.164.0 — the catalog bundled in the binary,
13
13
  * offline, on 2026-10-26 — the `visibility: "list"` models, with their
14
- * `supported_reasoning_levels` and `default_reasoning_level`.
14
+ * `supported_reasoning_levels` and `default_reasoning_level`;
15
+ * - Mistral: the `model` and `thinking` options of a `session/new` of `vibe-acp` 3.0.4, then of
16
+ * `session/set_config_option` for each model, on 2026-10-31, with a pay-as-you-go API key.
17
+ * Not all in the binary: Vibe's own default is Medium 3.5, and the others came from Mistral's
18
+ * remote config (a rollout of Large 4 as the default the account was in) — an account outside
19
+ * it may lack them, which the runner then says, with the models it does have.
15
20
  *
16
21
  * `packages/build/scripts/agent-catalogs.mjs` prints both again, to diff against this file when a
17
22
  * pin is bumped.
@@ -82,7 +87,7 @@ const CODEX_REASONING: AgentParamValue[] = [
82
87
 
83
88
  const ALL_BUT_ULTRA = ["low", "medium", "high", "xhigh", "max"]
84
89
 
85
- export const AGENT_CATALOGS: Record<"claude" | "codex", AgentCatalog> = {
90
+ export const AGENT_CATALOGS: Record<"claude" | "codex" | "mistral", AgentCatalog> = {
86
91
  claude: {
87
92
  // The SDK's aliases (`default`, `sonnet`, `opus`, `fable`, `haiku`) resolved to the ids they
88
93
  // stand for: a task records the model it ran, not a name whose meaning moves.
@@ -130,6 +135,28 @@ export const AGENT_CATALOGS: Record<"claude" | "codex", AgentCatalog> = {
130
135
  ],
131
136
  },
132
137
  ],
138
+ }, mistral: {
139
+ // Vibe's aliases, which are what `session/set_config_option` takes (and what `--model` gets).
140
+ models: [
141
+ { value: "mistral-medium-3.5", label: "Mistral Medium 3.5", description: "Vibe's own default (mistral-vibe-cli-latest)", default: true, params: { thinking: ["off", "high"] }, paramDefaults: { thinking: "high" } },
142
+ { value: "mistral-large-4-0-3", label: "Mistral Large 4", description: "mistral-large-4-0 — rolling out as Vibe's default", params: { thinking: ["low", "medium", "high"] }, paramDefaults: { thinking: "high" } },
143
+ { value: "glm-5-3", label: "GLM-5.3", description: "zai-glm-5-3, hosted by Mistral", params: { thinking: ["low", "high", "max"] }, paramDefaults: { thinking: "high" } },
144
+ { value: "zai-glm-5-3-flash", label: "GLM-5.3 Flash", description: "zai-glm-5-3-flash, hosted by Mistral", params: { thinking: ["low", "high", "max"] }, paramDefaults: { thinking: "high" } },
145
+ ],
146
+ params: [
147
+ {
148
+ id: "thinking",
149
+ label: "Thinking",
150
+ description: "How much the model reasons before it acts. Unset = the level Vibe last used for the model.",
151
+ values: [
152
+ { value: "off", label: "Off" },
153
+ { value: "low", label: "Low" },
154
+ { value: "medium", label: "Medium" },
155
+ { value: "high", label: "High" },
156
+ { value: "max", label: "Max" },
157
+ ],
158
+ },
159
+ ],
133
160
  },
134
161
  }
135
162
 
@@ -4,7 +4,8 @@
4
4
  // Each one lays down, under /opt/spunto/agents:
5
5
  // node/ a private Node — the runner must not depend on the project's (absent from a
6
6
  // Python image, swapped by an nvm feature that installs after this one)
7
- // <id>/ the harness's official SDK, pinned, with the native binary it drives
7
+ // <id>/ the harness's official SDK, pinned, with the native binary it drives — or, for
8
+ // a harness with no Node SDK (Mistral Vibe, Python), the harness itself, pinned
8
9
  // /usr/local/bin/ the harness's CLI for the humans in the terminal, unless the image has one
9
10
 
10
11
  /** Where every agent feature installs. */
@@ -13,7 +14,18 @@ export const AGENT_HOME = "/opt/spunto/agents"
13
14
  export const AGENT_NODE_VERSION = "v24.11.1"
14
15
  const NODE_VERSION = AGENT_NODE_VERSION
15
16
 
16
- function featureScript(p: { id: string; label: string; packages: string; cli: string }): string {
17
+ /** The pinned Mistral Vibe release, and the uv that installs it (with its own Python). */
18
+ export const MISTRAL_VIBE_VERSION = "3.0.4"
19
+ const UV_VERSION = "0.13.6"
20
+
21
+ /** The SDK, from npm, into `<id>/` — what both Node harnesses install. */
22
+ function npmInstall(id: string, packages: string): string {
23
+ return `mkdir -p "$HOME_DIR/${id}"
24
+ PATH="$NODE_DIR/bin:$PATH" "$NODE_DIR/bin/npm" install --prefix "$HOME_DIR/${id}" \\
25
+ --omit=dev --no-audit --no-fund --no-update-notifier --loglevel=error ${packages}`
26
+ }
27
+
28
+ function featureScript(p: { id: string; label: string; install: string; cli: string }): string {
17
29
  return `#!/bin/sh
18
30
  set -e
19
31
  echo "[feature] ${p.id}: installing ${p.label}..."
@@ -42,9 +54,7 @@ if ! "$NODE_DIR/bin/node" -e 'process.exit(0)' 2>/dev/null; then
42
54
  rm -f /tmp/_spunto_node.tgz
43
55
  fi
44
56
 
45
- mkdir -p "$HOME_DIR/${p.id}"
46
- PATH="$NODE_DIR/bin:$PATH" "$NODE_DIR/bin/npm" install --prefix "$HOME_DIR/${p.id}" \\
47
- --omit=dev --no-audit --no-fund --no-update-notifier --loglevel=error ${p.packages}
57
+ ${p.install}
48
58
  chmod -R a+rX "$HOME_DIR"
49
59
 
50
60
  ${p.cli}
@@ -57,7 +67,7 @@ export const AGENT_FEATURE_SCRIPTS: Record<string, string> = {
57
67
  "spunto-agent-claude.sh": featureScript({
58
68
  id: "claude",
59
69
  label: "the Claude Agent SDK",
60
- packages: "@anthropic-ai/claude-agent-sdk@0.3.310",
70
+ install: npmInstall("claude", "@anthropic-ai/claude-agent-sdk@0.3.310"),
61
71
  // The SDK drives a native \`claude\` binary from a platform package; the same binary is the CLI.
62
72
  cli: `if ! command -v claude >/dev/null 2>&1; then
63
73
  for b in "$HOME_DIR"/claude/node_modules/@anthropic-ai/claude-agent-sdk-linux-*/claude; do
@@ -68,10 +78,32 @@ fi`,
68
78
  "spunto-agent-codex.sh": featureScript({
69
79
  id: "codex",
70
80
  label: "the Codex SDK",
71
- packages: "@openai/codex-sdk@0.164.0",
81
+ install: npmInstall("codex", "@openai/codex-sdk@0.164.0"),
72
82
  cli: `if ! command -v codex >/dev/null 2>&1; then
73
83
  printf '#!/bin/sh\\nexec %s %s "$@"\\n' "$NODE_DIR/bin/node" "$HOME_DIR/codex/node_modules/@openai/codex/bin/codex.js" > /usr/local/bin/codex
74
84
  chmod 755 /usr/local/bin/codex
75
85
  fi`,
76
86
  }),
87
+ // Mistral Vibe has no Node SDK: the runner speaks ACP to `vibe-acp` itself (`../mistral.mjs`).
88
+ // Vibe is Python (≥ 3.12); uv installs it with a Python of its own, so the image's — absent, or
89
+ // too old — never matters, the way the private Node keeps the project's out of the runner.
90
+ "spunto-agent-mistral.sh": featureScript({
91
+ id: "mistral",
92
+ label: "Mistral Vibe",
93
+ install: `MISTRAL_DIR=$HOME_DIR/mistral
94
+ mkdir -p "$MISTRAL_DIR"
95
+ if ! "$MISTRAL_DIR/uv/uv" --version 2>/dev/null | grep -q "${UV_VERSION}"; then
96
+ echo "[feature] mistral: fetching uv ${UV_VERSION}"
97
+ fetch "https://astral.sh/uv/${UV_VERSION}/install.sh" /tmp/_spunto_uv_install.sh
98
+ UV_INSTALL_DIR="$MISTRAL_DIR/uv" UV_NO_MODIFY_PATH=1 sh /tmp/_spunto_uv_install.sh >/dev/null
99
+ rm -f /tmp/_spunto_uv_install.sh
100
+ fi
101
+ UV_PYTHON_INSTALL_DIR="$MISTRAL_DIR/python" UV_TOOL_DIR="$MISTRAL_DIR/tools" UV_TOOL_BIN_DIR="$MISTRAL_DIR/bin" \\
102
+ UV_CACHE_DIR=/tmp/_spunto_uv_cache UV_NO_CONFIG=1 \\
103
+ "$MISTRAL_DIR/uv/uv" tool install --force --managed-python --python 3.13 "mistral-vibe==${MISTRAL_VIBE_VERSION}"
104
+ rm -rf /tmp/_spunto_uv_cache`,
105
+ cli: `for b in vibe vibe-acp; do
106
+ if ! command -v "$b" >/dev/null 2>&1; then ln -sf "$HOME_DIR/mistral/bin/$b" "/usr/local/bin/$b"; fi
107
+ done`,
108
+ }),
77
109
  }
@@ -7,8 +7,8 @@
7
7
  * installs a private Node and the harness's **official SDK** (with the binary the SDK drives)
8
8
  * under `/opt/spunto/agents/<id>`. Shipped by the platform itself (`localScript`), so an app
9
9
  * never waits on a registry release;
10
- * - a **runner**, a `.mjs` entry of ours (`claude.mjs`, `codex.mjs`) with its harness's adapter
11
- * next to it (`claude/adapter.ts`, `codex/adapter.ts`). The caller **bundles** the two into one
10
+ * - a **runner**, a `.mjs` entry of ours (`claude.mjs`, `codex.mjs`, `mistral.mjs`) with its harness's
11
+ * adapter next to it (`claude/adapter.ts`, `codex/adapter.ts`, `mistral/adapter.ts`). The caller **bundles** the two into one
12
12
  * file (esbuild, `platform: node`, `format: esm`) and writes it into the machine at the moment a
13
13
  * session starts. Not baked: a runner fix reaches the next session, not the next rebuild;
14
14
  * - the **Spunto agent contract** (`agent-v1`, `../agent-contract`), which is how the platform
@@ -35,7 +35,7 @@ import { AGENT_HOME } from "./features"
35
35
  /** The Node every runner runs on — the private one the features install. */
36
36
  export const AGENT_NODE = `${AGENT_HOME}/node/bin/node`
37
37
 
38
- export type AgentHarnessId = "claude" | "codex"
38
+ export type AgentHarnessId = "claude" | "codex" | "mistral"
39
39
 
40
40
  export type AgentHarness = {
41
41
  id: AgentHarnessId
@@ -45,7 +45,8 @@ export type AgentHarness = {
45
45
  protocol: AgentProtocol
46
46
  /**
47
47
  * What this app's runner printed before the contract, and so how a session it started then is
48
- * still read, until it ends (RFC 0048 § 9: a session keeps the dialect it began on).
48
+ * still read, until it ends (RFC 0048 § 9: a session keeps the dialect it began on). An app born
49
+ * on the contract (Mistral) has no such past: `agent-v1`.
49
50
  */
50
51
  legacyProtocol: AgentProtocol
51
52
  /** The runner's entry, a file of this package — to be bundled with what it imports. */
@@ -71,6 +72,14 @@ export const AGENT_HARNESSES: Record<AgentHarnessId, AgentHarness> = {
71
72
  runnerFile: new URL("./codex.mjs", import.meta.url),
72
73
  skillDirs: ["${CODEX_HOME:-$HOME/.codex}/skills"],
73
74
  },
75
+ mistral: {
76
+ id: "mistral",
77
+ featureId: "spunto-agent-mistral",
78
+ protocol: "agent-v1",
79
+ legacyProtocol: "agent-v1",
80
+ runnerFile: new URL("./mistral.mjs", import.meta.url),
81
+ skillDirs: ["${VIBE_HOME:-$HOME/.vibe}/skills"],
82
+ },
74
83
  }
75
84
 
76
85
  /** The variable a runner's text travels in, base64 — see `runnerCommand`. */
@@ -102,6 +111,6 @@ export function harnessOfFeature(featureId: string): AgentHarness | null {
102
111
  return Object.values(AGENT_HARNESSES).find((h) => h.featureId === featureId) ?? null
103
112
  }
104
113
 
105
- export { AGENT_FEATURE_SCRIPTS, AGENT_HOME, AGENT_NODE_VERSION } from "./features"
114
+ export { AGENT_FEATURE_SCRIPTS, AGENT_HOME, AGENT_NODE_VERSION, MISTRAL_VIBE_VERSION } from "./features"
106
115
  export { AGENT_CATALOGS, MCP_ENV, OPTIONS_ENV, STREAM_ENV, agentOptionsError } from "./catalogs"
107
116
  export type { AgentCatalog, AgentMcpServer, AgentModel, AgentParam, AgentParamValue } from "./catalogs"
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Mistral Vibe's dialect, read into our vocabulary — the **Agent Client Protocol** (ACP) that
3
+ * `vibe-acp` speaks on stdio: the `session/update` notifications of a turn, and the answer to its
4
+ * `session/prompt`. Written against what Vibe 3.0.4 printed on 2026-10-31, against the real API
5
+ * (`test/fixtures/fake-vibe-acp.mjs` replays those messages, shortened, never reshaped).
6
+ *
7
+ * Lives with the Mistral runner (RFC 0048 § 9): the runner bundles it and prints the contract.
8
+ *
9
+ * What ACP is like, and why the translator keeps state:
10
+ *
11
+ * - text arrives in **chunks** (`agent_message_chunk`, `agent_thought_chunk`), each naming the
12
+ * message it belongs to (`messageId`). A finished `message` / `thinking` is printed when the
13
+ * message is over — another one starts, a tool is called, the turn ends — with the chunks joined
14
+ * as its `source`. Under `SPUNTO_AGENT_STREAM`, each chunk is also a `message.delta`;
15
+ * - a tool is a `tool_call` (`in_progress`, with its `rawInput`) then a `tool_call_update` that
16
+ * settles it (`completed` / `failed`) — the pair is our `tool.call` / `tool.result`;
17
+ * - usage is restated by Vibe after every step (`usage_update`: how full the window is, how large
18
+ * it is, the session's cost), and the turn's answer carries the **session's** running token
19
+ * totals (measured: the second turn's `inputTokens` include the first's, a resumed session's
20
+ * too). So it is one `usage` of scope `session` per turn, the latest winning;
21
+ * - the turn's end is the answer to `session/prompt`: a `stopReason`, or a JSON-RPC error.
22
+ */
23
+
24
+ import { clip } from "../../agent-contract/vocabulary"
25
+
26
+ type Obj = Record<string, unknown>
27
+ export type ContractLine = Record<string, unknown>
28
+
29
+ function obj(value: unknown): Obj {
30
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Obj) : {}
31
+ }
32
+
33
+ function num(value: unknown): number | null {
34
+ return typeof value === "number" && Number.isFinite(value) ? value : null
35
+ }
36
+
37
+ function str(value: unknown): string | null {
38
+ return typeof value === "string" && value ? value : null
39
+ }
40
+
41
+ /** The text of an ACP tool's `content` (its `content` blocks of text; diffs are the call's, not its output). */
42
+ function contentText(content: unknown): string {
43
+ if (!Array.isArray(content)) return ""
44
+ return content
45
+ .map((c) => obj(c))
46
+ .filter((c) => c.type === "content" && obj(c.content).type === "text")
47
+ .map((c) => String(obj(c.content).text ?? ""))
48
+ .join("\n")
49
+ }
50
+
51
+ /** A tool's name as the agent knows it: Vibe's own (`bash`, `write_file`…), else ACP's kind. */
52
+ function toolName(update: Obj): string {
53
+ return str(obj(update._meta).tool_name) ?? str(update.kind) ?? str(update.title) ?? "tool"
54
+ }
55
+
56
+ /** Vibe's application error codes (`vibe/acp/exceptions.py`) the contract has a word for. */
57
+ const RATE_LIMITED = -31001
58
+
59
+ export type MistralTranslator = {
60
+ /** One `session/update` notification's `update`. */
61
+ update(update: Obj): ContractLine[]
62
+ /** The answer to `session/prompt` — `{ result }` or `{ error }` — which ends the turn. */
63
+ ended(response: Obj): ContractLine[]
64
+ /** What Vibe said of the window last: how full, how large (for `session.started`). */
65
+ readonly window: { used: number | null; size: number | null }
66
+ /** The session's model, once the runner has set it — named on each turn's `usage`. */
67
+ model: string | null
68
+ /** The human's turn, as sent: Vibe's first "title" is a copy of it, said before the echo. */
69
+ prompted(text: string): void
70
+ }
71
+
72
+ export function mistralTranslator(opts: { stream?: boolean } = {}): MistralTranslator {
73
+ /** The text being written: one message (or thought) at a time, by `messageId`. */
74
+ let open: { kind: "message" | "thinking"; id: string | null; text: string; update: Obj } | null = null
75
+ /** Tool calls already settled — an update after the result says nothing new. */
76
+ const settled = new Set<string>()
77
+ /** The human's own words this session, to tell Vibe's own title from a copy of the prompt. */
78
+ const said: string[] = []
79
+ let lastTitle: string | null = null
80
+ const window = { used: null as number | null, size: null as number | null }
81
+ let costUsd: number | null = null
82
+
83
+ function flush(): ContractLine[] {
84
+ if (!open) return []
85
+ const { kind, text, update } = open
86
+ open = null
87
+ const clipped = clip(text)
88
+ if (!clipped.text.trim()) return []
89
+ // The chunks joined: what Vibe wrote, in one piece — the event's own words.
90
+ const source = { ...update, content: { type: "text", text } }
91
+ return kind === "message"
92
+ ? [{ type: "message", role: "assistant", text: clipped.text, truncated: clipped.truncated, source }]
93
+ : [{ type: "thinking", text: clipped.text, truncated: clipped.truncated, source }]
94
+ }
95
+
96
+ function chunk(kind: "message" | "thinking", update: Obj): ContractLine[] {
97
+ const text = typeof obj(update.content).text === "string" ? (obj(update.content).text as string) : ""
98
+ const id = str(update.messageId)
99
+ const out: ContractLine[] = []
100
+ const fresh = !open || open.kind !== kind || open.id !== id
101
+ if (fresh) {
102
+ out.push(...flush())
103
+ open = { kind, id, text: "", update }
104
+ }
105
+ open!.text += text
106
+ if (kind === "message" && opts.stream && text) out.push({ type: "message.delta", text, ...(fresh ? { start: true } : {}) })
107
+ return out
108
+ }
109
+
110
+ function result(update: Obj, id: string): ContractLine[] {
111
+ settled.add(id)
112
+ const raw = update.rawOutput
113
+ const text = contentText(update.content) || (typeof raw === "string" ? raw : raw === undefined ? "" : JSON.stringify(raw))
114
+ const { text: output, truncated } = clip(text)
115
+ return [{ type: "tool.result", callId: id, isError: update.status === "failed", output, truncated, source: update }]
116
+ }
117
+
118
+ const self: MistralTranslator = {
119
+ window,
120
+ model: null,
121
+ prompted(text) {
122
+ said.push(text)
123
+ },
124
+ update(update) {
125
+ switch (update.sessionUpdate) {
126
+ case "agent_message_chunk":
127
+ return chunk("message", update)
128
+ case "agent_thought_chunk":
129
+ return chunk("thinking", update)
130
+ case "user_message_chunk": {
131
+ // The human's own turn, echoed: the platform already has it. Kept to read the title by.
132
+ const text = obj(update.content).text
133
+ if (typeof text === "string") said.push(text)
134
+ return []
135
+ }
136
+ case "tool_call": {
137
+ const id = str(update.toolCallId)
138
+ const { text: input, truncated } = clip(update.rawInput ?? { title: update.title })
139
+ const out: ContractLine[] = [...flush(), { type: "tool.call", callId: id, name: toolName(update), input, truncated, source: update }]
140
+ if (id && (update.status === "completed" || update.status === "failed")) out.push(...result(update, id))
141
+ return out
142
+ }
143
+ case "tool_call_update": {
144
+ const id = str(update.toolCallId)
145
+ if (!id || settled.has(id) || (update.status !== "completed" && update.status !== "failed")) return []
146
+ return [...flush(), ...result(update, id)]
147
+ }
148
+ case "plan": {
149
+ const steps = (Array.isArray(update.entries) ? update.entries : [])
150
+ .map((e) => obj(e))
151
+ .map((e) => ({ text: clip(e.content).text, status: typeof e.status === "string" ? e.status : "pending" }))
152
+ .filter((s) => s.text)
153
+ return steps.length > 0 ? [...flush(), { type: "plan", steps, source: update }] : []
154
+ }
155
+ case "usage_update": {
156
+ window.used = num(update.used) ?? window.used
157
+ window.size = num(update.size) ?? window.size
158
+ const cost = obj(update.cost)
159
+ if (cost.currency === "USD" && num(cost.amount) !== null) costUsd = num(cost.amount)
160
+ return []
161
+ }
162
+ case "session_info_update": {
163
+ // Vibe names a session after the first prompt until it has a title of its own: only the
164
+ // latter is a name worth saying (the platform already has the prompt's).
165
+ // latter is a name worth saying (the platform already has the prompt's). Measured: the
166
+ // provisional one is the whole prompt, then « … ».
167
+ const title = str(update.title)?.replace(/\s+/g, " ").trim().slice(0, 200) ?? null
168
+ const stem = title?.replace(/(…|\.\.\.)$/, "").trim()
169
+ if (!title || !stem || title === lastTitle || said.some((s) => s.replace(/\s+/g, " ").trim().startsWith(stem))) return []
170
+ lastTitle = title
171
+ return [{ type: "session.title", title, source: update }]
172
+ }
173
+ // The session's slash commands, its mode, its options: Vibe's UI state, not the session's.
174
+ case "available_commands_update":
175
+ case "current_mode_update":
176
+ case "config_option_update":
177
+ return []
178
+ }
179
+ return [...flush(), { type: "raw", json: update }]
180
+ },
181
+ ended(response) {
182
+ const out = flush()
183
+ const error = response.error ? obj(response.error) : null
184
+ if (error) {
185
+ const message = clip(str(error.message) ?? "Mistral Vibe failed the turn.").text
186
+ const limit = error.code === RATE_LIMITED ? { kind: "rate", status: 429, message, resetsAt: null } : null
187
+ out.push({ type: "session.ended", status: "failed", stopReason: null, summary: message, ...(limit ? { limit } : {}), source: response })
188
+ return out
189
+ }
190
+ const res = obj(response.result)
191
+ const usage = obj(res.usage)
192
+ if (Object.keys(usage).length > 0 || window.used !== null) {
193
+ out.push({
194
+ type: "usage",
195
+ scope: "session",
196
+ model: self.model,
197
+ inputTokens: num(usage.inputTokens),
198
+ outputTokens: num(usage.outputTokens),
199
+ cacheReadTokens: num(usage.cachedReadTokens),
200
+ contextTokens: window.used,
201
+ contextWindow: window.size,
202
+ costUsd,
203
+ source: response,
204
+ })
205
+ }
206
+ const stopReason = str(res.stopReason)
207
+ if (stopReason === "end_turn") {
208
+ out.push({ type: "session.ended", status: "succeeded", stopReason, summary: "", source: response })
209
+ } else {
210
+ // `cancelled` (an interrupt), `max_tokens`, `max_turn_requests`, `refusal`: the turn did
211
+ // not finish its work, whatever the reason — the same reading as Claude's and Codex's.
212
+ const summary = stopReason === "cancelled" ? "Interrupted." : `Mistral Vibe stopped the turn (${stopReason ?? "no reason given"}).`
213
+ out.push({ type: "session.ended", status: "failed", stopReason, summary, source: response })
214
+ }
215
+ return out
216
+ },
217
+ }
218
+ return self
219
+ }
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+ // The Mistral runner (RFC 0048): same contract as `claude.mjs` and `codex.mjs`, over **Mistral
3
+ // Vibe**, which has no Node SDK — so the runner is an ACP client (Agent Client Protocol, JSON-RPC
4
+ // 2.0 on stdio) of `vibe-acp`, the binary the `spunto-agent-mistral` feature installs. Bundled
5
+ // with its adapter by the platform, like every runner.
6
+ //
7
+ // node mistral.mjs run [--model <alias>] [--resume <session id>]
8
+ // stdin {"type":"user","text"} · {"type":"interrupt","id"} · {"type":"title",…} (ignored:
9
+ // Vibe names its sessions itself, and the runner says so when it does) ; closed = the
10
+ // session ends after its turn
11
+ // stdout **our** vocabulary (`agent-v1`): each `session/update` and each turn's answer read
12
+ // here (`./mistral/adapter.ts`), with the ACP message itself as `source`
13
+ // env SPUNTO_AGENT_OPTIONS — the task's parameters (`./catalogs.ts`): `{"thinking":"high"}`
14
+ // SPUNTO_AGENT_MCP — the org's MCP servers, through the platform's gateway (RFC 0049)
15
+ // SPUNTO_AGENT_STREAM — "1": also print each chunk of a message as it is written
16
+ // exit 0 once the input is closed and the last turn is over, 1 when the session could not run
17
+ //
18
+ // One `vibe-acp` process is the whole session: it stays alive between turns, a message sent during
19
+ // a turn waits for it to end (ACP runs one prompt at a time), and an interrupt is ACP's own
20
+ // `session/cancel` — the tool in flight is stopped, the turn ends `cancelled`, the session listens.
21
+ //
22
+ // Credentials: `MISTRAL_API_KEY` as a secret, read by Vibe itself — or a `vibe --setup` done by
23
+ // hand in the machine's terminal, which Vibe keeps in `~/.vibe`.
24
+
25
+ import { spawn } from "node:child_process"
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
27
+ import { homedir } from "node:os"
28
+ import { join } from "node:path"
29
+ import { createInterface } from "node:readline"
30
+ import { mistralTranslator } from "./mistral/adapter"
31
+
32
+ const ACP = process.env.SPUNTO_VIBE_ACP || "/opt/spunto/agents/mistral/bin/vibe-acp"
33
+
34
+ function out(obj) {
35
+ process.stdout.write(`${JSON.stringify(obj)}\n`)
36
+ }
37
+
38
+ /** A failure the platform can read: the turn ends, failed, saying why — then a non-zero exit. */
39
+ function fail(message) {
40
+ out({ type: "session.ended", status: "failed", stopReason: null, summary: message })
41
+ child?.kill()
42
+ process.exit(1)
43
+ }
44
+
45
+ function parseArgs(argv) {
46
+ const [verb, ...rest] = argv
47
+ if (verb !== "run") fail(`mistral runner: unknown verb "${verb ?? ""}" (expected "run")`)
48
+ const args = {}
49
+ for (let i = 0; i < rest.length; i++) {
50
+ const flag = rest[i]
51
+ if (flag === "--model" || flag === "--resume") {
52
+ const value = rest[++i]
53
+ if (value) args[flag.slice(2)] = value
54
+ } else {
55
+ fail(`mistral runner: unknown flag "${flag}"`)
56
+ }
57
+ }
58
+ return args
59
+ }
60
+
61
+ let child = null
62
+ const args = parseArgs(process.argv.slice(2))
63
+
64
+ /** The task's parameters. Checked by the platform against the catalog; read defensively all the same. */
65
+ function readOptions() {
66
+ try {
67
+ const parsed = JSON.parse(process.env.SPUNTO_AGENT_OPTIONS || "{}")
68
+ return parsed && typeof parsed === "object" ? parsed : {}
69
+ } catch {
70
+ return {}
71
+ }
72
+ }
73
+ const params = readOptions()
74
+
75
+ /**
76
+ * The org's MCP servers (RFC 0049), as ACP's `mcpServers`: Streamable HTTP servers on the
77
+ * platform's gateway, with the machine's identity as the bearer — never the vendor's token.
78
+ */
79
+ function readMcpServers() {
80
+ let listed
81
+ try {
82
+ listed = JSON.parse(process.env.SPUNTO_AGENT_MCP || "[]")
83
+ } catch {
84
+ return []
85
+ }
86
+ const api = (process.env.SPUNTO_API_URL || "").replace(/\/$/, "")
87
+ const token = process.env.SPUNTO_WORKER_TOKEN
88
+ if (!Array.isArray(listed) || !api || !token) return []
89
+ return listed
90
+ .filter((s) => s && typeof s.name === "string" && typeof s.path === "string")
91
+ .map((s) => ({ type: "http", name: s.name, url: `${api}${s.path}`, headers: [{ name: "Authorization", value: `Bearer ${token}` }] }))
92
+ }
93
+
94
+ /**
95
+ * Trust the task's checkout, as Vibe's own prompt would on a first run: an untrusted folder's
96
+ * `AGENTS.md`, `.vibe/` config and skills are ignored — the session would not behave like the CLI
97
+ * in that repository. Nobody is there to answer the prompt, and the machine is the task's.
98
+ */
99
+ function trustCwd(cwd) {
100
+ try {
101
+ const home = process.env.VIBE_HOME || join(homedir(), ".vibe")
102
+ const file = join(home, "trusted_folders.toml")
103
+ const text = existsSync(file) ? readFileSync(file, "utf8") : ""
104
+ const entry = JSON.stringify(cwd)
105
+ if (text.includes(entry)) return
106
+ mkdirSync(home, { recursive: true })
107
+ const list = /^trusted\s*=\s*\[/m
108
+ writeFileSync(file, list.test(text) ? text.replace(list, (m) => `${m}${entry}, `) : `trusted = [${entry}]\n${text}`)
109
+ } catch (err) {
110
+ process.stderr.write(`mistral runner: could not trust ${cwd}: ${err?.message ?? err}\n`)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * A session being resumed is the platform's to hold, so any `vibe-acp` still holding it is let go.
116
+ * Measured: after the API restarted, it resumed a task's session in a new runner while the old one
117
+ * still ran — and Vibe opens a session once (an OS lock, `logs/session/active/<id>.lock`), so the
118
+ * new one failed on "Session is already open". The holder is named in the lease's diagnostic file;
119
+ * it is stopped only if it is a Vibe (a pid can be reused), and its runner exits with it.
120
+ */
121
+ async function releaseSession(id) {
122
+ const home = process.env.VIBE_HOME || join(homedir(), ".vibe")
123
+ const lease = join(home, "logs", "session", "active", `${id}.lock.json`)
124
+ let pid
125
+ try {
126
+ pid = JSON.parse(readFileSync(lease, "utf8")).process_id
127
+ } catch {
128
+ return
129
+ }
130
+ if (!Number.isInteger(pid) || pid === process.pid) return
131
+ try {
132
+ if (!readFileSync(`/proc/${pid}/cmdline`, "utf8").includes("vibe")) return
133
+ process.kill(pid, "SIGTERM")
134
+ } catch {
135
+ return // gone already
136
+ }
137
+ process.stderr.write(`mistral runner: stopped the Vibe (pid ${pid}) still holding session ${id}\n`)
138
+ for (let i = 0; i < 50; i++) {
139
+ try {
140
+ process.kill(pid, 0)
141
+ } catch {
142
+ return
143
+ }
144
+ await new Promise((resolve) => setTimeout(resolve, 100))
145
+ }
146
+ try {
147
+ process.kill(pid, "SIGKILL")
148
+ } catch {}
149
+ }
150
+
151
+ const cwd = process.cwd()
152
+ trustCwd(cwd)
153
+ if (args.resume) await releaseSession(args.resume)
154
+
155
+ // `vibe-acp`, or a test double of it (`.mjs`, run on this Node).
156
+ child = /\.m?js$/.test(ACP)
157
+ ? spawn(process.execPath, [ACP], { stdio: ["pipe", "pipe", "pipe"] })
158
+ : spawn(ACP, [], { stdio: ["pipe", "pipe", "pipe"] })
159
+
160
+ // Vibe logs freely on stderr; the tail is kept to say why, should it die.
161
+ let stderrTail = ""
162
+ child.stderr.on("data", (d) => {
163
+ stderrTail = (stderrTail + d).slice(-4000)
164
+ })
165
+ child.on("error", (err) => {
166
+ fail(`mistral runner: could not start Mistral Vibe at ${ACP} — is the project's image built with the Mistral app's feature? (${err?.message ?? err})`)
167
+ })
168
+ let closing = false
169
+ child.on("exit", (code) => {
170
+ if (closing) return
171
+ const tail = stderrTail.trim().split("\n").slice(-5).join("\n")
172
+ fail(`mistral runner: Mistral Vibe exited (${code}) during the session${tail ? `: ${tail}` : ""}`)
173
+ })
174
+
175
+ // ── JSON-RPC over the child's stdio ─────────────────────────────────────────────────────────────
176
+
177
+ let nextId = 0
178
+ const waiting = new Map()
179
+ /**
180
+ * `session/update`s are dropped until the first turn: while Vibe replays a loaded session's history
181
+ * (`session/load` — the platform has it), and while the runner sets the session up (a model change
182
+ * is announced as a pseudo tool call, `checkpoint-model-change`, which no one asked for).
183
+ */
184
+ let quiet = true
185
+ const translate = mistralTranslator({ stream: process.env.SPUNTO_AGENT_STREAM === "1" })
186
+
187
+ function send(message) {
188
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`)
189
+ }
190
+
191
+ /** A request; resolves with the whole response, `{ result }` or `{ error }`. */
192
+ function request(method, params) {
193
+ const id = ++nextId
194
+ send({ id, method, params })
195
+ return new Promise((resolve) => waiting.set(id, resolve))
196
+ }
197
+
198
+ /** Vibe asking the client: permissions are granted (nobody is there to answer), the rest refused. */
199
+ function answer(message) {
200
+ if (message.method === "session/request_permission") {
201
+ const options = Array.isArray(message.params?.options) ? message.params.options : []
202
+ const pick = options.find((o) => o.kind === "allow_always") ?? options.find((o) => o.kind === "allow_once")
203
+ send({ id: message.id, result: { outcome: pick ? { outcome: "selected", optionId: pick.optionId } : { outcome: "cancelled" } } })
204
+ return
205
+ }
206
+ send({ id: message.id, error: { code: -32601, message: `${message.method} is not offered by the Spunto runner` } })
207
+ }
208
+
209
+ createInterface({ input: child.stdout, crlfDelay: Infinity }).on("line", (line) => {
210
+ let message
211
+ try {
212
+ message = JSON.parse(line)
213
+ } catch {
214
+ return
215
+ }
216
+ if (!message || typeof message !== "object") return
217
+ if (message.method && message.id !== undefined && message.id !== null) return answer(message)
218
+ if (message.method === "session/update") {
219
+ const update = message.params?.update
220
+ // Only the window's figures are kept from the setup: they are the session's own.
221
+ if (update && (!quiet || update.sessionUpdate === "usage_update")) for (const l of translate.update(update)) out(l)
222
+ return
223
+ }
224
+ if (message.method) return // other notifications (`_vibe/…` extensions): Vibe's UI, not the session
225
+ const resolve = waiting.get(message.id)
226
+ if (resolve) {
227
+ waiting.delete(message.id)
228
+ resolve(message)
229
+ }
230
+ })
231
+
232
+ // ── The session ─────────────────────────────────────────────────────────────────────────────────
233
+
234
+ const init = await request("initialize", {
235
+ protocolVersion: 1,
236
+ // No file system, no terminal: Vibe uses its own tools in the machine, where the task's work is.
237
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
238
+ clientInfo: { name: "spunto", title: "Spunto", version: "agent-v1" },
239
+ })
240
+ if (init.error) fail(`mistral runner: Mistral Vibe refused to start: ${init.error.message}`)
241
+
242
+ const mcpServers = readMcpServers()
243
+ let sessionId = args.resume ?? null
244
+ let opened
245
+ if (args.resume) {
246
+ opened = await request("session/load", { sessionId: args.resume, cwd, mcpServers })
247
+ } else {
248
+ opened = await request("session/new", { cwd, mcpServers })
249
+ sessionId = opened.result?.sessionId ?? null
250
+ }
251
+ if (opened.error || !sessionId) {
252
+ const why = opened.error?.message ?? "no session id"
253
+ const hint = opened.error?.code === -32000 ? " — set MISTRAL_API_KEY in your secrets, or run `vibe --setup` in the machine's terminal" : ""
254
+ fail(`mistral runner: Mistral Vibe could not ${args.resume ? `resume session ${args.resume}` : "open a session"}: ${why}${hint}`)
255
+ }
256
+
257
+ /** The current value of one of the session's options (`model`, `thinking`, `mode`). */
258
+ let configOptions = opened.result?.configOptions ?? []
259
+ const optionValue = (id) => configOptions.find((o) => o?.id === id)?.currentValue ?? null
260
+
261
+ async function setOption(configId, value, what) {
262
+ const res = await request("session/set_config_option", { sessionId, configId, value })
263
+ if (res.error) {
264
+ const choices = configOptions.find((o) => o?.id === configId)?.options?.map((o) => o.value) ?? []
265
+ fail(`mistral runner: ${what} "${value}" is not available here${choices.length ? ` — one of ${choices.join(", ")}` : ""} (${res.error.message})`)
266
+ }
267
+ configOptions = res.result?.configOptions ?? configOptions
268
+ }
269
+
270
+ // A task runs unattended in a disposable machine: every tool call approved, what
271
+ // `bypassPermissions` is to Claude. Vibe's own name for it.
272
+ await setOption("mode", "auto-approve", "Mode")
273
+ if (args.model && optionValue("model") !== args.model) await setOption("model", args.model, "Model")
274
+ // Vibe keeps a model's thinking level in its config: set on every session that asks for one.
275
+ if (typeof params.thinking === "string" && params.thinking) await setOption("thinking", params.thinking, "Thinking")
276
+
277
+ const model = optionValue("model") ?? args.model ?? null
278
+ translate.model = model
279
+
280
+ let announced = false
281
+ function announce() {
282
+ if (announced) return
283
+ announced = true
284
+ quiet = false
285
+ out({ type: "session.started", harness: "mistral", model, sessionId, cwd, contextWindow: translate.window.size })
286
+ }
287
+
288
+ const pending = []
289
+ let closed = false
290
+ let inFlight = false
291
+ let wake = null
292
+
293
+ createInterface({ input: process.stdin, crlfDelay: Infinity })
294
+ .on("line", (line) => {
295
+ let msg
296
+ try {
297
+ msg = JSON.parse(line)
298
+ } catch {
299
+ return
300
+ }
301
+ if (!msg || typeof msg !== "object") return
302
+ if (msg.type === "user" && typeof msg.text === "string") {
303
+ pending.push(msg.text)
304
+ wake?.()
305
+ } else if (msg.type === "interrupt" && inFlight) {
306
+ cancel()
307
+ }
308
+ })
309
+ .on("close", () => {
310
+ closed = true
311
+ wake?.()
312
+ })
313
+
314
+ /**
315
+ * ACP's cancel, a notification. Said again every second until the turn answers: one that lands
316
+ * before Vibe has started the turn would otherwise be lost, and the turn would run to its end.
317
+ */
318
+ function cancel() {
319
+ const again = () => {
320
+ if (!inFlight) return clearInterval(timer)
321
+ send({ method: "session/cancel", params: { sessionId } })
322
+ }
323
+ const timer = setInterval(again, 1000)
324
+ again()
325
+ }
326
+
327
+ for (;;) {
328
+ if (pending.length === 0) {
329
+ if (closed) break
330
+ await new Promise((resolve) => (wake = resolve))
331
+ wake = null
332
+ continue
333
+ }
334
+ const text = pending.shift()
335
+ announce()
336
+ translate.prompted(text)
337
+ inFlight = true
338
+ const response = await request("session/prompt", { sessionId, prompt: [{ type: "text", text }] })
339
+ inFlight = false
340
+ for (const l of translate.ended(response)) out(l)
341
+ }
342
+
343
+ closing = true
344
+ child.stdin.end()
345
+ await new Promise((resolve) => {
346
+ const timer = setTimeout(() => {
347
+ child.kill()
348
+ resolve()
349
+ }, 5000)
350
+ child.once("exit", () => {
351
+ clearTimeout(timer)
352
+ resolve()
353
+ })
354
+ })
355
+ process.exit(0)
@@ -225,6 +225,13 @@ export const AVAILABLE_FEATURES: DevFeature[] = [
225
225
  description: "The Codex SDK and the Node its runner uses — injected by the Codex app",
226
226
  injected: true,
227
227
  },
228
+ {
229
+ id: "spunto-agent-mistral",
230
+ label: "Mistral Vibe (agent app)",
231
+ localScript: "spunto-agent-mistral.sh",
232
+ description: "Mistral Vibe (with its own Python) and the Node its runner uses — injected by the Mistral app",
233
+ injected: true,
234
+ },
228
235
  {
229
236
  // Always installed, whether or not it's picked here (lib/setup-script.ts § the two features
230
237
  // every worker image is made of): it's what creates the `vscode` user. Listed anyway so a
@@ -447,6 +447,15 @@ function buildFeatureInstallScript(ociRef: string, options?: Record<string, stri
447
447
  function buildLocalFeatureInstallScript(featureId: string, scriptFile: string): string | null {
448
448
  const scriptContent = localFeatureScript(scriptFile)
449
449
  if (!scriptContent) return null
450
+ return buildInlineFeatureInstallScript(featureId, scriptContent)
451
+ }
452
+
453
+ /**
454
+ * A feature whose install script the caller hands over as text — embedded in the recipe like a
455
+ * local one. What an app that serves its own feature gives (Spunto IDE's `ide.feature`, RFC 0059):
456
+ * no registry in between, and the recipe's text, so its key, moves with the script.
457
+ */
458
+ function buildInlineFeatureInstallScript(featureId: string, scriptContent: string): string {
450
459
  const encoded = b64(scriptContent)
451
460
  return [
452
461
  `echo "[feature] ${featureId}: running local install script..."`,
@@ -468,11 +477,15 @@ function buildLocalFeatureInstallScript(featureId: string, scriptFile: string):
468
477
  export type ResolvedFeature = { id: string; script: string; ociRef?: string }
469
478
 
470
479
  function resolveFeatures(
471
- features: { id: string; ociRef?: string; options?: Record<string, string> }[],
480
+ features: { id: string; ociRef?: string; options?: Record<string, string>; script?: string }[],
472
481
  warnId?: string,
473
482
  ): ResolvedFeature[] {
474
483
  const resolved: ResolvedFeature[] = []
475
484
  for (const f of features) {
485
+ if (f.script) {
486
+ resolved.push({ id: f.id, script: buildInlineFeatureInstallScript(f.id, f.script) })
487
+ continue
488
+ }
476
489
  const known = AVAILABLE_FEATURES.find((af) => af.id === f.id)
477
490
  if (known?.localScript) {
478
491
  const script = buildLocalFeatureInstallScript(f.id, known.localScript)
@@ -555,8 +568,14 @@ function commonUtilsOptions(): Record<string, string> {
555
568
  * Not strict: a base image where tmux won't install must still produce a usable worker, exactly as
556
569
  * the best-effort blocks it replaces did.
557
570
  */
558
- function spuntoPackOptions(params: { sshGatewayPublicKey?: string }): Record<string, string> {
559
- return { installSshd: params.sshGatewayPublicKey ? "true" : "false" }
571
+ function spuntoPackOptions(params: { sshGatewayPublicKey?: string; codeServerInPack?: boolean }): Record<string, string> {
572
+ return {
573
+ installSshd: params.sshGatewayPublicKey ? "true" : "false",
574
+ // A platform that installs the IDE apart (`spunto-ide`, Spunto's IDE app — RFC 0059) keeps the
575
+ // pack to its plumbing: tmux, dtach, sshd. Left out otherwise, so the recipe of a caller that
576
+ // never asks (spunto-lite) stays the same text.
577
+ ...(params.codeServerInPack === false ? { installCodeServer: "false" } : {}),
578
+ }
560
579
  }
561
580
 
562
581
  // The system-wide tmux configuration (mouse, OSC 52 clipboard, 50k scrollback, warm status bar)
@@ -689,11 +708,14 @@ export function defaultVscodeUserSettings(opts?: {
689
708
  * 1 → the hand-written blocks (vscode user, code-server, tmux, dtach, sshd).
690
709
  * 2 → common-utils + spunto-pack.
691
710
  * 3 → spunto-pack 1.1: the IDE wears Spunto's name, icons and themes (coderhammer/features).
711
+ * 4 → the IDE out of the pack (`codeServerInPack: false`): code-server comes from `spunto-ide`,
712
+ * the feature of an IDE app, and only in the image of a project that has one (RFC 0059).
692
713
  */
693
- export const IMAGE_RECIPE_VERSION = 3
714
+ export const IMAGE_RECIPE_VERSION = 4
694
715
 
695
716
  export function buildImageScript(params: {
696
- features: { id: string; ociRef?: string; options?: Record<string, string> }[]
717
+ /** `script`: the feature's install script itself, embedded as is (an app that serves its own). */
718
+ features: { id: string; ociRef?: string; options?: Record<string, string>; script?: string }[]
697
719
  vscodeExtensions?: string[]
698
720
  sshGatewayPublicKey?: string
699
721
  /** Project-level DinD opt-in (base image bakes Docker itself, no feature). See projects.dind. */
@@ -703,6 +725,13 @@ export function buildImageScript(params: {
703
725
  * `--install-extension` calls below resolve ids against. Absent/unusable = Open VSX.
704
726
  */
705
727
  extensionsGallery?: string | null
728
+ /**
729
+ * Whether spunto-pack installs code-server, as it does by default. `false`: the pack is plumbing
730
+ * only (tmux, dtach, sshd), and code-server comes from a feature of the caller's — Spunto's IDE
731
+ * app puts `spunto-ide` among `features` for a project that has an IDE (RFC 0059). Extensions are
732
+ * then installed only if one of the features brought a code-server.
733
+ */
734
+ codeServerInPack?: boolean
706
735
  }): { script: string; hasDinD: boolean; resolvedFeatures: ResolvedFeature[]; steps: BuildStep[] } {
707
736
  const lines: string[] = ["set -e"]
708
737
 
@@ -818,6 +847,18 @@ export function buildImageScript(params: {
818
847
  )
819
848
  }
820
849
  extLines.push('echo "[build] Extensions installed"')
850
+ // code-server from a feature of the caller's (`codeServerInPack: false`): said plainly when none
851
+ // brought it, rather than one "command not found" per extension.
852
+ if (params.codeServerInPack === false) {
853
+ extLines.splice(
854
+ 0,
855
+ 0,
856
+ "if ! command -v code-server >/dev/null 2>&1; then",
857
+ ' echo "[build] No code-server in this image (no IDE feature) — VS Code extensions skipped"',
858
+ "else",
859
+ )
860
+ extLines.push("fi")
861
+ }
821
862
  const n = params.vscodeExtensions.length
822
863
  block("extensions", "VS Code extensions", "extensions", `${n} extension${n > 1 ? "s" : ""} · ${registry}`, extLines)
823
864
  }
@@ -1229,6 +1270,14 @@ export type StartScriptParams = {
1229
1270
  * assume). For a box where 8080 is taken by the host itself — a Blaxel sandbox's own API.
1230
1271
  */
1231
1272
  idePort?: number
1273
+ /**
1274
+ * Whether the box has an IDE to start — code-server, as it always had. `false`: a project without
1275
+ * an IDE app (RFC 0059) — no extensions copied, no editor settings, no code-server loop; the
1276
+ * terminal (tmux/dtach, the agent's) is not the IDE's and stays.
1277
+ */
1278
+ ide?: boolean
1279
+ /** Settings the IDE app adds to the seeded defaults (`ide.config` → `userSettings`), over them. */
1280
+ ideUserSettings?: Record<string, unknown>
1232
1281
  }
1233
1282
 
1234
1283
  /** code-server's port when the caller doesn't say otherwise. */
@@ -1243,6 +1292,7 @@ function idePortOf(port: number | undefined): number {
1243
1292
  export function buildStartScript(params: StartScriptParams): { script: string; hasDinD: boolean } {
1244
1293
  const { project, sshGatewayPublicKey, colorTabsByProject, extensionsGallery } = params
1245
1294
  const idePort = idePortOf(params.idePort)
1295
+ const ide = params.ide !== false
1246
1296
  const homeDir = "/home/vscode"
1247
1297
  const username = "vscode"
1248
1298
 
@@ -1333,112 +1383,119 @@ export function buildStartScript(params: StartScriptParams): { script: string; h
1333
1383
  "done",
1334
1384
  )
1335
1385
 
1336
- // ── 4. Copy prebuilt extensions ───────────────────────────────────────────
1337
- push(
1338
- "",
1339
- 'if [ -d ' + EXTENSIONS_DIR + ' ] && [ "$(ls -A ' + EXTENSIONS_DIR + ' 2>/dev/null)" ]; then',
1340
- ` mkdir -p ${homeDir}/.local/share/code-server/extensions`,
1341
- ` cp -rn ${EXTENSIONS_DIR}/. ${homeDir}/.local/share/code-server/extensions/ 2>/dev/null || true`,
1342
- ` chown -R ${username}:${username} ${homeDir}/.local`,
1343
- "fi",
1344
- )
1386
+ // ── 4–5. The IDE: code-server, its extensions and settings (none without an IDE app) ───
1387
+ if (ide) {
1388
+ // ── 4. Copy prebuilt extensions ───────────────────────────────────────────
1389
+ push(
1390
+ "",
1391
+ 'if [ -d ' + EXTENSIONS_DIR + ' ] && [ "$(ls -A ' + EXTENSIONS_DIR + ' 2>/dev/null)" ]; then',
1392
+ ` mkdir -p ${homeDir}/.local/share/code-server/extensions`,
1393
+ ` cp -rn ${EXTENSIONS_DIR}/. ${homeDir}/.local/share/code-server/extensions/ 2>/dev/null || true`,
1394
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1395
+ "fi",
1396
+ )
1345
1397
 
1346
- // ── 4b. Seed code-server user settings (no-clobber, stays user-overridable) ─
1347
- // Reordered window.title + per-project window color make parallel worker tabs
1348
- // tellable apart at a glance (see defaultVscodeUserSettings). Written no-clobber
1349
- // (guarded on the file, not `cp -n`) so a user's own settings.json survives restarts.
1350
- const vscodeSettings = JSON.stringify(
1351
- defaultVscodeUserSettings({ projectName: project.name, colorByProject: colorTabsByProject }),
1352
- null,
1353
- 2,
1354
- )
1355
- push(
1356
- "",
1357
- `mkdir -p ${homeDir}/.local/share/code-server/User`,
1358
- `if [ ! -f ${homeDir}/.local/share/code-server/User/settings.json ]; then`,
1359
- ` echo ${JSON.stringify(b64(vscodeSettings))} | base64 -d > ${homeDir}/.local/share/code-server/User/settings.json`,
1360
- ` chown -R ${username}:${username} ${homeDir}/.local`,
1361
- "fi",
1362
- )
1398
+ // ── 4b. Seed code-server user settings (no-clobber, stays user-overridable) ─
1399
+ // Reordered window.title + per-project window color make parallel worker tabs
1400
+ // tellable apart at a glance (see defaultVscodeUserSettings). Written no-clobber
1401
+ // (guarded on the file, not `cp -n`) so a user's own settings.json survives restarts.
1402
+ const vscodeSettings = JSON.stringify(
1403
+ { ...defaultVscodeUserSettings({ projectName: project.name, colorByProject: colorTabsByProject }), ...params.ideUserSettings },
1404
+ null,
1405
+ 2,
1406
+ )
1407
+ push(
1408
+ "",
1409
+ `mkdir -p ${homeDir}/.local/share/code-server/User`,
1410
+ `if [ ! -f ${homeDir}/.local/share/code-server/User/settings.json ]; then`,
1411
+ ` echo ${JSON.stringify(b64(vscodeSettings))} | base64 -d > ${homeDir}/.local/share/code-server/User/settings.json`,
1412
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1413
+ "fi",
1414
+ )
1363
1415
 
1364
- // ── 4c. Restore the user's PORT secret for code-server's own terminal panel ─
1365
- // code-server reads $PORT to override --bind-addr (see § 5 below), so its
1366
- // whole process tree — including the integrated terminal it spawns — inherits
1367
- // PORT=<idePort> unless corrected. `terminal.integrated.env.linux` lets VS Code
1368
- // inject env overrides into terminals it spawns without touching the server
1369
- // process itself, so a `PORT` project secret still reaches the user's shell.
1370
- // Uses code-server's own bundled node (guaranteed present regardless of the
1371
- // devcontainer base image) since jq/node aren't guaranteed there.
1372
- const seedTerminalPortEnv = [
1373
- "const fs = require('fs')",
1374
- "const path = process.env.MP_SETTINGS_PATH",
1375
- "let settings = {}",
1376
- "try { settings = JSON.parse(fs.readFileSync(path, 'utf8')) } catch {}",
1377
- "settings['terminal.integrated.env.linux'] = { ...settings['terminal.integrated.env.linux'], PORT: process.env.MP_ORIGINAL_PORT }",
1378
- "fs.writeFileSync(path, JSON.stringify(settings, null, 2))",
1379
- ].join("\n")
1380
- const seedTerminalPortEnvEncoded = b64(seedTerminalPortEnv)
1416
+ // ── 4c. Restore the user's PORT secret for code-server's own terminal panel ─
1417
+ // code-server reads $PORT to override --bind-addr (see § 5 below), so its
1418
+ // whole process tree — including the integrated terminal it spawns — inherits
1419
+ // PORT=<idePort> unless corrected. `terminal.integrated.env.linux` lets VS Code
1420
+ // inject env overrides into terminals it spawns without touching the server
1421
+ // process itself, so a `PORT` project secret still reaches the user's shell.
1422
+ // Uses code-server's own bundled node (guaranteed present regardless of the
1423
+ // devcontainer base image) since jq/node aren't guaranteed there.
1424
+ const seedTerminalPortEnv = [
1425
+ "const fs = require('fs')",
1426
+ "const path = process.env.MP_SETTINGS_PATH",
1427
+ "let settings = {}",
1428
+ "try { settings = JSON.parse(fs.readFileSync(path, 'utf8')) } catch {}",
1429
+ "settings['terminal.integrated.env.linux'] = { ...settings['terminal.integrated.env.linux'], PORT: process.env.MP_ORIGINAL_PORT }",
1430
+ "fs.writeFileSync(path, JSON.stringify(settings, null, 2))",
1431
+ ].join("\n")
1432
+ const seedTerminalPortEnvEncoded = b64(seedTerminalPortEnv)
1381
1433
 
1382
- push(
1383
- "",
1384
- 'if [ -n "${PORT:-}" ]; then',
1385
- " _mp_cs_bin=$(command -v code-server 2>/dev/null || true)",
1386
- ' if [ -n "$_mp_cs_bin" ]; then',
1387
- ' _mp_cs_bin=$(readlink -f "$_mp_cs_bin" 2>/dev/null || echo "$_mp_cs_bin")',
1388
- ' _mp_cs_node="$(dirname "$(dirname "$_mp_cs_bin")")/lib/node"',
1389
- ' if [ -x "$_mp_cs_node" ]; then',
1390
- ` mkdir -p ${homeDir}/.local/share/code-server/User`,
1391
- ` echo ${JSON.stringify(seedTerminalPortEnvEncoded)} | base64 -d > ${homeDir}/.mp_seed_terminal_port.js`,
1392
- ` MP_ORIGINAL_PORT="$PORT" MP_SETTINGS_PATH="${homeDir}/.local/share/code-server/User/settings.json" "$_mp_cs_node" ${homeDir}/.mp_seed_terminal_port.js`,
1393
- ` rm -f ${homeDir}/.mp_seed_terminal_port.js`,
1394
- ` chown -R ${username}:${username} ${homeDir}/.local`,
1395
- " fi",
1396
- " fi",
1397
- "fi",
1398
- )
1434
+ push(
1435
+ "",
1436
+ 'if [ -n "${PORT:-}" ]; then',
1437
+ " _mp_cs_bin=$(command -v code-server 2>/dev/null || true)",
1438
+ ' if [ -n "$_mp_cs_bin" ]; then',
1439
+ ' _mp_cs_bin=$(readlink -f "$_mp_cs_bin" 2>/dev/null || echo "$_mp_cs_bin")',
1440
+ ' _mp_cs_node="$(dirname "$(dirname "$_mp_cs_bin")")/lib/node"',
1441
+ ' if [ -x "$_mp_cs_node" ]; then',
1442
+ ` mkdir -p ${homeDir}/.local/share/code-server/User`,
1443
+ ` echo ${JSON.stringify(seedTerminalPortEnvEncoded)} | base64 -d > ${homeDir}/.mp_seed_terminal_port.js`,
1444
+ ` MP_ORIGINAL_PORT="$PORT" MP_SETTINGS_PATH="${homeDir}/.local/share/code-server/User/settings.json" "$_mp_cs_node" ${homeDir}/.mp_seed_terminal_port.js`,
1445
+ ` rm -f ${homeDir}/.mp_seed_terminal_port.js`,
1446
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1447
+ " fi",
1448
+ " fi",
1449
+ "fi",
1450
+ )
1399
1451
 
1400
- // ── 5. Start code-server as vscode ────────────────────────────────────────
1401
- // Write the restart-loop to a temp script so quoting stays clean.
1402
- // Single-quoted heredoc: $VARS are NOT expanded here, they expand when the script runs
1403
- // (env is inherited from the start script via `su vscode` without -l).
1404
- // Write the restart-loop using base64 to avoid heredoc issues in DinD contexts
1405
- // where dockerd startup can affect /tmp mount visibility for child processes.
1406
- // Script lives in home dir (persistent volume) rather than /tmp.
1407
- const codeServerLoop = [
1408
- "#!/bin/bash",
1409
- "export SHELL=$(command -v zsh 2>/dev/null || echo /bin/bash)",
1410
- // Makes the editor's own Extensions view search the same registry the picker and the image
1411
- // build used (org setting — see lib/extension-registry.ts). Nothing when the org is on Open VSX.
1412
- ...galleryExport(extensionsGallery),
1413
- // The IDE is Spunto's, not Coder's (spunto-pack rebrands the files): the name in the title,
1414
- // no "Deploy code-server for your team" on the welcome page, no "code-server vX is out"
1415
- // notification. Each flag only if this code-server knows it — an unknown one stops it dead.
1416
- '_mp_cs_flags=""',
1417
- "for _f in --app-name --disable-getting-started-override --disable-update-check; do",
1418
- ' code-server --help 2>/dev/null | grep -q -- "$_f" || continue',
1419
- ' case "$_f" in --app-name) _mp_cs_flags="$_mp_cs_flags --app-name Spunto" ;; *) _mp_cs_flags="$_mp_cs_flags $_f" ;; esac',
1420
- "done",
1421
- "while true; do",
1422
- ' VSCODE_PROXY_URI="${PUBLIC_PROTOCOL}://${WORKER_SLUG}-{{port}}.${BASE_DOMAIN}" \\',
1423
- // PORT is overridden here (not unset globally) so a project secret named
1424
- // PORT can't make code-server bind elsewhere. This also leaks PORT=<idePort>
1425
- // into code-server's own process tree (its integrated terminal included) —
1426
- // § 4c above restores the original value for that terminal specifically.
1427
- // apps/agent/src/terminal.ts (dashboard's separate terminal feature) is a
1428
- // different process tree entirely and was never affected.
1429
- ` PORT=${idePort} code-server --bind-addr 0.0.0.0:${idePort} --auth none $_mp_cs_flags /workspace 2>&1`,
1430
- ' echo "[start] code-server exited ($?), restarting in 3s..."',
1431
- " sleep 3",
1432
- "done",
1433
- ].join("\n")
1434
- const codeServerEncoded = b64(codeServerLoop)
1452
+ // ── 5. Start code-server as vscode ────────────────────────────────────────
1453
+ // Write the restart-loop to a temp script so quoting stays clean.
1454
+ // Single-quoted heredoc: $VARS are NOT expanded here, they expand when the script runs
1455
+ // (env is inherited from the start script via `su vscode` without -l).
1456
+ // Write the restart-loop using base64 to avoid heredoc issues in DinD contexts
1457
+ // where dockerd startup can affect /tmp mount visibility for child processes.
1458
+ // Script lives in home dir (persistent volume) rather than /tmp.
1459
+ const codeServerLoop = [
1460
+ "#!/bin/bash",
1461
+ "export SHELL=$(command -v zsh 2>/dev/null || echo /bin/bash)",
1462
+ // Makes the editor's own Extensions view search the same registry the picker and the image
1463
+ // build used (org setting — see lib/extension-registry.ts). Nothing when the org is on Open VSX.
1464
+ ...galleryExport(extensionsGallery),
1465
+ // The IDE is Spunto's, not Coder's (spunto-pack rebrands the files): the name in the title,
1466
+ // no "Deploy code-server for your team" on the welcome page, no "code-server vX is out"
1467
+ // notification. Each flag only if this code-server knows it — an unknown one stops it dead.
1468
+ '_mp_cs_flags=""',
1469
+ "for _f in --app-name --disable-getting-started-override --disable-update-check; do",
1470
+ ' code-server --help 2>/dev/null | grep -q -- "$_f" || continue',
1471
+ ' case "$_f" in --app-name) _mp_cs_flags="$_mp_cs_flags --app-name Spunto" ;; *) _mp_cs_flags="$_mp_cs_flags $_f" ;; esac',
1472
+ "done",
1473
+ "while true; do",
1474
+ ' VSCODE_PROXY_URI="${PUBLIC_PROTOCOL}://${WORKER_SLUG}-{{port}}.${BASE_DOMAIN}" \\',
1475
+ // PORT is overridden here (not unset globally) so a project secret named
1476
+ // PORT can't make code-server bind elsewhere. This also leaks PORT=<idePort>
1477
+ // into code-server's own process tree (its integrated terminal included) —
1478
+ // § 4c above restores the original value for that terminal specifically.
1479
+ // apps/agent/src/terminal.ts (dashboard's separate terminal feature) is a
1480
+ // different process tree entirely and was never affected.
1481
+ ` PORT=${idePort} code-server --bind-addr 0.0.0.0:${idePort} --auth none $_mp_cs_flags /workspace 2>&1`,
1482
+ ' echo "[start] code-server exited ($?), restarting in 3s..."',
1483
+ " sleep 3",
1484
+ "done",
1485
+ ].join("\n")
1486
+ const codeServerEncoded = b64(codeServerLoop)
1435
1487
 
1436
- push(
1437
- ...banner("START: CODE-SERVER"),
1438
- `echo ${JSON.stringify(codeServerEncoded)} | base64 -d > ${homeDir}/.mp_codeserver.sh`,
1439
- `chmod +x ${homeDir}/.mp_codeserver.sh`,
1440
- `su ${username} -s /bin/bash -c "${homeDir}/.mp_codeserver.sh" &`,
1441
- )
1488
+ push(
1489
+ ...banner("START: CODE-SERVER"),
1490
+ `echo ${JSON.stringify(codeServerEncoded)} | base64 -d > ${homeDir}/.mp_codeserver.sh`,
1491
+ `chmod +x ${homeDir}/.mp_codeserver.sh`,
1492
+ `su ${username} -s /bin/bash -c "${homeDir}/.mp_codeserver.sh" &`,
1493
+ )
1494
+ }
1495
+
1496
+ // No IDE: nothing else would keep the script — and so the project's process, which the agent
1497
+ // reports as `running` only while it lives — waiting at the end. A sleeper does, in its place.
1498
+ if (!ide) push("", "while true; do sleep 3600; done &")
1442
1499
 
1443
1500
  // ── 6. postStartCommand ───────────────────────────────────────────────────
1444
1501
  // Wait for Docker if feature entrypoints were started — regardless of whether