@agent-compose/sdk 0.8.2 → 0.8.4
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/dist/agent/__tests__/perf-sampler.test.d.ts +10 -0
- package/dist/agent/agent-context.d.ts +1 -1
- package/dist/agent/agent-loop.d.ts +9 -1
- package/dist/agent/desktop-open.d.ts +184 -0
- package/dist/agent/perf-sampler.d.ts +99 -0
- package/dist/agent/services-manifest.d.ts +88 -0
- package/dist/agent/services-restore.d.ts +58 -0
- package/dist/client.d.ts +164 -8
- package/dist/display.d.ts +17 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +1393 -53
- package/dist/runtimes/_cli-agent.d.ts +359 -2
- package/dist/runtimes/claude-code.d.ts +12 -0
- package/dist/runtimes/codex.d.ts +8 -0
- package/dist/runtimes/openai-desktop.js +1329 -53
- package/dist/runtimes/session-env.test.d.ts +14 -0
- package/dist/sandbox.d.ts +1 -1
- package/dist/types/api-conversations.d.ts +309 -1
- package/dist/types/api-factory.d.ts +115 -10
- package/dist/types/api-runs.d.ts +21 -0
- package/dist/types/protocol.d.ts +44 -1
- package/dist/types/runtime.d.ts +120 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +100 -11
- package/src/agent/agent-loop.ts +15 -3
- package/src/agent/desktop-open.ts +418 -0
- package/src/agent/perf-sampler.ts +202 -0
- package/src/agent/services-manifest.ts +356 -0
- package/src/agent/services-restore.ts +195 -0
- package/src/client.ts +328 -12
- package/src/display.ts +44 -1
- package/src/index.ts +65 -2
- package/src/runtimes/_cli-agent.ts +911 -35
- package/src/runtimes/claude-code.ts +198 -14
- package/src/runtimes/codex.ts +58 -1
- package/src/sandbox/providers/e2b.ts +29 -1
- package/src/sandbox/providers/local.ts +16 -4
- package/src/sandbox.ts +1 -1
- package/src/types/api-conversations.ts +307 -3
- package/src/types/api-factory.ts +118 -10
- package/src/types/api-runs.ts +23 -0
- package/src/types/protocol.ts +44 -1
- package/src/types/runtime.ts +122 -0
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* token-metering gateway's Anthropic passthrough (ADR-0039).
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
import type { AgentMessage } from "../index.js";
|
|
37
|
+
import type { AgentMessage, AgentMessageTaskNotification } from "../index.js";
|
|
38
38
|
import { createCliAgentRuntime, shellQuote, type CliAgentSpec, type CliReasoningEffort } from "./_cli-agent.js";
|
|
39
39
|
import { formatError } from "../utils/errors.js";
|
|
40
40
|
|
|
@@ -72,6 +72,140 @@ function toolResultText(content: unknown): string {
|
|
|
72
72
|
return content == null ? "" : JSON.stringify(content) ?? "";
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// ── Task notifications (background-task completion evidence) ────────────────
|
|
76
|
+
//
|
|
77
|
+
// When a background task stops — an async Agent spawn finishing, a
|
|
78
|
+
// background command exiting — claude-code's completion evidence reaches
|
|
79
|
+
// the stream in TWO shapes, and BOTH are mapped here (either one dropped
|
|
80
|
+
// leaves transcripts believing async agents run forever — the incident
|
|
81
|
+
// where spawn cards never left "launched"):
|
|
82
|
+
//
|
|
83
|
+
// - `system` events with `subtype: "task_notification"` — the ONLY shape
|
|
84
|
+
// `-p --output-format stream-json` actually emits, verified live on
|
|
85
|
+
// 2.1.212 (the baked E2B version) and 2.1.236, both when the task
|
|
86
|
+
// finishes MID-turn and on the idle wake (where it precedes a fresh
|
|
87
|
+
// system/init and a result stamped `origin.kind: "task-notification"`).
|
|
88
|
+
// Flat JSON: task_id / tool_use_id / status / summary, plus
|
|
89
|
+
// `usage.{total_tokens,tool_uses,duration_ms}` for agent tasks.
|
|
90
|
+
// - `<task-notification>` XML blocks as USER-role text — the form the
|
|
91
|
+
// harness injects into the model's own conversation (and the shape a
|
|
92
|
+
// resumed turn can surface as a user event). Kept as the second arm so
|
|
93
|
+
// neither transport ever depends on which side of a resume the
|
|
94
|
+
// notification lands.
|
|
95
|
+
//
|
|
96
|
+
// Parsed into structure; the internal plumbing either shape carries
|
|
97
|
+
// (output-file paths, resume hints in <note>/<diagnostics>) is deliberately
|
|
98
|
+
// not forwarded — no renderer should ever see it.
|
|
99
|
+
|
|
100
|
+
const TASK_NOTIFICATION_RE = /<task-notification>([\s\S]*?)<\/task-notification>/g;
|
|
101
|
+
|
|
102
|
+
const NOTIFICATION_SUMMARY_MAX = 500;
|
|
103
|
+
const NOTIFICATION_REPORT_MAX = 20_000;
|
|
104
|
+
|
|
105
|
+
/** First `<tag>…</tag>` inside a notification body, or null. */
|
|
106
|
+
function innerTag(body: string, tag: string): string | null {
|
|
107
|
+
const m = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(body);
|
|
108
|
+
const text = m?.[1]?.trim() ?? "";
|
|
109
|
+
return text.length > 0 ? text : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The harness entity-escapes text it embeds into notification XML —
|
|
113
|
+
* reverse the closed five so the report reads as the agent wrote it. */
|
|
114
|
+
function unescapeEntities(s: string): string {
|
|
115
|
+
return s
|
|
116
|
+
.replace(/</g, "<")
|
|
117
|
+
.replace(/>/g, ">")
|
|
118
|
+
.replace(/"/g, '"')
|
|
119
|
+
.replace(/'/g, "'")
|
|
120
|
+
.replace(/&/g, "&");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const clip = (s: string, max: number): string =>
|
|
124
|
+
(s.length > max ? `${s.slice(0, max - 1)}…` : s);
|
|
125
|
+
|
|
126
|
+
function intTag(body: string, tag: string): number | undefined {
|
|
127
|
+
const raw = innerTag(body, tag);
|
|
128
|
+
if (!raw) return undefined;
|
|
129
|
+
const n = Number.parseInt(raw, 10);
|
|
130
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Parse every `<task-notification>` block out of one user-role text blob.
|
|
134
|
+
* Pure and tolerant over untrusted harness text: a block without a task id
|
|
135
|
+
* is skipped, absent fields stay absent, everything is clamped. Exported
|
|
136
|
+
* for tests. */
|
|
137
|
+
export function parseTaskNotifications(
|
|
138
|
+
text: string, timestamp: string,
|
|
139
|
+
): AgentMessageTaskNotification[] {
|
|
140
|
+
if (!text.includes("<task-notification>")) return [];
|
|
141
|
+
const out: AgentMessageTaskNotification[] = [];
|
|
142
|
+
TASK_NOTIFICATION_RE.lastIndex = 0;
|
|
143
|
+
for (let m = TASK_NOTIFICATION_RE.exec(text); m !== null; m = TASK_NOTIFICATION_RE.exec(text)) {
|
|
144
|
+
const body = m[1] ?? "";
|
|
145
|
+
const taskId = innerTag(body, "task-id");
|
|
146
|
+
if (!taskId) continue;
|
|
147
|
+
const toolUseId = innerTag(body, "tool-use-id");
|
|
148
|
+
const summary = innerTag(body, "summary");
|
|
149
|
+
const report = innerTag(body, "result");
|
|
150
|
+
const usageBody = /<usage>([\s\S]*?)<\/usage>/.exec(body)?.[1] ?? "";
|
|
151
|
+
const tokens = intTag(usageBody, "subagent_tokens");
|
|
152
|
+
const toolUses = intTag(usageBody, "tool_uses");
|
|
153
|
+
const durationMs = intTag(usageBody, "duration_ms");
|
|
154
|
+
out.push({
|
|
155
|
+
type: "task_notification",
|
|
156
|
+
taskId: clip(taskId, 128),
|
|
157
|
+
...(toolUseId ? { toolUseId: clip(toolUseId, 128) } : {}),
|
|
158
|
+
status: innerTag(body, "status") ?? "finished",
|
|
159
|
+
summary: summary ? clip(unescapeEntities(summary.replace(/\s+/g, " ")), NOTIFICATION_SUMMARY_MAX) : "",
|
|
160
|
+
...(report ? { report: clip(unescapeEntities(report), NOTIFICATION_REPORT_MAX) } : {}),
|
|
161
|
+
...(tokens !== undefined || toolUses !== undefined || durationMs !== undefined
|
|
162
|
+
? { usage: {
|
|
163
|
+
...(tokens !== undefined ? { tokens } : {}),
|
|
164
|
+
...(toolUses !== undefined ? { toolUses } : {}),
|
|
165
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
166
|
+
} }
|
|
167
|
+
: {}),
|
|
168
|
+
timestamp,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One `system`/`task_notification` stream-json event mapped onto the same
|
|
175
|
+
* structured message the XML parse produces, or null when the event names
|
|
176
|
+
* no task id. Pure and tolerant over untrusted harness JSON: absent fields
|
|
177
|
+
* stay absent, everything is clamped; `output_file` (internal plumbing) is
|
|
178
|
+
* deliberately not forwarded. Exported for tests. */
|
|
179
|
+
export function parseSystemTaskNotification(
|
|
180
|
+
p: Record<string, unknown>, timestamp: string,
|
|
181
|
+
): AgentMessageTaskNotification | null {
|
|
182
|
+
const taskId = typeof p.task_id === "string" && p.task_id.trim().length > 0 ? p.task_id.trim() : null;
|
|
183
|
+
if (!taskId) return null;
|
|
184
|
+
const toolUseId = typeof p.tool_use_id === "string" && p.tool_use_id.length > 0 ? p.tool_use_id : null;
|
|
185
|
+
const summary = typeof p.summary === "string" ? p.summary : "";
|
|
186
|
+
const nonneg = (v: unknown): number | undefined =>
|
|
187
|
+
typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
|
|
188
|
+
const u = (typeof p.usage === "object" && p.usage !== null ? p.usage : {}) as Record<string, unknown>;
|
|
189
|
+
const tokens = nonneg(u.total_tokens);
|
|
190
|
+
const toolUses = nonneg(u.tool_uses);
|
|
191
|
+
const durationMs = nonneg(u.duration_ms);
|
|
192
|
+
return {
|
|
193
|
+
type: "task_notification",
|
|
194
|
+
taskId: clip(taskId, 128),
|
|
195
|
+
...(toolUseId ? { toolUseId: clip(toolUseId, 128) } : {}),
|
|
196
|
+
status: typeof p.status === "string" && p.status.length > 0 ? p.status : "finished",
|
|
197
|
+
summary: clip(summary.replace(/\s+/g, " ").trim(), NOTIFICATION_SUMMARY_MAX),
|
|
198
|
+
...(tokens !== undefined || toolUses !== undefined || durationMs !== undefined
|
|
199
|
+
? { usage: {
|
|
200
|
+
...(tokens !== undefined ? { tokens } : {}),
|
|
201
|
+
...(toolUses !== undefined ? { toolUses } : {}),
|
|
202
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
203
|
+
} }
|
|
204
|
+
: {}),
|
|
205
|
+
timestamp,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
75
209
|
/** Claude Code's real reasoning knob is its own `--effort <level>` flag
|
|
76
210
|
* (low|medium|high|xhigh|max — verified against `claude -p --help`). The
|
|
77
211
|
* CliReasoningEffort union IS the CLI's vocabulary, so the level rides the
|
|
@@ -100,12 +234,30 @@ export const claudeCodeSpec: CliAgentSpec = {
|
|
|
100
234
|
'sudo cp "$REAL" /usr/local/bin/claude && sudo chmod 0755 /usr/local/bin/claude',
|
|
101
235
|
// Claude reads the prompt from stdin in -p mode.
|
|
102
236
|
promptPayload: (prompt) => prompt,
|
|
103
|
-
|
|
237
|
+
// Mid-turn stream input (steering): with `--input-format stream-json`,
|
|
238
|
+
// stdin carries JSONL user messages, and one that arrives WHILE a turn
|
|
239
|
+
// runs is folded into the running turn at the next tool boundary — the
|
|
240
|
+
// interactive UI's queued-user-input behaviour, verified live against
|
|
241
|
+
// claude 2.1.233 (a message injected during a 20s Bash call shaped the
|
|
242
|
+
// same turn's final reply, and the tool ran undisturbed). Slash-command
|
|
243
|
+
// expansion and `--resume` both work unchanged in this mode (verified on
|
|
244
|
+
// the same build). A message that lands after the result would start a
|
|
245
|
+
// NEW turn in-process, which is why the transport's feeder stops at the
|
|
246
|
+
// result line instead of forwarding past it.
|
|
247
|
+
streamInput: {
|
|
248
|
+
promptLine: (prompt) => JSON.stringify({ type: "user", message: { role: "user", content: prompt } }),
|
|
249
|
+
messageLine: (text) => JSON.stringify({ type: "user", message: { role: "user", content: text } }),
|
|
250
|
+
},
|
|
251
|
+
buildCommand: ({ promptPath, sessionId, model, cwd, effort, streamInput }) => {
|
|
104
252
|
const flags = [
|
|
105
253
|
"-p",
|
|
106
254
|
// stream-json is the JSONL event stream; -p requires --verbose with it.
|
|
107
255
|
"--output-format stream-json",
|
|
108
256
|
"--verbose",
|
|
257
|
+
// Stream-input turns feed stdin as JSONL user messages from the
|
|
258
|
+
// transport's FIFO (mid-turn injection); plain turns keep the raw
|
|
259
|
+
// prompt file.
|
|
260
|
+
...(streamInput ? ["--input-format stream-json"] : []),
|
|
109
261
|
// Raw API stream events ride along as `stream_event` lines — the
|
|
110
262
|
// text_delta source for progressive rendering (mapEvent below). The
|
|
111
263
|
// complete `assistant` message events still arrive; deltas are
|
|
@@ -139,11 +291,20 @@ export const claudeCodeSpec: CliAgentSpec = {
|
|
|
139
291
|
switch (p.type) {
|
|
140
292
|
// Assistant API message: content blocks → text / thinking / tool_use.
|
|
141
293
|
case "assistant": {
|
|
142
|
-
const message = p.message as { content?: ClaudeContentBlock[] } | undefined;
|
|
294
|
+
const message = p.message as { content?: ClaudeContentBlock[]; model?: unknown } | undefined;
|
|
295
|
+
// HARNESS-synthesized assistant messages: the CLI stamps
|
|
296
|
+
// `message.model: "<synthetic>"` on content it composed itself —
|
|
297
|
+
// slash-command stdout, model/skills advisories — never the model
|
|
298
|
+
// speaking (verified live on claude 2.1.212 and 2.1.236: /model and
|
|
299
|
+
// /context output both arrive this way). Their text forwards as
|
|
300
|
+
// `harness_notice` so downstream renders it as a quiet system note
|
|
301
|
+
// instead of agent prose (the 2026-08-22 plumbing-as-content leak).
|
|
302
|
+
// Structural marker only — no content sniffing.
|
|
303
|
+
const synthetic = message?.model === "<synthetic>";
|
|
143
304
|
const blocks = Array.isArray(message?.content) ? message.content : [];
|
|
144
305
|
return blocks.flatMap((b): AgentMessage[] => {
|
|
145
306
|
if (b.type === "text" && typeof b.text === "string" && b.text.length > 0) {
|
|
146
|
-
return [{ type: "text", text: b.text, timestamp: ts }];
|
|
307
|
+
return [{ type: synthetic ? "harness_notice" : "text", text: b.text, timestamp: ts }];
|
|
147
308
|
}
|
|
148
309
|
if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking.length > 0) {
|
|
149
310
|
return [{ type: "thinking", text: b.thinking, timestamp: ts }];
|
|
@@ -157,17 +318,29 @@ export const claudeCodeSpec: CliAgentSpec = {
|
|
|
157
318
|
return [];
|
|
158
319
|
});
|
|
159
320
|
}
|
|
160
|
-
// User API message: the CLI echoes tool results back as user content
|
|
321
|
+
// User API message: the CLI echoes tool results back as user content,
|
|
322
|
+
// and injects `<task-notification>` blocks (background-task completion
|
|
323
|
+
// evidence) as user TEXT — parsed into structure, never dropped and
|
|
324
|
+
// never forwarded raw. Other user text (the echo of the prompt, system
|
|
325
|
+
// reminders) stays unmapped: it is not agent output.
|
|
161
326
|
case "user": {
|
|
162
|
-
const message = p.message as { content?: ClaudeContentBlock[] } | undefined;
|
|
327
|
+
const message = p.message as { content?: ClaudeContentBlock[] | string } | undefined;
|
|
328
|
+
if (typeof message?.content === "string") {
|
|
329
|
+
return parseTaskNotifications(message.content, ts);
|
|
330
|
+
}
|
|
163
331
|
const blocks = Array.isArray(message?.content) ? message.content : [];
|
|
164
|
-
return blocks.flatMap((b): AgentMessage[] =>
|
|
165
|
-
b.type === "tool_result"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
332
|
+
return blocks.flatMap((b): AgentMessage[] => {
|
|
333
|
+
if (b.type === "tool_result") {
|
|
334
|
+
return [{
|
|
335
|
+
type: "tool_result", toolUseId: String(b.tool_use_id ?? ""),
|
|
336
|
+
output: toolResultText(b.content), isError: b.is_error === true, ...parent, timestamp: ts,
|
|
337
|
+
}];
|
|
338
|
+
}
|
|
339
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
340
|
+
return parseTaskNotifications(b.text, ts);
|
|
341
|
+
}
|
|
342
|
+
return [];
|
|
343
|
+
});
|
|
171
344
|
}
|
|
172
345
|
// Raw API stream event (--include-partial-messages): text deltas of
|
|
173
346
|
// the in-progress block map to the live-only `text_delta` kind so a
|
|
@@ -234,7 +407,18 @@ export const claudeCodeSpec: CliAgentSpec = {
|
|
|
234
407
|
timestamp: ts,
|
|
235
408
|
}];
|
|
236
409
|
}
|
|
237
|
-
//
|
|
410
|
+
// System events: init carries the session id (extractSessionId), and
|
|
411
|
+
// the background-task lane rides here too — `task_notification` is
|
|
412
|
+
// the completion evidence stream-json actually emits (see the section
|
|
413
|
+
// header above), so it maps to the structured message BOTH turn
|
|
414
|
+
// engines persist. Everything else under system (task_started,
|
|
415
|
+
// task_updated, background_tasks_changed, thinking_tokens) is
|
|
416
|
+
// lifecycle noise here.
|
|
417
|
+
case "system": {
|
|
418
|
+
if (p.subtype !== "task_notification") return [];
|
|
419
|
+
const notification = parseSystemTaskNotification(p, ts);
|
|
420
|
+
return notification ? [notification] : [];
|
|
421
|
+
}
|
|
238
422
|
default:
|
|
239
423
|
return [];
|
|
240
424
|
}
|
package/src/runtimes/codex.ts
CHANGED
|
@@ -53,6 +53,54 @@ export function isCodexAdvisoryNoise(text: string): boolean {
|
|
|
53
53
|
return CODEX_ADVISORY_PATTERNS.some((re) => re.test(text));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// ── Thread-store writer-lock preflight (2026-08-18 prod incident) ────────────
|
|
57
|
+
// codex guards each persisted thread with an OS advisory flock on
|
|
58
|
+
// `$CODEX_HOME/thread-writer-locks/<thread-id>.lock` (codex-rs
|
|
59
|
+
// thread-store/src/local/writer_lock.rs). Two consequences drive this
|
|
60
|
+
// preflight's shape:
|
|
61
|
+
// - the flock is held exactly as long as the holding PROCESS lives — the
|
|
62
|
+
// kernel releases it on any death (SIGKILL, VM crash), so a lock that
|
|
63
|
+
// still blocks is held by a LIVE process, never a stale file;
|
|
64
|
+
// - codex sweeps unheld (stale) lock FILES itself on store init.
|
|
65
|
+
// The incident: a canceled turn's codex survived its fire-and-forget SIGTERM
|
|
66
|
+
// long enough for the user's next turn to launch `codex exec resume`, which
|
|
67
|
+
// died with `thread-store conflict: thread … already has an active writer`
|
|
68
|
+
// (code -32600). So before a RESUME the launch script:
|
|
69
|
+
// 1. waits (bounded) for a dying previous writer to release the flock —
|
|
70
|
+
// the canceled predecessor was already TERM'd, it just needs a moment;
|
|
71
|
+
// 2. clears the lock file ONLY under a successfully acquired `flock -n`
|
|
72
|
+
// (proof the holder is dead) — belt-and-suspenders on top of codex's
|
|
73
|
+
// own sweep, and the guard against any future file-existence check;
|
|
74
|
+
// 3. NEVER touches a lock whose holder is alive: removing a live-held
|
|
75
|
+
// lock file would let a second writer flock a fresh inode (two writers
|
|
76
|
+
// on one rollout), so a still-held lock after the wait is left for
|
|
77
|
+
// codex to surface as the honest conflict it is.
|
|
78
|
+
// No `flock(1)` on the guest (or no lock file) → the preflight is a no-op.
|
|
79
|
+
|
|
80
|
+
/** Bounded wait for a dying previous writer: attempts × sleep = 5s, matching
|
|
81
|
+
* the teardown's SIGTERM grace (sdk _cli-agent.ts REAP_TERM_WAIT_ATTEMPTS). */
|
|
82
|
+
export const CODEX_LOCK_WAIT_ATTEMPTS = 20;
|
|
83
|
+
export const CODEX_LOCK_WAIT_SECONDS = "0.25";
|
|
84
|
+
|
|
85
|
+
/** Thread ids are uuid-shaped; anything else skips the preflight entirely so
|
|
86
|
+
* hostile config can never become shell injection via the lock path. */
|
|
87
|
+
const CODEX_THREAD_ID_SAFE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
88
|
+
|
|
89
|
+
/** The sh fragment prepended to a resume launch. `waitAttempts` is a test
|
|
90
|
+
* seam (the live-holder test must not sleep 5s); production callers take
|
|
91
|
+
* the default. Exported for tests. */
|
|
92
|
+
export function codexWriterLockPreflight(
|
|
93
|
+
threadId: string,
|
|
94
|
+
waitAttempts: number = CODEX_LOCK_WAIT_ATTEMPTS,
|
|
95
|
+
): string {
|
|
96
|
+
if (!CODEX_THREAD_ID_SAFE.test(threadId)) return "";
|
|
97
|
+
return `ac_lk="\${CODEX_HOME:-$HOME/.codex}/thread-writer-locks/${threadId}.lock"; `
|
|
98
|
+
+ `if [ -e "$ac_lk" ] && command -v flock >/dev/null 2>&1; then ac_lki=0; `
|
|
99
|
+
+ `while ! flock -n "$ac_lk" true 2>/dev/null && [ "$ac_lki" -lt ${waitAttempts} ]; do `
|
|
100
|
+
+ `sleep ${CODEX_LOCK_WAIT_SECONDS}; ac_lki=$((ac_lki+1)); done; `
|
|
101
|
+
+ `flock -n "$ac_lk" rm -f -- "$ac_lk" 2>/dev/null || true; fi; `;
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
/** Exported for the fixture-based parity tests (ADR-0020): the legacy JSONL
|
|
57
105
|
* `mapEvent` is the golden the ACP normaliser is asserted equal to. Not part
|
|
58
106
|
* of the public runtime surface — `createCodexRuntime` stays the entry point. */
|
|
@@ -60,6 +108,11 @@ export const codexSpec: CliAgentSpec = {
|
|
|
60
108
|
kind: "codex",
|
|
61
109
|
authEnv: "CODEX_API_KEY",
|
|
62
110
|
bin: "codex",
|
|
111
|
+
// codex holds a per-thread advisory flock for its writer's whole lifetime
|
|
112
|
+
// (see the writer-lock preflight above): a live predecessor process blocks
|
|
113
|
+
// every resume of the same thread, so supersede teardown must KILL it —
|
|
114
|
+
// never detach it alive (server runner-kill.ts consumes this flag).
|
|
115
|
+
exclusiveSessionWriter: true,
|
|
63
116
|
// ACP-mode invocation (ADR-0020 increment 1). `codex` has no native `--acp`
|
|
64
117
|
// flag; the adapter (a Rust binary shipped via npm) speaks ACP and drives a
|
|
65
118
|
// compatible bundled `@openai/codex`. The runner attempts this first and
|
|
@@ -103,12 +156,16 @@ export const codexSpec: CliAgentSpec = {
|
|
|
103
156
|
// died. `cd` sets the cwd identically for both paths; promptPath is
|
|
104
157
|
// absolute (/tmp/…), so the `< prompt` redirect survives the cd.
|
|
105
158
|
const cd = cwd ? `cd ${shellQuote(cwd)} && ` : "";
|
|
159
|
+
// Resume-only launch preflight: self-heal a released-or-dying writer
|
|
160
|
+
// lock before `codex exec resume` (see codexWriterLockPreflight). A
|
|
161
|
+
// fresh turn mints a new thread id — no lock can exist for it yet.
|
|
162
|
+
const preflight = sessionId ? codexWriterLockPreflight(sessionId) : "";
|
|
106
163
|
// Fresh turn: `codex exec <flags> - < prompt`. Continue a thread:
|
|
107
164
|
// `codex exec resume <id> <flags> - < prompt`. (`-` = read prompt from stdin.)
|
|
108
165
|
const exec = sessionId
|
|
109
166
|
? `codex exec resume ${shellQuote(sessionId)} ${flags}`
|
|
110
167
|
: `codex exec ${flags}`;
|
|
111
|
-
return `${cd}${exec} - < ${shellQuote(promptPath)}`;
|
|
168
|
+
return `${preflight}${cd}${exec} - < ${shellQuote(promptPath)}`;
|
|
112
169
|
},
|
|
113
170
|
extractSessionId: (p) =>
|
|
114
171
|
p.type === "thread.started" && typeof p.thread_id === "string" ? p.thread_id : undefined,
|
|
@@ -104,6 +104,9 @@ export function e2bMaxSandboxMs(): number {
|
|
|
104
104
|
return Number(process.env.E2B_MAX_SANDBOX_MS) || 60 * 60 * 1000;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** Once-per-process latch for the extendLifetime clamp warning below. */
|
|
108
|
+
let e2bExtendClampWarned = false;
|
|
109
|
+
|
|
107
110
|
/** Wrap an E2B `CommandHandle` as a provider-agnostic background process.
|
|
108
111
|
* `wait()` is normalised NOT to throw on a non-zero exit (mirroring the
|
|
109
112
|
* `commands.run` contract in `makeSandboxProvider`) so callers branch on
|
|
@@ -245,7 +248,20 @@ function makeE2bSandboxProvider(sb: Sandbox): SandboxProvider {
|
|
|
245
248
|
// the create timeout — an over-cap push would 400 and leave the OLD
|
|
246
249
|
// (possibly short) deadline standing.
|
|
247
250
|
async extendLifetime(ms) {
|
|
248
|
-
|
|
251
|
+
const maxMs = e2bMaxSandboxMs();
|
|
252
|
+
if (ms > maxMs && !e2bExtendClampWarned) {
|
|
253
|
+
// Once per process, not per extension: every far-horizon push clamps
|
|
254
|
+
// identically, and the fact worth surfacing is the CONFIGURATION —
|
|
255
|
+
// the server believes it bought `ms` of kill-clock slack but the
|
|
256
|
+
// plan cap silently shortens it, so the "orphan backstop" can reap
|
|
257
|
+
// a machine the platform still considers covered.
|
|
258
|
+
e2bExtendClampWarned = true;
|
|
259
|
+
console.warn(
|
|
260
|
+
`[sandbox] e2b lifetime extension clamped: requested ${ms}ms exceeds the plan cap ${maxMs}ms — ` +
|
|
261
|
+
`the kill-clock backstop fires at the cap, not the requested horizon. Raise E2B_MAX_SANDBOX_MS on plans that allow more.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
await sb.setTimeout(Math.min(ms, maxMs));
|
|
249
265
|
},
|
|
250
266
|
// Push a freshly-resolved egress policy onto the live sandbox via E2B's
|
|
251
267
|
// native `updateNetwork` — the E2B analogue of Vercel's `update({
|
|
@@ -317,6 +333,15 @@ export const e2bProviderDef: SandboxProviderDef = {
|
|
|
317
333
|
const sandboxOpts = {
|
|
318
334
|
...rest,
|
|
319
335
|
timeoutMs: Math.min(timeoutMs, maxMs),
|
|
336
|
+
// Lifetime-deadline expiry PAUSES instead of killing, and the next
|
|
337
|
+
// incoming connection auto-resumes (recycle forensics 2026-08-23:
|
|
338
|
+
// uncontrolled kill-clock loss was the dominant machine-end cause
|
|
339
|
+
// ~60:1 — under this policy a missed extension becomes a resumable
|
|
340
|
+
// park our redial paths revive). CREATE-time policy, persistent for
|
|
341
|
+
// the sandbox's life — connect() takes no lifecycle. NOTE: this is
|
|
342
|
+
// e2b's `lifecycle` contract, NOT the deprecated `autoPause` flag
|
|
343
|
+
// (which the create body would silently drop).
|
|
344
|
+
lifecycle: { onTimeout: "pause" as const, autoResume: true },
|
|
320
345
|
...(networkPolicy ? { network: toE2bNetwork(networkPolicy) } : {}),
|
|
321
346
|
};
|
|
322
347
|
// Boot template resolution, in priority order:
|
|
@@ -339,6 +364,9 @@ export const e2bProviderDef: SandboxProviderDef = {
|
|
|
339
364
|
);
|
|
340
365
|
},
|
|
341
366
|
reconnect: async (sandboxId) => makeE2bSandboxProvider(
|
|
367
|
+
// The pause-on-timeout policy is create-time and persistent — connect
|
|
368
|
+
// takes no lifecycle options (the old per-connect autoPause flag is
|
|
369
|
+
// deprecated API-side).
|
|
342
370
|
await Sandbox.connect(sandboxId, { apiKey: process.env.E2B_API_KEY ?? "", timeoutMs: 60 * 60 * 1000 }),
|
|
343
371
|
),
|
|
344
372
|
killAll: async () => {
|
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
import { promises as fs } from "node:fs";
|
|
6
6
|
import { dirname } from "node:path";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
+
/** The two listener registrations we need, declared LOCALLY.
|
|
9
|
+
* `ChildProcess` inherits `.on` from EventEmitter, but when a tree ends up
|
|
10
|
+
* with more than one @types/node the inheritance link breaks and `.on`
|
|
11
|
+
* disappears — which resolution you get depends on install layout, so this
|
|
12
|
+
* typechecked locally and failed CI three times. Referencing no node types
|
|
13
|
+
* at all is the only version-proof shape. */
|
|
14
|
+
type ProcListeners = {
|
|
15
|
+
on(event: "error", cb: (err: Error) => void): unknown;
|
|
16
|
+
on(event: "close", cb: (code: number | null) => void): unknown;
|
|
17
|
+
};
|
|
8
18
|
import { Readable, Writable } from "node:stream";
|
|
9
19
|
import type { SandboxProvider } from "../../types/sandbox.js";
|
|
10
20
|
|
|
@@ -36,8 +46,9 @@ export function makeLocalSandboxProvider(): SandboxProvider {
|
|
|
36
46
|
proc.stderr?.setEncoding("utf8");
|
|
37
47
|
proc.stdout?.on("data", (chunk: string) => { stdout += chunk; opts?.onStdout?.(chunk); });
|
|
38
48
|
proc.stderr?.on("data", (chunk: string) => { stderr += chunk; opts?.onStderr?.(chunk); });
|
|
39
|
-
proc
|
|
40
|
-
|
|
49
|
+
const ev = proc as unknown as ProcListeners;
|
|
50
|
+
ev.on("error", reject);
|
|
51
|
+
ev.on("close", (code: number | null) => resolve({ exitCode: code ?? 0, stdout, stderr }));
|
|
41
52
|
});
|
|
42
53
|
},
|
|
43
54
|
// Duplex spawn — the in-VM `child_process` pipe the ACP client needs.
|
|
@@ -67,8 +78,9 @@ export function makeLocalSandboxProvider(): SandboxProvider {
|
|
|
67
78
|
proc.stderr.on("data", (chunk: string) => { stderr += chunk; });
|
|
68
79
|
|
|
69
80
|
const exited = new Promise<{ exitCode: number; stderr: string }>((resolve, reject) => {
|
|
70
|
-
proc
|
|
71
|
-
|
|
81
|
+
const ev = proc as unknown as ProcListeners;
|
|
82
|
+
ev.on("error", reject);
|
|
83
|
+
ev.on("close", (code: number | null) => resolve({ exitCode: code ?? 0, stderr }));
|
|
72
84
|
});
|
|
73
85
|
|
|
74
86
|
return {
|
package/src/sandbox.ts
CHANGED
|
@@ -54,7 +54,7 @@ export type { SandboxCreateOpts, OwnedSandbox } from "./sandbox/provider-def.js"
|
|
|
54
54
|
export { parseSseExecStream } from "./sandbox/exec-stream.js";
|
|
55
55
|
export type { ParseSseExecStreamOptions } from "./sandbox/exec-stream.js";
|
|
56
56
|
|
|
57
|
-
export { makeSandboxProvider } from "./sandbox/providers/e2b.js";
|
|
57
|
+
export { makeSandboxProvider, e2bMaxSandboxMs } from "./sandbox/providers/e2b.js";
|
|
58
58
|
export { makeDesktopSandboxProvider } from "./sandbox/providers/desktop.js";
|
|
59
59
|
export { VERCEL_MAX_TAGS, buildVercelTags } from "./sandbox/providers/vercel.js";
|
|
60
60
|
export { makeLocalSandboxProvider } from "./sandbox/providers/local.js";
|