@karowanorg/orc-harness-claude 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ BSD Zero Clause License (0BSD)
2
+
3
+ Copyright (c) 2026 Kyle Rowan
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
9
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
10
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
11
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
12
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
13
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
14
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @karowanorg/orc-harness-claude
2
+
3
+ Claude harness for orc: Anthropic Agent SDK locally, claude CLI stream-json over SSH.
4
+
5
+ Part of orc - model-authored promise-native agent programs with deterministic replay, live monitoring, and pluggable harnesses. See the orc repository README for the full picture.
@@ -0,0 +1,10 @@
1
+ export declare function systemClaudePath(): string | undefined;
2
+ import type { Harness, Json } from "@karowanorg/orc-core";
3
+ export declare const claudeHarness: Harness;
4
+ /** Normalize a tool_result content field into a bounded string for the trace. */
5
+ export declare function toolResultText(content: unknown): Json;
6
+ /**
7
+ * Best-effort: parse the final text as JSON, else scan (string-aware) for the
8
+ * last top-level balanced {...} that parses.
9
+ */
10
+ export declare function extractJson(text: string): Json;
package/dist/index.js ADDED
@@ -0,0 +1,643 @@
1
+ /**
2
+ * The claude harness — built-in harness #1.
3
+ *
4
+ * Two transports behind one contract:
5
+ * - LOCAL (no host): @anthropic-ai/claude-agent-sdk `query()` — richest path:
6
+ * canUseTool approval bridging, settingSources (user's own settings are the
7
+ * policy substrate), native structured output, session resume. Note the SDK
8
+ * still SPAWNS the bundled CLI as a subprocess (verified in sdk source) — the
9
+ * SDK owns that child's lifecycle; cancellation goes through abortController.
10
+ * - REMOTE (host set): the claude CLI over the executor (ssh), speaking the
11
+ * same stream-json protocol. v1 remote supports approval modes auto/bypass
12
+ * only (manual/accept-edits need an approval back-channel — fail closed).
13
+ *
14
+ * Approval-mode mapping (verified against SDK 0.3.215 types):
15
+ * - manual -> permissionMode "default" + canUseTool -> requestApproval
16
+ * - accept-edits -> permissionMode "acceptEdits" + canUseTool for the rest
17
+ * - auto -> permissionMode "dontAsk" (deny-and-report; NOT "default",
18
+ * which prompts — the verified correction)
19
+ * - bypass -> permissionMode "bypassPermissions" + allowDangerouslySkipPermissions
20
+ *
21
+ * Read-only scoping uses disallowedTools (NOT bare allowedTools, which
22
+ * auto-approves before canUseTool — the verified interplay defect). Inherited
23
+ * settings and MCP tools are not disabled; side effects from configured hooks
24
+ * and integrations are outside the narrow direct command/filesystem guarantee.
25
+ */
26
+ import * as path from "node:path";
27
+ import { existsSync } from "node:fs";
28
+ import { execFileSync } from "node:child_process";
29
+ import { query } from "@anthropic-ai/claude-agent-sdk";
30
+ /**
31
+ * orc does NOT bundle the Claude Code CLI — the system `claude` is a
32
+ * prerequisite. Point the Agent SDK at it (never its own bundled binary, which
33
+ * we don't install). Override with ORC_CLAUDE_PATH.
34
+ */
35
+ let cachedClaudePath;
36
+ export function systemClaudePath() {
37
+ if (cachedClaudePath !== undefined)
38
+ return cachedClaudePath ?? undefined;
39
+ const env = process.env.ORC_CLAUDE_PATH;
40
+ if (env && existsSync(env))
41
+ return (cachedClaudePath = env);
42
+ try {
43
+ const cmd = process.platform === "win32" ? "where" : "which";
44
+ const found = execFileSync(cmd, ["claude"], { encoding: "utf8" }).split("\n")[0]?.trim();
45
+ cachedClaudePath = found && existsSync(found) ? found : null;
46
+ }
47
+ catch {
48
+ cachedClaudePath = null;
49
+ }
50
+ return cachedClaudePath ?? undefined;
51
+ }
52
+ const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];
53
+ const READ_ONLY_DISALLOWED_TOOLS = ["Bash", ...WRITE_TOOLS];
54
+ const POST_EXIT_DRAIN_MS = 10_000;
55
+ export const claudeHarness = {
56
+ name: "claude",
57
+ async discover({ executor }) {
58
+ const version = await executor.run(["claude", "--version"], { timeoutMs: 15_000 }).catch(() => null);
59
+ if (!version || version.code !== 0) {
60
+ return {
61
+ available: false,
62
+ models: [],
63
+ approvalModes: [],
64
+ structuredOutput: false,
65
+ sessions: false,
66
+ detail: "claude CLI not found on this executor",
67
+ };
68
+ }
69
+ let models = [];
70
+ if (!executor.host) {
71
+ // Native catalog via the SDK's supportedModels control request. Each
72
+ // ModelInfo carries its OWN supportedEffortLevels (per-model). Point at
73
+ // the system claude — orc doesn't ship the SDK's bundled CLI.
74
+ try {
75
+ const claudePath = systemClaudePath();
76
+ const q = query({
77
+ prompt: "",
78
+ options: { maxTurns: 0, ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}) },
79
+ });
80
+ // NB: clear the deadline timer once the race settles — an uncleared
81
+ // setTimeout keeps the Node event loop alive, so the CLI would print
82
+ // its result and then appear to hang until the timer fired.
83
+ let deadline;
84
+ const infos = await Promise.race([
85
+ q.supportedModels(),
86
+ new Promise((_, rej) => {
87
+ deadline = setTimeout(() => rej(new Error("timeout")), 20_000);
88
+ }),
89
+ ]).finally(() => deadline && clearTimeout(deadline));
90
+ const seen = new Set();
91
+ for (const m of infos) {
92
+ const id = m.resolvedModel ?? m.value;
93
+ if (!id || seen.has(id))
94
+ continue;
95
+ seen.add(id);
96
+ const info = m;
97
+ models.push({
98
+ id,
99
+ displayName: info.displayName,
100
+ reasoningEfforts: info.supportsEffort === false ? [] : (info.supportedEffortLevels ?? []),
101
+ });
102
+ }
103
+ await q.interrupt().catch(() => undefined);
104
+ }
105
+ catch {
106
+ /* version-only capability report */
107
+ }
108
+ }
109
+ return {
110
+ available: true,
111
+ version: version.stdout.trim().split("\n")[0],
112
+ models,
113
+ approvalModes: executor.host ? ["auto", "bypass"] : ["manual", "accept-edits", "auto", "bypass"],
114
+ structuredOutput: true,
115
+ sessions: true,
116
+ detail: executor.host
117
+ ? "claude CLI over ssh (stream-json); remote approvals limited to auto/bypass in v1"
118
+ : "Agent SDK; model catalog via supportedModels()",
119
+ };
120
+ },
121
+ invoke(req, ctx) {
122
+ return req.host ? invokeRemoteCli(req, ctx) : invokeSdk(req, ctx);
123
+ },
124
+ };
125
+ // ---------------------------------------------------------------------------
126
+ // Local transport: Agent SDK
127
+ // ---------------------------------------------------------------------------
128
+ async function* invokeSdk(req, ctx) {
129
+ if (ctx.signal.aborted) {
130
+ yield { kind: "error", message: `aborted: ${String(ctx.signal.reason ?? "cancelled")}` };
131
+ return;
132
+ }
133
+ const abort = new AbortController();
134
+ const onAbort = () => abort.abort(ctx.signal.reason);
135
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
136
+ if (ctx.signal.aborted)
137
+ onAbort();
138
+ const bridgeApprovals = !req.readOnly && (req.approvalMode === "manual" || req.approvalMode === "accept-edits");
139
+ const sandboxWrites = req.sandbox === true && !req.readOnly;
140
+ const roots = sandboxRoots(req);
141
+ const permissionMode = req.readOnly
142
+ ? "dontAsk"
143
+ : sandboxWrites && (req.approvalMode === "auto" || req.approvalMode === "bypass")
144
+ ? "default"
145
+ : req.approvalMode === "manual"
146
+ ? "default"
147
+ : req.approvalMode === "accept-edits"
148
+ ? "acceptEdits"
149
+ : req.approvalMode === "bypass"
150
+ ? "bypassPermissions"
151
+ : "dontAsk";
152
+ const canUseTool = bridgeApprovals || sandboxWrites
153
+ ? async (toolName, input, permission) => {
154
+ if (sandboxWrites && WRITE_TOOLS.includes(toolName)) {
155
+ const target = (input.file_path ?? input.notebook_path ?? input.path);
156
+ const targets = [target, permission.blockedPath].filter((candidate) => typeof candidate === "string");
157
+ if (targets.length === 0 ||
158
+ targets.some((candidate) => !pathWithin(candidate, roots, req.cwd))) {
159
+ return {
160
+ behavior: "deny",
161
+ message: `sandbox: ${toolName} target is outside the allowed roots`,
162
+ };
163
+ }
164
+ if (!bridgeApprovals) {
165
+ return { behavior: "allow", updatedInput: input };
166
+ }
167
+ }
168
+ if (!bridgeApprovals) {
169
+ return { behavior: "allow", updatedInput: input };
170
+ }
171
+ const decision = await ctx.requestApproval({
172
+ runId: req.runId,
173
+ seq: req.seq,
174
+ toolName,
175
+ input: input,
176
+ });
177
+ return decision.behavior === "allow"
178
+ ? { behavior: "allow", updatedInput: input }
179
+ : { behavior: "deny", message: decision.message ?? "denied by operator" };
180
+ }
181
+ : undefined;
182
+ const claudePath = systemClaudePath();
183
+ const options = {
184
+ cwd: req.cwd,
185
+ model: req.model,
186
+ effort: req.reasoningEffort,
187
+ resume: req.sessionId,
188
+ abortController: abort,
189
+ permissionMode,
190
+ ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
191
+ // Sandboxed write leaves isolate settings because settings can add writable
192
+ // roots. Read-only deliberately does not disable configured integrations.
193
+ settingSources: sandboxWrites ? [] : ["user", "project", "local"],
194
+ strictMcpConfig: sandboxWrites,
195
+ systemPrompt: { type: "preset", preset: "claude_code", append: req.system },
196
+ maxTurns: 100,
197
+ ...(req.approvalMode === "bypass" && !req.readOnly && !sandboxWrites
198
+ ? { allowDangerouslySkipPermissions: true }
199
+ : {}),
200
+ ...(req.readOnly ? { disallowedTools: READ_ONLY_DISALLOWED_TOOLS } : {}),
201
+ ...(sandboxWrites
202
+ ? {
203
+ additionalDirectories: roots.slice(1),
204
+ sandbox: {
205
+ enabled: true,
206
+ failIfUnavailable: true,
207
+ allowUnsandboxedCommands: false,
208
+ filesystem: { allowWrite: roots },
209
+ },
210
+ }
211
+ : {}),
212
+ ...(req.schema ? { outputFormat: { type: "json_schema", schema: req.schema } } : {}),
213
+ ...(canUseTool ? { canUseTool } : {}),
214
+ };
215
+ // Announce the requested model/effort immediately so the monitor shows them
216
+ // from the first frame; refine `model` to the SDK's resolved id when it arrives.
217
+ yield { kind: "model", model: req.model, reasoningEffort: req.reasoningEffort };
218
+ let reportedModel = req.model;
219
+ try {
220
+ for await (const message of query({ prompt: req.prompt, options })) {
221
+ const m = message.message?.model ??
222
+ message.model;
223
+ if (m && m !== reportedModel) {
224
+ reportedModel = m;
225
+ yield { kind: "model", model: m, reasoningEffort: req.reasoningEffort };
226
+ }
227
+ for (const ev of mapSdkMessage(message))
228
+ yield ev;
229
+ }
230
+ }
231
+ catch (err) {
232
+ if (ctx.signal.aborted) {
233
+ yield { kind: "error", message: `aborted: ${String(ctx.signal.reason ?? "cancelled")}` };
234
+ }
235
+ else {
236
+ yield { kind: "error", message: String(err instanceof Error ? err.message : err) };
237
+ }
238
+ }
239
+ finally {
240
+ ctx.signal.removeEventListener("abort", onAbort);
241
+ }
242
+ }
243
+ function* mapSdkMessage(message) {
244
+ const now = Date.now();
245
+ if (message.type === "system" && message.subtype === "init") {
246
+ yield { kind: "session", sessionId: message.session_id };
247
+ }
248
+ else if (message.type === "system" && message.subtype === "permission_denied") {
249
+ yield {
250
+ kind: "denied",
251
+ toolName: message.tool_name,
252
+ reason: message.decision_reason ??
253
+ message.decision_reason_type ??
254
+ "denied by permission policy",
255
+ atMs: now,
256
+ };
257
+ }
258
+ else if (message.type === "assistant") {
259
+ for (const block of message.message.content ?? []) {
260
+ if (typeof block === "object" && block !== null && block.type === "tool_use") {
261
+ const b = block;
262
+ yield { kind: "tool-call-open", id: b.id, name: b.name, input: b.input, atMs: now };
263
+ }
264
+ }
265
+ }
266
+ else if (message.type === "user") {
267
+ const content = message.message?.content;
268
+ if (Array.isArray(content)) {
269
+ for (const block of content) {
270
+ if (typeof block === "object" && block !== null && block.type === "tool_result") {
271
+ const b = block;
272
+ yield {
273
+ kind: "tool-call-close",
274
+ id: b.tool_use_id,
275
+ status: b.is_error ? "error" : "ok",
276
+ result: toolResultText(b.content),
277
+ atMs: now,
278
+ };
279
+ }
280
+ }
281
+ }
282
+ }
283
+ else if (message.type === "result") {
284
+ const usage = message.usage;
285
+ yield {
286
+ kind: "usage",
287
+ tokensIn: usage?.input_tokens,
288
+ tokensOut: usage?.output_tokens,
289
+ costUsd: message.total_cost_usd,
290
+ };
291
+ if (message.subtype === "success") {
292
+ const structured = message.structured_output;
293
+ if (structured !== undefined) {
294
+ yield { kind: "result", output: structured };
295
+ }
296
+ else {
297
+ yield { kind: "result", output: extractJson(message.result) };
298
+ }
299
+ }
300
+ else {
301
+ yield { kind: "error", message: `claude leaf failed: ${message.subtype}` };
302
+ }
303
+ }
304
+ }
305
+ // ---------------------------------------------------------------------------
306
+ // Remote transport: claude CLI over the executor (same stream-json protocol)
307
+ // ---------------------------------------------------------------------------
308
+ async function* invokeRemoteCli(req, ctx) {
309
+ if (ctx.signal.aborted) {
310
+ yield { kind: "error", message: `aborted: ${String(ctx.signal.reason ?? "cancelled")}` };
311
+ return;
312
+ }
313
+ if (req.approvalMode === "manual" || req.approvalMode === "accept-edits") {
314
+ yield {
315
+ kind: "error",
316
+ message: `approval mode "${req.approvalMode}" is not supported for remote claude leaves in v1 ` +
317
+ `(no approval back-channel over ssh yet) — use auto or bypass, fail-closed`,
318
+ };
319
+ return;
320
+ }
321
+ const args = ["claude", "-p", "--verbose", "--output-format", "stream-json"];
322
+ const sandboxWrites = req.sandbox === true && !req.readOnly;
323
+ if (sandboxWrites) {
324
+ args.push("--permission-mode", "acceptEdits");
325
+ }
326
+ else if (req.approvalMode === "bypass" && !req.readOnly) {
327
+ args.push("--permission-mode", "bypassPermissions", "--dangerously-skip-permissions");
328
+ }
329
+ else {
330
+ args.push("--permission-mode", "dontAsk");
331
+ }
332
+ const roots = sandboxRoots(req);
333
+ if (req.readOnly)
334
+ args.push("--disallowedTools", READ_ONLY_DISALLOWED_TOOLS.join(","));
335
+ if (sandboxWrites)
336
+ args.push("--setting-sources=", "--strict-mcp-config");
337
+ if (sandboxWrites) {
338
+ args.push("--settings", JSON.stringify({
339
+ sandbox: {
340
+ enabled: true,
341
+ failIfUnavailable: true,
342
+ allowUnsandboxedCommands: false,
343
+ filesystem: { allowWrite: roots },
344
+ },
345
+ }));
346
+ for (const root of roots.slice(1))
347
+ args.push("--add-dir", root);
348
+ }
349
+ if (req.model)
350
+ args.push("--model", req.model);
351
+ if (req.sessionId)
352
+ args.push("--resume", req.sessionId);
353
+ if (req.system)
354
+ args.push("--append-system-prompt", req.system);
355
+ if (req.schema)
356
+ args.push("--json-schema", JSON.stringify(req.schema));
357
+ const proc = ctx.executor.spawn(args, { cwd: req.cwd, stdin: "pipe" });
358
+ const onAbort = () => proc.kill();
359
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
360
+ if (ctx.signal.aborted)
361
+ onAbort();
362
+ const stderrDone = drainStderr(proc.stderr, ctx.log);
363
+ const cancelStdoutGuard = guardPostExitDrain(proc.stdout, proc.exited, "stdout", ctx.log);
364
+ const cancelStderrGuard = guardPostExitDrain(proc.stderr, proc.exited, "stderr", ctx.log);
365
+ let stdinError;
366
+ if (!proc.stdin) {
367
+ ctx.signal.removeEventListener("abort", onAbort);
368
+ proc.kill();
369
+ destroyStream(proc.stdout);
370
+ await stderrDone;
371
+ cancelStdoutGuard();
372
+ cancelStderrGuard();
373
+ yield { kind: "error", message: "remote claude stdin is unavailable" };
374
+ return;
375
+ }
376
+ proc.stdin.on("error", (err) => {
377
+ stdinError = err;
378
+ if (err.code !== "EPIPE")
379
+ proc.kill();
380
+ });
381
+ try {
382
+ proc.stdin.end(req.prompt);
383
+ }
384
+ catch (err) {
385
+ stdinError = err instanceof Error ? err : new Error(String(err));
386
+ proc.kill();
387
+ }
388
+ yield { kind: "model", model: req.model, reasoningEffort: req.reasoningEffort };
389
+ let sawResult = false;
390
+ try {
391
+ try {
392
+ for await (const line of lines(proc.stdout)) {
393
+ let msg;
394
+ try {
395
+ msg = JSON.parse(line);
396
+ }
397
+ catch {
398
+ continue; // non-JSON noise
399
+ }
400
+ for (const ev of mapCliLine(msg)) {
401
+ if (ev.kind === "result")
402
+ sawResult = true;
403
+ yield ev;
404
+ }
405
+ }
406
+ }
407
+ finally {
408
+ cancelStdoutGuard();
409
+ }
410
+ const code = await proc.exited;
411
+ if (!sawResult) {
412
+ yield {
413
+ kind: "error",
414
+ message: stdinError
415
+ ? `remote claude stdin failed: ${stdinError.message}`
416
+ : `remote claude exited with code ${code} without a result`,
417
+ };
418
+ }
419
+ }
420
+ finally {
421
+ ctx.signal.removeEventListener("abort", onAbort);
422
+ proc.kill();
423
+ await stderrDone;
424
+ cancelStdoutGuard();
425
+ cancelStderrGuard();
426
+ }
427
+ }
428
+ function* mapCliLine(msg) {
429
+ const now = Date.now();
430
+ const type = msg.type;
431
+ if (type === "system" && msg.subtype === "init" && typeof msg.session_id === "string") {
432
+ yield { kind: "session", sessionId: msg.session_id };
433
+ }
434
+ else if (type === "assistant") {
435
+ const content = msg.message?.content;
436
+ if (Array.isArray(content)) {
437
+ for (const block of content) {
438
+ const b = block;
439
+ if (b.type === "tool_use" && b.id && b.name) {
440
+ yield { kind: "tool-call-open", id: b.id, name: b.name, input: b.input, atMs: now };
441
+ }
442
+ }
443
+ }
444
+ }
445
+ else if (type === "user") {
446
+ const content = msg.message?.content;
447
+ if (Array.isArray(content)) {
448
+ for (const block of content) {
449
+ const b = block;
450
+ if (b.type === "tool_result" && b.tool_use_id) {
451
+ yield {
452
+ kind: "tool-call-close",
453
+ id: b.tool_use_id,
454
+ status: b.is_error ? "error" : "ok",
455
+ result: toolResultText(b.content),
456
+ atMs: now,
457
+ };
458
+ }
459
+ }
460
+ }
461
+ }
462
+ else if (type === "result") {
463
+ const usage = msg.usage;
464
+ yield {
465
+ kind: "usage",
466
+ tokensIn: usage?.input_tokens,
467
+ tokensOut: usage?.output_tokens,
468
+ costUsd: msg.total_cost_usd,
469
+ };
470
+ if (msg.subtype === "success") {
471
+ if (msg.structured_output !== undefined) {
472
+ yield { kind: "result", output: msg.structured_output };
473
+ }
474
+ else {
475
+ yield { kind: "result", output: extractJson(String(msg.result ?? "")) };
476
+ }
477
+ }
478
+ else {
479
+ yield { kind: "error", message: `claude leaf failed: ${String(msg.subtype)}` };
480
+ }
481
+ }
482
+ }
483
+ // ---------------------------------------------------------------------------
484
+ async function* lines(stream) {
485
+ let buf = "";
486
+ try {
487
+ for await (const chunk of stream) {
488
+ buf += chunk.toString();
489
+ let idx;
490
+ while ((idx = buf.indexOf("\n")) >= 0) {
491
+ const line = buf.slice(0, idx).trim();
492
+ buf = buf.slice(idx + 1);
493
+ if (line)
494
+ yield line;
495
+ }
496
+ }
497
+ }
498
+ catch (err) {
499
+ if (err.code !== "ERR_STREAM_PREMATURE_CLOSE")
500
+ throw err;
501
+ }
502
+ if (buf.trim())
503
+ yield buf.trim();
504
+ }
505
+ function sandboxRoots(req) {
506
+ return [
507
+ ...new Set([req.cwd, ...(req.sandboxDirs ?? [])].map((root) => path.normalize(path.isAbsolute(root) ? root : path.resolve(req.cwd, root)))),
508
+ ];
509
+ }
510
+ function pathWithin(target, roots, cwd) {
511
+ const candidate = path.resolve(cwd, target);
512
+ return roots.some((root) => {
513
+ const rel = path.relative(root, candidate);
514
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
515
+ });
516
+ }
517
+ function guardPostExitDrain(stream, exited, name, log) {
518
+ let cancelled = false;
519
+ let timer;
520
+ void exited.then(() => {
521
+ if (cancelled)
522
+ return;
523
+ timer = setTimeout(() => {
524
+ log(`remote claude ${name} remained open after exit; stopping drain`);
525
+ destroyStream(stream);
526
+ }, POST_EXIT_DRAIN_MS);
527
+ timer.unref?.();
528
+ });
529
+ return () => {
530
+ cancelled = true;
531
+ if (timer)
532
+ clearTimeout(timer);
533
+ };
534
+ }
535
+ function destroyStream(stream) {
536
+ stream.destroy?.();
537
+ }
538
+ async function drainStderr(stream, log) {
539
+ let buf = "";
540
+ try {
541
+ for await (const chunk of stream) {
542
+ buf += chunk.toString();
543
+ let idx;
544
+ while ((idx = buf.indexOf("\n")) >= 0) {
545
+ const line = buf.slice(0, idx).trim();
546
+ buf = buf.slice(idx + 1);
547
+ if (line)
548
+ log(line);
549
+ }
550
+ }
551
+ }
552
+ catch (err) {
553
+ if (err.code !== "ERR_STREAM_PREMATURE_CLOSE") {
554
+ log(`remote claude stderr read failed: ${String(err)}`);
555
+ }
556
+ }
557
+ finally {
558
+ if (buf.trim())
559
+ log(buf.trim());
560
+ }
561
+ }
562
+ /** Normalize a tool_result content field into a bounded string for the trace. */
563
+ export function toolResultText(content) {
564
+ const MAX = 8 * 1024;
565
+ let text;
566
+ if (typeof content === "string") {
567
+ text = content;
568
+ }
569
+ else if (Array.isArray(content)) {
570
+ text = content
571
+ .map((b) => {
572
+ if (typeof b === "string")
573
+ return b;
574
+ if (b && typeof b === "object" && typeof b.text === "string") {
575
+ return b.text;
576
+ }
577
+ return JSON.stringify(b);
578
+ })
579
+ .join("\n");
580
+ }
581
+ else if (content === undefined || content === null) {
582
+ return null;
583
+ }
584
+ else {
585
+ text = JSON.stringify(content);
586
+ }
587
+ return text.length > MAX ? text.slice(0, MAX) + `\n… [truncated ${text.length - MAX} chars]` : text;
588
+ }
589
+ /**
590
+ * Best-effort: parse the final text as JSON, else scan (string-aware) for the
591
+ * last top-level balanced {...} that parses.
592
+ */
593
+ export function extractJson(text) {
594
+ const trimmed = text.trim();
595
+ try {
596
+ return JSON.parse(trimmed);
597
+ }
598
+ catch {
599
+ /* fall through */
600
+ }
601
+ let last;
602
+ let depth = 0;
603
+ let start = -1;
604
+ let inString = false;
605
+ let escaped = false;
606
+ for (let i = 0; i < trimmed.length; i++) {
607
+ const c = trimmed[i];
608
+ if (inString) {
609
+ if (escaped)
610
+ escaped = false;
611
+ else if (c === "\\")
612
+ escaped = true;
613
+ else if (c === '"')
614
+ inString = false;
615
+ continue;
616
+ }
617
+ if (c === '"') {
618
+ if (depth > 0)
619
+ inString = true;
620
+ continue;
621
+ }
622
+ if (c === "{") {
623
+ if (depth === 0)
624
+ start = i;
625
+ depth++;
626
+ }
627
+ else if (c === "}") {
628
+ if (depth > 0) {
629
+ depth--;
630
+ if (depth === 0 && start >= 0) {
631
+ try {
632
+ last = JSON.parse(trimmed.slice(start, i + 1));
633
+ }
634
+ catch {
635
+ /* not a JSON object after all */
636
+ }
637
+ start = -1;
638
+ }
639
+ }
640
+ }
641
+ }
642
+ return last ?? { text: trimmed };
643
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@karowanorg/orc-harness-claude",
3
+ "version": "0.1.0",
4
+ "license": "0BSD",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "dependencies": {
8
+ "@karowanorg/orc-core": "^0.1.0",
9
+ "@karowanorg/orc-executors": "^0.1.0",
10
+ "@anthropic-ai/claude-agent-sdk": "^0.3.0",
11
+ "zod": "^4.1.0"
12
+ },
13
+ "description": "Claude harness for orc: Anthropic Agent SDK locally, claude CLI stream-json over SSH.",
14
+ "types": "dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ }
30
+ }