@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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +44 -299
  3. 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: Anthropic Agent SDK locally, claude CLI stream-json over SSH.
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
- * 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).
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
- 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 } : {}) },
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: executor.host ? ["auto", "bypass"] : ["manual", "accept-edits", "auto", "bypass"],
106
+ approvalModes: ["manual", "accept-edits", "auto", "bypass"],
114
107
  structuredOutput: true,
115
108
  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()",
109
+ detail: "Agent SDK; model catalog via supportedModels()",
119
110
  };
120
111
  },
121
- invoke(req, ctx) {
122
- return req.host ? invokeRemoteCli(req, ctx) : invokeSdk(req, ctx);
123
- },
112
+ invoke: invokeSdk,
124
113
  };
125
114
  // ---------------------------------------------------------------------------
126
- // Local transport: Agent SDK
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.0",
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.0",
9
- "@karowanorg/orc-executors": "^0.1.0",
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: Anthropic Agent SDK locally, claude CLI stream-json over SSH.",
13
+ "description": "Claude harness for orc via the Anthropic Agent SDK.",
14
14
  "types": "dist/index.d.ts",
15
15
  "exports": {
16
16
  ".": {