iteration-cli 0.9.1 → 0.10.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 (41) hide show
  1. package/README.md +4 -0
  2. package/dist/cli.js +18207 -429
  3. package/dist/cursor-hook-entry.js +11667 -139
  4. package/dist/launcher.js +6731 -0
  5. package/package.json +4 -3
  6. package/dist/auto-connect.d.ts +0 -32
  7. package/dist/auto-connect.js +0 -235
  8. package/dist/background-service.d.ts +0 -7
  9. package/dist/background-service.js +0 -37
  10. package/dist/claude-transcript.d.ts +0 -6
  11. package/dist/claude-transcript.js +0 -256
  12. package/dist/clerk-device-auth.d.ts +0 -7
  13. package/dist/clerk-device-auth.js +0 -279
  14. package/dist/cli.d.ts +0 -2
  15. package/dist/collector-fetch.d.ts +0 -9
  16. package/dist/collector-fetch.js +0 -57
  17. package/dist/collector.d.ts +0 -17
  18. package/dist/collector.js +0 -268
  19. package/dist/cursor-hook-entry.d.ts +0 -1
  20. package/dist/cursor-hooks.d.ts +0 -7
  21. package/dist/cursor-hooks.js +0 -163
  22. package/dist/cursor-transcript.d.ts +0 -8
  23. package/dist/cursor-transcript.js +0 -294
  24. package/dist/history-import.d.ts +0 -17
  25. package/dist/history-import.js +0 -63
  26. package/dist/launch-agent.d.ts +0 -13
  27. package/dist/launch-agent.js +0 -183
  28. package/dist/redact-trace.d.ts +0 -2
  29. package/dist/redact-trace.js +0 -186
  30. package/dist/rollout.d.ts +0 -11
  31. package/dist/rollout.js +0 -259
  32. package/dist/service.d.ts +0 -5
  33. package/dist/service.js +0 -249
  34. package/dist/status-view.d.ts +0 -33
  35. package/dist/status-view.js +0 -257
  36. package/dist/storage.d.ts +0 -94
  37. package/dist/storage.js +0 -809
  38. package/dist/systemd-user.d.ts +0 -7
  39. package/dist/systemd-user.js +0 -137
  40. package/dist/turn-summary.d.ts +0 -23
  41. package/dist/turn-summary.js +0 -365
@@ -1,7 +0,0 @@
1
- import type { CollectorSource } from "iteration-trace-contracts";
2
- export declare function ensureSystemdUserLinger(): Promise<void>;
3
- export declare function systemdLogPath(source: CollectorSource): string;
4
- export declare function systemdUserUnit(cliPath?: string, nodePath?: string, dataDirectory?: string, source?: CollectorSource, outputPath?: string, errorPath?: string): string;
5
- export declare function installSystemdUserService(dataDirectory: string, source: CollectorSource): Promise<string>;
6
- export declare function uninstallSystemdUserService(source: CollectorSource): Promise<void>;
7
- export declare function systemdUserServiceStatus(source: CollectorSource): Promise<boolean>;
@@ -1,137 +0,0 @@
1
- import { execFile as execFileCallback } from "node:child_process";
2
- import { promises as fs } from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { promisify } from "node:util";
7
- const execFile = promisify(execFileCallback);
8
- const SYSTEMCTL_TIMEOUT_MS = 10_000;
9
- const LOGINCTL_TIMEOUT_MS = 10_000;
10
- function currentUserId() {
11
- const userId = process.getuid?.();
12
- if (userId === undefined)
13
- throw new Error("Could not determine the current Linux user id.");
14
- return String(userId);
15
- }
16
- async function lingerState(userId) {
17
- const { stdout } = await execFile("loginctl", ["show-user", userId, "--property=Linger", "--value"], { timeout: LOGINCTL_TIMEOUT_MS });
18
- const state = stdout.trim();
19
- if (state === "yes" || state === "no")
20
- return state;
21
- throw new Error(`Could not read Linux background service setting: ${state || "empty response"}.`);
22
- }
23
- export async function ensureSystemdUserLinger() {
24
- const userId = currentUserId();
25
- let state = "no";
26
- let queryFailure;
27
- try {
28
- state = await lingerState(userId);
29
- }
30
- catch (error) {
31
- queryFailure = error;
32
- }
33
- if (state === "yes")
34
- return;
35
- try {
36
- await execFile("loginctl", ["--no-ask-password", "enable-linger", userId], {
37
- timeout: LOGINCTL_TIMEOUT_MS,
38
- });
39
- }
40
- catch (userError) {
41
- try {
42
- await execFile("sudo", ["-n", "loginctl", "enable-linger", userId], {
43
- timeout: LOGINCTL_TIMEOUT_MS,
44
- });
45
- }
46
- catch (adminError) {
47
- throw new Error("This server does not allow Iteration to keep collecting after you log out. Ask an administrator to allow background services for your account, then run iteration connect again.", {
48
- cause: new AggregateError(queryFailure
49
- ? [queryFailure, userError, adminError]
50
- : [userError, adminError], "Could not enable Linux background collection."),
51
- });
52
- }
53
- }
54
- try {
55
- if ((await lingerState(userId)) === "yes")
56
- return;
57
- }
58
- catch (error) {
59
- throw new Error("Could not verify Linux background collection after logout.", {
60
- cause: error,
61
- });
62
- }
63
- throw new Error("Linux did not enable background collection after logout.");
64
- }
65
- function unitName(source) {
66
- return `iteration-${source}-collector.service`;
67
- }
68
- function unitPath(source) {
69
- return path.join(os.homedir(), ".config", "systemd", "user", unitName(source));
70
- }
71
- export function systemdLogPath(source) {
72
- return path.join(os.homedir(), ".config", "iteration", "logs", `iteration-${source}-collector.error.log`);
73
- }
74
- function unitValue(value) {
75
- if (/[\r\n\0]/.test(value))
76
- throw new Error("Collector paths cannot contain line breaks.");
77
- return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
78
- }
79
- export function systemdUserUnit(cliPath = fileURLToPath(new URL("./cli.js", import.meta.url)), nodePath = process.execPath, dataDirectory = path.join(os.homedir(), ".config", "iteration", "codex-collector"), source = "codex", outputPath = path.join(os.homedir(), ".config", "iteration", "logs", `iteration-${source}-collector.log`), errorPath = systemdLogPath(source)) {
80
- return [
81
- "[Unit]",
82
- `Description=Iteration ${source} collector`,
83
- "[Service]",
84
- "Type=exec",
85
- `ExecStart=${[nodePath, cliPath, "run", source, "--data-directory", dataDirectory].map(unitValue).join(" ")}`,
86
- "Restart=always",
87
- "RestartSec=5",
88
- `StandardOutput=append:${unitValue(outputPath)}`,
89
- `StandardError=append:${unitValue(errorPath)}`,
90
- "[Install]",
91
- "WantedBy=default.target",
92
- "",
93
- ].join("\n");
94
- }
95
- async function systemctl(...args) {
96
- const { stdout } = await execFile("systemctl", ["--user", ...args], {
97
- timeout: SYSTEMCTL_TIMEOUT_MS,
98
- });
99
- return stdout.trim();
100
- }
101
- export async function installSystemdUserService(dataDirectory, source) {
102
- await ensureSystemdUserLinger();
103
- const file = unitPath(source);
104
- const logDirectory = path.dirname(systemdLogPath(source));
105
- await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
106
- await fs.mkdir(logDirectory, { recursive: true, mode: 0o700 });
107
- await fs.writeFile(file, systemdUserUnit(undefined, undefined, dataDirectory, source), {
108
- encoding: "utf8",
109
- mode: 0o644,
110
- });
111
- await systemctl("daemon-reload");
112
- await systemctl("enable", unitName(source));
113
- await systemctl("restart", unitName(source));
114
- return file;
115
- }
116
- export async function uninstallSystemdUserService(source) {
117
- const file = unitPath(source);
118
- try {
119
- await fs.access(file);
120
- }
121
- catch (error) {
122
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
123
- return;
124
- throw error;
125
- }
126
- await systemctl("disable", "--now", unitName(source));
127
- await fs.rm(file);
128
- await systemctl("daemon-reload");
129
- }
130
- export async function systemdUserServiceStatus(source) {
131
- const state = await systemctl("show", "--property=ActiveState", "--value", unitName(source));
132
- if (state === "active")
133
- return true;
134
- if (state === "inactive" || state === "failed")
135
- return false;
136
- throw new Error(`Could not determine ${source} collector service state: ${state || "empty response"}.`);
137
- }
@@ -1,23 +0,0 @@
1
- import { type CollectorFrictionSummary } from "iteration-trace-contracts";
2
- import { type CollectorFetcher } from "./collector-fetch.js";
3
- import { type CollectorConfig } from "./storage.js";
4
- export type TurnSummaryPaths = Readonly<{
5
- directory: string;
6
- script: string;
7
- requests: string;
8
- lines: string;
9
- shown: string;
10
- }>;
11
- export declare function turnSummaryPaths(dataDirectory: string): TurnSummaryPaths;
12
- export declare function installTurnSummary(config: CollectorConfig): Promise<void>;
13
- export declare function uninstallTurnSummary(config: CollectorConfig): Promise<void>;
14
- export declare function turnSummaryMessage(summary: CollectorFrictionSummary): string | null;
15
- export declare class TurnSummaryRefreshError extends Error {
16
- constructor(message: string, options?: ErrorOptions);
17
- }
18
- export declare function refreshTurnSummaries(config: CollectorConfig, refreshedAt: Map<string, Readonly<{
19
- directory: string;
20
- atMs: number;
21
- }>>, nowMs: number, fetcher?: CollectorFetcher, signal?: AbortSignal): Promise<number>;
22
- export type TurnSummaryApproval = "approved" | "needs_approval" | "not_installed";
23
- export declare function codexTurnSummaryApproval(config: CollectorConfig): Promise<TurnSummaryApproval>;
@@ -1,365 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { promises as fs } from "node:fs";
3
- import path from "node:path";
4
- import { isLaunchDirectory, parseCollectorFrictionSummary, } from "iteration-trace-contracts";
5
- import * as z from "zod";
6
- import { collectorFetch } from "./collector-fetch.js";
7
- import { ensurePrivateDirectory } from "./storage.js";
8
- const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
9
- const REQUEST_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
10
- const MAX_REFRESHES_PER_PASS = 20;
11
- const REQUEST_TIMEOUT_MS = 10_000;
12
- const HOOK_TIMEOUT_SECONDS = 1;
13
- const REQUEST_KEY_PATTERN = /^\d{1,10}$/;
14
- export function turnSummaryPaths(dataDirectory) {
15
- const directory = path.join(dataDirectory, "turn-summary");
16
- return {
17
- directory,
18
- script: path.join(directory, "stop-hook.sh"),
19
- requests: path.join(directory, "requests"),
20
- lines: path.join(directory, "lines"),
21
- shown: path.join(directory, "shown"),
22
- };
23
- }
24
- function shellQuote(value) {
25
- return `'${value.replaceAll("'", `'\\''`)}'`;
26
- }
27
- // The hook runs at the end of every agent turn, so it only reads files the
28
- // collector keeps current and never waits on the network. Every failure prints
29
- // the empty object both agents accept, so a broken summary never blocks a turn.
30
- function hookScript(paths) {
31
- return `#!/bin/sh
32
- exec 2>/dev/null
33
- cat >/dev/null
34
- summary=${shellQuote(paths.directory)}
35
- quiet() { printf '{}\\n'; exit 0; }
36
- unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_PREFIX
37
- cd "\${CLAUDE_PROJECT_DIR:-.}" || quiet
38
- directory=$(git rev-parse --show-toplevel) || quiet
39
- case $directory in /*) ;; *) quiet ;; esac
40
- set -- $(printf '%s' "$directory" | cksum)
41
- key=$1
42
- [ -n "$key" ] || quiet
43
- printf '%s\\n' "$directory" > "$summary/requests/$key"
44
- [ -f "$summary/lines/$key" ] || quiet
45
- { IFS= read -r owner && IFS= read -r message; } < "$summary/lines/$key" || quiet
46
- [ "$owner" = "$directory" ] || quiet
47
- last=
48
- [ -f "$summary/shown/$key" ] && IFS= read -r last < "$summary/shown/$key"
49
- [ "$last" = "$message" ] && quiet
50
- printf '%s\\n' "$message" > "$summary/shown/$key" || quiet
51
- printf '%s\\n' "$message"
52
- `;
53
- }
54
- function hookCommand(paths) {
55
- return `/bin/sh ${shellQuote(paths.script)}`;
56
- }
57
- function agentHookFile(config) {
58
- if ("cursorHome" in config)
59
- throw new Error("Cursor uses its own trace hooks.");
60
- return "claudeHome" in config
61
- ? path.join(config.claudeHome, "settings.json")
62
- : path.join(config.codexHome, "hooks.json");
63
- }
64
- const hookEntrySchema = z.object({ command: z.string().optional() }).loose();
65
- const hookGroupSchema = z
66
- .object({ hooks: z.array(hookEntrySchema).optional() })
67
- .loose();
68
- const hookFileSchema = z
69
- .object({
70
- hooks: z
71
- .object({ Stop: z.array(hookGroupSchema).optional() })
72
- .loose()
73
- .optional(),
74
- })
75
- .loose();
76
- async function readHookFile(filename) {
77
- let contents;
78
- let mode = 0o600;
79
- try {
80
- contents = await fs.readFile(filename, "utf8");
81
- mode = (await fs.stat(filename)).mode & 0o777;
82
- }
83
- catch (error) {
84
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
85
- return { file: {}, mode };
86
- }
87
- throw new Error(`Could not read agent hooks at ${filename}.`, {
88
- cause: error,
89
- });
90
- }
91
- let decoded;
92
- try {
93
- decoded = JSON.parse(contents);
94
- }
95
- catch (error) {
96
- throw new Error(`Agent hooks at ${filename} are not valid JSON.`, {
97
- cause: error,
98
- });
99
- }
100
- const parsed = hookFileSchema.safeParse(decoded);
101
- if (!parsed.success) {
102
- throw new Error(`Agent hooks at ${filename} have an unexpected shape.`, {
103
- cause: parsed.error,
104
- });
105
- }
106
- return { file: parsed.data, mode };
107
- }
108
- async function writeHookFile(filename, file, mode) {
109
- await fs.mkdir(path.dirname(filename), { recursive: true });
110
- // Settings kept in a dotfiles repository are often symlinks; replacing the
111
- // link with a file would silently detach them from that repository.
112
- const target = await fs.realpath(filename).catch((error) => {
113
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
114
- return filename;
115
- throw error;
116
- });
117
- const temporary = `${target}.iteration-${process.pid}.tmp`;
118
- await fs.writeFile(temporary, `${JSON.stringify(file, null, 2)}\n`, {
119
- encoding: "utf8",
120
- mode,
121
- });
122
- await fs.rename(temporary, target);
123
- }
124
- function withoutCommand(groups, command) {
125
- return groups.flatMap((group) => {
126
- const hooks = group.hooks?.filter((hook) => hook.command !== command);
127
- if (hooks === undefined || hooks.length === group.hooks?.length)
128
- return [group];
129
- return hooks.length === 0 ? [] : [{ ...group, hooks }];
130
- });
131
- }
132
- function hasCommand(groups, command) {
133
- return groups.some((group) => group.hooks?.some((hook) => hook.command === command));
134
- }
135
- export async function installTurnSummary(config) {
136
- const paths = turnSummaryPaths(config.dataDirectory);
137
- await ensurePrivateDirectory(paths.directory);
138
- await Promise.all([paths.requests, paths.lines, paths.shown].map(ensurePrivateDirectory));
139
- await fs.writeFile(paths.script, hookScript(paths), {
140
- encoding: "utf8",
141
- mode: 0o700,
142
- });
143
- await fs.chmod(paths.script, 0o700);
144
- const filename = agentHookFile(config);
145
- const { file, mode } = await readHookFile(filename);
146
- const command = hookCommand(paths);
147
- const stop = file.hooks?.Stop ?? [];
148
- if (hasCommand(stop, command))
149
- return;
150
- await writeHookFile(filename, {
151
- ...file,
152
- hooks: {
153
- ...file.hooks,
154
- Stop: [
155
- ...stop,
156
- {
157
- hooks: [
158
- { type: "command", command, timeout: HOOK_TIMEOUT_SECONDS },
159
- ],
160
- },
161
- ],
162
- },
163
- }, mode);
164
- }
165
- export async function uninstallTurnSummary(config) {
166
- const filename = agentHookFile(config);
167
- const { file, mode } = await readHookFile(filename);
168
- const stop = file.hooks?.Stop ?? [];
169
- const command = hookCommand(turnSummaryPaths(config.dataDirectory));
170
- if (!hasCommand(stop, command))
171
- return;
172
- const remaining = withoutCommand(stop, command);
173
- const { Stop: _removed, ...otherEvents } = file.hooks ?? {};
174
- await writeHookFile(filename, {
175
- ...file,
176
- hooks: remaining.length === 0
177
- ? otherEvents
178
- : { ...otherEvents, Stop: remaining },
179
- }, mode);
180
- }
181
- export function turnSummaryMessage(summary) {
182
- if (summary.weekEpisodes === 0)
183
- return null;
184
- const episodes = `${summary.weekEpisodes} friction episode${summary.weekEpisodes === 1 ? "" : "s"}`;
185
- const recent = summary.dayEpisodes === 0
186
- ? ""
187
- : ` · ${summary.dayEpisodes} new since yesterday`;
188
- return `◆ Iteration · ${episodes} in this repo this week${recent}`;
189
- }
190
- export class TurnSummaryRefreshError extends Error {
191
- constructor(message, options) {
192
- super(message, options);
193
- this.name = "TurnSummaryRefreshError";
194
- }
195
- }
196
- async function fetchSummary(config, directory, fetcher, signal) {
197
- const url = new URL("/api/traces/friction-summary", config.endpoint);
198
- url.searchParams.set("directory", directory);
199
- let response;
200
- try {
201
- response = await fetcher(url, {
202
- headers: { authorization: `Bearer ${config.token}` },
203
- signal: signal
204
- ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
205
- : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
206
- });
207
- }
208
- catch (error) {
209
- if (signal?.aborted)
210
- throw error;
211
- throw new TurnSummaryRefreshError("Friction summary could not reach Iteration.", { cause: error });
212
- }
213
- if (response.status !== 200) {
214
- throw new TurnSummaryRefreshError(`Friction summary failed with HTTP ${response.status}.`);
215
- }
216
- let body;
217
- try {
218
- body = await response.json();
219
- }
220
- catch (error) {
221
- throw new TurnSummaryRefreshError("Friction summary returned an unreadable response.", { cause: error });
222
- }
223
- const parsed = parseCollectorFrictionSummary(body);
224
- if (!parsed.success)
225
- throw new TurnSummaryRefreshError(parsed.error);
226
- return parsed.value;
227
- }
228
- async function readRequest(filename) {
229
- try {
230
- const [directory] = (await fs.readFile(filename, "utf8")).split("\n");
231
- return directory ?? null;
232
- }
233
- catch (error) {
234
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
235
- return null;
236
- throw error;
237
- }
238
- }
239
- async function writeLine(paths, key, directory, message) {
240
- const filename = path.join(paths.lines, key);
241
- if (message === null) {
242
- await fs.rm(filename, { force: true });
243
- return;
244
- }
245
- const temporary = `${filename}.${process.pid}.tmp`;
246
- await fs.writeFile(temporary, `${directory}\n${JSON.stringify({ systemMessage: message })}\n`, { encoding: "utf8", mode: 0o600 });
247
- await fs.rename(temporary, filename);
248
- }
249
- async function forget(paths, key) {
250
- await Promise.all([paths.requests, paths.lines, paths.shown].map((directory) => fs.rm(path.join(directory, key), { force: true })));
251
- }
252
- // Refreshes the summary line for each repository the hook asked about. The
253
- // hook reads the result on a later turn, so a slow or failed request never
254
- // delays the agent.
255
- export async function refreshTurnSummaries(config, refreshedAt, nowMs, fetcher = collectorFetch, signal) {
256
- const paths = turnSummaryPaths(config.dataDirectory);
257
- let keys;
258
- try {
259
- keys = await fs.readdir(paths.requests);
260
- }
261
- catch (error) {
262
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
263
- return 0;
264
- throw error;
265
- }
266
- let refreshed = 0;
267
- for (const key of keys.sort()) {
268
- signal?.throwIfAborted();
269
- if (!REQUEST_KEY_PATTERN.test(key))
270
- continue;
271
- const filename = path.join(paths.requests, key);
272
- const stats = await fs.stat(filename).catch((error) => {
273
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
274
- return null;
275
- throw error;
276
- });
277
- if (stats === null)
278
- continue;
279
- const directory = await readRequest(filename);
280
- if (nowMs - stats.mtimeMs > REQUEST_RETENTION_MS ||
281
- directory === null ||
282
- !isLaunchDirectory(directory)) {
283
- await forget(paths, key);
284
- refreshedAt.delete(key);
285
- continue;
286
- }
287
- const previous = refreshedAt.get(key);
288
- if (previous !== undefined &&
289
- previous.directory === directory &&
290
- nowMs - previous.atMs < REFRESH_INTERVAL_MS) {
291
- continue;
292
- }
293
- if (refreshed === MAX_REFRESHES_PER_PASS)
294
- break;
295
- refreshedAt.set(key, { directory, atMs: nowMs });
296
- refreshed += 1;
297
- const summary = await fetchSummary(config, directory, fetcher, signal);
298
- await writeLine(paths, key, directory, turnSummaryMessage(summary));
299
- }
300
- return refreshed;
301
- }
302
- function tomlBasicString(value) {
303
- return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
304
- }
305
- // Codex runs a hook only after the developer trusts it in /hooks, and records
306
- // that trust as a hash of the normalized hook. Recomputing the same hash tells
307
- // whether Codex will run this exact entry; any other result keeps the reminder.
308
- function codexHookHash(command) {
309
- const identity = {
310
- event_name: "stop",
311
- hooks: [
312
- {
313
- async: false,
314
- command,
315
- timeout: HOOK_TIMEOUT_SECONDS,
316
- type: "command",
317
- },
318
- ],
319
- };
320
- return `sha256:${createHash("sha256").update(JSON.stringify(identity)).digest("hex")}`;
321
- }
322
- function codexTrustedHash(configToml, key) {
323
- const lines = configToml.split(/\r?\n/);
324
- const header = `[hooks.state.${tomlBasicString(key)}]`;
325
- const start = lines.findIndex((line) => line.trim() === header);
326
- if (start < 0)
327
- return null;
328
- let trustedHash = null;
329
- for (const line of lines.slice(start + 1)) {
330
- const trimmed = line.trim();
331
- if (trimmed.startsWith("["))
332
- break;
333
- if (/^enabled\s*=\s*false\b/.test(trimmed))
334
- return null;
335
- const match = /^trusted_hash\s*=\s*"(sha256:[0-9a-f]{64})"/.exec(trimmed);
336
- if (match?.[1] !== undefined)
337
- trustedHash = match[1];
338
- }
339
- return trustedHash;
340
- }
341
- export async function codexTurnSummaryApproval(config) {
342
- if ("claudeHome" in config || "cursorHome" in config)
343
- return "approved";
344
- const filename = agentHookFile(config);
345
- const { file } = await readHookFile(filename);
346
- const command = hookCommand(turnSummaryPaths(config.dataDirectory));
347
- const stop = file.hooks?.Stop ?? [];
348
- for (const [groupIndex, group] of stop.entries()) {
349
- const handlerIndex = group.hooks?.findIndex((hook) => hook.command === command) ?? -1;
350
- if (handlerIndex < 0)
351
- continue;
352
- let configToml;
353
- try {
354
- configToml = await fs.readFile(path.join(config.codexHome, "config.toml"), "utf8");
355
- }
356
- catch (error) {
357
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
358
- return "needs_approval";
359
- throw new Error("Could not read the Codex config.", { cause: error });
360
- }
361
- const trusted = codexTrustedHash(configToml, `${filename}:stop:${groupIndex}:${handlerIndex}`);
362
- return trusted === codexHookHash(command) ? "approved" : "needs_approval";
363
- }
364
- return "not_installed";
365
- }