@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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
package/src/catalogs/index.ts
CHANGED
|
@@ -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
|