orizu 0.4.1 → 0.5.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.
Files changed (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +226 -0
  11. package/dist/help.js +271 -16
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +855 -237
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +39 -2
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +366 -0
  48. package/src/help.ts +272 -16
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +987 -296
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +70 -2
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +3 -1
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +3 -3
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Standalone authenticated smart-HTTP git server for the ALI-925 rehearsal.
3
+ *
4
+ * Runs as its OWN process (spawned via `bun <this file>`), for the same reason
5
+ * the fake broker does: the local-sim `SandboxSession.exec` blocks on a
6
+ * `spawnSync` git clone, and the in-sandbox credential helper that clone drives
7
+ * makes real HTTP calls out. A server sharing the test's event loop could not
8
+ * answer while that loop is blocked — so the git server must be its own process.
9
+ *
10
+ * It proves the integrated credential path end-to-end: it (a) requires HTTP Basic
11
+ * auth and rejects unauthenticated requests with 401 + `WWW-Authenticate`, forcing
12
+ * git to invoke the credential helper, and (b) on authenticated requests serves a
13
+ * real bare repo by shelling to `git http-backend` as CGI. Every Basic-auth
14
+ * password it receives is journaled to a file so the rehearsal can assert the
15
+ * password git presented equals the token the broker minted (git → helper →
16
+ * broker → token → authenticated clone).
17
+ *
18
+ * Not shipped to end users — a test/rehearsal fixture only.
19
+ */
20
+ import { execFileSync } from 'child_process';
21
+ import { appendFileSync } from 'fs';
22
+ import { basename, dirname, join } from 'path';
23
+ function bun() {
24
+ const b = globalThis.Bun;
25
+ if (!b?.serve || !b?.spawn)
26
+ throw new Error('hosted-git-http-server requires bun');
27
+ return b;
28
+ }
29
+ /** Resolve the `git-http-backend` CGI binary from git's exec-path. */
30
+ function resolveHttpBackend() {
31
+ const execPath = execFileSync('git', ['--exec-path']).toString().trim();
32
+ return join(execPath, 'git-http-backend');
33
+ }
34
+ export function readGitHttpConfigFromEnv(env) {
35
+ return {
36
+ repoDir: env.HG_REPO_DIR ?? '',
37
+ authLogFile: env.HG_AUTH_LOG,
38
+ port: env.HG_PORT ? Number.parseInt(env.HG_PORT, 10) : 0,
39
+ };
40
+ }
41
+ /** The env keys the server reads (kept here so callers do not hand-roll them). */
42
+ export function gitHttpEnv(config) {
43
+ const env = { HG_REPO_DIR: config.repoDir };
44
+ if (config.authLogFile)
45
+ env.HG_AUTH_LOG = config.authLogFile;
46
+ return env;
47
+ }
48
+ /** The clone path segment a client uses (the bare repo's basename). */
49
+ export function gitHttpRepoSegment(config) {
50
+ return basename(config.repoDir);
51
+ }
52
+ export function startGitHttpServer(config) {
53
+ const backend = resolveHttpBackend();
54
+ const projectRoot = dirname(config.repoDir);
55
+ const unauthorized = () => new Response('authentication required', {
56
+ status: 401,
57
+ headers: { 'WWW-Authenticate': 'Basic realm="orizu-sim"' },
58
+ });
59
+ return bun().serve({
60
+ port: config.port ?? 0,
61
+ hostname: '127.0.0.1',
62
+ async fetch(request) {
63
+ // (a) Require Basic auth; reject unauthenticated requests so git invokes the
64
+ // credential helper (which mints from the broker) before retrying.
65
+ const authHeader = request.headers.get('authorization') ?? '';
66
+ if (!authHeader.startsWith('Basic '))
67
+ return unauthorized();
68
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
69
+ const sep = decoded.indexOf(':');
70
+ const user = sep >= 0 ? decoded.slice(0, sep) : decoded;
71
+ const pass = sep >= 0 ? decoded.slice(sep + 1) : '';
72
+ // Journal the credential git presented (proves password === minted token).
73
+ if (config.authLogFile)
74
+ appendFileSync(config.authLogFile, `${JSON.stringify({ user, pass })}\n`);
75
+ if (!pass)
76
+ return unauthorized();
77
+ // (b) Serve the bare repo via `git http-backend` as CGI.
78
+ const url = new URL(request.url);
79
+ const body = request.method === 'POST' ? new Uint8Array(await request.arrayBuffer()) : undefined;
80
+ const cgiEnv = {
81
+ ...process.env,
82
+ GIT_PROJECT_ROOT: projectRoot,
83
+ GIT_HTTP_EXPORT_ALL: '1',
84
+ PATH_INFO: url.pathname,
85
+ QUERY_STRING: url.search.replace(/^\?/, ''),
86
+ REQUEST_METHOD: request.method,
87
+ CONTENT_TYPE: request.headers.get('content-type') ?? '',
88
+ REMOTE_USER: user,
89
+ };
90
+ const contentEncoding = request.headers.get('content-encoding');
91
+ if (contentEncoding)
92
+ cgiEnv.HTTP_CONTENT_ENCODING = contentEncoding;
93
+ if (body)
94
+ cgiEnv.CONTENT_LENGTH = String(body.length);
95
+ const proc = bun().spawn({
96
+ cmd: [backend],
97
+ env: cgiEnv,
98
+ stdin: body ?? 'ignore',
99
+ stdout: 'pipe',
100
+ stderr: 'ignore',
101
+ });
102
+ const out = Buffer.from(await new Response(proc.stdout).arrayBuffer());
103
+ await proc.exited;
104
+ // Split the CGI response (headers, blank line, body) — body may be binary.
105
+ let boundary = out.indexOf('\r\n\r\n');
106
+ let boundaryLen = 4;
107
+ if (boundary < 0) {
108
+ boundary = out.indexOf('\n\n');
109
+ boundaryLen = 2;
110
+ }
111
+ const headerText = boundary >= 0 ? out.subarray(0, boundary).toString('utf8') : '';
112
+ const payload = boundary >= 0 ? out.subarray(boundary + boundaryLen) : out;
113
+ let status = 200;
114
+ const headers = new Headers();
115
+ for (const line of headerText.split(/\r?\n/)) {
116
+ const colon = line.indexOf(':');
117
+ if (colon < 0)
118
+ continue;
119
+ const key = line.slice(0, colon).trim();
120
+ const value = line.slice(colon + 1).trim();
121
+ if (key.toLowerCase() === 'status') {
122
+ status = Number.parseInt(value, 10) || 200;
123
+ continue;
124
+ }
125
+ headers.set(key, value);
126
+ }
127
+ return new Response(payload, { status, headers });
128
+ },
129
+ });
130
+ }
131
+ // When executed directly (`bun hosted-git-http-server.ts`), start the server and
132
+ // print `LISTENING <port>` so the spawning parent can resolve the port.
133
+ if (import.meta.main) {
134
+ const server = startGitHttpServer(readGitHttpConfigFromEnv(process.env));
135
+ process.stdout.write(`LISTENING ${server.port}\n`);
136
+ }
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Claude-Agent-SDK driver for the AgentHarness seam (ALI-929 / P3.6, per ADR-005
3
+ * §4a — the swappability proof).
4
+ *
5
+ * This is the SECOND `AgentHarness` implementation behind the same seam as the
6
+ * OpenCode driver (`hosted-harness-opencode.ts`). It drives
7
+ * `@anthropic-ai/claude-agent-sdk`'s `query()` loop — an IN-PROCESS agent loop,
8
+ * with NO server process to spawn and no localhost SSE to parse — and normalizes
9
+ * the SDK's message/tool stream into the SAME `HarnessEvent` vocabulary the
10
+ * event bridge (`hosted-run-event-sink.ts`) already consumes. It carries ZERO
11
+ * Claude-Agent-SDK types across the seam: everything below stays behind
12
+ * `createClaudeAgentHarness`, exactly as the OpenCode SSE schema stays behind
13
+ * `createOpenCodeHarness`.
14
+ *
15
+ * The founder's locked escape-hatch ("OpenInspect, but we can move to our own
16
+ * harness whenever we want") is proven by this module existing and passing the
17
+ * SAME session-cell invariants through the SAME sink — see
18
+ * `test/cli/hosted-harness-swap.test.ts`.
19
+ *
20
+ * INJECTABLE LOADER: like the Daytona/Vercel SDK adapters, the SDK is imported
21
+ * LAZILY through a NON-LITERAL specifier so the CLI/app build resolve without the
22
+ * package, and a `loadModule` dep is injectable so tests exercise the
23
+ * normalization against a fake `query()` with no real SDK and no model calls.
24
+ *
25
+ * ── query() → runPrompt() mapping ────────────────────────────────────────────
26
+ * ONE `query()` call drives ONE `runPrompt()` to completion. The SDK's async
27
+ * iterable yields `SDKMessage`s; we translate them:
28
+ * • system/init → capture session_id; emit `ready` once.
29
+ * • assistant.message → text blocks → `token` (keyed by the SDK message id
30
+ * for coalescing); tool_use blocks → `tool_call`. The
31
+ * per-turn `message.usage` the SDK ALWAYS populates is
32
+ * deliberately NOT emitted as a `step_finish` (see
33
+ * leak #3): the driver coalesces to ONE terminal
34
+ * step_finish so both harnesses share one granularity.
35
+ * • user.message → tool_result blocks → `tool_result` (paired to the
36
+ * tool_use by callId, exactly once).
37
+ * • stream_event (partial) → text deltas are accumulated into the running token
38
+ * snapshot for coalescing; consumed, not 1:1 mapped.
39
+ * • result/success → final `step_finish` with cost+tokens, then
40
+ * `execution_complete` (success, or aborted on stop).
41
+ * • result/error_* → `error` (critical terminal).
42
+ * The stream ALWAYS ends with exactly one terminal event.
43
+ *
44
+ * ── WHAT LEAKS THROUGH THE BOUNDARY (map-conservatively evidence) ─────────────
45
+ * These SDK signals have NO clean `HarnessEvent` kind and are DROPPED here (not a
46
+ * reason to widen the seam — this is the audit of the boundary's fit):
47
+ * 1. `thinking` / `redacted_thinking` content blocks — the harness vocabulary
48
+ * has no reasoning kind (OpenCode's driver drops reasoning too), so extended
49
+ * thinking is not forwarded. Cost of surfacing it: a new `thinking` kind on
50
+ * the seam + a bridge mapping — a deliberate future decision, not done here.
51
+ * 2. `system/compact_boundary` — context-compaction markers are dropped (the
52
+ * OpenCode driver handles `session.compacted` internally and forwards
53
+ * nothing either; parity, not a leak).
54
+ * 3. Per-assistant-turn USAGE granularity — the real contract is that every
55
+ * `SDKAssistantMessage.message` is a `BetaMessage` whose `usage: BetaUsage`
56
+ * is ALWAYS populated (input/output token counts are non-optional), so the
57
+ * SDK reports usage on EACH assistant turn, not only at the terminal. The
58
+ * driver deliberately DOES NOT emit a `step_finish` per assistant turn: it
59
+ * coalesces to exactly ONE terminal `step_finish` at the `result` message,
60
+ * carrying `total_cost_usd` + aggregate `usage`. OpenCode emits one
61
+ * `step_finish` per `step-finish` part; for a one-tool task that is also a
62
+ * single terminal step, so the two streams are legitimately identical at the
63
+ * seam. This coalescing is itself a swappability win — the seam absorbs the
64
+ * raw per-turn/per-step granularity difference so downstream sees one
65
+ * normalized `agent_step_finish` from either harness. (Cost is only known at
66
+ * the terminal `result`; per-turn `usage` carries tokens but no cost, which
67
+ * is why folding to the terminal loses nothing the seam surfaces.)
68
+ * 4. `result.num_turns` / `duration_ms` / `permission_denials` — folded into
69
+ * the terminal transition's shape (success/aborted/error), not surfaced as
70
+ * distinct events.
71
+ * 5. Git author identity is plumbed via `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
72
+ * ENV on the query options (the SDK has no first-class author param), where
73
+ * OpenCode takes it structurally. Same effect, different plumbing point.
74
+ * NOTE the `env` CONTRACT: the SDK's `env` REPLACES the subprocess
75
+ * environment entirely (it is NOT merged with `process.env`), so we spread
76
+ * `...process.env` FIRST — without it the `claude` subprocess would lose
77
+ * `PATH`/`HOME`/`ANTHROPIC_API_KEY` and die. G3 credential model: like the
78
+ * OpenCode path (which hands `opencode` the non-secret dummy key), the SDK
79
+ * client is given the same `anthropicDummyKey` as `ANTHROPIC_API_KEY`; the
80
+ * real key is injected by the sandbox firewall on egress to
81
+ * api.anthropic.com, so NO raw key ever enters the in-process client. See
82
+ * the G3/credentials axis in ali-929-harness-comparison.md.
83
+ * 6. The resumable agent session id is assigned LAZILY by the SDK at
84
+ * `system/init` — it does not exist before the first `runPrompt`. So
85
+ * `start()` returns the RESUME id if given, else `''`; the real id first
86
+ * reaches the bridge via the `ready` event. OpenCode creates its session
87
+ * eagerly in `start()`. This is the one place the seam's "start returns the
88
+ * session id" shape fits OpenCode better than the SDK — the run still
89
+ * completes identically (the sink records the id from `ready`); see the
90
+ * comparison doc's swappability verdict.
91
+ * 7. Provider-qualified model strings (e.g. `anthropic/claude-opus-4-8`) are
92
+ * reduced to the bare model id the SDK expects (leading `anthropic/` is
93
+ * stripped; other providers pass through unchanged and would be the SDK's to
94
+ * reject).
95
+ */
96
+ // The package name lives in a variable so TypeScript treats the dynamic import as
97
+ // `any` (no static module resolution / ts2307 at app-build time) and no bundler
98
+ // traces it — this keeps the SDK genuinely optional, loaded only when the
99
+ // `claude-agent-sdk` harness is actually selected.
100
+ const CLAUDE_AGENT_SDK_SPECIFIER = '@anthropic-ai/claude-agent-sdk';
101
+ async function loadClaudeAgentSdk() {
102
+ try {
103
+ const specifier = CLAUDE_AGENT_SDK_SPECIFIER;
104
+ const mod = (await import(specifier));
105
+ if (!mod || typeof mod.query !== 'function') {
106
+ throw new Error('module did not export a query() function');
107
+ }
108
+ return mod;
109
+ }
110
+ catch (error) {
111
+ const detail = error instanceof Error ? error.message : String(error);
112
+ throw new Error(`Claude Agent SDK unavailable (${detail}). Install it before the live run: \`bun install --cwd packages/cli\` (declares @anthropic-ai/claude-agent-sdk).`);
113
+ }
114
+ }
115
+ // -- helpers -----------------------------------------------------------------
116
+ function asRecord(value) {
117
+ return value && typeof value === 'object' && !Array.isArray(value)
118
+ ? value
119
+ : {};
120
+ }
121
+ /** Reduce a provider-qualified model ("anthropic/claude-opus-4-8") to the bare id
122
+ * the Agent SDK expects; other providers pass through for the SDK to judge. */
123
+ function toSdkModel(model) {
124
+ if (!model)
125
+ return undefined;
126
+ return model.startsWith('anthropic/') ? model.slice('anthropic/'.length) : model;
127
+ }
128
+ /** Map the seam's structural MCP descriptors to the SDK's config record. */
129
+ function toSdkMcpServers(servers) {
130
+ if (!servers || servers.length === 0)
131
+ return undefined;
132
+ const out = {};
133
+ for (const s of servers) {
134
+ out[s.name] = {
135
+ type: s.transport ?? (s.url ? 'http' : 'stdio'),
136
+ command: s.command,
137
+ args: s.args,
138
+ url: s.url,
139
+ env: s.env,
140
+ };
141
+ }
142
+ return out;
143
+ }
144
+ /** Flatten an SDK message's content blocks (array or bare string) to blocks. */
145
+ function contentBlocks(message) {
146
+ const content = message?.content;
147
+ if (Array.isArray(content))
148
+ return content;
149
+ if (typeof content === 'string')
150
+ return [{ type: 'text', text: content }];
151
+ return [];
152
+ }
153
+ /** Coerce a tool_result block's `content` to a string output. */
154
+ function toolResultOutput(content) {
155
+ if (typeof content === 'string')
156
+ return content;
157
+ if (Array.isArray(content)) {
158
+ return content
159
+ .map(part => {
160
+ const rec = asRecord(part);
161
+ return typeof rec.text === 'string' ? rec.text : '';
162
+ })
163
+ .join('');
164
+ }
165
+ return '';
166
+ }
167
+ // -- The driver --------------------------------------------------------------
168
+ export function createClaudeAgentHarness(options = {}) {
169
+ const load = options.loadModule ?? loadClaudeAgentSdk;
170
+ const permissionMode = options.permissionMode ?? 'bypassPermissions';
171
+ const anthropicDummyKey = options.anthropicDummyKey;
172
+ let sdk = null;
173
+ let startModel;
174
+ let startWorkspaceDir;
175
+ let startMcpServers;
176
+ // The resumable session id: seeded from start()'s resume option, then updated
177
+ // to whatever the SDK assigns at each system/init so subsequent prompts
178
+ // continue the same conversation (parity with OpenCode session reuse).
179
+ let currentSessionId = null;
180
+ let readyEmitted = false;
181
+ // Set true by the public stop(); makes the resulting terminal a cancellation.
182
+ let stopRequested = false;
183
+ // The in-flight query's handles, so stop() can interrupt/abort it.
184
+ let activeQuery = null;
185
+ let activeAbort = null;
186
+ async function ensureSdk() {
187
+ if (!sdk)
188
+ sdk = await load();
189
+ return sdk;
190
+ }
191
+ return {
192
+ name: 'claude-agent-sdk',
193
+ async start(opts) {
194
+ startModel = opts.model;
195
+ startWorkspaceDir = opts.workspaceDir;
196
+ startMcpServers = opts.mcpServers;
197
+ // Load eagerly so a missing SDK fails at start() (like OpenCode validating
198
+ // its session), not mid-stream. The session id is assigned lazily by the
199
+ // SDK at init — see leak #6 — so we can only echo a resume id here.
200
+ await ensureSdk();
201
+ currentSessionId = opts.resumeAgentSessionId ?? null;
202
+ return { agentSessionId: opts.resumeAgentSessionId ?? '' };
203
+ },
204
+ async *runPrompt(prompt, signal) {
205
+ const mod = await ensureSdk();
206
+ // Per-prompt state.
207
+ stopRequested = false;
208
+ const abortController = new AbortController();
209
+ activeAbort = abortController;
210
+ // Bridge the caller's signal into the SDK's abort controller.
211
+ const onAbort = () => abortController.abort();
212
+ if (signal.aborted)
213
+ abortController.abort();
214
+ else
215
+ signal.addEventListener('abort', onAbort, { once: true });
216
+ // Per-message accumulated text for coalesced token snapshots.
217
+ const messageText = new Map();
218
+ // The id of the assistant message currently streaming (from the partial
219
+ // `message_start` event), so text deltas coalesce under the SAME id the
220
+ // final assistant snapshot uses — not a synthetic partial key.
221
+ let partialMessageId = null;
222
+ // Tool calls already forwarded, so a re-observed block isn't double-emitted.
223
+ const emittedToolCalls = new Set();
224
+ const emittedToolResults = new Set();
225
+ const author = prompt.author;
226
+ const queryOptions = {
227
+ model: toSdkModel(startModel),
228
+ cwd: startWorkspaceDir,
229
+ resume: currentSessionId ?? undefined,
230
+ abortController,
231
+ permissionMode,
232
+ // The SDK requires this be true whenever permissionMode is
233
+ // 'bypassPermissions' (safety gate). Leave unset for any other mode.
234
+ allowDangerouslySkipPermissions: permissionMode === 'bypassPermissions' ? true : undefined,
235
+ includePartialMessages: true,
236
+ mcpServers: toSdkMcpServers(startMcpServers),
237
+ // `env` REPLACES the subprocess environment entirely — spread process.env
238
+ // FIRST so PATH/HOME/ANTHROPIC_API_KEY survive; without it the subprocess
239
+ // dies. G3: hand the SDK client the non-secret dummy key (the firewall
240
+ // brokers the real key on egress). Git author identity plumbed here too
241
+ // (leak #5) since the SDK has no first-class author param.
242
+ env: {
243
+ ...process.env,
244
+ ...(anthropicDummyKey ? { ANTHROPIC_API_KEY: anthropicDummyKey } : {}),
245
+ GIT_AUTHOR_NAME: author.name,
246
+ GIT_AUTHOR_EMAIL: author.email,
247
+ GIT_COMMITTER_NAME: author.name,
248
+ GIT_COMMITTER_EMAIL: author.email,
249
+ },
250
+ };
251
+ let q;
252
+ try {
253
+ q = mod.query({ prompt: prompt.content, options: queryOptions });
254
+ }
255
+ catch (error) {
256
+ activeQuery = null;
257
+ activeAbort = null;
258
+ signal.removeEventListener('abort', onAbort);
259
+ yield errorEvent(prompt.messageId, error);
260
+ return;
261
+ }
262
+ activeQuery = q;
263
+ let terminalYielded = false;
264
+ try {
265
+ for await (const msg of q) {
266
+ if (msg.type === 'system' && msg.subtype === 'init') {
267
+ if (typeof msg.session_id === 'string')
268
+ currentSessionId = msg.session_id;
269
+ if (!readyEmitted) {
270
+ readyEmitted = true;
271
+ yield {
272
+ kind: 'ready',
273
+ messageId: prompt.messageId,
274
+ payload: { agentSessionId: currentSessionId ?? '' },
275
+ };
276
+ }
277
+ continue;
278
+ }
279
+ // compact_boundary and any other system subtype: no mapping (leak #2).
280
+ if (msg.type === 'system')
281
+ continue;
282
+ if (msg.type === 'stream_event') {
283
+ const ev = msg.event ?? {};
284
+ // Track the streaming message id so deltas coalesce under the same key
285
+ // the assistant snapshot will use.
286
+ if (ev.type === 'message_start' && typeof ev.message?.id === 'string') {
287
+ partialMessageId = ev.message.id;
288
+ }
289
+ // Accumulate text deltas into the running per-message snapshot so the
290
+ // sink coalesces token growth (leak: partials consumed, not 1:1).
291
+ if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
292
+ const msgId = partialMessageId ?? (typeof msg.message?.id === 'string' ? msg.message.id : '_partial');
293
+ const next = (messageText.get(msgId) ?? '') + (ev.delta.text ?? '');
294
+ messageText.set(msgId, next);
295
+ if (next)
296
+ yield { kind: 'token', messageId: msgId, payload: { text: next } };
297
+ }
298
+ continue;
299
+ }
300
+ if (msg.type === 'assistant') {
301
+ const msgId = typeof msg.message?.id === 'string' ? msg.message.id : '';
302
+ // Accumulate all text first so the coalesced token snapshot for the
303
+ // turn is emitted BEFORE any tool_call — matching the OpenCode driver's
304
+ // token-then-tool ordering (thinking/redacted_thinking dropped, leak #1).
305
+ let combined = '';
306
+ const toolUseBlocks = [];
307
+ for (const block of contentBlocks(msg.message)) {
308
+ if (block.type === 'text') {
309
+ combined += typeof block.text === 'string' ? block.text : '';
310
+ }
311
+ else if (block.type === 'tool_use') {
312
+ toolUseBlocks.push(block);
313
+ }
314
+ }
315
+ // Emit the authoritative full-text snapshot for the turn (coalesces
316
+ // with any partial snapshots the sink already buffered).
317
+ if (combined && msgId) {
318
+ const prev = messageText.get(msgId) ?? '';
319
+ if (combined.length >= prev.length) {
320
+ messageText.set(msgId, combined);
321
+ yield { kind: 'token', messageId: msgId, payload: { text: combined } };
322
+ }
323
+ }
324
+ for (const block of toolUseBlocks) {
325
+ const callId = typeof block.id === 'string' ? block.id : '';
326
+ if (!callId || emittedToolCalls.has(callId))
327
+ continue;
328
+ emittedToolCalls.add(callId);
329
+ yield {
330
+ kind: 'tool_call',
331
+ messageId: msgId || prompt.messageId,
332
+ payload: {
333
+ tool: typeof block.name === 'string' ? block.name : '',
334
+ args: asRecord(block.input),
335
+ callId,
336
+ status: 'running',
337
+ },
338
+ };
339
+ }
340
+ // NB: `msg.message.usage` (BetaUsage) is ALWAYS populated on real
341
+ // assistant turns, but we deliberately do NOT emit a per-turn
342
+ // step_finish — the driver coalesces to ONE terminal step_finish at
343
+ // the `result` message so both harnesses normalize to the same
344
+ // granularity at the seam (leak #3).
345
+ continue;
346
+ }
347
+ if (msg.type === 'user') {
348
+ for (const block of contentBlocks(msg.message)) {
349
+ if (block.type !== 'tool_result')
350
+ continue;
351
+ const callId = typeof block.tool_use_id === 'string' ? block.tool_use_id : '';
352
+ if (!callId || emittedToolResults.has(callId))
353
+ continue;
354
+ emittedToolResults.add(callId);
355
+ yield {
356
+ kind: 'tool_result',
357
+ messageId: prompt.messageId,
358
+ payload: {
359
+ callId,
360
+ status: block.is_error === true ? 'error' : 'completed',
361
+ output: toolResultOutput(block.content),
362
+ },
363
+ };
364
+ }
365
+ continue;
366
+ }
367
+ if (msg.type === 'result') {
368
+ const success = msg.subtype === 'success' && msg.is_error !== true;
369
+ if (success) {
370
+ // Final step_finish carries the total cost + usage (leak #3).
371
+ yield {
372
+ kind: 'step_finish',
373
+ messageId: prompt.messageId,
374
+ payload: { cost: msg.total_cost_usd ?? null, tokens: msg.usage ?? null, reason: 'result' },
375
+ };
376
+ terminalYielded = true;
377
+ yield {
378
+ kind: 'execution_complete',
379
+ messageId: prompt.messageId,
380
+ critical: true,
381
+ payload: stopRequested ? { success: false, aborted: true } : { success: true },
382
+ };
383
+ return;
384
+ }
385
+ // error_max_turns / error_during_execution → critical error terminal.
386
+ terminalYielded = true;
387
+ yield {
388
+ kind: 'error',
389
+ messageId: prompt.messageId,
390
+ critical: true,
391
+ payload: { error: describeResultError(msg) },
392
+ };
393
+ return;
394
+ }
395
+ // Unknown message type: no mapping — skip (documented boundary).
396
+ }
397
+ // Stream ended without a result message (server/loop closed early).
398
+ if (!terminalYielded) {
399
+ if (stopRequested || signal.aborted) {
400
+ yield {
401
+ kind: 'execution_complete',
402
+ messageId: prompt.messageId,
403
+ critical: true,
404
+ payload: { success: false, aborted: true },
405
+ };
406
+ }
407
+ else {
408
+ yield {
409
+ kind: 'execution_complete',
410
+ messageId: prompt.messageId,
411
+ critical: true,
412
+ payload: { success: true },
413
+ };
414
+ }
415
+ }
416
+ }
417
+ catch (error) {
418
+ // An abort (caller signal or stop()) resolves as a cancellation, never a
419
+ // plain failure (parity with the OpenCode driver's P2-2 rule).
420
+ if (stopRequested || signal.aborted || isAbortError(error)) {
421
+ if (!terminalYielded) {
422
+ yield {
423
+ kind: 'execution_complete',
424
+ messageId: prompt.messageId,
425
+ critical: true,
426
+ payload: { success: false, aborted: true },
427
+ };
428
+ }
429
+ }
430
+ else if (!terminalYielded) {
431
+ yield errorEvent(prompt.messageId, error);
432
+ }
433
+ }
434
+ finally {
435
+ signal.removeEventListener('abort', onAbort);
436
+ activeQuery = null;
437
+ activeAbort = null;
438
+ }
439
+ },
440
+ async stop() {
441
+ // Cancellation is via `abortController.abort()` — that works in ALL modes
442
+ // and is the REAL mechanism here. `query().interrupt()` is a control request
443
+ // the SDK only honors in streaming-INPUT mode; this driver uses string-prompt
444
+ // mode, so interrupt() is a no-op harmless best-effort at most. We call it
445
+ // first (in case a future streaming-input path is added) but the abort below
446
+ // is what actually cancels the in-flight query.
447
+ stopRequested = true;
448
+ const q = activeQuery;
449
+ if (q && typeof q.interrupt === 'function') {
450
+ try {
451
+ await q.interrupt();
452
+ }
453
+ catch {
454
+ // best-effort — the abort below is the real cancellation
455
+ }
456
+ }
457
+ try {
458
+ activeAbort?.abort();
459
+ }
460
+ catch {
461
+ // controller already aborted / gone
462
+ }
463
+ },
464
+ async shutdown() {
465
+ currentSessionId = null;
466
+ readyEmitted = false;
467
+ stopRequested = false;
468
+ activeQuery = null;
469
+ activeAbort = null;
470
+ sdk = null;
471
+ },
472
+ };
473
+ }
474
+ /** A terminal `error` HarnessEvent from a thrown/rejected query. */
475
+ function errorEvent(messageId, error) {
476
+ const message = error instanceof Error ? error.message : String(error);
477
+ return { kind: 'error', messageId, critical: true, payload: { error: message } };
478
+ }
479
+ function isAbortError(error) {
480
+ return error instanceof Error && (error.name === 'AbortError' || error.name === 'DOMException');
481
+ }
482
+ /** Human-readable reason for a non-success result message. */
483
+ function describeResultError(msg) {
484
+ if (msg.subtype === 'error_max_turns')
485
+ return 'agent stopped: max turns reached';
486
+ if (msg.subtype === 'error_max_budget_usd')
487
+ return 'agent stopped: max budget reached';
488
+ if (msg.subtype === 'error_during_execution')
489
+ return 'agent error during execution';
490
+ if (typeof msg.result === 'string' && msg.result)
491
+ return msg.result;
492
+ return 'agent error';
493
+ }