@karowanorg/orc-harness-claude 0.1.0 → 0.1.2

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 +66 -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) {
@@ -216,6 +205,7 @@ async function* invokeSdk(req, ctx) {
216
205
  // from the first frame; refine `model` to the SDK's resolved id when it arrives.
217
206
  yield { kind: "model", model: req.model, reasoningEffort: req.reasoningEffort };
218
207
  let reportedModel = req.model;
208
+ let syntheticFailure;
219
209
  try {
220
210
  for await (const message of query({ prompt: req.prompt, options })) {
221
211
  const m = message.message?.model ??
@@ -224,6 +214,16 @@ async function* invokeSdk(req, ctx) {
224
214
  reportedModel = m;
225
215
  yield { kind: "model", model: m, reasoningEffort: req.reasoningEffort };
226
216
  }
217
+ if (m === "<synthetic>") {
218
+ syntheticFailure = assistantText(message) || "Claude Code returned a synthetic local failure";
219
+ yield { kind: "error", message: `claude unavailable: ${syntheticFailure}` };
220
+ continue;
221
+ }
222
+ // Claude Code emits a nominal success result after local auth/quota
223
+ // failures. Do not turn that status prose into `{text: ...}` and then
224
+ // misreport it as an output-schema defect.
225
+ if (syntheticFailure && message.type === "result")
226
+ continue;
227
227
  for (const ev of mapSdkMessage(message))
228
228
  yield ev;
229
229
  }
@@ -240,6 +240,17 @@ async function* invokeSdk(req, ctx) {
240
240
  ctx.signal.removeEventListener("abort", onAbort);
241
241
  }
242
242
  }
243
+ function assistantText(message) {
244
+ if (message.type !== "assistant")
245
+ return "";
246
+ return (message.message.content ?? [])
247
+ .flatMap((block) => {
248
+ const candidate = block;
249
+ return candidate.type === "text" && typeof candidate.text === "string" ? [candidate.text] : [];
250
+ })
251
+ .join("\n")
252
+ .trim();
253
+ }
243
254
  function* mapSdkMessage(message) {
244
255
  const now = Date.now();
245
256
  if (message.type === "system" && message.subtype === "init") {
@@ -303,205 +314,6 @@ function* mapSdkMessage(message) {
303
314
  }
304
315
  }
305
316
  // ---------------------------------------------------------------------------
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
317
  function sandboxRoots(req) {
506
318
  return [
507
319
  ...new Set([req.cwd, ...(req.sandboxDirs ?? [])].map((root) => path.normalize(path.isAbsolute(root) ? root : path.resolve(req.cwd, root)))),
@@ -514,51 +326,6 @@ function pathWithin(target, roots, cwd) {
514
326
  return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
515
327
  });
516
328
  }
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
329
  /** Normalize a tool_result content field into a bounded string for the trace. */
563
330
  export function toolResultText(content) {
564
331
  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.2",
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
  ".": {