@spunto/build 0.23.0 → 0.25.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.23.0",
3
+ "version": "0.25.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`. */
@@ -85,8 +94,11 @@ export const RUNNER_ENV = "SPUNTO_AGENT_RUNNER"
85
94
  * platform shows ("what the platform ran"): eight kilobytes of base64 there would bury the one line
86
95
  * worth reading. `--model` is appended by the platform like for any harness; `resume` adds the
87
96
  * session the platform read off the stream, passed as `$SPUNTO_TASK_SESSION_ID`.
97
+ *
98
+ * Only the app's id is needed (it names the file): the platform calls this for apps it learns of by
99
+ * their manifest, outside this package (RFC 0057).
88
100
  */
89
- export function runnerCommand(harness: AgentHarness, opts: { resume?: boolean } = {}): string {
101
+ export function runnerCommand(harness: Pick<AgentHarness, "id"> | { id: string }, opts: { resume?: boolean } = {}): string {
90
102
  const file = `"$HOME/.spunto/agents/${harness.id}.mjs"`
91
103
  return [
92
104
  `mkdir -p "$HOME/.spunto/agents" && printf %s "$${RUNNER_ENV}" | base64 -d > ${file} && unset ${RUNNER_ENV} &&`,
@@ -99,6 +111,6 @@ export function harnessOfFeature(featureId: string): AgentHarness | null {
99
111
  return Object.values(AGENT_HARNESSES).find((h) => h.featureId === featureId) ?? null
100
112
  }
101
113
 
102
- 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"
103
115
  export { AGENT_CATALOGS, MCP_ENV, OPTIONS_ENV, STREAM_ENV, agentOptionsError } from "./catalogs"
104
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