faberun 0.3.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,42 @@
1
+ # The closed-packet profile: what a worker or a judge that may inspect only the
2
+ # paths its packet names actually needs.
3
+ #
4
+ # Measured on one closed packet (fix a function, verify with an exit code),
5
+ # same model, against the unpatched profile: 60,994 prompt tokens across 6
6
+ # model calls, versus 11,959 across 4 with every row below disabled. The
7
+ # preamble is re-sent on each call, so the saving multiplies by the number of
8
+ # steps a node takes, not by the number of nodes.
9
+ #
10
+ # Rows are disabled by loader id, never removed: the services behind them still
11
+ # mount, so nothing that waits on them fails to activate.
12
+ - id: tool-jobs
13
+ disabled: true
14
+ - id: tool-skill
15
+ disabled: true
16
+ - id: tool-subagent
17
+ disabled: true
18
+ - id: tool-subagent-fork
19
+ disabled: true
20
+ - id: tool-subagent-control
21
+ disabled: true
22
+ - id: tool-subagent-list-agents
23
+ disabled: true
24
+ - id: tool-workflow
25
+ disabled: true
26
+ - id: tool-todo
27
+ disabled: true
28
+ - id: tool-goal
29
+ disabled: true
30
+ - id: tool-ralph
31
+ disabled: true
32
+ - id: tool-web
33
+ disabled: true
34
+ - id: tool-pwsh
35
+ disabled: true
36
+ - id: plan-mode
37
+ disabled: true
38
+ # The packet is the instruction set: inheriting the repository's AGENTS.md
39
+ # would hand a closed-packet worker rules nobody asked it to follow. In this
40
+ # repository that file alone is ~2,500 prompt tokens per session.
41
+ - id: agent-instructions
42
+ disabled: true
@@ -0,0 +1,210 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { canonicalUsage, extractJson, failed, isQuotaText, isVerdictCandidate, parseJsonLines, parseVersion } from "../protocol.mjs";
4
+
5
+ const HERE = dirname(fileURLToPath(import.meta.url));
6
+
7
+ /**
8
+ * The harness has no one-shot surface that reports what a turn cost, so the
9
+ * adapter's real provider is this client: it speaks the `sdk` JSON-RPC profile
10
+ * and folds the session firehose into the envelope. The `dsh` binary named by
11
+ * `executable()` is the harness the client drives, and probing it is what
12
+ * preflight means by "the provider binary exists".
13
+ */
14
+ const RUNNER = join(HERE, "runner.mjs");
15
+
16
+ /**
17
+ * Every runtime gets the closed-packet profile: the shipped harness advertises
18
+ * twenty-five tools and describes goals, subagents, and skills it was never
19
+ * asked to use. Measured on the same closed packet, that preamble costs
20
+ * 60,994 prompt tokens against 11,959 with this patch — the overhead is
21
+ * re-sent on every step, so it is paid once per model call, not once per node.
22
+ * `agent-instructions` is off for the same reason the packet is closed: the
23
+ * worker's instructions come from its packet, not from whatever `AGENTS.md`
24
+ * sits above the worktree.
25
+ */
26
+ const CLOSED_PACKET_PATCH = join(HERE, "closed-packet.patch.yml");
27
+
28
+ /**
29
+ * @type {import("../index.mjs").HarnessAdapter}
30
+ */
31
+ export const dshHarness = {
32
+ capabilities: {
33
+ // The schema rides in the prompt and the verdict is extracted from the
34
+ // final message, exactly as `agy` does; nothing in the wire enforces it.
35
+ structuredOutput: true,
36
+ promptTransport: "stdin",
37
+ // `runtime.sandbox` maps onto DSH_PERMISSION_MODE, which is the harness's
38
+ // own file-effect boundary and its approval policy in one value.
39
+ sandbox: true,
40
+ permissions: false,
41
+ // `sdk` and `headless` are create-only: `session/resume` exists on the ACP
42
+ // profile alone, and reusing a persisted session id is refused outright.
43
+ continuation: false,
44
+ tokenBudget: false,
45
+ costBudget: false,
46
+ usage: true,
47
+ cost: false,
48
+ toolPolicy: false,
49
+ // Measured 2026-09-10: runner.mjs writeSync's each dsh.message as the
50
+ // session emits it, not just at the end. A three-tool-call turn against
51
+ // deepseek-official/deepseek-flash grew the redirected stdout file from
52
+ // 67 to 1,380 to 1,441 to 2,084 bytes across a 14s turn.
53
+ streamsOutput: true,
54
+ },
55
+
56
+ // sandbox maps to DSH_PERMISSION_MODE, which is the harness's file-effect
57
+ // boundary and its approval policy in one value. Both modes execute, so both
58
+ // are declared: measured 2026-09-11, a worker left at the harness default
59
+ // `workspace-write` ran `printf ... > exec-probe.txt` through the shell (the
60
+ // packet forbade the write tool) and the file reached the integrated commit.
61
+ // What that default cannot do is reach outside the worktree -- something a
62
+ // detached run cannot answer an approval prompt for, and the reason
63
+ // `danger-full-access` is the mode for a packet with effects beyond it.
64
+ permissionExecution: { field: "sandbox", executingModes: ["workspace-write", "danger-full-access"], defaultMode: "workspace-write" },
65
+
66
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
67
+ executable(runtime) {
68
+ return process.env.FABERUN_DSH_BIN ?? runtime.executable ?? "dsh";
69
+ },
70
+
71
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string[]} */
72
+ versionArgs(runtime) {
73
+ return runtime.versionArgs ?? ["--version"];
74
+ },
75
+
76
+ parseVersion,
77
+
78
+ /**
79
+ * @param {import("../index.mjs").HarnessRuntime} runtime
80
+ * @param {string} prompt
81
+ * @param {import("../index.mjs").CommandOptions} options
82
+ * @returns {import("../index.mjs").HarnessCommand}
83
+ */
84
+ command(runtime, prompt, options = {}) {
85
+ const args = [
86
+ RUNNER,
87
+ "--dsh", this.executable(runtime),
88
+ "--provider", /** @type {string} */ (runtime.config?.provider),
89
+ "--model", runtime.model,
90
+ ];
91
+ if (runtime.reasoning) args.push("--reasoning", runtime.reasoning);
92
+ if (runtime.sandbox) args.push("--sandbox", runtime.sandbox);
93
+ args.push("--patch", CLOSED_PACKET_PATCH);
94
+ const extraPatch = runtime.config?.patch;
95
+ if (typeof extraPatch === "string" && extraPatch.length) args.push("--patch", extraPatch);
96
+ return {
97
+ executable: process.execPath,
98
+ args,
99
+ promptTransport: "stdin",
100
+ input: withSchema(prompt, options.schema),
101
+ };
102
+ },
103
+
104
+ /**
105
+ * @param {string} stdout
106
+ * @param {number|null} exitCode
107
+ * @param {string|null} signal
108
+ * @param {import("../index.mjs").NormalizeOptions} [options]
109
+ * @returns {import("../index.mjs").ProviderEnvelope}
110
+ */
111
+ normalize(stdout, exitCode, signal, options = {}) {
112
+ if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
113
+ let events;
114
+ try {
115
+ events = parseJsonLines(stdout, "dsh");
116
+ } catch (error) {
117
+ return failed("invalid_protocol", error instanceof Error ? error.message : String(error));
118
+ }
119
+ const terminal = events.findLast((event) => event.type === "dsh.completed" || event.type === "dsh.failed");
120
+ if (!terminal) {
121
+ const detail = options.stderr?.trim();
122
+ return failed(
123
+ "incomplete_stream",
124
+ `dsh emitted no terminal event${detail ? `: ${detail.slice(-512)}` : ""}${exitCode === null ? "" : ` (exit ${exitCode})`}`,
125
+ );
126
+ }
127
+ const usage = canonicalUsage(terminal.usage);
128
+ if (terminal.type === "dsh.completed") {
129
+ const text = typeof terminal.result === "string" ? terminal.result : null;
130
+ const result = options.preferStructured ? extractJson(text) ?? text : text;
131
+ const verdicts = options.preferStructured ? countVerdicts(events) : null;
132
+ return {
133
+ status: result?.trim() ? "done" : "no-op",
134
+ result,
135
+ continuationId: null,
136
+ usage,
137
+ costUsd: null,
138
+ error: null,
139
+ ...(verdicts === null ? {} : { judgeCandidates: verdicts }),
140
+ };
141
+ }
142
+ const error = terminal.error && typeof terminal.error === "object" ? /** @type {Record<string, unknown>} */ (terminal.error) : {};
143
+ const kind = typeof terminal.kind === "string" ? terminal.kind : "error";
144
+ const code = typeof error.code === "string" && error.code ? error.code : kind;
145
+ const message = typeof error.message === "string" && error.message ? error.message : code;
146
+ const resetAt = resetTimestamp(error.retryAfterMs);
147
+ return {
148
+ status: statusFor(kind, `${code} ${message}`),
149
+ result: null,
150
+ continuationId: null,
151
+ usage,
152
+ costUsd: null,
153
+ error: { code, message, ...(resetAt ? { resetAt } : {}) },
154
+ ...(resetAt ? { exhaustedUntil: resetAt } : {}),
155
+ };
156
+ },
157
+ };
158
+
159
+ /**
160
+ * Append the output schema the judge prompt refers to. Codex and Claude receive
161
+ * it through a native flag; this harness has none, so it travels in the prompt.
162
+ *
163
+ * @param {string} prompt
164
+ * @param {object|undefined} schema
165
+ * @returns {string}
166
+ */
167
+ function withSchema(prompt, schema) {
168
+ if (!schema) return prompt;
169
+ return `${prompt}\n\nOutput schema (the JSON object must validate against it):\n${JSON.stringify(schema)}`;
170
+ }
171
+
172
+ /**
173
+ * Count the verdict-shaped final messages, the way the codex adapter does: two
174
+ * verdicts in one turn must be visible at the provider boundary, because the
175
+ * last one alone is indistinguishable from a single clean answer.
176
+ *
177
+ * @param {Record<string, unknown>[]} events
178
+ * @returns {number}
179
+ */
180
+ function countVerdicts(events) {
181
+ return events.filter((event) => event.type === "dsh.message" && isVerdictCandidate(event.text)).length;
182
+ }
183
+
184
+ /**
185
+ * @param {string} kind
186
+ * @param {string} text
187
+ * @returns {"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"}
188
+ */
189
+ function statusFor(kind, text) {
190
+ if (kind === "aborted") return "canceled";
191
+ if (kind === "blocked") return "blocked";
192
+ if (isQuotaText(text) || /insufficient balance/iu.test(text)) return "exhausted";
193
+ if (/permission|approval|sandbox/iu.test(text)) return "blocked";
194
+ return "failed";
195
+ }
196
+
197
+ /**
198
+ * Turn the harness's relative retry hint into the absolute instant the
199
+ * controller needs; without one it takes the failover edge instead of waiting.
200
+ *
201
+ * @param {unknown} retryAfterMs
202
+ * @returns {string|null}
203
+ */
204
+ function resetTimestamp(retryAfterMs) {
205
+ if (typeof retryAfterMs !== "number" || !Number.isFinite(retryAfterMs) || retryAfterMs <= 0) return null;
206
+ return new Date(Date.now() + retryAfterMs).toISOString();
207
+ }
208
+
209
+ export const harness = dshHarness;
210
+ export default dshHarness;
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Faberun's DeepSeek Harness client: one prompt on stdin, one JSONL
3
+ * transcript on stdout, no interactive surface. The adapter runs this file
4
+ * under `process.execPath`, so spawning it never depends on a shebang, an
5
+ * executable bit, or `node` being on the provider's PATH.
6
+ *
7
+ * Why JSON-RPC instead of `dsh --profile headless`: headless discards every
8
+ * usage chunk it receives and prints the answer alone, so the tokens the
9
+ * controller records would be invented. `--profile sdk` streams every session
10
+ * event, including `assistant/message.data.usage`, and closes the turn with a
11
+ * structured reason whose `error.code` carries the provider's own QUOTA or
12
+ * RATE_LIMIT verdict — the fact the declared failover edge depends on.
13
+ *
14
+ * Transcript, the only thing this process writes to stdout:
15
+ * {"type":"dsh.started","sessionId":string}
16
+ * {"type":"dsh.message","text":string} one per non-empty assistant message
17
+ * {"type":"dsh.completed","sessionId":string,"result":string,"usage":Usage}
18
+ * {"type":"dsh.failed","sessionId":string,"kind":string,"error":{...},"usage":Usage}
19
+ * where Usage is `{inputTokens, outputTokens, cacheReadInputTokens}` with
20
+ * `inputTokens` excluding the cached prefix, matching `canonicalUsage`.
21
+ */
22
+
23
+ import { spawn } from "node:child_process";
24
+ import { writeSync } from "node:fs";
25
+
26
+ const HANDSHAKE_ID = 1;
27
+ const PROMPT_ID = 2;
28
+
29
+ /** Effective provider name for a failed harness start with no protocol output. */
30
+ const HARNESS_EXIT_CODE = "harness_exit";
31
+
32
+ /** @typedef {{dsh: string, provider: string|null, model: string|null, reasoning: string|null, sandbox: string|null, patches: string[]}} RunnerOptions */
33
+
34
+ /**
35
+ * @param {string[]} argv
36
+ * @returns {RunnerOptions}
37
+ */
38
+ function parseArgs(argv) {
39
+ /** @type {RunnerOptions} */
40
+ const options = {
41
+ dsh: "dsh",
42
+ provider: null,
43
+ model: null,
44
+ reasoning: null,
45
+ sandbox: null,
46
+ patches: [],
47
+ };
48
+ for (let index = 0; index < argv.length; index += 2) {
49
+ const flag = argv[index];
50
+ const value = argv[index + 1];
51
+ if (typeof value !== "string") throw new Error(`${flag} needs a value`);
52
+ if (flag === "--dsh") options.dsh = value;
53
+ else if (flag === "--provider") options.provider = value;
54
+ else if (flag === "--model") options.model = value;
55
+ else if (flag === "--reasoning") options.reasoning = value;
56
+ else if (flag === "--sandbox") options.sandbox = value;
57
+ else if (flag === "--patch") options.patches.push(value);
58
+ else throw new Error(`unknown argument: ${flag}`);
59
+ }
60
+ if (!options.provider) throw new Error("--provider is required");
61
+ if (!options.model) throw new Error("--model is required");
62
+ return options;
63
+ }
64
+
65
+ /**
66
+ * Whole-line writes to fd 1: `process.exit` cannot lose an unfinished write.
67
+ *
68
+ * @param {Record<string, unknown>} event
69
+ */
70
+ function emit(event) {
71
+ const line = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
72
+ for (let written = 0; written < line.length; ) written += writeSync(1, line, written);
73
+ }
74
+
75
+ async function readPrompt() {
76
+ const chunks = [];
77
+ for await (const chunk of process.stdin) chunks.push(chunk);
78
+ return Buffer.concat(chunks).toString("utf8");
79
+ }
80
+
81
+ /**
82
+ * @param {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} totals
83
+ * @param {unknown} usage
84
+ * @returns {boolean} whether any counter was observed
85
+ */
86
+ function addUsage(totals, usage) {
87
+ if (!usage || typeof usage !== "object") return false;
88
+ const record = /** @type {Record<string, unknown>} */ (usage);
89
+ let observed = false;
90
+ /** @type {[string, keyof typeof totals][]} */
91
+ const counters = [["inputTokens", "inputTokens"], ["outputTokens", "outputTokens"], ["cacheReadTokens", "cacheReadInputTokens"]];
92
+ for (const [source, target] of counters) {
93
+ const value = record[source];
94
+ if (typeof value === "number" && Number.isFinite(value)) {
95
+ totals[target] += value;
96
+ observed = true;
97
+ }
98
+ }
99
+ return observed;
100
+ }
101
+
102
+ /** @param {unknown} reason @returns {{kind: string, error: {code: string, message: string, retryAfterMs?: number} | null}} */
103
+ function describeReason(reason) {
104
+ const record = reason && typeof reason === "object" ? /** @type {Record<string, unknown>} */ (reason) : {};
105
+ const kind = typeof record.kind === "string" ? record.kind : "unknown";
106
+ const failure = record.error && typeof record.error === "object" ? /** @type {Record<string, unknown>} */ (record.error) : null;
107
+ if (!failure) return { kind, error: null };
108
+ const code = typeof failure.code === "string" ? failure.code : kind;
109
+ const message = typeof failure.message === "string" ? failure.message : `${code}`;
110
+ const retryAfterMs = failure.providerRetryAfterMs;
111
+ const retryAfter = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) ? { retryAfterMs } : {};
112
+ return { kind, error: { code, message, ...retryAfter } };
113
+ }
114
+
115
+ const options = parseArgs(process.argv.slice(2));
116
+ const prompt = await readPrompt();
117
+ const sessionId = `faberun-${process.pid}-${Date.now().toString(36)}`;
118
+ const env = { ...process.env };
119
+ // The harness owns the boundary: `sandbox` names the file-effect mode the
120
+ // contract asked for, and DSH derives its approval policy from the same value.
121
+ if (options.sandbox) env.DSH_PERMISSION_MODE = options.sandbox;
122
+
123
+ const args = ["--profile", "sdk"];
124
+ for (const patch of options.patches) args.push("--patch", patch);
125
+ const child = spawn(options.dsh, args, { cwd: process.cwd(), env, stdio: ["pipe", "pipe", "pipe"] });
126
+
127
+ const usage = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 };
128
+ let sawUsage = false;
129
+ /** @type {string[]} */
130
+ const texts = [];
131
+ let stderrTail = "";
132
+ let buffered = "";
133
+ let prompted = false;
134
+ let settled = false;
135
+
136
+ /** @param {Record<string, unknown>} event @param {number} exitCode */
137
+ function settle(event, exitCode) {
138
+ if (settled) return;
139
+ settled = true;
140
+ emit({ ...event, usage: sawUsage ? usage : null });
141
+ child.kill("SIGTERM");
142
+ process.exit(exitCode);
143
+ }
144
+
145
+ /** @param {string} kind @param {{code: string, message: string, retryAfterMs?: number}} error @param {number} [exitCode] */
146
+ function fail(kind, error, exitCode = 1) {
147
+ settle({ type: "dsh.failed", sessionId, kind, error }, exitCode);
148
+ }
149
+
150
+ /** @param {Record<string, any>} event */
151
+ function fold(event) {
152
+ if (event.type === "assistant/message") {
153
+ const data = event.data && typeof event.data === "object" ? /** @type {Record<string, unknown>} */ (event.data) : {};
154
+ sawUsage = addUsage(usage, data.usage) || sawUsage;
155
+ const message = data.message && typeof data.message === "object" ? /** @type {Record<string, unknown>} */ (data.message) : {};
156
+ const blocks = Array.isArray(message.content) ? message.content : [];
157
+ const text = blocks.map((block) => (block && typeof block === "object" && typeof block.text === "string" ? block.text : "")).join("");
158
+ if (text.trim()) {
159
+ texts.push(text);
160
+ // Every message, not only the last: a judge that returns two verdicts
161
+ // must be visible as two, and the adapter counts them here.
162
+ emit({ type: "dsh.message", text });
163
+ }
164
+ return;
165
+ }
166
+ if (event.type !== "turn/end") return;
167
+ const data = event.data && typeof event.data === "object" ? /** @type {Record<string, unknown>} */ (event.data) : {};
168
+ const { kind, error } = describeReason(data.reason);
169
+ if (kind === "completed") {
170
+ settle({ type: "dsh.completed", sessionId, result: texts.at(-1) ?? "" }, 0);
171
+ return;
172
+ }
173
+ fail(kind, error ?? { code: kind, message: `the turn ended: ${kind}` });
174
+ }
175
+
176
+ child.stdout.setEncoding("utf8");
177
+ child.stdout.on("data", (chunk) => {
178
+ buffered += chunk;
179
+ const lines = buffered.split("\n");
180
+ buffered = lines.pop() ?? "";
181
+ for (const line of lines) {
182
+ if (!line.trim()) continue;
183
+ let message;
184
+ try {
185
+ message = JSON.parse(line);
186
+ } catch {
187
+ // stdout carries the JSON-RPC transport only; a non-JSON line is a
188
+ // harness defect, not provider prose, and must not be silently dropped.
189
+ fail("invalid_protocol", { code: "invalid_protocol", message: `the harness wrote a non-JSON line: ${line.slice(0, 200)}` });
190
+ return;
191
+ }
192
+ if (message.id === HANDSHAKE_ID) {
193
+ if (message.error) {
194
+ fail("initialize_failed", { code: "initialize_failed", message: JSON.stringify(message.error).slice(0, 512) });
195
+ return;
196
+ }
197
+ if (prompted) continue;
198
+ prompted = true;
199
+ emit({ type: "dsh.started", sessionId });
200
+ child.stdin.write(`${JSON.stringify({
201
+ jsonrpc: "2.0",
202
+ id: PROMPT_ID,
203
+ method: "session/prompt",
204
+ params: { sessionId, contentBlocks: [{ type: "text", text: prompt }] },
205
+ })}\n`);
206
+ continue;
207
+ }
208
+ if (message.id === PROMPT_ID && message.error) {
209
+ fail("prompt_rejected", { code: "prompt_rejected", message: JSON.stringify(message.error).slice(0, 512) });
210
+ return;
211
+ }
212
+ if (message.method === "session.event" && message.params?.sessionId === sessionId && message.params.event) {
213
+ fold(message.params.event);
214
+ }
215
+ }
216
+ });
217
+
218
+ child.stderr.setEncoding("utf8");
219
+ child.stderr.on("data", (chunk) => {
220
+ stderrTail = `${stderrTail}${chunk}`.slice(-4096);
221
+ process.stderr.write(chunk);
222
+ });
223
+
224
+ child.on("error", (error) => {
225
+ fail(HARNESS_EXIT_CODE, { code: HARNESS_EXIT_CODE, message: `cannot start ${options.dsh}: ${error.message}` });
226
+ });
227
+
228
+ child.on("close", (code) => {
229
+ const detail = stderrTail.trim() ? `: ${stderrTail.trim().slice(-512)}` : "";
230
+ fail(HARNESS_EXIT_CODE, {
231
+ code: HARNESS_EXIT_CODE,
232
+ message: `the harness exited with code ${code ?? 1} before the turn ended${detail}`,
233
+ });
234
+ });
235
+
236
+ for (const signal of ["SIGTERM", "SIGINT"]) {
237
+ process.on(signal, () => {
238
+ child.kill(/** @type {NodeJS.Signals} */ (signal));
239
+ process.exit(1);
240
+ });
241
+ }
242
+
243
+ child.stdin.write(`${JSON.stringify({
244
+ jsonrpc: "2.0",
245
+ id: HANDSHAKE_ID,
246
+ method: "initialize",
247
+ params: {
248
+ cwd: process.cwd(),
249
+ provider: options.provider,
250
+ model: options.model,
251
+ ...(options.reasoning ? { reasoningEffort: options.reasoning } : {}),
252
+ },
253
+ })}\n`);
254
+
255
+ process.on("exit", () => {
256
+ // `settle` already asked the harness to stop; only an exit that never
257
+ // reached it leaves a harness that must not outlive this process.
258
+ if (!settled) child.kill("SIGKILL");
259
+ });