@agent-compose/sdk 0.8.2 → 0.8.3

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.
Files changed (40) hide show
  1. package/dist/agent/__tests__/perf-sampler.test.d.ts +10 -0
  2. package/dist/agent/agent-context.d.ts +1 -1
  3. package/dist/agent/agent-loop.d.ts +5 -1
  4. package/dist/agent/desktop-open.d.ts +184 -0
  5. package/dist/agent/perf-sampler.d.ts +99 -0
  6. package/dist/agent/services-manifest.d.ts +88 -0
  7. package/dist/agent/services-restore.d.ts +58 -0
  8. package/dist/client.d.ts +164 -8
  9. package/dist/display.d.ts +17 -0
  10. package/dist/index.d.ts +13 -4
  11. package/dist/index.js +1374 -51
  12. package/dist/runtimes/_cli-agent.d.ts +347 -2
  13. package/dist/runtimes/claude-code.d.ts +12 -0
  14. package/dist/runtimes/codex.d.ts +8 -0
  15. package/dist/runtimes/openai-desktop.js +1312 -51
  16. package/dist/runtimes/session-env.test.d.ts +14 -0
  17. package/dist/types/api-conversations.d.ts +309 -1
  18. package/dist/types/api-factory.d.ts +115 -10
  19. package/dist/types/api-runs.d.ts +21 -0
  20. package/dist/types/protocol.d.ts +32 -1
  21. package/dist/types/runtime.d.ts +120 -0
  22. package/package.json +1 -1
  23. package/src/agent/agent-context.ts +100 -11
  24. package/src/agent/agent-loop.ts +10 -3
  25. package/src/agent/desktop-open.ts +418 -0
  26. package/src/agent/perf-sampler.ts +202 -0
  27. package/src/agent/services-manifest.ts +356 -0
  28. package/src/agent/services-restore.ts +195 -0
  29. package/src/client.ts +328 -12
  30. package/src/display.ts +44 -1
  31. package/src/index.ts +63 -1
  32. package/src/runtimes/_cli-agent.ts +891 -35
  33. package/src/runtimes/claude-code.ts +187 -12
  34. package/src/runtimes/codex.ts +58 -1
  35. package/src/sandbox/providers/local.ts +16 -4
  36. package/src/types/api-conversations.ts +307 -3
  37. package/src/types/api-factory.ts +118 -10
  38. package/src/types/api-runs.ts +23 -0
  39. package/src/types/protocol.ts +30 -1
  40. 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(/&lt;/g, "<")
117
+ .replace(/&gt;/g, ">")
118
+ .replace(/&quot;/g, '"')
119
+ .replace(/&#39;/g, "'")
120
+ .replace(/&amp;/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
- buildCommand: ({ promptPath, sessionId, model, cwd, effort }) => {
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
@@ -157,17 +309,29 @@ export const claudeCodeSpec: CliAgentSpec = {
157
309
  return [];
158
310
  });
159
311
  }
160
- // User API message: the CLI echoes tool results back as user content.
312
+ // User API message: the CLI echoes tool results back as user content,
313
+ // and injects `<task-notification>` blocks (background-task completion
314
+ // evidence) as user TEXT — parsed into structure, never dropped and
315
+ // never forwarded raw. Other user text (the echo of the prompt, system
316
+ // reminders) stays unmapped: it is not agent output.
161
317
  case "user": {
162
- const message = p.message as { content?: ClaudeContentBlock[] } | undefined;
318
+ const message = p.message as { content?: ClaudeContentBlock[] | string } | undefined;
319
+ if (typeof message?.content === "string") {
320
+ return parseTaskNotifications(message.content, ts);
321
+ }
163
322
  const blocks = Array.isArray(message?.content) ? message.content : [];
164
- return blocks.flatMap((b): AgentMessage[] =>
165
- b.type === "tool_result"
166
- ? [{
167
- type: "tool_result", toolUseId: String(b.tool_use_id ?? ""),
168
- output: toolResultText(b.content), isError: b.is_error === true, ...parent, timestamp: ts,
169
- }]
170
- : []);
323
+ return blocks.flatMap((b): AgentMessage[] => {
324
+ if (b.type === "tool_result") {
325
+ return [{
326
+ type: "tool_result", toolUseId: String(b.tool_use_id ?? ""),
327
+ output: toolResultText(b.content), isError: b.is_error === true, ...parent, timestamp: ts,
328
+ }];
329
+ }
330
+ if (b.type === "text" && typeof b.text === "string") {
331
+ return parseTaskNotifications(b.text, ts);
332
+ }
333
+ return [];
334
+ });
171
335
  }
172
336
  // Raw API stream event (--include-partial-messages): text deltas of
173
337
  // the in-progress block map to the live-only `text_delta` kind so a
@@ -234,7 +398,18 @@ export const claudeCodeSpec: CliAgentSpec = {
234
398
  timestamp: ts,
235
399
  }];
236
400
  }
237
- // system/init carries the session id (extractSessionId); nothing to map.
401
+ // System events: init carries the session id (extractSessionId), and
402
+ // the background-task lane rides here too — `task_notification` is
403
+ // the completion evidence stream-json actually emits (see the section
404
+ // header above), so it maps to the structured message BOTH turn
405
+ // engines persist. Everything else under system (task_started,
406
+ // task_updated, background_tasks_changed, thinking_tokens) is
407
+ // lifecycle noise here.
408
+ case "system": {
409
+ if (p.subtype !== "task_notification") return [];
410
+ const notification = parseSystemTaskNotification(p, ts);
411
+ return notification ? [notification] : [];
412
+ }
238
413
  default:
239
414
  return [];
240
415
  }
@@ -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,
@@ -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.on("error", reject);
40
- proc.on("close", (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
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.on("error", reject);
71
- proc.on("close", (code) => resolve({ exitCode: code ?? 0, stderr }));
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 {