@karowanorg/orc-harness-claude 0.1.0 → 0.1.1
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/README.md +1 -1
- package/dist/index.js +44 -299
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# @karowanorg/orc-harness-claude
|
|
2
2
|
|
|
3
|
-
Claude harness for orc
|
|
3
|
+
Claude harness for orc via the Anthropic Agent SDK.
|
|
4
4
|
|
|
5
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.
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The claude harness — built-in harness #1.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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).
|
|
4
|
+
* Transport: @anthropic-ai/claude-agent-sdk `query()` — canUseTool approval
|
|
5
|
+
* bridging, settingSources (user's own settings are the policy substrate),
|
|
6
|
+
* native structured output, session resume. Note the SDK still SPAWNS the
|
|
7
|
+
* bundled CLI as a subprocess (verified in sdk source) — the SDK owns that
|
|
8
|
+
* child's lifecycle; cancellation goes through abortController.
|
|
13
9
|
*
|
|
14
10
|
* Approval-mode mapping (verified against SDK 0.3.215 types):
|
|
15
11
|
* - manual -> permissionMode "default" + canUseTool -> requestApproval
|
|
@@ -51,7 +47,6 @@ export function systemClaudePath() {
|
|
|
51
47
|
}
|
|
52
48
|
const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];
|
|
53
49
|
const READ_ONLY_DISALLOWED_TOOLS = ["Bash", ...WRITE_TOOLS];
|
|
54
|
-
const POST_EXIT_DRAIN_MS = 10_000;
|
|
55
50
|
export const claudeHarness = {
|
|
56
51
|
name: "claude",
|
|
57
52
|
async discover({ executor }) {
|
|
@@ -66,64 +61,58 @@ export const claudeHarness = {
|
|
|
66
61
|
detail: "claude CLI not found on this executor",
|
|
67
62
|
};
|
|
68
63
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
64
|
+
const models = [];
|
|
65
|
+
// Native catalog via the SDK's supportedModels control request. Each
|
|
66
|
+
// ModelInfo carries its OWN supportedEffortLevels (per-model). Point at
|
|
67
|
+
// the system claude — orc doesn't ship the SDK's bundled CLI.
|
|
68
|
+
try {
|
|
69
|
+
const claudePath = systemClaudePath();
|
|
70
|
+
const q = query({
|
|
71
|
+
prompt: "",
|
|
72
|
+
options: { maxTurns: 0, ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}) },
|
|
73
|
+
});
|
|
74
|
+
// NB: clear the deadline timer once the race settles — an uncleared
|
|
75
|
+
// setTimeout keeps the Node event loop alive, so the CLI would print
|
|
76
|
+
// its result and then appear to hang until the timer fired.
|
|
77
|
+
let deadline;
|
|
78
|
+
const infos = await Promise.race([
|
|
79
|
+
q.supportedModels(),
|
|
80
|
+
new Promise((_, rej) => {
|
|
81
|
+
deadline = setTimeout(() => rej(new Error("timeout")), 20_000);
|
|
82
|
+
}),
|
|
83
|
+
]).finally(() => deadline && clearTimeout(deadline));
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const m of infos) {
|
|
86
|
+
const id = m.resolvedModel ?? m.value;
|
|
87
|
+
if (!id || seen.has(id))
|
|
88
|
+
continue;
|
|
89
|
+
seen.add(id);
|
|
90
|
+
const info = m;
|
|
91
|
+
models.push({
|
|
92
|
+
id,
|
|
93
|
+
displayName: info.displayName,
|
|
94
|
+
reasoningEfforts: info.supportsEffort === false ? [] : (info.supportedEffortLevels ?? []),
|
|
79
95
|
});
|
|
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
96
|
}
|
|
97
|
+
await q.interrupt().catch(() => undefined);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* version-only capability report */
|
|
108
101
|
}
|
|
109
102
|
return {
|
|
110
103
|
available: true,
|
|
111
104
|
version: version.stdout.trim().split("\n")[0],
|
|
112
105
|
models,
|
|
113
|
-
approvalModes:
|
|
106
|
+
approvalModes: ["manual", "accept-edits", "auto", "bypass"],
|
|
114
107
|
structuredOutput: true,
|
|
115
108
|
sessions: true,
|
|
116
|
-
detail:
|
|
117
|
-
? "claude CLI over ssh (stream-json); remote approvals limited to auto/bypass in v1"
|
|
118
|
-
: "Agent SDK; model catalog via supportedModels()",
|
|
109
|
+
detail: "Agent SDK; model catalog via supportedModels()",
|
|
119
110
|
};
|
|
120
111
|
},
|
|
121
|
-
invoke
|
|
122
|
-
return req.host ? invokeRemoteCli(req, ctx) : invokeSdk(req, ctx);
|
|
123
|
-
},
|
|
112
|
+
invoke: invokeSdk,
|
|
124
113
|
};
|
|
125
114
|
// ---------------------------------------------------------------------------
|
|
126
|
-
//
|
|
115
|
+
// Transport: Agent SDK
|
|
127
116
|
// ---------------------------------------------------------------------------
|
|
128
117
|
async function* invokeSdk(req, ctx) {
|
|
129
118
|
if (ctx.signal.aborted) {
|
|
@@ -303,205 +292,6 @@ function* mapSdkMessage(message) {
|
|
|
303
292
|
}
|
|
304
293
|
}
|
|
305
294
|
// ---------------------------------------------------------------------------
|
|
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
295
|
function sandboxRoots(req) {
|
|
506
296
|
return [
|
|
507
297
|
...new Set([req.cwd, ...(req.sandboxDirs ?? [])].map((root) => path.normalize(path.isAbsolute(root) ? root : path.resolve(req.cwd, root)))),
|
|
@@ -514,51 +304,6 @@ function pathWithin(target, roots, cwd) {
|
|
|
514
304
|
return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
515
305
|
});
|
|
516
306
|
}
|
|
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
307
|
/** Normalize a tool_result content field into a bounded string for the trace. */
|
|
563
308
|
export function toolResultText(content) {
|
|
564
309
|
const MAX = 8 * 1024;
|
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@karowanorg/orc-harness-claude",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"license": "0BSD",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@karowanorg/orc-core": "^0.1.
|
|
9
|
-
"@karowanorg/orc-executors": "^0.1.
|
|
8
|
+
"@karowanorg/orc-core": "^0.1.1",
|
|
9
|
+
"@karowanorg/orc-executors": "^0.1.1",
|
|
10
10
|
"@anthropic-ai/claude-agent-sdk": "^0.3.0",
|
|
11
11
|
"zod": "^4.1.0"
|
|
12
12
|
},
|
|
13
|
-
"description": "Claude harness for orc
|
|
13
|
+
"description": "Claude harness for orc via the Anthropic Agent SDK.",
|
|
14
14
|
"types": "dist/index.d.ts",
|
|
15
15
|
"exports": {
|
|
16
16
|
".": {
|