balladeer 1.0.14 → 1.0.16

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 (77) hide show
  1. package/README.md +2 -0
  2. package/dist/behavior-map-schema.d.ts +107 -0
  3. package/dist/behavior-map-schema.js +232 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +77 -2
  6. package/dist/commands/guidance.d.ts +1 -1
  7. package/dist/commands/guidance.js +6 -0
  8. package/dist/commands/judge.d.ts +271 -0
  9. package/dist/commands/judge.js +1170 -0
  10. package/dist/commands/map.d.ts +120 -0
  11. package/dist/commands/map.js +601 -0
  12. package/dist/commands/mcp.d.ts +6 -0
  13. package/dist/commands/mcp.js +53 -0
  14. package/dist/commands/offers.d.ts +265 -0
  15. package/dist/commands/offers.js +801 -0
  16. package/dist/commands/risk.d.ts +29 -0
  17. package/dist/commands/risk.js +133 -0
  18. package/dist/copy.d.ts +24 -1
  19. package/dist/copy.js +91 -0
  20. package/dist/guidance-hook.mjs +230 -74
  21. package/dist/guidance.d.ts +2 -0
  22. package/dist/guidance.js +7 -1
  23. package/dist/headless-agent.d.ts +166 -0
  24. package/dist/headless-agent.js +416 -0
  25. package/dist/hook-trust.d.ts +31 -0
  26. package/dist/hook-trust.js +63 -0
  27. package/dist/judge-brief.d.ts +36 -0
  28. package/dist/judge-brief.js +89 -0
  29. package/dist/judge-hook.d.ts +84 -0
  30. package/dist/judge-hook.js +420 -0
  31. package/dist/map-brief.d.ts +16 -0
  32. package/dist/map-brief.js +41 -0
  33. package/dist/offer-brief.d.ts +95 -0
  34. package/dist/offer-brief.js +217 -0
  35. package/dist/owned-process.d.ts +65 -0
  36. package/dist/owned-process.js +146 -0
  37. package/dist/promise-meaning.d.ts +126 -0
  38. package/dist/promise-meaning.js +292 -0
  39. package/dist/risk/contract.d.ts +145 -0
  40. package/dist/risk/contract.js +74 -0
  41. package/dist/risk/describe.d.ts +7 -0
  42. package/dist/risk/describe.js +45 -0
  43. package/dist/risk/diff.d.ts +26 -0
  44. package/dist/risk/diff.js +174 -0
  45. package/dist/risk/extract.d.ts +43 -0
  46. package/dist/risk/extract.js +336 -0
  47. package/dist/risk/git.d.ts +30 -0
  48. package/dist/risk/git.js +118 -0
  49. package/dist/risk/import-graph.d.ts +41 -0
  50. package/dist/risk/import-graph.js +487 -0
  51. package/dist/risk/index.d.ts +20 -0
  52. package/dist/risk/index.js +20 -0
  53. package/dist/risk/paths.d.ts +16 -0
  54. package/dist/risk/paths.js +73 -0
  55. package/dist/risk/pipeline.d.ts +47 -0
  56. package/dist/risk/pipeline.js +121 -0
  57. package/dist/risk/priors.d.ts +18 -0
  58. package/dist/risk/priors.js +86 -0
  59. package/dist/risk/resources.d.ts +56 -0
  60. package/dist/risk/resources.js +374 -0
  61. package/dist/risk/score.d.ts +85 -0
  62. package/dist/risk/score.js +543 -0
  63. package/dist/risk/symbols.d.ts +35 -0
  64. package/dist/risk/symbols.js +348 -0
  65. package/dist/risk/text.d.ts +43 -0
  66. package/dist/risk/text.js +277 -0
  67. package/dist/risk/validate.d.ts +19 -0
  68. package/dist/risk/validate.js +154 -0
  69. package/dist/scratch-worktree.d.ts +51 -0
  70. package/dist/scratch-worktree.js +153 -0
  71. package/dist/self-update.d.ts +29 -2
  72. package/dist/self-update.js +96 -11
  73. package/dist/user-scope.d.ts +2 -0
  74. package/dist/user-scope.js +26 -2
  75. package/dist/wire.d.ts +55 -2
  76. package/dist/wire.js +5 -1
  77. package/package.json +1 -1
@@ -0,0 +1,166 @@
1
+ import { type KillGroup, type SpawnLike } from "./owned-process.js";
2
+ /**
3
+ * The customer's own coding agent, run once, headless, on the subscription the
4
+ * person already signed in with.
5
+ *
6
+ * Balladeer holds no model key and asks for none. Everything a map or a judge
7
+ * costs is spent through Claude Code or Codex on this laptop, under the login
8
+ * that laptop's owner already has, so the only thing this module verifies
9
+ * before it spends anything is that such a login exists: Claude Code signed in
10
+ * through claude.ai, or Codex signed in with ChatGPT. Every environment
11
+ * variable that could redirect the run to an API key or another provider is
12
+ * removed, the way `scripts/client-compatibility/run.mjs` and
13
+ * `scripts/guidance-acceptance/runner.ts` remove them.
14
+ *
15
+ * The run is private: no saved session, no hooks, no MCP servers, no project or
16
+ * user settings, no slash commands, an explicit tool list and nothing outside
17
+ * it. Output is bounded and the whole process group is killed at the deadline.
18
+ */
19
+ export type HeadlessClient = "claude" | "codex";
20
+ export type LoginCheck = Readonly<{
21
+ client: HeadlessClient;
22
+ verified: boolean;
23
+ /** `claude.ai` or `ChatGPT` when verified. Never an account identifier. */
24
+ method?: string;
25
+ }>;
26
+ export type HeadlessAgentOptions = Readonly<{
27
+ client: HeadlessClient | "auto";
28
+ cwd: string;
29
+ prompt: string;
30
+ /**
31
+ * Claude Code's permission rules: built-in tool names, or narrower rules such
32
+ * as `Bash(git diff:*)` when `tools` names the tools themselves. Codex takes
33
+ * its sandbox instead.
34
+ */
35
+ allowedTools: readonly string[];
36
+ /**
37
+ * The built-in tools Claude Code makes available, when that is not the same
38
+ * list as the permission rules. With it, a rule narrower than a whole tool
39
+ * (only some shell commands) is what is allowed, and everything else the tool
40
+ * could do is refused by the permission mode.
41
+ */
42
+ tools?: readonly string[];
43
+ timeoutMs: number;
44
+ /** The caller's environment. Provider and key variables are removed from it. */
45
+ env: NodeJS.ProcessEnv;
46
+ /** When true, the last JSON object in the final message is parsed out. */
47
+ jsonOutput: boolean;
48
+ /** For `auto`: the client that started the hook, tried first. */
49
+ prefer?: HeadlessClient;
50
+ /** Codex's sandbox. Read-only unless the run has to execute tests. */
51
+ sandbox?: "read-only" | "workspace-write";
52
+ /** Claude Code permission rules refused outright, such as `Bash(git push:*)`. */
53
+ disallowedTools?: readonly string[];
54
+ /** Fires when the caller's budget runs out; the run is stopped, not failed. */
55
+ signal?: AbortSignal;
56
+ spawn?: SpawnLike;
57
+ killGroup?: KillGroup;
58
+ /** A login already verified by the caller, so a batch checks it once. */
59
+ login?: LoginCheck;
60
+ maxOutputBytes?: number;
61
+ /** Where the private settings directory is made. Defaults to the system temp directory. */
62
+ scratch?: string;
63
+ }>;
64
+ export type HeadlessAgentResult = Readonly<{
65
+ ok: boolean;
66
+ /** The agent's final message. */
67
+ text: string;
68
+ /** The last JSON object in that message, when one was asked for and found. */
69
+ json?: Record<string, unknown>;
70
+ client: HeadlessClient | "none";
71
+ model?: string;
72
+ durationMs: number;
73
+ inputTokens?: number;
74
+ outputTokens?: number;
75
+ exitCode: number | null;
76
+ stderrTail: string;
77
+ timedOut: boolean;
78
+ aborted: boolean;
79
+ /** Why `ok` is false, as a code a caller can branch on. */
80
+ reason?: "no_verified_login" | "spawn_failed" | "timed_out" | "aborted" | "output_bounded" | "exit_nonzero" | "no_answer" | "no_json";
81
+ }>;
82
+ /** Set on every run this module starts, so a hook inside it knows not to judge again. */
83
+ export declare const JUDGE_ACTIVE_VARIABLE = "BALLADEER_JUDGE_ACTIVE";
84
+ export declare function agentEnvironment(parent: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
85
+ /** Which binary runs each client. An override is read before the environment is stripped. */
86
+ export declare function clientBinary(client: HeadlessClient, env: NodeJS.ProcessEnv): string;
87
+ /**
88
+ * Claude Code's arguments, all flags, prompt on standard input.
89
+ *
90
+ * The prompt goes in on stdin rather than as an argument: a promise and its map
91
+ * can run to kilobytes, an argument is visible to every process listing on the
92
+ * machine, and `--allowedTools` takes a variable number of values, which is the
93
+ * argument-position failure the compatibility README records.
94
+ */
95
+ export declare function claudeArguments(input: Readonly<{
96
+ settingsPath: string;
97
+ allowedTools: readonly string[];
98
+ disallowedTools?: readonly string[];
99
+ tools?: readonly string[];
100
+ }>): string[];
101
+ /** Codex's arguments. `-` reads the prompt from standard input. */
102
+ export declare function codexArguments(input: Readonly<{
103
+ cwd: string;
104
+ sandbox: "read-only" | "workspace-write";
105
+ }>): string[];
106
+ /**
107
+ * Whether this laptop has the subscription login the client needs.
108
+ *
109
+ * Only the verdict and the method leave this function. The account, the email
110
+ * and the organization the status command prints are read to decide and then
111
+ * dropped, the same way the compatibility canary treats them.
112
+ */
113
+ export declare function verifyLogin(client: HeadlessClient, options: Readonly<{
114
+ env: NodeJS.ProcessEnv;
115
+ cwd: string;
116
+ spawn?: SpawnLike;
117
+ killGroup?: KillGroup;
118
+ }>): Promise<LoginCheck>;
119
+ /**
120
+ * The client to run, or none. `auto` tries the hook's own client first, then
121
+ * Claude Code, then Codex, and takes the first whose login verifies.
122
+ */
123
+ export declare function resolveClient(requested: HeadlessClient | "auto", options: Readonly<{
124
+ env: NodeJS.ProcessEnv;
125
+ cwd: string;
126
+ prefer?: HeadlessClient;
127
+ spawn?: SpawnLike;
128
+ killGroup?: KillGroup;
129
+ }>): Promise<LoginCheck | undefined>;
130
+ /**
131
+ * The last JSON object in a piece of text.
132
+ *
133
+ * An agent asked for one object sometimes wraps it in a fence, sometimes adds a
134
+ * sentence after it, and sometimes quotes an example before it. Every balanced,
135
+ * string-aware `{...}` at the top level is tried, and the last one that parses
136
+ * to an object is the answer.
137
+ */
138
+ export declare function extractLastJsonObject(text: string): Record<string, unknown> | undefined;
139
+ type Parsed = Readonly<{
140
+ text: string;
141
+ model?: string;
142
+ inputTokens?: number;
143
+ outputTokens?: number;
144
+ isError: boolean;
145
+ }>;
146
+ /**
147
+ * Claude Code's `--output-format json` answer: one result object.
148
+ *
149
+ * Input tokens are counted the way a subscription spends them, cache reads and
150
+ * cache writes included, because a judge that re-reads the same files is not
151
+ * free just because the provider cached them. The model is the one that wrote
152
+ * the most output, which is the one that did the judging.
153
+ */
154
+ export declare function parseClaudeOutput(stdout: string): Parsed | undefined;
155
+ /** Codex's `--json` event stream: the last agent message, and usage summed over turns. */
156
+ export declare function parseCodexOutput(stdout: string): Parsed | undefined;
157
+ /**
158
+ * Run the prompt once on the person's own agent and return its final message.
159
+ *
160
+ * Never throws for anything the agent or the machine does: a missing login, a
161
+ * binary that is not installed, a timeout and an answer with no JSON in it all
162
+ * come back as `ok: false` with a reason, because the callers of this are hooks
163
+ * that must answer something truthful whatever happened.
164
+ */
165
+ export declare function runHeadlessAgent(options: HeadlessAgentOptions): Promise<HeadlessAgentResult>;
166
+ export {};
@@ -0,0 +1,416 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { runOwnedProcess, tail, } from "./owned-process.js";
5
+ /**
6
+ * Variables that would send a run somewhere other than the person's own
7
+ * subscription, or hand it a credential it was not meant to have. The union of
8
+ * the two harnesses' lists, plus Balladeer's own so a nested command never
9
+ * reads this machine's connection.
10
+ */
11
+ const STRIPPED = /API_KEY|AUTH_TOKEN|ACCESS_TOKEN|BEARER|BASE_URL|ENDPOINT|^AWS_|^AZURE_|^GOOGLE_|^ANTHROPIC_|^OPENAI_|^CLAUDE_CODE_USE_|^CLAUDECODE|^BALLADEER_|^CODEX_API|^GOOGLE_APPLICATION_CREDENTIALS$/;
12
+ /** Set on every run this module starts, so a hook inside it knows not to judge again. */
13
+ export const JUDGE_ACTIVE_VARIABLE = "BALLADEER_JUDGE_ACTIVE";
14
+ export function agentEnvironment(parent) {
15
+ const env = {};
16
+ for (const [key, value] of Object.entries(parent)) {
17
+ if (value === undefined || STRIPPED.test(key))
18
+ continue;
19
+ env[key] = value;
20
+ }
21
+ env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
22
+ env.ENABLE_CLAUDEAI_MCP_SERVERS = "false";
23
+ env[JUDGE_ACTIVE_VARIABLE] = "1";
24
+ return env;
25
+ }
26
+ /** Which binary runs each client. An override is read before the environment is stripped. */
27
+ export function clientBinary(client, env) {
28
+ if (client === "claude")
29
+ return env.BALLADEER_CLAUDE_BIN?.trim() || env.CLAUDE_BIN?.trim() || "claude";
30
+ return env.BALLADEER_CODEX_BIN?.trim() || env.CODEX_BIN?.trim() || "codex";
31
+ }
32
+ /**
33
+ * Claude Code's arguments, all flags, prompt on standard input.
34
+ *
35
+ * The prompt goes in on stdin rather than as an argument: a promise and its map
36
+ * can run to kilobytes, an argument is visible to every process listing on the
37
+ * machine, and `--allowedTools` takes a variable number of values, which is the
38
+ * argument-position failure the compatibility README records.
39
+ */
40
+ export function claudeArguments(input) {
41
+ const tools = (input.tools ?? input.allowedTools).join(",");
42
+ // Rules narrower than a tool carry spaces (`Bash(git diff:*)`), so they go
43
+ // in as separate values rather than as one comma list.
44
+ const allowed = input.tools === undefined ? [tools] : [...input.allowedTools];
45
+ return [
46
+ "--print",
47
+ "--output-format",
48
+ "json",
49
+ "--no-session-persistence",
50
+ "--strict-mcp-config",
51
+ "--mcp-config",
52
+ '{"mcpServers":{}}',
53
+ "--settings",
54
+ input.settingsPath,
55
+ "--setting-sources",
56
+ "",
57
+ "--disable-slash-commands",
58
+ "--permission-mode",
59
+ "dontAsk",
60
+ "--tools",
61
+ tools,
62
+ "--allowedTools",
63
+ ...allowed,
64
+ ...((input.disallowedTools ?? []).length > 0
65
+ ? ["--disallowedTools", ...(input.disallowedTools ?? [])]
66
+ : []),
67
+ ];
68
+ }
69
+ /** Codex's arguments. `-` reads the prompt from standard input. */
70
+ export function codexArguments(input) {
71
+ return [
72
+ "exec",
73
+ "--ephemeral",
74
+ "--ignore-rules",
75
+ "--ignore-user-config",
76
+ "--sandbox",
77
+ input.sandbox,
78
+ "-C",
79
+ input.cwd,
80
+ "--json",
81
+ "-c",
82
+ 'approval_policy="never"',
83
+ "--disable",
84
+ "hooks",
85
+ "-",
86
+ ];
87
+ }
88
+ /**
89
+ * Whether this laptop has the subscription login the client needs.
90
+ *
91
+ * Only the verdict and the method leave this function. The account, the email
92
+ * and the organization the status command prints are read to decide and then
93
+ * dropped, the same way the compatibility canary treats them.
94
+ */
95
+ export async function verifyLogin(client, options) {
96
+ const env = agentEnvironment(options.env);
97
+ const run = await runOwnedProcess({
98
+ command: clientBinary(client, options.env),
99
+ args: client === "claude" ? ["auth", "status", "--json"] : ["login", "status"],
100
+ cwd: options.cwd,
101
+ env,
102
+ timeoutMs: 15_000,
103
+ maxOutputBytes: 64 * 1024,
104
+ ...(options.spawn === undefined ? {} : { spawn: options.spawn }),
105
+ ...(options.killGroup === undefined ? {} : { killGroup: options.killGroup }),
106
+ });
107
+ if (run.spawnError !== undefined || run.exitCode !== 0)
108
+ return { client, verified: false };
109
+ if (client === "claude") {
110
+ try {
111
+ const status = JSON.parse(run.stdout);
112
+ const verified = status.loggedIn === true && status.authMethod === "claude.ai";
113
+ return verified ? { client, verified, method: "claude.ai" } : { client, verified: false };
114
+ }
115
+ catch {
116
+ return { client, verified: false };
117
+ }
118
+ }
119
+ const verified = /Logged in using ChatGPT/.test(`${run.stdout}\n${run.stderr}`);
120
+ return verified ? { client, verified, method: "ChatGPT" } : { client, verified: false };
121
+ }
122
+ /**
123
+ * The client to run, or none. `auto` tries the hook's own client first, then
124
+ * Claude Code, then Codex, and takes the first whose login verifies.
125
+ */
126
+ export async function resolveClient(requested, options) {
127
+ const order = requested !== "auto"
128
+ ? [requested]
129
+ : options.prefer === "codex"
130
+ ? ["codex", "claude"]
131
+ : ["claude", "codex"];
132
+ for (const client of order) {
133
+ const check = await verifyLogin(client, options);
134
+ if (check.verified)
135
+ return check;
136
+ }
137
+ return undefined;
138
+ }
139
+ /**
140
+ * The last JSON object in a piece of text.
141
+ *
142
+ * An agent asked for one object sometimes wraps it in a fence, sometimes adds a
143
+ * sentence after it, and sometimes quotes an example before it. Every balanced,
144
+ * string-aware `{...}` at the top level is tried, and the last one that parses
145
+ * to an object is the answer.
146
+ */
147
+ export function extractLastJsonObject(text) {
148
+ let found;
149
+ let index = 0;
150
+ while (index < text.length) {
151
+ const start = text.indexOf("{", index);
152
+ if (start < 0)
153
+ break;
154
+ const end = matchingBrace(text, start);
155
+ if (end < 0) {
156
+ index = start + 1;
157
+ continue;
158
+ }
159
+ try {
160
+ const value = JSON.parse(text.slice(start, end + 1));
161
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
162
+ found = value;
163
+ index = end + 1;
164
+ continue;
165
+ }
166
+ }
167
+ catch {
168
+ /* Not JSON at this brace; an inner one may be. */
169
+ }
170
+ index = start + 1;
171
+ }
172
+ return found;
173
+ }
174
+ function matchingBrace(text, start) {
175
+ let depth = 0;
176
+ let inString = false;
177
+ let escaped = false;
178
+ for (let at = start; at < text.length; at += 1) {
179
+ const char = text[at];
180
+ if (inString) {
181
+ if (escaped)
182
+ escaped = false;
183
+ else if (char === "\\")
184
+ escaped = true;
185
+ else if (char === '"')
186
+ inString = false;
187
+ continue;
188
+ }
189
+ if (char === '"')
190
+ inString = true;
191
+ else if (char === "{")
192
+ depth += 1;
193
+ else if (char === "}") {
194
+ depth -= 1;
195
+ if (depth === 0)
196
+ return at;
197
+ }
198
+ }
199
+ return -1;
200
+ }
201
+ const count = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
202
+ /**
203
+ * Claude Code's `--output-format json` answer: one result object.
204
+ *
205
+ * Input tokens are counted the way a subscription spends them, cache reads and
206
+ * cache writes included, because a judge that re-reads the same files is not
207
+ * free just because the provider cached them. The model is the one that wrote
208
+ * the most output, which is the one that did the judging.
209
+ */
210
+ export function parseClaudeOutput(stdout) {
211
+ let body;
212
+ try {
213
+ body = JSON.parse(stdout.trim());
214
+ }
215
+ catch {
216
+ const last = extractLastJsonObject(stdout);
217
+ if (last === undefined)
218
+ return undefined;
219
+ body = last;
220
+ }
221
+ const text = typeof body.result === "string" ? body.result : "";
222
+ let model;
223
+ let inputTokens;
224
+ let outputTokens;
225
+ const usageByModel = body.modelUsage;
226
+ if (usageByModel !== null && typeof usageByModel === "object") {
227
+ let most = -1;
228
+ inputTokens = 0;
229
+ outputTokens = 0;
230
+ for (const [name, usage] of Object.entries(usageByModel)) {
231
+ const row = (usage ?? {});
232
+ const out = count(row.outputTokens);
233
+ inputTokens +=
234
+ count(row.inputTokens) +
235
+ count(row.cacheReadInputTokens) +
236
+ count(row.cacheCreationInputTokens);
237
+ outputTokens += out;
238
+ if (out > most) {
239
+ most = out;
240
+ model = name;
241
+ }
242
+ }
243
+ }
244
+ else if (body.usage !== null && typeof body.usage === "object") {
245
+ const usage = body.usage;
246
+ inputTokens =
247
+ count(usage.input_tokens) +
248
+ count(usage.cache_read_input_tokens) +
249
+ count(usage.cache_creation_input_tokens);
250
+ outputTokens = count(usage.output_tokens);
251
+ }
252
+ return {
253
+ text,
254
+ isError: body.is_error === true,
255
+ ...(model === undefined ? {} : { model }),
256
+ ...(inputTokens === undefined ? {} : { inputTokens }),
257
+ ...(outputTokens === undefined ? {} : { outputTokens }),
258
+ };
259
+ }
260
+ /** Codex's `--json` event stream: the last agent message, and usage summed over turns. */
261
+ export function parseCodexOutput(stdout) {
262
+ let text;
263
+ let model;
264
+ let inputTokens;
265
+ let outputTokens;
266
+ let isError = false;
267
+ let events = 0;
268
+ for (const line of stdout.split("\n")) {
269
+ if (!line.trim())
270
+ continue;
271
+ let event;
272
+ try {
273
+ event = JSON.parse(line);
274
+ }
275
+ catch {
276
+ continue;
277
+ }
278
+ events += 1;
279
+ if (typeof event.model === "string")
280
+ model = event.model;
281
+ const item = event.item;
282
+ if (event.type === "item.completed" &&
283
+ item?.type === "agent_message" &&
284
+ typeof item.text === "string")
285
+ text = item.text;
286
+ if (event.type === "turn.completed") {
287
+ const usage = (event.usage ?? {});
288
+ inputTokens = (inputTokens ?? 0) + count(usage.input_tokens);
289
+ outputTokens = (outputTokens ?? 0) + count(usage.output_tokens);
290
+ }
291
+ if (event.type === "turn.failed" || event.type === "error")
292
+ isError = true;
293
+ }
294
+ if (events === 0)
295
+ return undefined;
296
+ return {
297
+ text: text ?? "",
298
+ isError: isError && text === undefined,
299
+ ...(model === undefined ? {} : { model }),
300
+ ...(inputTokens === undefined ? {} : { inputTokens }),
301
+ ...(outputTokens === undefined ? {} : { outputTokens }),
302
+ };
303
+ }
304
+ /**
305
+ * Run the prompt once on the person's own agent and return its final message.
306
+ *
307
+ * Never throws for anything the agent or the machine does: a missing login, a
308
+ * binary that is not installed, a timeout and an answer with no JSON in it all
309
+ * come back as `ok: false` with a reason, because the callers of this are hooks
310
+ * that must answer something truthful whatever happened.
311
+ */
312
+ export async function runHeadlessAgent(options) {
313
+ const started = Date.now();
314
+ const login = options.login ??
315
+ (await resolveClient(options.client, {
316
+ env: options.env,
317
+ cwd: options.cwd,
318
+ ...(options.prefer === undefined ? {} : { prefer: options.prefer }),
319
+ ...(options.spawn === undefined ? {} : { spawn: options.spawn }),
320
+ ...(options.killGroup === undefined ? {} : { killGroup: options.killGroup }),
321
+ }));
322
+ if (login === undefined || !login.verified) {
323
+ return {
324
+ ok: false,
325
+ text: "",
326
+ client: "none",
327
+ durationMs: Date.now() - started,
328
+ exitCode: null,
329
+ stderrTail: "",
330
+ timedOut: false,
331
+ aborted: false,
332
+ reason: "no_verified_login",
333
+ };
334
+ }
335
+ const client = login.client;
336
+ const privateRoot = realpathSync(mkdtempSync(join(options.scratch ?? tmpdir(), "balladeer-agent-")));
337
+ try {
338
+ const env = agentEnvironment(options.env);
339
+ let args;
340
+ if (client === "claude") {
341
+ const settingsPath = join(privateRoot, "settings.json");
342
+ writeFileSync(settingsPath, JSON.stringify({ disableAllHooks: true }), { mode: 0o600 });
343
+ args = claudeArguments({
344
+ settingsPath,
345
+ allowedTools: options.allowedTools,
346
+ ...(options.tools === undefined ? {} : { tools: options.tools }),
347
+ ...(options.disallowedTools === undefined
348
+ ? {}
349
+ : { disallowedTools: options.disallowedTools }),
350
+ });
351
+ }
352
+ else {
353
+ // A private configuration home holding only a link to the existing
354
+ // login, so no user configuration, trust entry, MCP server or hook of
355
+ // the person's comes along. The link is removed with the directory.
356
+ const codexHome = join(privateRoot, "codex");
357
+ mkdirSync(codexHome, { mode: 0o700 });
358
+ const auth = join(options.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "auth.json");
359
+ if (existsSync(auth))
360
+ symlinkSync(auth, join(codexHome, "auth.json"));
361
+ env.CODEX_HOME = codexHome;
362
+ args = codexArguments({ cwd: options.cwd, sandbox: options.sandbox ?? "read-only" });
363
+ }
364
+ const run = await runOwnedProcess({
365
+ command: clientBinary(client, options.env),
366
+ args,
367
+ cwd: options.cwd,
368
+ env,
369
+ input: options.prompt,
370
+ timeoutMs: options.timeoutMs,
371
+ ...(options.maxOutputBytes === undefined ? {} : { maxOutputBytes: options.maxOutputBytes }),
372
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
373
+ ...(options.spawn === undefined ? {} : { spawn: options.spawn }),
374
+ ...(options.killGroup === undefined ? {} : { killGroup: options.killGroup }),
375
+ });
376
+ const parsed = client === "claude" ? parseClaudeOutput(run.stdout) : parseCodexOutput(run.stdout);
377
+ const base = {
378
+ text: parsed?.text ?? "",
379
+ client,
380
+ durationMs: Date.now() - started,
381
+ exitCode: run.exitCode,
382
+ stderrTail: tail(run.stderr),
383
+ timedOut: run.timedOut,
384
+ aborted: run.aborted,
385
+ ...(parsed?.model === undefined ? {} : { model: parsed.model }),
386
+ ...(parsed?.inputTokens === undefined ? {} : { inputTokens: parsed.inputTokens }),
387
+ ...(parsed?.outputTokens === undefined ? {} : { outputTokens: parsed.outputTokens }),
388
+ };
389
+ const failed = (reason) => ({
390
+ ...base,
391
+ ok: false,
392
+ reason,
393
+ });
394
+ if (run.spawnError !== undefined)
395
+ return failed("spawn_failed");
396
+ if (run.aborted)
397
+ return failed("aborted");
398
+ if (run.timedOut)
399
+ return failed("timed_out");
400
+ if (run.bounded)
401
+ return failed("output_bounded");
402
+ if (run.exitCode !== 0)
403
+ return failed("exit_nonzero");
404
+ if (parsed === undefined || parsed.isError || parsed.text.trim() === "")
405
+ return failed("no_answer");
406
+ if (!options.jsonOutput)
407
+ return { ...base, ok: true };
408
+ const json = extractLastJsonObject(parsed.text);
409
+ if (json === undefined)
410
+ return failed("no_json");
411
+ return { ...base, ok: true, json };
412
+ }
413
+ finally {
414
+ rmSync(privateRoot, { recursive: true, force: true });
415
+ }
416
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Knowing when a host has not run the hooks this command last wrote, so the
3
+ * person is told to approve them whenever that is true, not only at setup.
4
+ *
5
+ * Robert, 29 September 2026: hooks will change from release to release, and
6
+ * most releases arrive through the background update, which says nothing to
7
+ * anybody. Codex trusts a hook by the hash of its definition and skips a new
8
+ * or changed one until the person trusts it under /hooks, so a laptop can
9
+ * update itself into silence. Two small marks settle it without reading
10
+ * anything of the host's: when this command last wrote a host's hooks, and
11
+ * when one of that host's hooks last ran. Written after fired means the
12
+ * current hooks have never run, and the host's own session is where the
13
+ * person hears it.
14
+ */
15
+ export type TrustingHost = "codex";
16
+ /** Hosts that run a hook only after the person has approved it. */
17
+ export declare const HOSTS_REQUIRING_TRUST: readonly TrustingHost[];
18
+ /**
19
+ * This command wrote or changed the host's hooks, or found them in place with
20
+ * no record that it ever had: either way, from now the host has to have run
21
+ * them once before anybody assumes it does.
22
+ */
23
+ export declare function noteHooksWritten(environment: NodeJS.ProcessEnv, host: TrustingHost, changed: boolean, now?: number): void;
24
+ /** One of the host's hooks ran, which it does only once they are trusted. */
25
+ export declare function noteHookFired(environment: NodeJS.ProcessEnv, host: TrustingHost, now?: number): void;
26
+ /** True while the hooks last written for this host have never run in it. */
27
+ export declare function hooksAwaitingTrust(environment: NodeJS.ProcessEnv, host: TrustingHost): boolean;
28
+ /** The host a session belongs to, from the name it gave when it said hello. */
29
+ export declare function trustingHost(clientName: unknown): TrustingHost | undefined;
30
+ /** What the agent is asked to pass on, once, in the host where the approval happens. */
31
+ export declare const HOOKS_AWAITING_TRUST_NOTICE = "Note from Balladeer on this laptop, to pass on to the person once: Balladeer's hooks here were added or changed and Codex has not run them. Codex runs a hook only after the person trusts it. Ask them to type /hooks in Codex and trust Balladeer's three hooks; until then Codex has Balladeer's tools without its session-start guidance or its updates.";
@@ -0,0 +1,63 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configHome } from "./store.js";
4
+ /** Hosts that run a hook only after the person has approved it. */
5
+ export const HOSTS_REQUIRING_TRUST = ["codex"];
6
+ const FILE = "hook-trust.json";
7
+ function read(environment) {
8
+ try {
9
+ const parsed = JSON.parse(readFileSync(join(configHome(environment), FILE), "utf8"));
10
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
11
+ ? parsed
12
+ : {};
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ }
18
+ function write(environment, marks) {
19
+ try {
20
+ mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
21
+ writeFileSync(join(configHome(environment), FILE), JSON.stringify(marks) + "\n", {
22
+ mode: 0o600,
23
+ });
24
+ }
25
+ catch {
26
+ /* A mark that cannot be written costs a notice, never a session. */
27
+ }
28
+ }
29
+ /**
30
+ * This command wrote or changed the host's hooks, or found them in place with
31
+ * no record that it ever had: either way, from now the host has to have run
32
+ * them once before anybody assumes it does.
33
+ */
34
+ export function noteHooksWritten(environment, host, changed, now = Date.now()) {
35
+ const marks = read(environment);
36
+ if (!changed && marks[host]?.writtenAt !== undefined)
37
+ return;
38
+ write(environment, { ...marks, [host]: { ...marks[host], writtenAt: now } });
39
+ }
40
+ /** One of the host's hooks ran, which it does only once they are trusted. */
41
+ export function noteHookFired(environment, host, now = Date.now()) {
42
+ const marks = read(environment);
43
+ const mark = marks[host];
44
+ // Nothing to settle, or already settled: no write on the session's path.
45
+ if (mark?.firedAt !== undefined && mark.firedAt >= (mark.writtenAt ?? 0))
46
+ return;
47
+ write(environment, { ...marks, [host]: { ...mark, firedAt: now } });
48
+ }
49
+ /** True while the hooks last written for this host have never run in it. */
50
+ export function hooksAwaitingTrust(environment, host) {
51
+ const mark = read(environment)[host];
52
+ if (mark?.writtenAt === undefined)
53
+ return false;
54
+ return mark.firedAt === undefined || mark.firedAt < mark.writtenAt;
55
+ }
56
+ /** The host a session belongs to, from the name it gave when it said hello. */
57
+ export function trustingHost(clientName) {
58
+ return typeof clientName === "string" && clientName.toLowerCase().startsWith("codex")
59
+ ? "codex"
60
+ : undefined;
61
+ }
62
+ /** What the agent is asked to pass on, once, in the host where the approval happens. */
63
+ export const HOOKS_AWAITING_TRUST_NOTICE = "Note from Balladeer on this laptop, to pass on to the person once: Balladeer's hooks here were added or changed and Codex has not run them. Codex runs a hook only after the person trusts it. Ask them to type /hooks in Codex and trust Balladeer's three hooks; until then Codex has Balladeer's tools without its session-start guidance or its updates.";