iteration-cli 0.9.0 → 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.
- package/README.md +4 -0
- package/dist/cli.js +18207 -429
- package/dist/cursor-hook-entry.js +11667 -139
- package/dist/launcher.js +6731 -0
- package/package.json +5 -3
- package/dist/auto-connect.d.ts +0 -32
- package/dist/auto-connect.js +0 -235
- package/dist/background-service.d.ts +0 -7
- package/dist/background-service.js +0 -37
- package/dist/claude-transcript.d.ts +0 -6
- package/dist/claude-transcript.js +0 -256
- package/dist/clerk-device-auth.d.ts +0 -7
- package/dist/clerk-device-auth.js +0 -279
- package/dist/cli.d.ts +0 -2
- package/dist/collector-fetch.d.ts +0 -9
- package/dist/collector-fetch.js +0 -57
- package/dist/collector.d.ts +0 -17
- package/dist/collector.js +0 -256
- package/dist/cursor-hook-entry.d.ts +0 -1
- package/dist/cursor-hooks.d.ts +0 -7
- package/dist/cursor-hooks.js +0 -163
- package/dist/cursor-transcript.d.ts +0 -8
- package/dist/cursor-transcript.js +0 -294
- package/dist/history-import.d.ts +0 -17
- package/dist/history-import.js +0 -63
- package/dist/launch-agent.d.ts +0 -13
- package/dist/launch-agent.js +0 -183
- package/dist/redact-trace.d.ts +0 -2
- package/dist/redact-trace.js +0 -182
- package/dist/rollout.d.ts +0 -11
- package/dist/rollout.js +0 -259
- package/dist/service.d.ts +0 -5
- package/dist/service.js +0 -249
- package/dist/status-view.d.ts +0 -33
- package/dist/status-view.js +0 -257
- package/dist/storage.d.ts +0 -94
- package/dist/storage.js +0 -809
- package/dist/systemd-user.d.ts +0 -7
- package/dist/systemd-user.js +0 -137
- package/dist/turn-summary.d.ts +0 -23
- package/dist/turn-summary.js +0 -365
package/dist/systemd-user.d.ts
DELETED
|
@@ -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>;
|
package/dist/systemd-user.js
DELETED
|
@@ -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
|
-
}
|
package/dist/turn-summary.d.ts
DELETED
|
@@ -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>;
|
package/dist/turn-summary.js
DELETED
|
@@ -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
|
-
}
|