@spunto/build 0.22.0 → 0.23.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.
@@ -1,16 +1,17 @@
1
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.
2
+ // The Claude runner (RFC 0048): written into the machine by the platform when a session starts —
3
+ // one file, **bundled** with its adapter by the platform (esbuild) so there is nothing to install —
4
+ // and run with the Node the `spunto-agent-claude` feature put in the image. It drives Claude
5
+ // through the official Agent SDK the same feature installed.
5
6
  //
6
- // The contract, the same for every agent app (`../agent-runners/index.ts`):
7
+ // The Spunto agent contract, the same for every agent app (docs/agent-contract.md, `../agent-contract`):
7
8
  //
8
9
  // node claude.mjs run [--model <id>] [--resume <session id>]
9
10
  // stdin one JSON object per line — {"type":"user","text"} · {"type":"interrupt","id"} ·
10
11
  // {"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`)
12
+ // stdout **our** vocabulary, one JSON object per line (`agent-v1`): each SDK message is read
13
+ // here (`./claude/adapter.ts`) and printed as the events it means, with the message
14
+ // itself as `source` — the platform has no Claude reader for it
14
15
  // env SPUNTO_AGENT_OPTIONS — the task's parameters, as declared by the app's catalog
15
16
  // (`./catalogs.ts`): `{"effort":"high"}`
16
17
  // SPUNTO_AGENT_MCP — the org's MCP servers (RFC 0049), in the platform's words:
@@ -21,10 +22,14 @@
21
22
  // exit 0 once the input is closed and the last turn is over, 1 when the session could not run
22
23
  //
23
24
  // Credentials are not this file's business: whatever the member set as secrets (an
24
- // `ANTHROPIC_API_KEY`) reaches the SDK's binary through the environment, untouched — this runner
25
- // reads no credential itself.
25
+ // `ANTHROPIC_API_KEY`) reaches the SDK's binary through the environment, untouched. The one thing
26
+ // the runner does with that key itself is ask the Models API how large the session's window is
27
+ // (`resolveContextWindow`) — an API key only, never a subscription token, which is the rule the
28
+ // platform followed when it asked on the session's behalf.
26
29
 
27
30
  import { createInterface } from "node:readline"
31
+ import { claudeTranslator } from "./claude/adapter"
32
+ import { resolveContextWindow } from "../agent-stream/model-window"
28
33
 
29
34
  const SDK = process.env.SPUNTO_CLAUDE_SDK || "/opt/spunto/agents/claude/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs"
30
35
 
@@ -32,12 +37,32 @@ function out(obj) {
32
37
  process.stdout.write(`${JSON.stringify(obj)}\n`)
33
38
  }
34
39
 
35
- /** A failure the platform can read: the shape of the CLI's own failed `result`. */
40
+ /** A failure the platform can read: the turn ends, failed, saying why — then a non-zero exit. */
36
41
  function fail(message) {
37
- out({ type: "result", subtype: "error_during_execution", is_error: true, result: message })
42
+ out({ type: "session.ended", status: "failed", stopReason: null, summary: message })
38
43
  process.exit(1)
39
44
  }
40
45
 
46
+ /** How the window is asked about: an API key, the only credential the platform ever used for it. */
47
+ const apiKey = process.env.ANTHROPIC_API_KEY?.trim()
48
+ const credential = apiKey ? { kind: "api-key", value: apiKey } : null
49
+
50
+ /**
51
+ * Print one SDK message as the contract. The line that opens the session carries the model's own
52
+ * window when the Models API answers (the adapter only knows a table), so it is measured here, once
53
+ * per session, before the line goes out — the platform no longer asks anyone on a session's behalf.
54
+ */
55
+ const translate = claudeTranslator()
56
+ async function emit(message) {
57
+ for (const line of translate(message)) {
58
+ if (line.type === "session.started" && typeof line.model === "string") {
59
+ const measured = await resolveContextWindow(line.model, credential).catch(() => null)
60
+ if (measured !== null) line.contextWindow = measured
61
+ }
62
+ out(line)
63
+ }
64
+ }
65
+
41
66
  function parseArgs(argv) {
42
67
  const [verb, ...rest] = argv
43
68
  if (verb !== "run") fail(`claude runner: unknown verb "${verb ?? ""}" (expected "run")`)
@@ -165,15 +190,13 @@ createInterface({ input: process.stdin, crlfDelay: Infinity })
165
190
  } else if (msg.type === "interrupt") {
166
191
  session.interrupt().catch((err) => process.stderr.write(`claude runner: interrupt failed: ${err?.message ?? err}\n`))
167
192
  } else if (msg.type === "title" && typeof msg.description === "string") {
168
- // Answered with the `control_response` the CLI would have printed, so the platform reads
169
- // a title one way whichever side of the SDK the session runs on.
193
+ // Asked of the SDK (Claude names the conversation from the human's words), and said in band.
170
194
  if (typeof session.generateSessionTitle !== "function") return
171
195
  session
172
196
  .generateSessionTitle(msg.description, { persist: true })
173
197
  .then((title) => {
174
- if (typeof title === "string" && title) {
175
- out({ type: "control_response", response: { subtype: "success", request_id: msg.id, response: { title } } })
176
- }
198
+ const clean = typeof title === "string" ? title.replace(/\s+/g, " ").trim().slice(0, 200) : ""
199
+ if (clean) out({ type: "session.title", title: clean })
177
200
  })
178
201
  .catch((err) => process.stderr.write(`claude runner: title failed: ${err?.message ?? err}\n`))
179
202
  }
@@ -185,7 +208,7 @@ createInterface({ input: process.stdin, crlfDelay: Infinity })
185
208
  let lastType = null
186
209
  try {
187
210
  for await (const message of session) {
188
- out(message)
211
+ await emit(message)
189
212
  lastType = message?.type ?? null
190
213
  }
191
214
  } catch (err) {
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Codex's dialect, read into our vocabulary — the events of `@openai/codex-sdk`'s `runStreamed()`
3
+ * (`ThreadEvent`). Written against the SDK's own types (0.164.0).
4
+ *
5
+ * Lives with the Codex runner (RFC 0048 § 9): the runner bundles it and prints the contract
6
+ * (`codexContractLines`). The platform's legacy reader still imports `adaptCodex` for sessions
7
+ * started on the runner's old dialect, until they end.
8
+ *
9
+ * Codex works per **turn**, not per session: `turn.completed` is a hand-back the way Claude's
10
+ * `result` is, so it ends the turn here too. Its usage is the turn's sum, which is why it is filed
11
+ * as a `call` (summed over the task) without a `contextTokens`: no single number of it is the
12
+ * size of the prompt last sent.
13
+ */
14
+
15
+ import { clip, type NormalizedEvent } from "../../agent-contract/vocabulary"
16
+
17
+ function num(value: unknown): number | null {
18
+ return typeof value === "number" && Number.isFinite(value) ? value : null
19
+ }
20
+
21
+ export function adaptCodex(obj: Record<string, unknown>): NormalizedEvent[] {
22
+ const type = obj.type
23
+ if (type === "thread.started") {
24
+ return [{ type: "session.started", payload: { harness: "codex", model: null, sessionId: typeof obj.thread_id === "string" ? obj.thread_id : null } }]
25
+ }
26
+ if (type === "turn.started") return []
27
+ if (type === "turn.completed") {
28
+ const u = (obj.usage ?? {}) as Record<string, unknown>
29
+ return [
30
+ {
31
+ type: "usage",
32
+ payload: {
33
+ scope: "call",
34
+ model: null,
35
+ inputTokens: num(u.input_tokens) ?? 0,
36
+ outputTokens: (num(u.output_tokens) ?? 0) + (num(u.reasoning_output_tokens) ?? 0),
37
+ cacheReadTokens: num(u.cached_input_tokens) ?? 0,
38
+ cacheCreationTokens: num(u.cache_write_input_tokens) ?? 0,
39
+ },
40
+ },
41
+ { type: "session.ended", payload: { status: "succeeded", stopReason: null, summary: "" } },
42
+ ]
43
+ }
44
+ if (type === "turn.failed") {
45
+ const error = (obj.error ?? {}) as Record<string, unknown>
46
+ return [{ type: "session.ended", payload: { status: "failed", stopReason: null, summary: clip(error.message).text } }]
47
+ }
48
+ // Not an end: measured, Codex emits `error` for every retry ("Reconnecting... 2/5 (401 …)") and
49
+ // only the `turn.failed` that follows the last one ends the turn. Kept visible, not fatal.
50
+ if (type === "error") return [{ type: "raw", payload: { json: obj } }]
51
+ if (type !== "item.started" && type !== "item.updated" && type !== "item.completed") {
52
+ return [{ type: "raw", payload: { json: obj } }]
53
+ }
54
+ const item = (obj.item ?? {}) as Record<string, unknown>
55
+ const callId = typeof item.id === "string" ? item.id : null
56
+ const started = type === "item.started"
57
+ const completed = type === "item.completed"
58
+ switch (item.type) {
59
+ case "agent_message": {
60
+ if (!completed) return []
61
+ const { text, truncated } = clip(item.text)
62
+ return text ? [{ type: "message", payload: { role: "assistant", text, truncated } }] : []
63
+ }
64
+ case "reasoning": {
65
+ if (!completed) return []
66
+ const { text, truncated } = clip(item.text)
67
+ return text ? [{ type: "thinking", payload: { text, truncated } }] : []
68
+ }
69
+ case "command_execution": {
70
+ if (started) {
71
+ const { text: input, truncated } = clip({ command: item.command })
72
+ return [{ type: "tool.call", payload: { callId, name: "Bash", input, truncated } }]
73
+ }
74
+ if (!completed) return []
75
+ const { text: output, truncated } = clip(item.aggregated_output)
76
+ const failed = item.status === "failed" || (typeof item.exit_code === "number" && item.exit_code !== 0)
77
+ return [{ type: "tool.result", payload: { callId, isError: failed, output, truncated } }]
78
+ }
79
+ case "file_change": {
80
+ // Reported once, applied: the call and its outcome arrive together.
81
+ if (!completed) return []
82
+ const changes = Array.isArray(item.changes) ? item.changes : []
83
+ const { text: input, truncated } = clip({ changes })
84
+ const output = changes
85
+ .filter((c): c is Record<string, unknown> => !!c && typeof c === "object")
86
+ .map((c) => `${c.kind} ${c.path}`)
87
+ .join("\n")
88
+ return [
89
+ { type: "tool.call", payload: { callId, name: "Edit", input, truncated } },
90
+ { type: "tool.result", payload: { callId, isError: item.status === "failed", output, truncated: false } },
91
+ ]
92
+ }
93
+ case "mcp_tool_call": {
94
+ const name = `${typeof item.server === "string" ? item.server : "mcp"}.${typeof item.tool === "string" ? item.tool : "tool"}`
95
+ if (started) {
96
+ const { text: input, truncated } = clip(item.arguments)
97
+ return [{ type: "tool.call", payload: { callId, name, input, truncated } }]
98
+ }
99
+ if (!completed) return []
100
+ const error = (item.error ?? null) as Record<string, unknown> | null
101
+ const { text: output, truncated } = clip(error ? error.message : (item.result as Record<string, unknown> | undefined)?.content ?? "")
102
+ return [{ type: "tool.result", payload: { callId, isError: !!error || item.status === "failed", output, truncated } }]
103
+ }
104
+ case "web_search": {
105
+ if (!started) return []
106
+ const { text: input, truncated } = clip({ query: item.query })
107
+ return [{ type: "tool.call", payload: { callId, name: "WebSearch", input, truncated } }]
108
+ }
109
+ case "todo_list": {
110
+ const steps = (Array.isArray(item.items) ? item.items : [])
111
+ .filter((t): t is Record<string, unknown> => !!t && typeof t === "object")
112
+ .map((t) => ({ text: clip(t.text).text, status: t.completed === true ? "completed" : "pending" }))
113
+ .filter((s) => s.text)
114
+ return steps.length > 0 ? [{ type: "plan", payload: { steps } }] : []
115
+ }
116
+ }
117
+ return [{ type: "raw", payload: { json: obj } }]
118
+ }
119
+
120
+ /**
121
+ * An `agent_message` item being written, as the contract's transient `message.delta`. Codex hands
122
+ * the whole text so far on `item.started` / `item.updated`, not a delta, so every one opens the
123
+ * draft afresh with it (`start`); the completed item, stored as the `message`, closes it.
124
+ */
125
+ export function codexDelta(obj: Record<string, unknown>): Record<string, unknown>[] {
126
+ if (obj.type !== "item.started" && obj.type !== "item.updated") return []
127
+ const item = (obj.item ?? {}) as Record<string, unknown>
128
+ if (item.type !== "agent_message" || typeof item.text !== "string" || !item.text) return []
129
+ return [{ type: "message.delta", start: true, text: item.text }]
130
+ }
131
+
132
+ /** One `ThreadEvent`, as the lines of the contract the Codex runner prints, each with the event as `source`. */
133
+ export function codexContractLines(event: Record<string, unknown>): Record<string, unknown>[] {
134
+ return [...codexDelta(event), ...adaptCodex(event).map((e) => ({ type: e.type, ...e.payload, source: event }))]
135
+ }
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
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.
3
+ // with the `codex` binary it drives by the `spunto-agent-codex` feature. Bundled with its adapter
4
+ // by the platform, like every runner.
4
5
  //
5
6
  // node codex.mjs run [--model <id>] [--resume <thread id>]
6
7
  // stdin {"type":"user","text"} · {"type":"interrupt","id"} · {"type":"title",…} (ignored: Codex
7
8
  // 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
+ // stdout **our** vocabulary (`agent-v1`): each `ThreadEvent` read here (`./codex/adapter.ts`),
10
+ // with the event itself as `source`
9
11
  // env SPUNTO_AGENT_MCP — the org's MCP servers, through the platform's gateway (RFC 0049)
10
12
  // env SPUNTO_AGENT_OPTIONS — the task's parameters (`./catalogs.ts`):
11
13
  // `{"reasoningEffort":"high","webSearch":"live"}`
@@ -19,6 +21,7 @@
19
21
  // the machine's terminal (`~/.codex/auth.json`), which the binary reads on its own.
20
22
 
21
23
  import { createInterface } from "node:readline"
24
+ import { codexContractLines } from "./codex/adapter"
22
25
 
23
26
  const SDK = process.env.SPUNTO_CODEX_SDK || "/opt/spunto/agents/codex/node_modules/@openai/codex-sdk/dist/index.js"
24
27
 
@@ -26,8 +29,13 @@ function out(obj) {
26
29
  process.stdout.write(`${JSON.stringify(obj)}\n`)
27
30
  }
28
31
 
32
+ /** One `ThreadEvent` (or one the runner stands in for the SDK), as the contract. */
33
+ function emit(event) {
34
+ for (const line of codexContractLines(event)) out(line)
35
+ }
36
+
29
37
  function fail(message) {
30
- out({ type: "turn.failed", error: { message } })
38
+ emit({ type: "turn.failed", error: { message } })
31
39
  process.exit(1)
32
40
  }
33
41
 
@@ -110,7 +118,7 @@ const threadOptions = {
110
118
  const thread = args.resume ? codex.resumeThread(args.resume, threadOptions) : codex.startThread(threadOptions)
111
119
  // A resumed thread is not announced by the SDK (`thread.started` only comes with a new one), and
112
120
  // the platform learns the session id from that line — so the runner says it.
113
- if (args.resume) out({ type: "thread.started", thread_id: args.resume })
121
+ if (args.resume) emit({ type: "thread.started", thread_id: args.resume })
114
122
 
115
123
  const pending = []
116
124
  let closed = false
@@ -154,14 +162,14 @@ for (;;) {
154
162
  try {
155
163
  const { events } = await thread.runStreamed(text, { signal: current.signal })
156
164
  for await (const event of events) {
157
- out(event)
165
+ emit(event)
158
166
  lastType = event?.type ?? null
159
167
  }
160
168
  } catch (err) {
161
169
  if (current.signal.aborted) {
162
- out({ type: "turn.failed", error: { message: "Interrupted." } })
170
+ emit({ type: "turn.failed", error: { message: "Interrupted." } })
163
171
  } else if (lastType !== "turn.failed") {
164
- out({ type: "turn.failed", error: { message: `codex runner: ${err?.message ?? err}` } })
172
+ emit({ type: "turn.failed", error: { message: `codex runner: ${err?.message ?? err}` } })
165
173
  failed = true
166
174
  }
167
175
  }
@@ -7,23 +7,25 @@
7
7
  * installs a private Node and the harness's **official SDK** (with the binary the SDK drives)
8
8
  * under `/opt/spunto/agents/<id>`. Shipped by the platform itself (`localScript`), so an app
9
9
  * never waits on a registry release;
10
- * - a **runner**, 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.
10
+ * - a **runner**, a `.mjs` entry of ours (`claude.mjs`, `codex.mjs`) with its harness's adapter
11
+ * next to it (`claude/adapter.ts`, `codex/adapter.ts`). The caller **bundles** the two into one
12
+ * file (esbuild, `platform: node`, `format: esm`) and writes it into the machine at the moment a
13
+ * session starts. Not baked: a runner fix reaches the next session, not the next rebuild;
14
+ * - the **Spunto agent contract** (`agent-v1`, `../agent-contract`), which is how the platform
15
+ * writes the runner's stdin and reads its stdout — the same for every app.
15
16
  *
16
- * The runner contract, identical for every app:
17
+ * The runner contract, identical for every app (docs/agent-contract.md):
17
18
  *
18
19
  * <node> <runner>.mjs run [--model <id>] [--resume <session id>]
19
20
  * stdin one JSON object per line: {"type":"user","text"} · {"type":"interrupt","id"} ·
20
21
  * {"type":"title","id","description"} (`RunnerInput`); closed ⇒ end after the turn
21
22
  * 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
23
+ * stdout our vocabulary, one JSON object per line, each event with its harness message as `source`
24
+ * exit 0 = the input closed and the last turn ended; ≠ 0 = the session could not run, and its
25
+ * last line is a failed `session.ended` saying why
24
26
  *
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
+ * Pure like the rest of the package: what a caller gets is *where* a runner entry is, and the shell
28
+ * that starts it. Bundling and reading the file are the caller's I/O.
27
29
  */
28
30
 
29
31
  import type { AgentProtocol } from "../agent-stream/agent-stream"
@@ -39,9 +41,14 @@ export type AgentHarness = {
39
41
  id: AgentHarnessId
40
42
  /** The catalog id of the feature the platform injects (`AVAILABLE_FEATURES`, `localScript`). */
41
43
  featureId: string
42
- /** How the platform talks to the runner. */
44
+ /** How the platform talks to the runner — the contract, for every app. */
43
45
  protocol: AgentProtocol
44
- /** The runner, a file of this package. */
46
+ /**
47
+ * What this app's runner printed before the contract, and so how a session it started then is
48
+ * still read, until it ends (RFC 0048 § 9: a session keeps the dialect it began on).
49
+ */
50
+ legacyProtocol: AgentProtocol
51
+ /** The runner's entry, a file of this package — to be bundled with what it imports. */
45
52
  runnerFile: URL
46
53
  /** Where the harness looks for user-level skills (RFC 0033), as shell evaluated in the machine. */
47
54
  skillDirs: string[]
@@ -51,14 +58,16 @@ export const AGENT_HARNESSES: Record<AgentHarnessId, AgentHarness> = {
51
58
  claude: {
52
59
  id: "claude",
53
60
  featureId: "spunto-agent-claude",
54
- protocol: "claude-runner",
61
+ protocol: "agent-v1",
62
+ legacyProtocol: "claude-runner",
55
63
  runnerFile: new URL("./claude.mjs", import.meta.url),
56
64
  skillDirs: ["${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills"],
57
65
  },
58
66
  codex: {
59
67
  id: "codex",
60
68
  featureId: "spunto-agent-codex",
61
- protocol: "codex-runner",
69
+ protocol: "agent-v1",
70
+ legacyProtocol: "codex-runner",
62
71
  runnerFile: new URL("./codex.mjs", import.meta.url),
63
72
  skillDirs: ["${CODEX_HOME:-$HOME/.codex}/skills"],
64
73
  },