@spunto/build 0.19.1 → 0.20.1
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 +8 -2
- package/src/agent-runners/catalogs.ts +140 -0
- package/src/agent-runners/claude.mjs +160 -0
- package/src/agent-runners/codex.mjs +143 -0
- package/src/agent-runners/features.ts +77 -0
- package/src/agent-runners/index.ts +95 -0
- package/src/agent-stream/agent-stream.ts +157 -5
- package/src/agent-stream/index.ts +3 -0
- package/src/catalogs/index.ts +20 -0
- package/src/script/local-features.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
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, on
|
|
11
|
+
* 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,160 @@
|
|
|
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: whatever the member set as secrets (an
|
|
19
|
+
// `ANTHROPIC_API_KEY`) reaches the SDK's binary through the environment, untouched — this runner
|
|
20
|
+
// reads no credential itself.
|
|
21
|
+
|
|
22
|
+
import { createInterface } from "node:readline"
|
|
23
|
+
|
|
24
|
+
const SDK = process.env.SPUNTO_CLAUDE_SDK || "/opt/spunto/agents/claude/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs"
|
|
25
|
+
|
|
26
|
+
function out(obj) {
|
|
27
|
+
process.stdout.write(`${JSON.stringify(obj)}\n`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A failure the platform can read: the shape of the CLI's own failed `result`. */
|
|
31
|
+
function fail(message) {
|
|
32
|
+
out({ type: "result", subtype: "error_during_execution", is_error: true, result: message })
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const [verb, ...rest] = argv
|
|
38
|
+
if (verb !== "run") fail(`claude runner: unknown verb "${verb ?? ""}" (expected "run")`)
|
|
39
|
+
const args = {}
|
|
40
|
+
for (let i = 0; i < rest.length; i++) {
|
|
41
|
+
const flag = rest[i]
|
|
42
|
+
if (flag === "--model" || flag === "--resume") {
|
|
43
|
+
const value = rest[++i]
|
|
44
|
+
if (value) args[flag.slice(2)] = value
|
|
45
|
+
} else {
|
|
46
|
+
fail(`claude runner: unknown flag "${flag}"`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return args
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The human's turns, as the async iterable the SDK reads its prompt from. Ends when stdin does. */
|
|
53
|
+
function inputQueue() {
|
|
54
|
+
const pending = []
|
|
55
|
+
let wake = null
|
|
56
|
+
let closed = false
|
|
57
|
+
return {
|
|
58
|
+
push(message) {
|
|
59
|
+
pending.push(message)
|
|
60
|
+
wake?.()
|
|
61
|
+
},
|
|
62
|
+
close() {
|
|
63
|
+
closed = true
|
|
64
|
+
wake?.()
|
|
65
|
+
},
|
|
66
|
+
async *[Symbol.asyncIterator]() {
|
|
67
|
+
for (;;) {
|
|
68
|
+
if (pending.length > 0) {
|
|
69
|
+
yield pending.shift()
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (closed) return
|
|
73
|
+
await new Promise((resolve) => (wake = resolve))
|
|
74
|
+
wake = null
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const args = parseArgs(process.argv.slice(2))
|
|
81
|
+
|
|
82
|
+
/** The task's parameters. Checked by the platform against the catalog; read defensively all the same. */
|
|
83
|
+
function readOptions() {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(process.env.SPUNTO_AGENT_OPTIONS || "{}")
|
|
86
|
+
return parsed && typeof parsed === "object" ? parsed : {}
|
|
87
|
+
} catch {
|
|
88
|
+
return {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const params = readOptions()
|
|
92
|
+
|
|
93
|
+
let sdk
|
|
94
|
+
try {
|
|
95
|
+
sdk = await import(SDK)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
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})`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const input = inputQueue()
|
|
101
|
+
const session = sdk.query({
|
|
102
|
+
prompt: input,
|
|
103
|
+
options: {
|
|
104
|
+
cwd: process.cwd(),
|
|
105
|
+
...(args.model ? { model: args.model } : {}),
|
|
106
|
+
...(args.resume ? { resume: args.resume } : {}),
|
|
107
|
+
...(typeof params.effort === "string" ? { effort: params.effort } : {}),
|
|
108
|
+
// A task runs unattended in a disposable machine — what `--dangerously-skip-permissions` did.
|
|
109
|
+
permissionMode: "bypassPermissions",
|
|
110
|
+
allowDangerouslySkipPermissions: true,
|
|
111
|
+
// Claude Code's own system prompt, CLAUDE.md, settings and skills: the session must behave
|
|
112
|
+
// exactly like the CLI it replaces, not like a bare SDK agent.
|
|
113
|
+
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
114
|
+
settingSources: ["user", "project", "local"],
|
|
115
|
+
stderr: (line) => process.stderr.write(line),
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
120
|
+
.on("line", (line) => {
|
|
121
|
+
let msg
|
|
122
|
+
try {
|
|
123
|
+
msg = JSON.parse(line)
|
|
124
|
+
} catch {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (!msg || typeof msg !== "object") return
|
|
128
|
+
if (msg.type === "user" && typeof msg.text === "string") {
|
|
129
|
+
input.push({ type: "user", message: { role: "user", content: [{ type: "text", text: msg.text }] }, parent_tool_use_id: null })
|
|
130
|
+
} else if (msg.type === "interrupt") {
|
|
131
|
+
session.interrupt().catch((err) => process.stderr.write(`claude runner: interrupt failed: ${err?.message ?? err}\n`))
|
|
132
|
+
} else if (msg.type === "title" && typeof msg.description === "string") {
|
|
133
|
+
// Answered with the `control_response` the CLI would have printed, so the platform reads
|
|
134
|
+
// a title one way whichever side of the SDK the session runs on.
|
|
135
|
+
if (typeof session.generateSessionTitle !== "function") return
|
|
136
|
+
session
|
|
137
|
+
.generateSessionTitle(msg.description, { persist: true })
|
|
138
|
+
.then((title) => {
|
|
139
|
+
if (typeof title === "string" && title) {
|
|
140
|
+
out({ type: "control_response", response: { subtype: "success", request_id: msg.id, response: { title } } })
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
.catch((err) => process.stderr.write(`claude runner: title failed: ${err?.message ?? err}\n`))
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
.on("close", () => input.close())
|
|
147
|
+
|
|
148
|
+
// The SDK also throws after a failed `result` it has just yielded; that result already said why,
|
|
149
|
+
// and a second one would end the turn twice in the timeline.
|
|
150
|
+
let lastType = null
|
|
151
|
+
try {
|
|
152
|
+
for await (const message of session) {
|
|
153
|
+
out(message)
|
|
154
|
+
lastType = message?.type ?? null
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (lastType === "result") process.exit(1)
|
|
158
|
+
fail(`claude runner: ${err?.message ?? err}`)
|
|
159
|
+
}
|
|
160
|
+
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
|
|
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
|
-
|
|
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,
|
package/src/catalogs/index.ts
CHANGED
|
@@ -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
|
|
@@ -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
|