@spunto/build 0.19.0 → 0.20.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.19.0",
3
+ "version": "0.20.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",
@@ -81,7 +81,13 @@
81
81
  },
82
82
  "./sandbox-agent/agent.mjs": "./src/sandbox-agent/agent.mjs",
83
83
  "./sandbox-agent/bootstrap.sh": "./src/sandbox-agent/bootstrap.sh",
84
- "./sandbox-agent/runner.sh": "./src/sandbox-agent/runner.sh"
84
+ "./sandbox-agent/runner.sh": "./src/sandbox-agent/runner.sh",
85
+ "./agent-runners": {
86
+ "types": "./src/agent-runners/index.ts",
87
+ "import": "./src/agent-runners/index.ts"
88
+ },
89
+ "./agent-runners/claude.mjs": "./src/agent-runners/claude.mjs",
90
+ "./agent-runners/codex.mjs": "./src/agent-runners/codex.mjs"
85
91
  },
86
92
  "files": [
87
93
  "src",
@@ -0,0 +1,140 @@
1
+ /**
2
+ * What each agent app (RFC 0048) lets a task choose: its models, and **its own parameters** —
3
+ * effort, reasoning, web search… The platform knows none of them by name. It shows what an app
4
+ * declares, checks a task's choice against it (`validateAgentOptions`), and hands the choice to the
5
+ * runner (`OPTIONS_ENV`), which is the only code that knows what a parameter means to its SDK.
6
+ *
7
+ * **Measured, not remembered.** Each catalog is what the SDK pinned by its feature
8
+ * (`./features.ts`) answers, and it changes when that pin moves:
9
+ *
10
+ * - Claude: `query().supportedModels()` of `@anthropic-ai/claude-agent-sdk` 0.3.310, with a
11
+ * subscription token, on 2026-10-26. Each model comes back with `supportedEffortLevels`;
12
+ * - Codex: `codex debug models` of `@openai/codex` 0.164.0 — the catalog bundled in the binary,
13
+ * offline, on 2026-10-26 — the `visibility: "list"` models, with their
14
+ * `supported_reasoning_levels` and `default_reasoning_level`.
15
+ *
16
+ * `packages/build/scripts/agent-catalogs.mjs` prints both again, to diff against this file when a
17
+ * pin is bumped.
18
+ */
19
+
20
+ export type AgentParamValue = { value: string; label: string; description?: string }
21
+
22
+ /** A parameter an app defines. A choice among values — the one shape both SDKs need today. */
23
+ export type AgentParam = {
24
+ id: string
25
+ label: string
26
+ description?: string
27
+ values: AgentParamValue[]
28
+ }
29
+
30
+ export type AgentModel = {
31
+ value: string
32
+ label: string
33
+ description?: string
34
+ /** What the harness runs when no model is picked. */
35
+ default?: boolean
36
+ /** Values of a parameter this model allows, when it does not take all of them. */
37
+ params?: Record<string, string[]>
38
+ /** What this model runs with when the parameter is left unset — shown as the chip's placeholder. */
39
+ paramDefaults?: Record<string, string>
40
+ }
41
+
42
+ export type AgentCatalog = {
43
+ models: AgentModel[]
44
+ params: AgentParam[]
45
+ }
46
+
47
+ /** The variable a task's chosen parameters reach the runner in, as one JSON object. */
48
+ export const OPTIONS_ENV = "SPUNTO_AGENT_OPTIONS"
49
+
50
+ const CLAUDE_EFFORT: AgentParamValue[] = [
51
+ { value: "low", label: "Low" },
52
+ { value: "medium", label: "Medium" },
53
+ { value: "high", label: "High" },
54
+ { value: "xhigh", label: "Extra high" },
55
+ { value: "max", label: "Max" },
56
+ ]
57
+
58
+ const CODEX_REASONING: AgentParamValue[] = [
59
+ { value: "low", label: "Low", description: "Fast responses with lighter reasoning" },
60
+ { value: "medium", label: "Medium", description: "Balances speed and reasoning depth for everyday tasks" },
61
+ { value: "high", label: "High", description: "Greater reasoning depth for complex problems" },
62
+ { value: "xhigh", label: "Extra high", description: "Extra high reasoning depth for complex problems" },
63
+ { value: "max", label: "Max", description: "Maximum reasoning depth for the hardest problems" },
64
+ { value: "ultra", label: "Ultra", description: "Maximum reasoning with automatic task delegation" },
65
+ ]
66
+
67
+ const ALL_BUT_ULTRA = ["low", "medium", "high", "xhigh", "max"]
68
+
69
+ export const AGENT_CATALOGS: Record<"claude" | "codex", AgentCatalog> = {
70
+ claude: {
71
+ // The SDK's aliases (`default`, `sonnet`, `opus`, `fable`, `haiku`) resolved to the ids they
72
+ // stand for: a task records the model it ran, not a name whose meaning moves.
73
+ models: [
74
+ { value: "claude-sonnet-5-5", label: "Claude Sonnet 5.5", description: "Default (recommended)", default: true },
75
+ { value: "claude-opus-5-5", label: "Claude Opus 5.5" },
76
+ { value: "claude-fable-5-1", label: "Claude Fable 5.1" },
77
+ { value: "claude-haiku-5-5", label: "Claude Haiku 5.5" },
78
+ ],
79
+ params: [
80
+ {
81
+ id: "effort",
82
+ label: "Effort",
83
+ description: "How much Claude thinks before it acts. Unset = the model's own default.",
84
+ values: CLAUDE_EFFORT,
85
+ },
86
+ ],
87
+ },
88
+ codex: {
89
+ models: [
90
+ { value: "gpt-6-astra", label: "GPT-6-Astra", description: "Frontier intelligence for the most demanding work.", paramDefaults: { reasoningEffort: "low" } },
91
+ { value: "gpt-6.1-sol", label: "GPT-6.1-Sol", description: "Latest workhorse model for coding and everyday work.", paramDefaults: { reasoningEffort: "low" } },
92
+ { value: "gpt-6-sol", label: "GPT-6-Sol", description: "Previous generation workhorse model.", paramDefaults: { reasoningEffort: "medium" } },
93
+ { value: "gpt-6-luna", label: "GPT-6-Luna", description: "Fast and affordable model for easier tasks.", params: { reasoningEffort: ALL_BUT_ULTRA }, paramDefaults: { reasoningEffort: "medium" } },
94
+ { value: "gpt-5.6-sol", label: "GPT-5.6-Sol", description: "Older generation workhorse model.", paramDefaults: { reasoningEffort: "low" } },
95
+ { value: "gpt-5.6-terra", label: "GPT-5.6-Terra", description: "Older balanced model for straightforward work.", paramDefaults: { reasoningEffort: "medium" } },
96
+ { value: "gpt-5.6-luna", label: "GPT-5.6-Luna", description: "Older fast and efficient model.", params: { reasoningEffort: ALL_BUT_ULTRA }, paramDefaults: { reasoningEffort: "medium" } },
97
+ { value: "gpt-5.5", label: "GPT-5.5", description: "Legacy coding model.", params: { reasoningEffort: ["low", "medium", "high", "xhigh"] }, paramDefaults: { reasoningEffort: "medium" } },
98
+ ],
99
+ params: [
100
+ {
101
+ id: "reasoningEffort",
102
+ label: "Reasoning",
103
+ description: "How deeply Codex reasons. Unset = the model's own default.",
104
+ values: CODEX_REASONING,
105
+ },
106
+ {
107
+ id: "webSearch",
108
+ label: "Web search",
109
+ description: "Whether Codex may search the web: from a cache, live, or not at all. Unset = Codex's default.",
110
+ values: [
111
+ { value: "disabled", label: "Off" },
112
+ { value: "cached", label: "Cached" },
113
+ { value: "live", label: "Live" },
114
+ ],
115
+ },
116
+ ],
117
+ },
118
+ }
119
+
120
+ /**
121
+ * Why these options cannot run under this catalog and model — or null when they can. An unknown
122
+ * parameter, a value the parameter does not have, or one the model does not take. A model the
123
+ * catalog does not list (typed by hand) is held to the parameter's values only: the catalog cannot
124
+ * speak for it.
125
+ */
126
+ export function agentOptionsError(catalog: AgentCatalog, model: string | null | undefined, options: Record<string, string>): string | null {
127
+ const known = model ? catalog.models.find((m) => m.value === model) : undefined
128
+ for (const [id, value] of Object.entries(options)) {
129
+ const param = catalog.params.find((p) => p.id === id)
130
+ if (!param) return `Unknown parameter "${id}" — this harness takes ${catalog.params.map((p) => p.id).join(", ") || "none"}.`
131
+ if (!param.values.some((v) => v.value === value)) {
132
+ return `"${value}" is not a value of ${param.label} — one of ${param.values.map((v) => v.value).join(", ")}.`
133
+ }
134
+ const allowed = known?.params?.[id]
135
+ if (allowed && !allowed.includes(value)) {
136
+ return `${known!.label} does not take ${param.label} "${value}" — one of ${allowed.join(", ")}.`
137
+ }
138
+ }
139
+ return null
140
+ }
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // The Claude runner (RFC 0048): one file, no dependency of its own, written into the machine by
3
+ // the platform when a session starts and run with the Node the `spunto-agent-claude` feature put
4
+ // in the image. It drives Claude through the official Agent SDK the same feature installed.
5
+ //
6
+ // The contract, the same for every agent app (`../agent-runners/index.ts`):
7
+ //
8
+ // node claude.mjs run [--model <id>] [--resume <session id>]
9
+ // stdin one JSON object per line — {"type":"user","text"} · {"type":"interrupt","id"} ·
10
+ // {"type":"title","id","description"} ; closed = the session ends after its turn
11
+ // stdout the harness's own dialect, one JSON object per line — here the SDK's messages, which
12
+ // are exactly the CLI's `stream-json` shapes, so the platform reads them with the
13
+ // adapter it already had (`claude-runner`)
14
+ // env SPUNTO_AGENT_OPTIONS — the task's parameters, as declared by the app's catalog
15
+ // (`./catalogs.ts`): `{"effort":"high"}`
16
+ // exit 0 once the input is closed and the last turn is over, 1 when the session could not run
17
+ //
18
+ // Credentials are not this file's business: `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`, set
19
+ // as secrets, reach the SDK's binary through the environment like they reached the CLI before.
20
+
21
+ import { createInterface } from "node:readline"
22
+
23
+ const SDK = process.env.SPUNTO_CLAUDE_SDK || "/opt/spunto/agents/claude/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs"
24
+
25
+ function out(obj) {
26
+ process.stdout.write(`${JSON.stringify(obj)}\n`)
27
+ }
28
+
29
+ /** A failure the platform can read: the shape of the CLI's own failed `result`. */
30
+ function fail(message) {
31
+ out({ type: "result", subtype: "error_during_execution", is_error: true, result: message })
32
+ process.exit(1)
33
+ }
34
+
35
+ function parseArgs(argv) {
36
+ const [verb, ...rest] = argv
37
+ if (verb !== "run") fail(`claude runner: unknown verb "${verb ?? ""}" (expected "run")`)
38
+ const args = {}
39
+ for (let i = 0; i < rest.length; i++) {
40
+ const flag = rest[i]
41
+ if (flag === "--model" || flag === "--resume") {
42
+ const value = rest[++i]
43
+ if (value) args[flag.slice(2)] = value
44
+ } else {
45
+ fail(`claude runner: unknown flag "${flag}"`)
46
+ }
47
+ }
48
+ return args
49
+ }
50
+
51
+ /** The human's turns, as the async iterable the SDK reads its prompt from. Ends when stdin does. */
52
+ function inputQueue() {
53
+ const pending = []
54
+ let wake = null
55
+ let closed = false
56
+ return {
57
+ push(message) {
58
+ pending.push(message)
59
+ wake?.()
60
+ },
61
+ close() {
62
+ closed = true
63
+ wake?.()
64
+ },
65
+ async *[Symbol.asyncIterator]() {
66
+ for (;;) {
67
+ if (pending.length > 0) {
68
+ yield pending.shift()
69
+ continue
70
+ }
71
+ if (closed) return
72
+ await new Promise((resolve) => (wake = resolve))
73
+ wake = null
74
+ }
75
+ },
76
+ }
77
+ }
78
+
79
+ const args = parseArgs(process.argv.slice(2))
80
+
81
+ /** The task's parameters. Checked by the platform against the catalog; read defensively all the same. */
82
+ function readOptions() {
83
+ try {
84
+ const parsed = JSON.parse(process.env.SPUNTO_AGENT_OPTIONS || "{}")
85
+ return parsed && typeof parsed === "object" ? parsed : {}
86
+ } catch {
87
+ return {}
88
+ }
89
+ }
90
+ const params = readOptions()
91
+
92
+ let sdk
93
+ try {
94
+ sdk = await import(SDK)
95
+ } catch (err) {
96
+ fail(`claude runner: the Agent SDK is not installed at ${SDK} — is the project's image built with the Claude app's feature? (${err?.message ?? err})`)
97
+ }
98
+
99
+ const input = inputQueue()
100
+ const session = sdk.query({
101
+ prompt: input,
102
+ options: {
103
+ cwd: process.cwd(),
104
+ ...(args.model ? { model: args.model } : {}),
105
+ ...(args.resume ? { resume: args.resume } : {}),
106
+ ...(typeof params.effort === "string" ? { effort: params.effort } : {}),
107
+ // A task runs unattended in a disposable machine — what `--dangerously-skip-permissions` did.
108
+ permissionMode: "bypassPermissions",
109
+ allowDangerouslySkipPermissions: true,
110
+ // Claude Code's own system prompt, CLAUDE.md, settings and skills: the session must behave
111
+ // exactly like the CLI it replaces, not like a bare SDK agent.
112
+ systemPrompt: { type: "preset", preset: "claude_code" },
113
+ settingSources: ["user", "project", "local"],
114
+ stderr: (line) => process.stderr.write(line),
115
+ },
116
+ })
117
+
118
+ createInterface({ input: process.stdin, crlfDelay: Infinity })
119
+ .on("line", (line) => {
120
+ let msg
121
+ try {
122
+ msg = JSON.parse(line)
123
+ } catch {
124
+ return
125
+ }
126
+ if (!msg || typeof msg !== "object") return
127
+ if (msg.type === "user" && typeof msg.text === "string") {
128
+ input.push({ type: "user", message: { role: "user", content: [{ type: "text", text: msg.text }] }, parent_tool_use_id: null })
129
+ } else if (msg.type === "interrupt") {
130
+ session.interrupt().catch((err) => process.stderr.write(`claude runner: interrupt failed: ${err?.message ?? err}\n`))
131
+ } else if (msg.type === "title" && typeof msg.description === "string") {
132
+ // Answered with the `control_response` the CLI would have printed, so the platform reads
133
+ // a title one way whichever side of the SDK the session runs on.
134
+ if (typeof session.generateSessionTitle !== "function") return
135
+ session
136
+ .generateSessionTitle(msg.description, { persist: true })
137
+ .then((title) => {
138
+ if (typeof title === "string" && title) {
139
+ out({ type: "control_response", response: { subtype: "success", request_id: msg.id, response: { title } } })
140
+ }
141
+ })
142
+ .catch((err) => process.stderr.write(`claude runner: title failed: ${err?.message ?? err}\n`))
143
+ }
144
+ })
145
+ .on("close", () => input.close())
146
+
147
+ // The SDK also throws after a failed `result` it has just yielded; that result already said why,
148
+ // and a second one would end the turn twice in the timeline.
149
+ let lastType = null
150
+ try {
151
+ for await (const message of session) {
152
+ out(message)
153
+ lastType = message?.type ?? null
154
+ }
155
+ } catch (err) {
156
+ if (lastType === "result") process.exit(1)
157
+ fail(`claude runner: ${err?.message ?? err}`)
158
+ }
159
+ process.exit(0)
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // The Codex runner (RFC 0048): same contract as `claude.mjs`, over `@openai/codex-sdk`, installed
3
+ // with the `codex` binary it drives by the `spunto-agent-codex` feature.
4
+ //
5
+ // node codex.mjs run [--model <id>] [--resume <thread id>]
6
+ // stdin {"type":"user","text"} · {"type":"interrupt","id"} · {"type":"title",…} (ignored: Codex
7
+ // does not name its threads) ; closed = the session ends after its turn
8
+ // stdout the SDK's `ThreadEvent`s, one per line (`codex-runner` in the platform's adapters)
9
+ // env SPUNTO_AGENT_OPTIONS — the task's parameters (`./catalogs.ts`):
10
+ // `{"reasoningEffort":"high","webSearch":"live"}`
11
+ // exit 0 once the input is closed and the last turn is over, 1 when the session could not run
12
+ //
13
+ // Codex works turn by turn: a message that arrives while a turn runs waits for it to end, then
14
+ // starts the next one in the same thread — what a "queued message" already means on the platform.
15
+ // An interrupt aborts the turn in flight, and the runner says so with a `turn.failed`.
16
+ //
17
+ // Credentials: `OPENAI_API_KEY` (or `CODEX_API_KEY`) as a secret, or a `codex login` done by hand in
18
+ // the machine's terminal (`~/.codex/auth.json`), which the binary reads on its own.
19
+
20
+ import { createInterface } from "node:readline"
21
+
22
+ const SDK = process.env.SPUNTO_CODEX_SDK || "/opt/spunto/agents/codex/node_modules/@openai/codex-sdk/dist/index.js"
23
+
24
+ function out(obj) {
25
+ process.stdout.write(`${JSON.stringify(obj)}\n`)
26
+ }
27
+
28
+ function fail(message) {
29
+ out({ type: "turn.failed", error: { message } })
30
+ process.exit(1)
31
+ }
32
+
33
+ function parseArgs(argv) {
34
+ const [verb, ...rest] = argv
35
+ if (verb !== "run") fail(`codex runner: unknown verb "${verb ?? ""}" (expected "run")`)
36
+ const args = {}
37
+ for (let i = 0; i < rest.length; i++) {
38
+ const flag = rest[i]
39
+ if (flag === "--model" || flag === "--resume") {
40
+ const value = rest[++i]
41
+ if (value) args[flag.slice(2)] = value
42
+ } else {
43
+ fail(`codex runner: unknown flag "${flag}"`)
44
+ }
45
+ }
46
+ return args
47
+ }
48
+
49
+ const args = parseArgs(process.argv.slice(2))
50
+
51
+ /** The task's parameters. Checked by the platform against the catalog; read defensively all the same. */
52
+ function readOptions() {
53
+ try {
54
+ const parsed = JSON.parse(process.env.SPUNTO_AGENT_OPTIONS || "{}")
55
+ return parsed && typeof parsed === "object" ? parsed : {}
56
+ } catch {
57
+ return {}
58
+ }
59
+ }
60
+ const params = readOptions()
61
+
62
+ let sdk
63
+ try {
64
+ sdk = await import(SDK)
65
+ } catch (err) {
66
+ fail(`codex runner: the Codex SDK is not installed at ${SDK} — is the project's image built with the Codex app's feature? (${err?.message ?? err})`)
67
+ }
68
+
69
+ const apiKey = process.env.OPENAI_API_KEY || process.env.CODEX_API_KEY
70
+ const codex = new sdk.Codex(apiKey ? { apiKey } : {})
71
+ const threadOptions = {
72
+ workingDirectory: process.cwd(),
73
+ ...(args.model ? { model: args.model } : {}),
74
+ ...(typeof params.reasoningEffort === "string" ? { modelReasoningEffort: params.reasoningEffort } : {}),
75
+ ...(typeof params.webSearch === "string" ? { webSearchMode: params.webSearch } : {}),
76
+ // A task runs unattended in a disposable machine: no approval prompt nobody would answer, and
77
+ // no sandbox of Codex's own inside the one the machine already is (it needs kernel features a
78
+ // container rarely has).
79
+ approvalPolicy: "never",
80
+ sandboxMode: "danger-full-access",
81
+ networkAccessEnabled: true,
82
+ }
83
+ const thread = args.resume ? codex.resumeThread(args.resume, threadOptions) : codex.startThread(threadOptions)
84
+ // A resumed thread is not announced by the SDK (`thread.started` only comes with a new one), and
85
+ // the platform learns the session id from that line — so the runner says it.
86
+ if (args.resume) out({ type: "thread.started", thread_id: args.resume })
87
+
88
+ const pending = []
89
+ let closed = false
90
+ let current = null // AbortController of the turn in flight
91
+ let wake = null
92
+
93
+ createInterface({ input: process.stdin, crlfDelay: Infinity })
94
+ .on("line", (line) => {
95
+ let msg
96
+ try {
97
+ msg = JSON.parse(line)
98
+ } catch {
99
+ return
100
+ }
101
+ if (!msg || typeof msg !== "object") return
102
+ if (msg.type === "user" && typeof msg.text === "string") {
103
+ pending.push(msg.text)
104
+ wake?.()
105
+ } else if (msg.type === "interrupt") {
106
+ current?.abort()
107
+ }
108
+ })
109
+ .on("close", () => {
110
+ closed = true
111
+ wake?.()
112
+ })
113
+
114
+ let failed = false
115
+ for (;;) {
116
+ if (pending.length === 0) {
117
+ if (closed) break
118
+ await new Promise((resolve) => (wake = resolve))
119
+ wake = null
120
+ continue
121
+ }
122
+ const text = pending.shift()
123
+ current = new AbortController()
124
+ // The SDK also throws once the binary exits after a `turn.failed` it has just yielded; that line
125
+ // already said why, and a second one would end the turn twice in the timeline.
126
+ let lastType = null
127
+ try {
128
+ const { events } = await thread.runStreamed(text, { signal: current.signal })
129
+ for await (const event of events) {
130
+ out(event)
131
+ lastType = event?.type ?? null
132
+ }
133
+ } catch (err) {
134
+ if (current.signal.aborted) {
135
+ out({ type: "turn.failed", error: { message: "Interrupted." } })
136
+ } else if (lastType !== "turn.failed") {
137
+ out({ type: "turn.failed", error: { message: `codex runner: ${err?.message ?? err}` } })
138
+ failed = true
139
+ }
140
+ }
141
+ current = null
142
+ }
143
+ process.exit(failed ? 1 : 0)
@@ -0,0 +1,77 @@
1
+ // The install scripts of the agent apps' features (RFC 0048), shipped by the platform itself like
2
+ // `google-cloud-cli.sh` (`../script/local-features.ts`). Root, at image build time.
3
+ //
4
+ // Each one lays down, under /opt/spunto/agents:
5
+ // node/ a private Node — the runner must not depend on the project's (absent from a
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
8
+ // /usr/local/bin/ the harness's CLI for the humans in the terminal, unless the image has one
9
+
10
+ /** Where every agent feature installs. */
11
+ export const AGENT_HOME = "/opt/spunto/agents"
12
+ /** The private Node the features fetch — the release the sandbox bootstrap fetches too. */
13
+ export const AGENT_NODE_VERSION = "v24.11.1"
14
+ const NODE_VERSION = AGENT_NODE_VERSION
15
+
16
+ function featureScript(p: { id: string; label: string; packages: string; cli: string }): string {
17
+ return `#!/bin/sh
18
+ set -e
19
+ echo "[feature] ${p.id}: installing ${p.label}..."
20
+
21
+ HOME_DIR=${AGENT_HOME}
22
+ NODE_DIR=$HOME_DIR/node
23
+ mkdir -p "$HOME_DIR"
24
+
25
+ fetch() {
26
+ if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 5 --retry-delay 2 "$1" -o "$2"
27
+ elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1"
28
+ else echo "[feature] ${p.id}: neither curl nor wget in this image" >&2; exit 1
29
+ fi
30
+ }
31
+
32
+ if ! "$NODE_DIR/bin/node" -e 'process.exit(0)' 2>/dev/null; then
33
+ case "$(uname -m)" in
34
+ x86_64|amd64) ARCH=x64 ;;
35
+ aarch64|arm64) ARCH=arm64 ;;
36
+ *) echo "[feature] ${p.id}: unsupported arch $(uname -m)" >&2; exit 1 ;;
37
+ esac
38
+ echo "[feature] ${p.id}: fetching node ${NODE_VERSION} ($ARCH)"
39
+ fetch "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-$ARCH.tar.gz" /tmp/_spunto_node.tgz
40
+ rm -rf "$NODE_DIR" && mkdir -p "$NODE_DIR"
41
+ tar -xzf /tmp/_spunto_node.tgz -C "$NODE_DIR" --strip-components=1
42
+ rm -f /tmp/_spunto_node.tgz
43
+ fi
44
+
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}
48
+ chmod -R a+rX "$HOME_DIR"
49
+
50
+ ${p.cli}
51
+
52
+ echo "[feature] ${p.id}: done"
53
+ `
54
+ }
55
+
56
+ export const AGENT_FEATURE_SCRIPTS: Record<string, string> = {
57
+ "spunto-agent-claude.sh": featureScript({
58
+ id: "claude",
59
+ label: "the Claude Agent SDK",
60
+ packages: "@anthropic-ai/claude-agent-sdk@0.3.310",
61
+ // The SDK drives a native \`claude\` binary from a platform package; the same binary is the CLI.
62
+ cli: `if ! command -v claude >/dev/null 2>&1; then
63
+ for b in "$HOME_DIR"/claude/node_modules/@anthropic-ai/claude-agent-sdk-linux-*/claude; do
64
+ if [ -x "$b" ]; then ln -sf "$b" /usr/local/bin/claude; break; fi
65
+ done
66
+ fi`,
67
+ }),
68
+ "spunto-agent-codex.sh": featureScript({
69
+ id: "codex",
70
+ label: "the Codex SDK",
71
+ packages: "@openai/codex-sdk@0.164.0",
72
+ cli: `if ! command -v codex >/dev/null 2>&1; then
73
+ 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
+ chmod 755 /usr/local/bin/codex
75
+ fi`,
76
+ }),
77
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Agent apps (RFC 0048): what it takes to run a harness in a machine, on both sides of the seam.
3
+ *
4
+ * An agent app is three things, and this module is where they meet:
5
+ *
6
+ * - a **feature** the platform injects into the image of every project that picks the app — it
7
+ * installs a private Node and the harness's **official SDK** (with the binary the SDK drives)
8
+ * under `/opt/spunto/agents/<id>`. Shipped by the platform itself (`localScript`), so an app
9
+ * never waits on a registry release;
10
+ * - a **runner**, one dependency-free `.mjs` file of ours (`claude.mjs`, `codex.mjs`), written into
11
+ * the machine at the moment a session starts. Not baked: a runner fix reaches the next session,
12
+ * not the next rebuild;
13
+ * - a **dialect** (`claude-runner`, `codex-runner` in `../agent-stream`), which is how the platform
14
+ * writes the runner's stdin and reads its stdout.
15
+ *
16
+ * The runner contract, identical for every app:
17
+ *
18
+ * <node> <runner>.mjs run [--model <id>] [--resume <session id>]
19
+ * stdin one JSON object per line: {"type":"user","text"} · {"type":"interrupt","id"} ·
20
+ * {"type":"title","id","description"} (`RunnerInput`); closed ⇒ end after the turn
21
+ * env SPUNTO_AGENT_OPTIONS — the task's parameters (`./catalogs.ts`), one JSON object
22
+ * stdout the harness's own events, one JSON object per line — normalised by the platform
23
+ * exit 0 = the input closed and the last turn ended; 1 = the session could not run
24
+ *
25
+ * Pure like the rest of the package: what a caller gets is *where* a runner file is, and the shell
26
+ * that starts it. Reading the file is the caller's I/O.
27
+ */
28
+
29
+ import type { AgentProtocol } from "../agent-stream/agent-stream"
30
+
31
+ import { AGENT_HOME } from "./features"
32
+
33
+ /** The Node every runner runs on — the private one the features install. */
34
+ export const AGENT_NODE = `${AGENT_HOME}/node/bin/node`
35
+
36
+ export type AgentHarnessId = "claude" | "codex"
37
+
38
+ export type AgentHarness = {
39
+ id: AgentHarnessId
40
+ /** The catalog id of the feature the platform injects (`AVAILABLE_FEATURES`, `localScript`). */
41
+ featureId: string
42
+ /** How the platform talks to the runner. */
43
+ protocol: AgentProtocol
44
+ /** The runner, a file of this package. */
45
+ runnerFile: URL
46
+ /** Where the harness looks for user-level skills (RFC 0033), as shell evaluated in the machine. */
47
+ skillDirs: string[]
48
+ }
49
+
50
+ export const AGENT_HARNESSES: Record<AgentHarnessId, AgentHarness> = {
51
+ claude: {
52
+ id: "claude",
53
+ featureId: "spunto-agent-claude",
54
+ protocol: "claude-runner",
55
+ runnerFile: new URL("./claude.mjs", import.meta.url),
56
+ skillDirs: ["${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills"],
57
+ },
58
+ codex: {
59
+ id: "codex",
60
+ featureId: "spunto-agent-codex",
61
+ protocol: "codex-runner",
62
+ runnerFile: new URL("./codex.mjs", import.meta.url),
63
+ skillDirs: ["${CODEX_HOME:-$HOME/.codex}/skills"],
64
+ },
65
+ }
66
+
67
+ /** The variable a runner's text travels in, base64 — see `runnerCommand`. */
68
+ export const RUNNER_ENV = "SPUNTO_AGENT_RUNNER"
69
+
70
+ /**
71
+ * The shell that starts a session: write the runner out of `$SPUNTO_AGENT_RUNNER` (base64, set by
72
+ * the caller in the command's environment), then `exec` it on the feature's Node so the session
73
+ * *is* the runner — stdin, signals and exit code included.
74
+ *
75
+ * The text rides in the environment rather than in the command because the command is what the
76
+ * platform shows ("what the platform ran"): eight kilobytes of base64 there would bury the one line
77
+ * worth reading. `--model` is appended by the platform like for any harness; `resume` adds the
78
+ * session the platform read off the stream, passed as `$SPUNTO_TASK_SESSION_ID`.
79
+ */
80
+ export function runnerCommand(harness: AgentHarness, opts: { resume?: boolean } = {}): string {
81
+ const file = `"$HOME/.spunto/agents/${harness.id}.mjs"`
82
+ return [
83
+ `mkdir -p "$HOME/.spunto/agents" && printf %s "$${RUNNER_ENV}" | base64 -d > ${file} && unset ${RUNNER_ENV} &&`,
84
+ `exec ${AGENT_NODE} ${file} run${opts.resume ? ` --resume "$SPUNTO_TASK_SESSION_ID"` : ""}`,
85
+ ].join(" ")
86
+ }
87
+
88
+ /** The harness an injected feature id belongs to, if any. */
89
+ export function harnessOfFeature(featureId: string): AgentHarness | null {
90
+ return Object.values(AGENT_HARNESSES).find((h) => h.featureId === featureId) ?? null
91
+ }
92
+
93
+ export { AGENT_FEATURE_SCRIPTS, AGENT_HOME, AGENT_NODE_VERSION } from "./features"
94
+ export { AGENT_CATALOGS, OPTIONS_ENV, agentOptionsError } from "./catalogs"
95
+ export type { AgentCatalog, AgentModel, AgentParam, AgentParamValue } from "./catalogs"
@@ -22,9 +22,43 @@
22
22
  import { contextWindowOf } from "./model-window"
23
23
  import { claudeTurnLimit } from "./turn-limit"
24
24
 
25
- export const AGENT_PROTOCOLS = ["none", "claude-json", "claude-stream", "jsonl"] as const
25
+ export const AGENT_PROTOCOLS = ["none", "claude-json", "claude-stream", "jsonl", "claude-runner", "codex-runner"] as const
26
26
  export type AgentProtocol = (typeof AGENT_PROTOCOLS)[number]
27
27
 
28
+ /**
29
+ * What a **project** can be set to by hand (`projects.taskAgentProtocol`). The `*-runner` dialects
30
+ * are not in it: they belong to an agent app (RFC 0048) and come with the app's runner, never with
31
+ * a command someone typed.
32
+ */
33
+ export const PROJECT_AGENT_PROTOCOLS = ["none", "claude-json", "claude-stream", "jsonl"] as const
34
+
35
+ /**
36
+ * A runner of an agent app (RFC 0048, `../agent-runners`): our own process in the machine, driving a
37
+ * harness through its official SDK. Its stdin speaks **our** contract (`RunnerInput`), whatever the
38
+ * harness; its stdout is the harness's own dialect, normalised here like any other.
39
+ */
40
+ export function isRunner(protocol: AgentProtocol): boolean {
41
+ return protocol === "claude-runner" || protocol === "codex-runner"
42
+ }
43
+
44
+ /**
45
+ * One line of a runner's stdin. The whole input side of the contract — three verbs, the same for
46
+ * every harness, so the platform never writes a harness's own shape into a runner.
47
+ */
48
+ export type RunnerInput =
49
+ | { type: "user"; text: string }
50
+ | { type: "interrupt"; id: string }
51
+ | { type: "title"; id: string; description: string }
52
+
53
+ function runnerLine(input: RunnerInput): string {
54
+ return `${JSON.stringify(input)}\n`
55
+ }
56
+
57
+ /** Claude's dialect, whichever way it reaches us — the CLI's `stream-json` or the SDK's messages (the same shapes). */
58
+ function speaksClaude(protocol: AgentProtocol): boolean {
59
+ return protocol === "claude-json" || protocol === "claude-stream" || protocol === "claude-runner"
60
+ }
61
+
28
62
  /**
29
63
  * The normalized vocabulary. Deliberately short — it has to be translatable *from* several
30
64
  * harnesses, so anything specific to one of them does not belong in it.
@@ -100,7 +134,7 @@ export function applyProtocol(command: string, protocol: AgentProtocol): string
100
134
 
101
135
  /** Does this protocol keep the harness alive, listening on stdin, between turns? */
102
136
  export function isInteractive(protocol: AgentProtocol): boolean {
103
- return protocol === "claude-stream"
137
+ return protocol === "claude-stream" || isRunner(protocol)
104
138
  }
105
139
 
106
140
  /**
@@ -110,6 +144,7 @@ export function isInteractive(protocol: AgentProtocol): boolean {
110
144
  * stops two messages from interleaving into one unparseable line.
111
145
  */
112
146
  export function userTurnLine(protocol: AgentProtocol, text: string): string {
147
+ if (isRunner(protocol)) return runnerLine({ type: "user", text })
113
148
  if (protocol !== "claude-stream") return `${text}\n`
114
149
  return `${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } })}\n`
115
150
  }
@@ -122,6 +157,7 @@ export function userTurnLine(protocol: AgentProtocol, text: string): string {
122
157
  * message is a normal turn rather than a resume.
123
158
  */
124
159
  export function interruptLine(protocol: AgentProtocol, requestId: string): string | null {
160
+ if (isRunner(protocol)) return runnerLine({ type: "interrupt", id: requestId })
125
161
  if (protocol !== "claude-stream") return null
126
162
  return `${JSON.stringify({ type: "control_request", request_id: requestId, request: { subtype: "interrupt" } })}\n`
127
163
  }
@@ -144,9 +180,12 @@ export function interruptLine(protocol: AgentProtocol, requestId: string): strin
144
180
  * `control_response` (an interrupt's, say) without keeping state between write and read.
145
181
  */
146
182
  export function sessionTitleRequestLine(protocol: AgentProtocol, requestId: string, description: string): string | null {
147
- if (protocol !== "claude-stream") return null
183
+ if (protocol !== "claude-stream" && protocol !== "claude-runner") return null
148
184
  const text = description.trim()
149
185
  if (!text) return null
186
+ // The Claude runner asks the SDK (`generateSessionTitle`), and answers with the very
187
+ // `control_response` the CLI would have printed — so the reading side below is one path.
188
+ if (protocol === "claude-runner") return runnerLine({ type: "title", id: `${TITLE_REQUEST_PREFIX}${requestId}`, description: text })
150
189
  return `${JSON.stringify({
151
190
  type: "control_request",
152
191
  request_id: `${TITLE_REQUEST_PREFIX}${requestId}`,
@@ -207,7 +246,7 @@ export function sessionTitleProbe(protocol: AgentProtocol, sessionId: string): s
207
246
  * session id is looked up, so a harness that says its name in band pays nothing for the question.
208
247
  */
209
248
  export function readsTitleOutOfBand(protocol: AgentProtocol): boolean {
210
- return protocol === "claude-json" || protocol === "claude-stream"
249
+ return speaksClaude(protocol)
211
250
  }
212
251
 
213
252
  /** Longest name we keep. The column allows 200; a harness that writes an essay gets cut. */
@@ -262,7 +301,9 @@ export function parseStreamLine(protocol: AgentProtocol, line: string): Normaliz
262
301
  }
263
302
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return []
264
303
  const obj = parsed as Record<string, unknown>
265
- return protocol === "claude-json" || protocol === "claude-stream" ? adaptClaudeJson(obj) : adaptPassthrough(obj)
304
+ if (speaksClaude(protocol)) return adaptClaudeJson(obj)
305
+ if (protocol === "codex-runner") return adaptCodex(obj)
306
+ return adaptPassthrough(obj)
266
307
  }
267
308
 
268
309
  /**
@@ -280,6 +321,117 @@ function adaptPassthrough(obj: Record<string, unknown>): NormalizedEvent[] {
280
321
  return [{ type: type as TaskEventType, payload }]
281
322
  }
282
323
 
324
+ /**
325
+ * `codex-runner` — the events of `@openai/codex-sdk`'s `runStreamed()` (`ThreadEvent`), printed one
326
+ * per line by the Codex runner (RFC 0048). Written against the SDK's own types (0.164.0); the
327
+ * runner adds nothing of its own beyond a `thread.started` for a resumed thread (the SDK only
328
+ * announces new ones) and a `turn.failed` when a turn is interrupted. Not yet run against a real
329
+ * OpenAI account: the shapes below were measured on a failing turn (401) only.
330
+ *
331
+ * Codex works per **turn**, not per session: `turn.completed` is a hand-back the way Claude's
332
+ * `result` is, so it ends the turn here too. Its usage is the turn's sum, which is why it is filed
333
+ * as a `call` (summed over the task) without a `contextTokens`: no single number of it is the
334
+ * size of the prompt last sent.
335
+ */
336
+ function adaptCodex(obj: Record<string, unknown>): NormalizedEvent[] {
337
+ const type = obj.type
338
+ if (type === "thread.started") {
339
+ return [{ type: "session.started", payload: { harness: "codex", model: null, sessionId: typeof obj.thread_id === "string" ? obj.thread_id : null } }]
340
+ }
341
+ if (type === "turn.started") return []
342
+ if (type === "turn.completed") {
343
+ const u = (obj.usage ?? {}) as Record<string, unknown>
344
+ return [
345
+ {
346
+ type: "usage",
347
+ payload: {
348
+ scope: "call",
349
+ model: null,
350
+ inputTokens: num(u.input_tokens) ?? 0,
351
+ outputTokens: (num(u.output_tokens) ?? 0) + (num(u.reasoning_output_tokens) ?? 0),
352
+ cacheReadTokens: num(u.cached_input_tokens) ?? 0,
353
+ cacheCreationTokens: num(u.cache_write_input_tokens) ?? 0,
354
+ },
355
+ },
356
+ { type: "session.ended", payload: { status: "succeeded", stopReason: null, summary: "" } },
357
+ ]
358
+ }
359
+ if (type === "turn.failed") {
360
+ const error = (obj.error ?? {}) as Record<string, unknown>
361
+ return [{ type: "session.ended", payload: { status: "failed", stopReason: null, summary: clip(error.message).text } }]
362
+ }
363
+ // Not an end: measured, Codex emits `error` for every retry ("Reconnecting... 2/5 (401 …)") and
364
+ // only the `turn.failed` that follows the last one ends the turn. Kept visible, not fatal.
365
+ if (type === "error") return [{ type: "raw", payload: { json: obj } }]
366
+ if (type !== "item.started" && type !== "item.updated" && type !== "item.completed") {
367
+ return [{ type: "raw", payload: { json: obj } }]
368
+ }
369
+ const item = (obj.item ?? {}) as Record<string, unknown>
370
+ const callId = typeof item.id === "string" ? item.id : null
371
+ const started = type === "item.started"
372
+ const completed = type === "item.completed"
373
+ switch (item.type) {
374
+ case "agent_message": {
375
+ if (!completed) return []
376
+ const { text, truncated } = clip(item.text)
377
+ return text ? [{ type: "message", payload: { role: "assistant", text, truncated } }] : []
378
+ }
379
+ case "reasoning": {
380
+ if (!completed) return []
381
+ const { text, truncated } = clip(item.text)
382
+ return text ? [{ type: "thinking", payload: { text, truncated } }] : []
383
+ }
384
+ case "command_execution": {
385
+ if (started) {
386
+ const { text: input, truncated } = clip({ command: item.command })
387
+ return [{ type: "tool.call", payload: { callId, name: "Bash", input, truncated } }]
388
+ }
389
+ if (!completed) return []
390
+ const { text: output, truncated } = clip(item.aggregated_output)
391
+ const failed = item.status === "failed" || (typeof item.exit_code === "number" && item.exit_code !== 0)
392
+ return [{ type: "tool.result", payload: { callId, isError: failed, output, truncated } }]
393
+ }
394
+ case "file_change": {
395
+ // Reported once, applied: the call and its outcome arrive together.
396
+ if (!completed) return []
397
+ const changes = Array.isArray(item.changes) ? item.changes : []
398
+ const { text: input, truncated } = clip({ changes })
399
+ const output = changes
400
+ .filter((c): c is Record<string, unknown> => !!c && typeof c === "object")
401
+ .map((c) => `${c.kind} ${c.path}`)
402
+ .join("\n")
403
+ return [
404
+ { type: "tool.call", payload: { callId, name: "Edit", input, truncated } },
405
+ { type: "tool.result", payload: { callId, isError: item.status === "failed", output, truncated: false } },
406
+ ]
407
+ }
408
+ case "mcp_tool_call": {
409
+ const name = `${typeof item.server === "string" ? item.server : "mcp"}.${typeof item.tool === "string" ? item.tool : "tool"}`
410
+ if (started) {
411
+ const { text: input, truncated } = clip(item.arguments)
412
+ return [{ type: "tool.call", payload: { callId, name, input, truncated } }]
413
+ }
414
+ if (!completed) return []
415
+ const error = (item.error ?? null) as Record<string, unknown> | null
416
+ const { text: output, truncated } = clip(error ? error.message : (item.result as Record<string, unknown> | undefined)?.content ?? "")
417
+ return [{ type: "tool.result", payload: { callId, isError: !!error || item.status === "failed", output, truncated } }]
418
+ }
419
+ case "web_search": {
420
+ if (!started) return []
421
+ const { text: input, truncated } = clip({ query: item.query })
422
+ return [{ type: "tool.call", payload: { callId, name: "WebSearch", input, truncated } }]
423
+ }
424
+ case "todo_list": {
425
+ const steps = (Array.isArray(item.items) ? item.items : [])
426
+ .filter((t): t is Record<string, unknown> => !!t && typeof t === "object")
427
+ .map((t) => ({ text: clip(t.text).text, status: t.completed === true ? "completed" : "pending" }))
428
+ .filter((s) => s.text)
429
+ return steps.length > 0 ? [{ type: "plan", payload: { steps } }] : []
430
+ }
431
+ }
432
+ return [{ type: "raw", payload: { json: obj } }]
433
+ }
434
+
283
435
  /**
284
436
  * `claude-json` — Claude Code's `--output-format stream-json --verbose`.
285
437
  *
@@ -20,9 +20,11 @@ export {
20
20
  cleanTitle,
21
21
  interruptLine,
22
22
  isInteractive,
23
+ isRunner,
23
24
  parseLines,
24
25
  parseSessionTitle,
25
26
  parseStreamLine,
27
+ PROJECT_AGENT_PROTOCOLS,
26
28
  readsTitleOutOfBand,
27
29
  sessionTitleProbe,
28
30
  sessionTitleRequestLine,
@@ -38,6 +40,7 @@ export type { TurnLimit, TurnLimitKind } from "./turn-limit"
38
40
  export type {
39
41
  AgentProtocol,
40
42
  NormalizedEvent,
43
+ RunnerInput,
41
44
  SessionUsage,
42
45
  SourcedEvent,
43
46
  TaskEventType,
@@ -40,6 +40,11 @@ export type DevFeature = {
40
40
  options?: DevFeatureOption[]
41
41
  /** Options always passed to the install script. A user's choice is merged over these. */
42
42
  defaultOptions?: Record<string, string>
43
+ /**
44
+ * Never picked by hand: the platform injects it into the image of a project whose agent app
45
+ * (RFC 0048) asks for it. Left out of the feature picker, but still resolved here like any other.
46
+ */
47
+ injected?: boolean
43
48
  }
44
49
 
45
50
  /**
@@ -205,6 +210,21 @@ export const AVAILABLE_FEATURES: DevFeature[] = [
205
210
  localScript: "google-cloud-cli.sh",
206
211
  description: "gcloud, gsutil, bq (local install script)",
207
212
  },
213
+ {
214
+ // The agent apps' features (RFC 0048, `../agent-runners`): injected, never picked.
215
+ id: "spunto-agent-claude",
216
+ label: "Claude (agent app)",
217
+ localScript: "spunto-agent-claude.sh",
218
+ description: "The Claude Agent SDK and the Node its runner uses — injected by the Claude app",
219
+ injected: true,
220
+ },
221
+ {
222
+ id: "spunto-agent-codex",
223
+ label: "Codex (agent app)",
224
+ localScript: "spunto-agent-codex.sh",
225
+ description: "The Codex SDK and the Node its runner uses — injected by the Codex app",
226
+ injected: true,
227
+ },
208
228
  {
209
229
  // Always installed, whether or not it's picked here (lib/setup-script.ts § the two features
210
230
  // every worker image is made of): it's what creates the `vscode` user. Listed anyway so a
@@ -181,8 +181,17 @@ function startDone(worker) {
181
181
  function projectState() {
182
182
  if (!project) return null
183
183
  const { plan } = project
184
- const baked = existsSync(join(DIR, `.baked-${plan.bakeKey}`))
185
184
  const alive = runnerPid() != null
185
+ // The recipe of the runner of this boot, not the plan's: a recipe change deployed while the box
186
+ // runs (new bakeKey, the plan re-sent at the agent's reconnection) applies at its next start, and
187
+ // the plan's marker is not there before. Read against the plan, the box said `baking` until then,
188
+ // `building` in the dashboard, while it was up and ready (2026-10-26, IMAGE_RECIPE_VERSION 3).
189
+ // A runner started by an agent that kept no key in started.json: past its bake once setup is done.
190
+ const last = lastStart()
191
+ const ofThisBoot = last != null && (alive || last.boot === bootId())
192
+ const baked = ofThisBoot && last.bakeKey
193
+ ? existsSync(join(DIR, `.baked-${last.bakeKey}`))
194
+ : existsSync(join(DIR, `.baked-${plan.bakeKey}`)) || (alive && existsSync(SETUP_DONE))
186
195
  let worker = null
187
196
  try {
188
197
  worker = JSON.parse(readFileSync(MP_STATUS, "utf8"))
@@ -304,7 +313,7 @@ function applyProject(plan) {
304
313
  }
305
314
  const log = openSync(join(DIR, "project.log"), "a")
306
315
  // Before the spawn: the worker script's `ready` of this start must be newer than it (startDone).
307
- writeFileSync(STARTED(), JSON.stringify({ boot, version: plan.version, exitCode: null, at: new Date().toISOString() }))
316
+ writeFileSync(STARTED(), JSON.stringify({ boot, version: plan.version, bakeKey: plan.bakeKey, exitCode: null, at: new Date().toISOString() }))
308
317
  // Detached, in its own session: it outlives an agent restart, like a container's CMD would.
309
318
  const child = spawn("bash", [join(DIR, "runner.sh"), DIR, plan.bakeKey, plan.bakeOnly ? "bake-only" : "full"], {
310
319
  cwd: "/",
@@ -2,13 +2,17 @@
2
2
  //
3
3
  // Embedded as strings rather than read from disk: the package does no I/O (see the README), and a
4
4
  // build script that inlines its own payload is also a build script that cannot fail on a missing
5
- // file. There is one today, and one is the right number — a local script is a maintenance burden
5
+ // file. There is one of those today, and one is the right number — a local script is a maintenance burden
6
6
  // the devcontainer ecosystem is supposed to carry, so the bar for adding another is "the upstream
7
7
  // feature does not exist or is broken", not "this was quicker".
8
8
  //
9
9
  // Keyed by the catalog's `localScript` filename so the two stay legible together.
10
10
 
11
+ import { AGENT_FEATURE_SCRIPTS } from "../agent-runners/features"
12
+
11
13
  export const LOCAL_FEATURE_SCRIPTS: Record<string, string> = {
14
+ // The agent apps' features (RFC 0048): ours by definition — nobody upstream installs our runner's SDK.
15
+ ...AGENT_FEATURE_SCRIPTS,
12
16
  // Google's own apt repository; the upstream devcontainer feature pins a gcloud version and
13
17
  // breaks whenever Google retires it.
14
18
  "google-cloud-cli.sh": `#!/bin/sh