iteration-cli 0.5.0 → 0.6.1
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 -3
- package/dist/auto-connect.d.ts +30 -0
- package/dist/auto-connect.js +220 -0
- package/dist/clerk-device-auth.d.ts +1 -0
- package/dist/clerk-device-auth.js +1 -0
- package/dist/cli.js +180 -15
- package/dist/service.js +45 -0
- package/dist/status-view.d.ts +2 -0
- package/dist/status-view.js +8 -3
- package/dist/storage.d.ts +1 -0
- package/dist/storage.js +1 -0
- package/dist/turn-summary.d.ts +22 -0
- package/dist/turn-summary.js +362 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,14 +4,15 @@ Install one CLI for Iteration's supported coding-agent sources:
|
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install --global iteration-cli
|
|
7
|
-
iteration connect
|
|
8
|
-
iteration connect claude
|
|
7
|
+
iteration connect
|
|
9
8
|
```
|
|
10
9
|
|
|
11
|
-
The collectors currently run on macOS with Node.js 22 or later.
|
|
10
|
+
The collectors currently run on macOS with Node.js 22 or later. `iteration connect` finds supported agents with local session directories, asks once before uploading their saved sessions and future activity, and connects them to one workspace. A connected collector checks for newly detected supported agents and connects them under that approval. Each source has its own background process, local checkpoint, and upload queue. Run `iteration` to see connected sources, `iteration status codex` or `iteration status claude` for details, and `iteration help` for other commands. `iteration connect codex` and `iteration connect claude` remain available for advanced source-specific setups.
|
|
12
11
|
|
|
13
12
|
Claude Code sessions are read from `~/.claude/projects`, including session subagents. If Claude uses `CLAUDE_CONFIG_DIR`, the collector follows that setting during connection; `iteration connect claude --claude-home DIR` selects another directory explicitly. The collector reads only saved local JSONL records. Claude Code may remove old transcripts under its own retention settings, so connect before local history is removed. The records can contain prompts, reasoning, tool input and output, and context snapshots; workspace members can read what uploads. Unknown record shapes are reported as capture gaps in the collector log. Remote Claude sessions are outside this local collector.
|
|
14
13
|
|
|
14
|
+
Connecting also adds a Stop hook to the coding agent. After an agent turn in a git repository where you had friction episodes this week, it shows one line such as `◆ Iteration · 3 friction episodes in this repo this week · 1 new since yesterday`. The hook reads local files only and adds a few milliseconds to a turn. Codex asks you to approve it once with `/hooks`. `iteration disconnect` removes it.
|
|
15
|
+
|
|
15
16
|
To rescan older local sessions, preview with `iteration import-history claude`, then confirm with `iteration import-history claude --yes`. Use `iteration start claude` to restart collection and `iteration disconnect claude` to stop its background process without deleting local pending data.
|
|
16
17
|
|
|
17
18
|
If you previously installed the Codex-specific package, remove it with the package manager you used:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type CollectorSource } from "iteration-trace-contracts";
|
|
2
|
+
import { type CollectorConfig, type CollectorState } from "./storage.js";
|
|
3
|
+
export type MachineConsent = Readonly<{
|
|
4
|
+
version: 1;
|
|
5
|
+
endpoint: string;
|
|
6
|
+
organizationId: string;
|
|
7
|
+
anchorSource: CollectorSource;
|
|
8
|
+
anchorDataDirectory: string;
|
|
9
|
+
sourceHomes: Readonly<{
|
|
10
|
+
codex: string;
|
|
11
|
+
claude: string;
|
|
12
|
+
}>;
|
|
13
|
+
}>;
|
|
14
|
+
export declare function sourceHome(source: CollectorSource): string;
|
|
15
|
+
export declare function detectedSources(sourceHomes: MachineConsent["sourceHomes"]): Promise<CollectorSource[]>;
|
|
16
|
+
export declare function loadMachineConsent(): Promise<MachineConsent | null>;
|
|
17
|
+
export declare function saveMachineConsent(consent: MachineConsent): Promise<void>;
|
|
18
|
+
export type SourceEnrollment = Readonly<{
|
|
19
|
+
token: string;
|
|
20
|
+
organizationId: string;
|
|
21
|
+
organizationName: string;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function readCollectorScope(anchor: CollectorConfig): Promise<Readonly<{
|
|
24
|
+
organizationId: string;
|
|
25
|
+
organizationName: string;
|
|
26
|
+
}>>;
|
|
27
|
+
export declare function expandSource(anchor: CollectorConfig, source: CollectorSource, organizationId: string | null, request?: typeof fetch): Promise<SourceEnrollment>;
|
|
28
|
+
export declare function activateSource(source: CollectorSource, home: string, endpoint: string, enrollment: SourceEnrollment, state: CollectorState): Promise<CollectorConfig>;
|
|
29
|
+
export declare function connectNewDetectedSources(anchor: CollectorConfig, consent: MachineConsent): Promise<CollectorSource[]>;
|
|
30
|
+
export declare function connectedConfig(source: CollectorSource): Promise<CollectorConfig | null>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
import { COLLECTOR_SOURCES, parseCollectorEnrollmentResponse, } from "iteration-trace-contracts";
|
|
7
|
+
import { installLaunchAgent } from "./launch-agent.js";
|
|
8
|
+
import { installTurnSummary } from "./turn-summary.js";
|
|
9
|
+
import { assertFreshCollectorDirectory, collectorPaths, defaultCollectorDirectory, ensurePrivateDirectory, loadConfig, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
|
|
10
|
+
const consentSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
version: z.literal(1),
|
|
13
|
+
endpoint: z.url(),
|
|
14
|
+
organizationId: z.uuid(),
|
|
15
|
+
anchorSource: z.enum(COLLECTOR_SOURCES),
|
|
16
|
+
anchorDataDirectory: z.string(),
|
|
17
|
+
sourceHomes: z.object({ codex: z.string(), claude: z.string() }).strict(),
|
|
18
|
+
})
|
|
19
|
+
.strict();
|
|
20
|
+
function consentPath() {
|
|
21
|
+
return path.join(os.homedir(), ".config", "iteration", "auto-connect.json");
|
|
22
|
+
}
|
|
23
|
+
export function sourceHome(source) {
|
|
24
|
+
return path.resolve(source === "codex"
|
|
25
|
+
? (process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"))
|
|
26
|
+
: (process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude")));
|
|
27
|
+
}
|
|
28
|
+
export async function detectedSources(sourceHomes) {
|
|
29
|
+
const detected = [];
|
|
30
|
+
for (const source of COLLECTOR_SOURCES) {
|
|
31
|
+
const directory = path.join(sourceHomes[source], source === "codex" ? "sessions" : "projects");
|
|
32
|
+
let stats;
|
|
33
|
+
try {
|
|
34
|
+
stats = await fs.stat(directory);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
38
|
+
continue;
|
|
39
|
+
throw new Error(`Could not inspect ${source} sessions at ${directory}.`, {
|
|
40
|
+
cause: error,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (!stats.isDirectory()) {
|
|
44
|
+
throw new Error(`${source} session path is not a directory: ${directory}.`);
|
|
45
|
+
}
|
|
46
|
+
detected.push(source);
|
|
47
|
+
}
|
|
48
|
+
return detected;
|
|
49
|
+
}
|
|
50
|
+
export async function loadMachineConsent() {
|
|
51
|
+
let text;
|
|
52
|
+
try {
|
|
53
|
+
text = await fs.readFile(consentPath(), "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
57
|
+
return null;
|
|
58
|
+
throw new Error("Could not read automatic source connection settings.", {
|
|
59
|
+
cause: error,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
let decoded;
|
|
63
|
+
try {
|
|
64
|
+
decoded = JSON.parse(text);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
throw new Error("Automatic source connection settings are invalid.", {
|
|
68
|
+
cause: error,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const parsed = consentSchema.safeParse(decoded);
|
|
72
|
+
if (!parsed.success)
|
|
73
|
+
throw new Error("Automatic source connection settings are invalid.");
|
|
74
|
+
validateCollectorEndpoint(parsed.data.endpoint);
|
|
75
|
+
return parsed.data;
|
|
76
|
+
}
|
|
77
|
+
export async function saveMachineConsent(consent) {
|
|
78
|
+
const parsed = consentSchema.parse(consent);
|
|
79
|
+
validateCollectorEndpoint(parsed.endpoint);
|
|
80
|
+
const filename = consentPath();
|
|
81
|
+
await ensurePrivateDirectory(path.dirname(filename));
|
|
82
|
+
const temporary = `${filename}.${process.pid}.tmp`;
|
|
83
|
+
const handle = await fs.open(temporary, "w", 0o600);
|
|
84
|
+
try {
|
|
85
|
+
await handle.writeFile(`${JSON.stringify(parsed)}\n`, "utf8");
|
|
86
|
+
await handle.sync();
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await handle.close();
|
|
90
|
+
}
|
|
91
|
+
await fs.rename(temporary, filename);
|
|
92
|
+
}
|
|
93
|
+
export async function readCollectorScope(anchor) {
|
|
94
|
+
const endpoint = validateCollectorEndpoint(anchor.endpoint);
|
|
95
|
+
const response = await fetch(new URL("./collector-auth/expand", endpoint), {
|
|
96
|
+
headers: { Authorization: `Bearer ${anchor.token}` },
|
|
97
|
+
signal: AbortSignal.timeout(10_000),
|
|
98
|
+
});
|
|
99
|
+
const payload = await response.json();
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
const error = z.object({ error: z.string() }).safeParse(payload);
|
|
102
|
+
throw new Error(`Could not identify the collector workspace: ${error.success ? error.data.error : `HTTP ${response.status}`}`);
|
|
103
|
+
}
|
|
104
|
+
const parsed = z
|
|
105
|
+
.object({ organizationId: z.uuid(), organizationName: z.string().min(1) })
|
|
106
|
+
.strict()
|
|
107
|
+
.safeParse(payload);
|
|
108
|
+
if (!parsed.success)
|
|
109
|
+
throw new Error("Iteration returned an invalid collector workspace.");
|
|
110
|
+
return parsed.data;
|
|
111
|
+
}
|
|
112
|
+
export async function expandSource(anchor, source, organizationId, request = fetch) {
|
|
113
|
+
const endpoint = validateCollectorEndpoint(anchor.endpoint);
|
|
114
|
+
const token = createHmac("sha256", anchor.token)
|
|
115
|
+
.update(`iteration-source:${source}`, "utf8")
|
|
116
|
+
.digest("base64url");
|
|
117
|
+
const response = await request(new URL("./collector-auth/expand", endpoint), {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
Authorization: `Bearer ${anchor.token}`,
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
source,
|
|
125
|
+
token,
|
|
126
|
+
...(organizationId === null ? {} : { organizationId }),
|
|
127
|
+
}),
|
|
128
|
+
signal: AbortSignal.timeout(15_000),
|
|
129
|
+
});
|
|
130
|
+
const payload = await response.json();
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const error = z.object({ error: z.string() }).safeParse(payload);
|
|
133
|
+
throw new Error(`Could not connect ${source}: ${error.success ? error.data.error : `HTTP ${response.status}`}`);
|
|
134
|
+
}
|
|
135
|
+
const parsed = parseCollectorEnrollmentResponse(payload);
|
|
136
|
+
if (!parsed.success || parsed.value.status !== "enrolled") {
|
|
137
|
+
throw new Error(`Iteration returned an invalid ${source} connection response.`);
|
|
138
|
+
}
|
|
139
|
+
if (organizationId !== null &&
|
|
140
|
+
parsed.value.organizationId !== organizationId) {
|
|
141
|
+
throw new Error(`Iteration connected ${source} to an unexpected workspace.`);
|
|
142
|
+
}
|
|
143
|
+
return parsed.value;
|
|
144
|
+
}
|
|
145
|
+
export async function activateSource(source, home, endpoint, enrollment, state) {
|
|
146
|
+
const directory = defaultCollectorDirectory(source);
|
|
147
|
+
const paths = collectorPaths(directory);
|
|
148
|
+
await assertFreshCollectorDirectory(paths, source);
|
|
149
|
+
const common = {
|
|
150
|
+
endpoint,
|
|
151
|
+
token: enrollment.token,
|
|
152
|
+
dataDirectory: directory,
|
|
153
|
+
organizationId: enrollment.organizationId,
|
|
154
|
+
organizationName: enrollment.organizationName,
|
|
155
|
+
};
|
|
156
|
+
const config = source === "codex"
|
|
157
|
+
? { ...common, codexHome: home }
|
|
158
|
+
: { ...common, source: "claude", claudeHome: home };
|
|
159
|
+
await saveConfig(config, paths);
|
|
160
|
+
try {
|
|
161
|
+
await saveState(state, paths);
|
|
162
|
+
await installLaunchAgent(directory, source);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
throw new Error(`${source} was connected, but local startup failed. Run iteration start ${source} to repair it.`, { cause: error });
|
|
166
|
+
}
|
|
167
|
+
return config;
|
|
168
|
+
}
|
|
169
|
+
export async function connectNewDetectedSources(anchor, consent) {
|
|
170
|
+
if (anchor.dataDirectory !== consent.anchorDataDirectory ||
|
|
171
|
+
anchor.endpoint !== consent.endpoint) {
|
|
172
|
+
throw new Error("Automatic connection settings do not match the running collector.");
|
|
173
|
+
}
|
|
174
|
+
const detected = await detectedSources(consent.sourceHomes);
|
|
175
|
+
const connected = [];
|
|
176
|
+
for (const source of detected) {
|
|
177
|
+
if (source === consent.anchorSource)
|
|
178
|
+
continue;
|
|
179
|
+
const paths = collectorPaths(defaultCollectorDirectory(source));
|
|
180
|
+
try {
|
|
181
|
+
await fs.access(paths.config);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
await assertFreshCollectorDirectory(paths, source);
|
|
189
|
+
const state = await prepareSourceInitialState(source, consent.sourceHomes[source], true);
|
|
190
|
+
const enrollment = await expandSource(anchor, source, consent.organizationId);
|
|
191
|
+
const config = await activateSource(source, consent.sourceHomes[source], consent.endpoint, enrollment, state);
|
|
192
|
+
connected.push(source);
|
|
193
|
+
try {
|
|
194
|
+
await installTurnSummary(config);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
console.error(JSON.stringify({
|
|
198
|
+
level: "warning",
|
|
199
|
+
source,
|
|
200
|
+
operation: "install_turn_summary",
|
|
201
|
+
message: error instanceof Error
|
|
202
|
+
? error.message
|
|
203
|
+
: "The turn summary could not be installed.",
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return connected;
|
|
208
|
+
}
|
|
209
|
+
export async function connectedConfig(source) {
|
|
210
|
+
const paths = collectorPaths(defaultCollectorDirectory(source));
|
|
211
|
+
try {
|
|
212
|
+
await fs.access(paths.config);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
216
|
+
return null;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
return loadConfig(paths);
|
|
220
|
+
}
|
|
@@ -2,5 +2,6 @@ import { type CollectorSource } from "iteration-trace-contracts";
|
|
|
2
2
|
export declare function authenticateAndEnrollCollector(endpoint: URL, deviceNameValue: string, organizationId: string | null, source?: CollectorSource): Promise<Readonly<{
|
|
3
3
|
token: string;
|
|
4
4
|
deviceId: string;
|
|
5
|
+
organizationId: string;
|
|
5
6
|
organizationName: string;
|
|
6
7
|
}>>;
|
package/dist/cli.js
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { promises as fs } from "node:fs";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
6
7
|
import * as z from "zod";
|
|
7
|
-
import { collectorSourceLabel, } from "iteration-trace-contracts";
|
|
8
|
+
import { COLLECTOR_SOURCES, collectorSourceLabel, } from "iteration-trace-contracts";
|
|
8
9
|
import { authenticateAndEnrollCollector } from "./clerk-device-auth.js";
|
|
10
|
+
import { activateSource, connectedConfig, detectedSources, expandSource, loadMachineConsent, readCollectorScope, saveMachineConsent, sourceHome, } from "./auto-connect.js";
|
|
9
11
|
import { importSourceHistory } from "./history-import.js";
|
|
10
12
|
import { runCollector } from "./service.js";
|
|
11
|
-
import { renderDetailedStatus, renderHome, } from "./status-view.js";
|
|
13
|
+
import { renderDetailedStatus, renderHome, safeWorkspaceName, } from "./status-view.js";
|
|
14
|
+
import { installTurnSummary, codexTurnSummaryApproval, uninstallTurnSummary, } from "./turn-summary.js";
|
|
12
15
|
import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
|
|
13
16
|
import { assertFreshCollectorDirectory, collectorSource, collectorSourceDirectory, collectorPaths, defaultCollectorDirectory, ensureStartableState, healthSnapshot, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
|
|
14
17
|
const DEFAULT_INGEST_ENDPOINT = "https://useiteration.com/api/traces/ingest";
|
|
@@ -52,6 +55,7 @@ function usage(command) {
|
|
|
52
55
|
if (command === "connect") {
|
|
53
56
|
process.stdout.write([
|
|
54
57
|
"Connect a coding agent to Iteration",
|
|
58
|
+
" iteration connect Connect all detected agents on this Mac",
|
|
55
59
|
" iteration connect codex",
|
|
56
60
|
" iteration connect claude",
|
|
57
61
|
"",
|
|
@@ -62,7 +66,8 @@ function usage(command) {
|
|
|
62
66
|
" --organization-id UUID Select a workspace without a prompt",
|
|
63
67
|
" --codex-home DIR Use a different Codex data directory",
|
|
64
68
|
" --claude-home DIR Use a different Claude data directory",
|
|
65
|
-
" --
|
|
69
|
+
" --yes Approve uploads without a terminal prompt",
|
|
70
|
+
" --data-directory DIR Use a different state directory with a source-specific command",
|
|
66
71
|
" --endpoint URL Use a different Iteration server",
|
|
67
72
|
].join("\n") + "\n");
|
|
68
73
|
return;
|
|
@@ -71,15 +76,15 @@ function usage(command) {
|
|
|
71
76
|
"Iteration CLI",
|
|
72
77
|
"",
|
|
73
78
|
"Get started:",
|
|
74
|
-
" iteration
|
|
79
|
+
" iteration connect Connect detected agents and import sessions",
|
|
75
80
|
"",
|
|
76
81
|
"Commands:",
|
|
77
|
-
" iteration
|
|
78
|
-
" iteration connect claude Connect Claude Code directly",
|
|
82
|
+
" iteration See connection status",
|
|
79
83
|
" iteration status See scan, upload, and error details",
|
|
84
|
+
" iteration connect codex|claude Connect one source (advanced)",
|
|
80
85
|
" iteration disconnect codex|claude Stop a source",
|
|
81
86
|
"",
|
|
82
|
-
"Run iteration connect
|
|
87
|
+
"Run iteration connect --help for advanced setup options.",
|
|
83
88
|
].join("\n") + "\n");
|
|
84
89
|
}
|
|
85
90
|
async function fileExists(filename) {
|
|
@@ -107,18 +112,38 @@ async function defaultCommand() {
|
|
|
107
112
|
].filter(({ snapshot }) => snapshot.kind !== "not_connected");
|
|
108
113
|
if (connected.length > 0) {
|
|
109
114
|
process.stdout.write(`${connected.map(({ source, snapshot }) => renderHome(snapshot, Date.now(), source)).join("\n\n")}\n`);
|
|
115
|
+
const discovered = await detectedSources({
|
|
116
|
+
codex: sourceHome("codex"),
|
|
117
|
+
claude: sourceHome("claude"),
|
|
118
|
+
});
|
|
119
|
+
const missing = discovered.filter((source) => (source === "codex" ? codex : claude).kind === "not_connected");
|
|
120
|
+
if (missing.length > 0) {
|
|
121
|
+
process.stdout.write(`\n${missing.map(collectorSourceLabel).join(" and ")} found on this Mac but not connected. Run iteration connect to include ${missing.length === 1 ? "it" : "them"}.\n`);
|
|
122
|
+
}
|
|
110
123
|
return;
|
|
111
124
|
}
|
|
112
125
|
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
|
113
|
-
process.stdout.write("Run iteration connect
|
|
126
|
+
process.stdout.write("Run iteration connect in a terminal.\n");
|
|
114
127
|
return;
|
|
115
128
|
}
|
|
116
|
-
await
|
|
129
|
+
await connectAll({});
|
|
117
130
|
}
|
|
118
131
|
function dataDirectory(source, values) {
|
|
119
132
|
return path.resolve(optionalString(values, "data-directory") ??
|
|
120
133
|
defaultCollectorDirectory(source));
|
|
121
134
|
}
|
|
135
|
+
async function turnSummaryNotice(config) {
|
|
136
|
+
try {
|
|
137
|
+
await installTurnSummary(config);
|
|
138
|
+
if ((await codexTurnSummaryApproval(config)) === "needs_approval") {
|
|
139
|
+
return "\nOne more step: in Codex, type /hooks and approve Iteration to see your friction summary after each turn.";
|
|
140
|
+
}
|
|
141
|
+
return " A one-line friction summary now appears after agent turns in your repositories.";
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
return ` The turn summary could not be installed: ${error instanceof Error ? error.message : "unknown error"} Run iteration start ${collectorSource(config)} to retry it.`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
122
147
|
async function initialize(source, values) {
|
|
123
148
|
if (process.platform !== "darwin") {
|
|
124
149
|
throw new Error("Automatic startup is currently supported on macOS only.");
|
|
@@ -143,6 +168,7 @@ async function initialize(source, values) {
|
|
|
143
168
|
endpoint: endpointUrl.toString(),
|
|
144
169
|
token: enrollment.token,
|
|
145
170
|
dataDirectory: directory,
|
|
171
|
+
organizationId: enrollment.organizationId,
|
|
146
172
|
organizationName: enrollment.organizationName,
|
|
147
173
|
};
|
|
148
174
|
const config = source === "codex"
|
|
@@ -161,7 +187,134 @@ async function initialize(source, values) {
|
|
|
161
187
|
catch (error) {
|
|
162
188
|
throw new Error(`Collector access was approved, but background startup failed. Run iteration start ${source} to retry it.`, { cause: error });
|
|
163
189
|
}
|
|
164
|
-
|
|
190
|
+
const notice = await turnSummaryNotice(config);
|
|
191
|
+
process.stdout.write(`${collectorSourceLabel(source)} is connected to ${enrollment.organizationName}. Existing sessions are importing and new activity is collected automatically. Run iteration to check progress.${notice}\n`);
|
|
192
|
+
return config;
|
|
193
|
+
}
|
|
194
|
+
async function approveAllSources(sources, workspaceName, yes) {
|
|
195
|
+
const names = sources.map(collectorSourceLabel).join(" and ");
|
|
196
|
+
const safeName = safeWorkspaceName(workspaceName);
|
|
197
|
+
process.stdout.write(`Detected ${names || "no saved agent sessions"} on this Mac.\n` +
|
|
198
|
+
`Connect supported agents${safeName ? ` to ${safeName}` : " to the workspace you select"}? Existing sessions and future activity, including prompts, code, and tool output, will be uploaded. Workspace members can read them. Newly detected supported agents will connect automatically.\n`);
|
|
199
|
+
if (yes)
|
|
200
|
+
return;
|
|
201
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
202
|
+
throw new Error("Run iteration connect interactively or pass --yes to approve uploads.");
|
|
203
|
+
}
|
|
204
|
+
const prompt = createInterface({
|
|
205
|
+
input: process.stdin,
|
|
206
|
+
output: process.stdout,
|
|
207
|
+
});
|
|
208
|
+
try {
|
|
209
|
+
const answer = (await prompt.question("Connect agents? [y/N] "))
|
|
210
|
+
.trim()
|
|
211
|
+
.toLowerCase();
|
|
212
|
+
if (answer !== "y" && answer !== "yes")
|
|
213
|
+
throw new Error("No agents were connected.");
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
prompt.close();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function connectAll(values) {
|
|
220
|
+
if (process.platform !== "darwin") {
|
|
221
|
+
throw new Error("Automatic startup is currently supported on macOS only.");
|
|
222
|
+
}
|
|
223
|
+
if (values["data-directory"] !== undefined) {
|
|
224
|
+
throw new Error("--data-directory requires a source-specific connect command.");
|
|
225
|
+
}
|
|
226
|
+
const configs = await Promise.all(COLLECTOR_SOURCES.map(connectedConfig));
|
|
227
|
+
const sourceHomes = {
|
|
228
|
+
codex: path.resolve(optionalString(values, "codex-home") ??
|
|
229
|
+
(configs[0] && "codexHome" in configs[0]
|
|
230
|
+
? configs[0].codexHome
|
|
231
|
+
: sourceHome("codex"))),
|
|
232
|
+
claude: path.resolve(optionalString(values, "claude-home") ??
|
|
233
|
+
(configs[1] && "claudeHome" in configs[1]
|
|
234
|
+
? configs[1].claudeHome
|
|
235
|
+
: sourceHome("claude"))),
|
|
236
|
+
};
|
|
237
|
+
const detected = await detectedSources(sourceHomes);
|
|
238
|
+
const existing = COLLECTOR_SOURCES.flatMap((source, index) => configs[index] ? [{ source, config: configs[index] }] : []);
|
|
239
|
+
if (detected.length === 0 && existing.length === 0) {
|
|
240
|
+
process.stdout.write("No supported local coding agents found yet. Run iteration connect after using Codex or Claude Code on this Mac.\n");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const anchorSource = existing[0]?.source ?? detected[0];
|
|
244
|
+
const anchorExisting = existing[0]?.config ?? null;
|
|
245
|
+
const endpoint = validateCollectorEndpoint(optionalString(values, "endpoint") ??
|
|
246
|
+
anchorExisting?.endpoint ??
|
|
247
|
+
DEFAULT_INGEST_ENDPOINT).toString();
|
|
248
|
+
const existingScopes = await Promise.all(existing.map(async (item) => ({
|
|
249
|
+
...item,
|
|
250
|
+
scope: await readCollectorScope(item.config),
|
|
251
|
+
})));
|
|
252
|
+
for (const item of existingScopes) {
|
|
253
|
+
if (item.config.endpoint !== endpoint) {
|
|
254
|
+
throw new Error("Existing sources use different Iteration servers. Connect one source at a time.");
|
|
255
|
+
}
|
|
256
|
+
if (existingScopes[0]?.scope.organizationId !== item.scope.organizationId) {
|
|
257
|
+
throw new Error("Existing sources use different workspaces. Connect one source at a time.");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const requestedOrganizationId = optionalString(values, "organization-id");
|
|
261
|
+
if (requestedOrganizationId &&
|
|
262
|
+
existingScopes[0] &&
|
|
263
|
+
requestedOrganizationId !== existingScopes[0].scope.organizationId) {
|
|
264
|
+
throw new Error("The existing collector belongs to a different workspace.");
|
|
265
|
+
}
|
|
266
|
+
const previousConsent = await loadMachineConsent();
|
|
267
|
+
if (previousConsent === null ||
|
|
268
|
+
anchorExisting === null ||
|
|
269
|
+
previousConsent.endpoint !== endpoint ||
|
|
270
|
+
previousConsent.anchorSource !== anchorSource ||
|
|
271
|
+
previousConsent.anchorDataDirectory !== anchorExisting.dataDirectory ||
|
|
272
|
+
previousConsent.organizationId !==
|
|
273
|
+
existingScopes[0]?.scope.organizationId ||
|
|
274
|
+
previousConsent.sourceHomes.codex !== sourceHomes.codex ||
|
|
275
|
+
previousConsent.sourceHomes.claude !== sourceHomes.claude) {
|
|
276
|
+
await approveAllSources(detected, existingScopes[0]?.scope.organizationName ?? null, values.yes === true);
|
|
277
|
+
}
|
|
278
|
+
let anchor = anchorExisting ?? (await initialize(anchorSource, values));
|
|
279
|
+
let organizationId = requestedOrganizationId ??
|
|
280
|
+
existingScopes[0]?.scope.organizationId ??
|
|
281
|
+
anchor.organizationId ??
|
|
282
|
+
null;
|
|
283
|
+
for (const source of detected) {
|
|
284
|
+
if (source === anchorSource ||
|
|
285
|
+
configs[COLLECTOR_SOURCES.indexOf(source)] !== null)
|
|
286
|
+
continue;
|
|
287
|
+
const paths = collectorPaths(defaultCollectorDirectory(source));
|
|
288
|
+
await assertFreshCollectorDirectory(paths, source);
|
|
289
|
+
const state = await prepareSourceInitialState(source, sourceHomes[source], true);
|
|
290
|
+
const enrollment = await expandSource(anchor, source, organizationId);
|
|
291
|
+
const config = await activateSource(source, sourceHomes[source], endpoint, enrollment, state);
|
|
292
|
+
organizationId = enrollment.organizationId;
|
|
293
|
+
const notice = await turnSummaryNotice(config);
|
|
294
|
+
process.stdout.write(`${collectorSourceLabel(source)} is connected to ${enrollment.organizationName}.${notice}\n`);
|
|
295
|
+
}
|
|
296
|
+
if (organizationId === null) {
|
|
297
|
+
organizationId = (await readCollectorScope(anchor)).organizationId;
|
|
298
|
+
}
|
|
299
|
+
if (anchor.organizationId === undefined) {
|
|
300
|
+
anchor = { ...anchor, organizationId };
|
|
301
|
+
await saveConfig(anchor, collectorPaths(anchor.dataDirectory));
|
|
302
|
+
}
|
|
303
|
+
await saveMachineConsent({
|
|
304
|
+
version: 1,
|
|
305
|
+
endpoint,
|
|
306
|
+
organizationId,
|
|
307
|
+
anchorSource,
|
|
308
|
+
anchorDataDirectory: anchor.dataDirectory,
|
|
309
|
+
sourceHomes,
|
|
310
|
+
});
|
|
311
|
+
for (const item of existing) {
|
|
312
|
+
await ensureStartableState(item.config, collectorPaths(item.config.dataDirectory));
|
|
313
|
+
await installLaunchAgent(item.config.dataDirectory, item.source);
|
|
314
|
+
const notice = await turnSummaryNotice(item.config);
|
|
315
|
+
process.stdout.write(`${collectorSourceLabel(item.source)} is still connected.${notice}\n`);
|
|
316
|
+
}
|
|
317
|
+
process.stdout.write("Detected agents are connected. New supported agents on this Mac will connect automatically.\n");
|
|
165
318
|
}
|
|
166
319
|
async function importHistory(source, values) {
|
|
167
320
|
if (process.platform !== "darwin") {
|
|
@@ -192,7 +345,8 @@ async function start(source, values) {
|
|
|
192
345
|
throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
|
|
193
346
|
const repairedState = await ensureStartableState(config, paths);
|
|
194
347
|
await installLaunchAgent(directory, source);
|
|
195
|
-
|
|
348
|
+
const notice = await turnSummaryNotice(config);
|
|
349
|
+
process.stdout.write(`${collectorSourceLabel(source)} is collecting again. Run iteration to check progress.${repairedState ? " Incomplete setup was repaired." : ""}${notice}\n`);
|
|
196
350
|
}
|
|
197
351
|
async function statusSnapshot(source, values) {
|
|
198
352
|
const paths = collectorPaths(dataDirectory(source, values));
|
|
@@ -209,12 +363,13 @@ async function statusSnapshot(source, values) {
|
|
|
209
363
|
const config = await loadConfig(paths);
|
|
210
364
|
if (collectorSource(config) !== source)
|
|
211
365
|
throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
|
|
212
|
-
const [running, size, ids, scan, localHealth] = await Promise.all([
|
|
366
|
+
const [running, size, ids, scan, localHealth, approval] = await Promise.all([
|
|
213
367
|
launchAgentStatus(source),
|
|
214
368
|
outboxSize(paths),
|
|
215
369
|
pendingEventIds(paths),
|
|
216
370
|
historyScanProgress(collectorSourceDirectory(config), paths, source),
|
|
217
371
|
healthSnapshot(paths),
|
|
372
|
+
codexTurnSummaryApproval(config),
|
|
218
373
|
]);
|
|
219
374
|
const pendingEvents = size === 0 ? 0 : ids.size;
|
|
220
375
|
const pendingBytes = ids.size === 0 ? 0 : size;
|
|
@@ -227,6 +382,7 @@ async function statusSnapshot(source, values) {
|
|
|
227
382
|
pendingBytes,
|
|
228
383
|
health: localHealth.health,
|
|
229
384
|
healthAvailable: localHealth.kind === "available",
|
|
385
|
+
turnSummaryNeedsApproval: approval === "needs_approval",
|
|
230
386
|
tracesUrl: new URL("/app/sessions", config.endpoint).toString(),
|
|
231
387
|
logPath: path.join(os.homedir(), "Library", "Logs", `iteration-${source}-collector.error.log`),
|
|
232
388
|
};
|
|
@@ -253,7 +409,10 @@ async function main() {
|
|
|
253
409
|
!(command === "connect" && selectedSource !== null)) {
|
|
254
410
|
throw new Error("Existing sessions are included automatically. Run iteration to connect.");
|
|
255
411
|
}
|
|
256
|
-
if (command === "
|
|
412
|
+
if (command === "connect" && source === undefined) {
|
|
413
|
+
return connectAll(values);
|
|
414
|
+
}
|
|
415
|
+
if (command === "") {
|
|
257
416
|
if (Object.keys(values).length > 0) {
|
|
258
417
|
throw new Error("Options require a source. Run iteration connect --help for details.");
|
|
259
418
|
}
|
|
@@ -265,8 +424,10 @@ async function main() {
|
|
|
265
424
|
if (selectedSource === "claude" && values["codex-home"] !== undefined)
|
|
266
425
|
throw new Error("--codex-home requires the Codex source.");
|
|
267
426
|
}
|
|
268
|
-
if (command === "connect" && selectedSource !== null)
|
|
269
|
-
|
|
427
|
+
if (command === "connect" && selectedSource !== null) {
|
|
428
|
+
await initialize(selectedSource, values);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
270
431
|
if (command === "run") {
|
|
271
432
|
if (source !== undefined && selectedSource === null)
|
|
272
433
|
throw new Error("The run source must be codex or claude.");
|
|
@@ -284,6 +445,10 @@ async function main() {
|
|
|
284
445
|
return start(selectedSource, values);
|
|
285
446
|
if (command === "disconnect" && selectedSource !== null) {
|
|
286
447
|
await uninstallLaunchAgent(selectedSource);
|
|
448
|
+
const paths = collectorPaths(dataDirectory(selectedSource, values));
|
|
449
|
+
if (await fileExists(paths.config)) {
|
|
450
|
+
await uninstallTurnSummary(await loadConfig(paths));
|
|
451
|
+
}
|
|
287
452
|
process.stdout.write("Collector startup removed. Local pending data was retained.\n");
|
|
288
453
|
return;
|
|
289
454
|
}
|
package/dist/service.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { connectNewDetectedSources, loadMachineConsent, } from "./auto-connect.js";
|
|
4
5
|
import { collectorTick, TraceUploadError } from "./collector.js";
|
|
6
|
+
import { refreshTurnSummaries } from "./turn-summary.js";
|
|
5
7
|
import { collectorSource, collectorPaths, ensureClaudeHistoryBackfill, ensureHistoryBackfill, ensurePrivateDirectory, healthSnapshot, loadConfig, saveHealth, } from "./storage.js";
|
|
6
8
|
function errorCode(error) {
|
|
7
9
|
if (error instanceof Error && "code" in error)
|
|
@@ -111,7 +113,34 @@ export async function runCollector(directory, expectedSource) {
|
|
|
111
113
|
await ensureClaudeHistoryBackfill(config.claudeHome, paths);
|
|
112
114
|
else
|
|
113
115
|
await ensureHistoryBackfill(config.codexHome, paths);
|
|
116
|
+
const summariesRefreshedAt = new Map();
|
|
117
|
+
let lastAutoCheckAt = 0;
|
|
114
118
|
while (!controller.signal.aborted) {
|
|
119
|
+
if (Date.now() - lastAutoCheckAt >= 60_000) {
|
|
120
|
+
lastAutoCheckAt = Date.now();
|
|
121
|
+
try {
|
|
122
|
+
const consent = await loadMachineConsent();
|
|
123
|
+
if (consent?.anchorSource === source) {
|
|
124
|
+
const connected = await connectNewDetectedSources(config, consent);
|
|
125
|
+
if (connected.length > 0) {
|
|
126
|
+
console.info(JSON.stringify({
|
|
127
|
+
level: "info",
|
|
128
|
+
operation: "auto_connect_sources",
|
|
129
|
+
connected,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
console.error(JSON.stringify({
|
|
136
|
+
level: "error",
|
|
137
|
+
operation: "auto_connect_sources",
|
|
138
|
+
message: error instanceof Error
|
|
139
|
+
? error.message
|
|
140
|
+
: "Automatic source connection failed.",
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
115
144
|
let acknowledgedAtMs = null;
|
|
116
145
|
try {
|
|
117
146
|
const result = await collectorTick(config, paths, fetch, controller.signal, () => {
|
|
@@ -159,6 +188,22 @@ export async function runCollector(directory, expectedSource) {
|
|
|
159
188
|
await persistHealth(health, paths);
|
|
160
189
|
retryDelayMs = Math.min(retryDelayMs * 2, 60_000);
|
|
161
190
|
}
|
|
191
|
+
try {
|
|
192
|
+
await refreshTurnSummaries(config, summariesRefreshedAt, Date.now(), fetch, controller.signal);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (controller.signal.aborted)
|
|
196
|
+
break;
|
|
197
|
+
console.error(JSON.stringify({
|
|
198
|
+
level: "warning",
|
|
199
|
+
source,
|
|
200
|
+
operation: "refresh_turn_summaries",
|
|
201
|
+
errorName: error instanceof Error ? error.name : "UnknownError",
|
|
202
|
+
message: error instanceof Error
|
|
203
|
+
? error.message
|
|
204
|
+
: "Unknown turn summary failure.",
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
162
207
|
try {
|
|
163
208
|
await delay(retryDelayMs, undefined, { signal: controller.signal });
|
|
164
209
|
}
|
package/dist/status-view.d.ts
CHANGED
|
@@ -22,9 +22,11 @@ export type CliStatus = Readonly<{
|
|
|
22
22
|
pendingBytes: number;
|
|
23
23
|
health: CollectorHealth;
|
|
24
24
|
healthAvailable: boolean;
|
|
25
|
+
turnSummaryNeedsApproval: boolean;
|
|
25
26
|
tracesUrl: string;
|
|
26
27
|
logPath: string;
|
|
27
28
|
}>;
|
|
29
|
+
export declare function safeWorkspaceName(value: string | null): string | null;
|
|
28
30
|
export declare function renderHome(status: CliStatus, nowMs?: number, source?: CollectorSource): string;
|
|
29
31
|
export declare function renderDetailedStatus(status: CliStatus, nowMs?: number, source?: CollectorSource): string;
|
|
30
32
|
export {};
|
package/dist/status-view.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { collectorSourceLabel, } from "iteration-trace-contracts";
|
|
2
|
+
const CODEX_APPROVAL_STEP = "Friction summary is off: in Codex, type /hooks and approve Iteration.";
|
|
2
3
|
function sourceName(source) {
|
|
3
4
|
return collectorSourceLabel(source);
|
|
4
5
|
}
|
|
5
|
-
function safeWorkspaceName(value) {
|
|
6
|
+
export function safeWorkspaceName(value) {
|
|
6
7
|
const cleaned = value
|
|
7
8
|
?.split("")
|
|
8
9
|
.map((character) => {
|
|
@@ -122,7 +123,7 @@ export function renderHome(status, nowMs = Date.now(), source = "codex") {
|
|
|
122
123
|
"Iteration",
|
|
123
124
|
"",
|
|
124
125
|
`${name} is not connected.`,
|
|
125
|
-
|
|
126
|
+
"Run iteration connect to import your existing sessions.",
|
|
126
127
|
].join("\n");
|
|
127
128
|
}
|
|
128
129
|
if (status.kind === "needs_repair") {
|
|
@@ -197,13 +198,15 @@ export function renderHome(status, nowMs = Date.now(), source = "codex") {
|
|
|
197
198
|
status.health.lastUploadAtMs !== null) {
|
|
198
199
|
lines.push(`Last successful upload ${relativeTime(status.health.lastUploadAtMs, nowMs)}.`);
|
|
199
200
|
}
|
|
201
|
+
if (status.turnSummaryNeedsApproval)
|
|
202
|
+
lines.push("", CODEX_APPROVAL_STEP);
|
|
200
203
|
lines.push("", `Explore traces ${status.tracesUrl}`);
|
|
201
204
|
return lines.join("\n");
|
|
202
205
|
}
|
|
203
206
|
export function renderDetailedStatus(status, nowMs = Date.now(), source = "codex") {
|
|
204
207
|
const name = sourceName(source);
|
|
205
208
|
if (status.kind === "not_connected") {
|
|
206
|
-
return `${name} is not connected. Run iteration connect
|
|
209
|
+
return `${name} is not connected. Run iteration connect to connect.`;
|
|
207
210
|
}
|
|
208
211
|
if (status.kind === "needs_repair") {
|
|
209
212
|
return [
|
|
@@ -239,6 +242,8 @@ export function renderDetailedStatus(status, nowMs = Date.now(), source = "codex
|
|
|
239
242
|
: "Local collector error";
|
|
240
243
|
lines.push(`Last issue: ${label} (${relativeTime(failure.atMs, nowMs)})`, `Collector log: ${status.logPath}`);
|
|
241
244
|
}
|
|
245
|
+
if (status.turnSummaryNeedsApproval)
|
|
246
|
+
lines.push(CODEX_APPROVAL_STEP);
|
|
242
247
|
if (!status.running)
|
|
243
248
|
lines.push(`Resume: iteration start ${source}`);
|
|
244
249
|
lines.push(`Explore traces: ${status.tracesUrl}`);
|
package/dist/storage.d.ts
CHANGED
package/dist/storage.js
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type CollectorFrictionSummary } from "iteration-trace-contracts";
|
|
2
|
+
import { type CollectorConfig } from "./storage.js";
|
|
3
|
+
export type TurnSummaryPaths = Readonly<{
|
|
4
|
+
directory: string;
|
|
5
|
+
script: string;
|
|
6
|
+
requests: string;
|
|
7
|
+
lines: string;
|
|
8
|
+
shown: string;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function turnSummaryPaths(dataDirectory: string): TurnSummaryPaths;
|
|
11
|
+
export declare function installTurnSummary(config: CollectorConfig): Promise<void>;
|
|
12
|
+
export declare function uninstallTurnSummary(config: CollectorConfig): Promise<void>;
|
|
13
|
+
export declare function turnSummaryMessage(summary: CollectorFrictionSummary): string | null;
|
|
14
|
+
export declare class TurnSummaryRefreshError extends Error {
|
|
15
|
+
constructor(message: string, options?: ErrorOptions);
|
|
16
|
+
}
|
|
17
|
+
export declare function refreshTurnSummaries(config: CollectorConfig, refreshedAt: Map<string, Readonly<{
|
|
18
|
+
directory: string;
|
|
19
|
+
atMs: number;
|
|
20
|
+
}>>, nowMs: number, fetcher?: typeof fetch, signal?: AbortSignal): Promise<number>;
|
|
21
|
+
export type TurnSummaryApproval = "approved" | "needs_approval" | "not_installed";
|
|
22
|
+
export declare function codexTurnSummaryApproval(config: CollectorConfig): Promise<TurnSummaryApproval>;
|
|
@@ -0,0 +1,362 @@
|
|
|
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 { ensurePrivateDirectory } from "./storage.js";
|
|
7
|
+
const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
8
|
+
const REQUEST_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const MAX_REFRESHES_PER_PASS = 20;
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
11
|
+
const HOOK_TIMEOUT_SECONDS = 1;
|
|
12
|
+
const REQUEST_KEY_PATTERN = /^\d{1,10}$/;
|
|
13
|
+
export function turnSummaryPaths(dataDirectory) {
|
|
14
|
+
const directory = path.join(dataDirectory, "turn-summary");
|
|
15
|
+
return {
|
|
16
|
+
directory,
|
|
17
|
+
script: path.join(directory, "stop-hook.sh"),
|
|
18
|
+
requests: path.join(directory, "requests"),
|
|
19
|
+
lines: path.join(directory, "lines"),
|
|
20
|
+
shown: path.join(directory, "shown"),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function shellQuote(value) {
|
|
24
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
25
|
+
}
|
|
26
|
+
// The hook runs at the end of every agent turn, so it only reads files the
|
|
27
|
+
// collector keeps current and never waits on the network. Every failure prints
|
|
28
|
+
// the empty object both agents accept, so a broken summary never blocks a turn.
|
|
29
|
+
function hookScript(paths) {
|
|
30
|
+
return `#!/bin/sh
|
|
31
|
+
exec 2>/dev/null
|
|
32
|
+
cat >/dev/null
|
|
33
|
+
summary=${shellQuote(paths.directory)}
|
|
34
|
+
quiet() { printf '{}\\n'; exit 0; }
|
|
35
|
+
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_PREFIX
|
|
36
|
+
cd "\${CLAUDE_PROJECT_DIR:-.}" || quiet
|
|
37
|
+
directory=$(git rev-parse --show-toplevel) || quiet
|
|
38
|
+
case $directory in /*) ;; *) quiet ;; esac
|
|
39
|
+
set -- $(printf '%s' "$directory" | cksum)
|
|
40
|
+
key=$1
|
|
41
|
+
[ -n "$key" ] || quiet
|
|
42
|
+
printf '%s\\n' "$directory" > "$summary/requests/$key"
|
|
43
|
+
[ -f "$summary/lines/$key" ] || quiet
|
|
44
|
+
{ IFS= read -r owner && IFS= read -r message; } < "$summary/lines/$key" || quiet
|
|
45
|
+
[ "$owner" = "$directory" ] || quiet
|
|
46
|
+
last=
|
|
47
|
+
[ -f "$summary/shown/$key" ] && IFS= read -r last < "$summary/shown/$key"
|
|
48
|
+
[ "$last" = "$message" ] && quiet
|
|
49
|
+
printf '%s\\n' "$message" > "$summary/shown/$key" || quiet
|
|
50
|
+
printf '%s\\n' "$message"
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
function hookCommand(paths) {
|
|
54
|
+
return `/bin/sh ${shellQuote(paths.script)}`;
|
|
55
|
+
}
|
|
56
|
+
function agentHookFile(config) {
|
|
57
|
+
return "claudeHome" in config
|
|
58
|
+
? path.join(config.claudeHome, "settings.json")
|
|
59
|
+
: path.join(config.codexHome, "hooks.json");
|
|
60
|
+
}
|
|
61
|
+
const hookEntrySchema = z.object({ command: z.string().optional() }).loose();
|
|
62
|
+
const hookGroupSchema = z
|
|
63
|
+
.object({ hooks: z.array(hookEntrySchema).optional() })
|
|
64
|
+
.loose();
|
|
65
|
+
const hookFileSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
hooks: z
|
|
68
|
+
.object({ Stop: z.array(hookGroupSchema).optional() })
|
|
69
|
+
.loose()
|
|
70
|
+
.optional(),
|
|
71
|
+
})
|
|
72
|
+
.loose();
|
|
73
|
+
async function readHookFile(filename) {
|
|
74
|
+
let contents;
|
|
75
|
+
let mode = 0o600;
|
|
76
|
+
try {
|
|
77
|
+
contents = await fs.readFile(filename, "utf8");
|
|
78
|
+
mode = (await fs.stat(filename)).mode & 0o777;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
82
|
+
return { file: {}, mode };
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`Could not read agent hooks at ${filename}.`, {
|
|
85
|
+
cause: error,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
let decoded;
|
|
89
|
+
try {
|
|
90
|
+
decoded = JSON.parse(contents);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
throw new Error(`Agent hooks at ${filename} are not valid JSON.`, {
|
|
94
|
+
cause: error,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const parsed = hookFileSchema.safeParse(decoded);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
throw new Error(`Agent hooks at ${filename} have an unexpected shape.`, {
|
|
100
|
+
cause: parsed.error,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { file: parsed.data, mode };
|
|
104
|
+
}
|
|
105
|
+
async function writeHookFile(filename, file, mode) {
|
|
106
|
+
await fs.mkdir(path.dirname(filename), { recursive: true });
|
|
107
|
+
// Settings kept in a dotfiles repository are often symlinks; replacing the
|
|
108
|
+
// link with a file would silently detach them from that repository.
|
|
109
|
+
const target = await fs.realpath(filename).catch((error) => {
|
|
110
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
111
|
+
return filename;
|
|
112
|
+
throw error;
|
|
113
|
+
});
|
|
114
|
+
const temporary = `${target}.iteration-${process.pid}.tmp`;
|
|
115
|
+
await fs.writeFile(temporary, `${JSON.stringify(file, null, 2)}\n`, {
|
|
116
|
+
encoding: "utf8",
|
|
117
|
+
mode,
|
|
118
|
+
});
|
|
119
|
+
await fs.rename(temporary, target);
|
|
120
|
+
}
|
|
121
|
+
function withoutCommand(groups, command) {
|
|
122
|
+
return groups.flatMap((group) => {
|
|
123
|
+
const hooks = group.hooks?.filter((hook) => hook.command !== command);
|
|
124
|
+
if (hooks === undefined || hooks.length === group.hooks?.length)
|
|
125
|
+
return [group];
|
|
126
|
+
return hooks.length === 0 ? [] : [{ ...group, hooks }];
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function hasCommand(groups, command) {
|
|
130
|
+
return groups.some((group) => group.hooks?.some((hook) => hook.command === command));
|
|
131
|
+
}
|
|
132
|
+
export async function installTurnSummary(config) {
|
|
133
|
+
const paths = turnSummaryPaths(config.dataDirectory);
|
|
134
|
+
await ensurePrivateDirectory(paths.directory);
|
|
135
|
+
await Promise.all([paths.requests, paths.lines, paths.shown].map(ensurePrivateDirectory));
|
|
136
|
+
await fs.writeFile(paths.script, hookScript(paths), {
|
|
137
|
+
encoding: "utf8",
|
|
138
|
+
mode: 0o700,
|
|
139
|
+
});
|
|
140
|
+
await fs.chmod(paths.script, 0o700);
|
|
141
|
+
const filename = agentHookFile(config);
|
|
142
|
+
const { file, mode } = await readHookFile(filename);
|
|
143
|
+
const command = hookCommand(paths);
|
|
144
|
+
const stop = file.hooks?.Stop ?? [];
|
|
145
|
+
if (hasCommand(stop, command))
|
|
146
|
+
return;
|
|
147
|
+
await writeHookFile(filename, {
|
|
148
|
+
...file,
|
|
149
|
+
hooks: {
|
|
150
|
+
...file.hooks,
|
|
151
|
+
Stop: [
|
|
152
|
+
...stop,
|
|
153
|
+
{
|
|
154
|
+
hooks: [
|
|
155
|
+
{ type: "command", command, timeout: HOOK_TIMEOUT_SECONDS },
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
}, mode);
|
|
161
|
+
}
|
|
162
|
+
export async function uninstallTurnSummary(config) {
|
|
163
|
+
const filename = agentHookFile(config);
|
|
164
|
+
const { file, mode } = await readHookFile(filename);
|
|
165
|
+
const stop = file.hooks?.Stop ?? [];
|
|
166
|
+
const command = hookCommand(turnSummaryPaths(config.dataDirectory));
|
|
167
|
+
if (!hasCommand(stop, command))
|
|
168
|
+
return;
|
|
169
|
+
const remaining = withoutCommand(stop, command);
|
|
170
|
+
const { Stop: _removed, ...otherEvents } = file.hooks ?? {};
|
|
171
|
+
await writeHookFile(filename, {
|
|
172
|
+
...file,
|
|
173
|
+
hooks: remaining.length === 0
|
|
174
|
+
? otherEvents
|
|
175
|
+
: { ...otherEvents, Stop: remaining },
|
|
176
|
+
}, mode);
|
|
177
|
+
}
|
|
178
|
+
export function turnSummaryMessage(summary) {
|
|
179
|
+
if (summary.weekEpisodes === 0)
|
|
180
|
+
return null;
|
|
181
|
+
const episodes = `${summary.weekEpisodes} friction episode${summary.weekEpisodes === 1 ? "" : "s"}`;
|
|
182
|
+
const recent = summary.dayEpisodes === 0
|
|
183
|
+
? ""
|
|
184
|
+
: ` · ${summary.dayEpisodes} new since yesterday`;
|
|
185
|
+
return `◆ Iteration · ${episodes} in this repo this week${recent}`;
|
|
186
|
+
}
|
|
187
|
+
export class TurnSummaryRefreshError extends Error {
|
|
188
|
+
constructor(message, options) {
|
|
189
|
+
super(message, options);
|
|
190
|
+
this.name = "TurnSummaryRefreshError";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function fetchSummary(config, directory, fetcher, signal) {
|
|
194
|
+
const url = new URL("/api/traces/friction-summary", config.endpoint);
|
|
195
|
+
url.searchParams.set("directory", directory);
|
|
196
|
+
let response;
|
|
197
|
+
try {
|
|
198
|
+
response = await fetcher(url, {
|
|
199
|
+
headers: { authorization: `Bearer ${config.token}` },
|
|
200
|
+
signal: signal
|
|
201
|
+
? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
|
|
202
|
+
: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (signal?.aborted)
|
|
207
|
+
throw error;
|
|
208
|
+
throw new TurnSummaryRefreshError("Friction summary could not reach Iteration.", { cause: error });
|
|
209
|
+
}
|
|
210
|
+
if (response.status !== 200) {
|
|
211
|
+
throw new TurnSummaryRefreshError(`Friction summary failed with HTTP ${response.status}.`);
|
|
212
|
+
}
|
|
213
|
+
let body;
|
|
214
|
+
try {
|
|
215
|
+
body = await response.json();
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
throw new TurnSummaryRefreshError("Friction summary returned an unreadable response.", { cause: error });
|
|
219
|
+
}
|
|
220
|
+
const parsed = parseCollectorFrictionSummary(body);
|
|
221
|
+
if (!parsed.success)
|
|
222
|
+
throw new TurnSummaryRefreshError(parsed.error);
|
|
223
|
+
return parsed.value;
|
|
224
|
+
}
|
|
225
|
+
async function readRequest(filename) {
|
|
226
|
+
try {
|
|
227
|
+
const [directory] = (await fs.readFile(filename, "utf8")).split("\n");
|
|
228
|
+
return directory ?? null;
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
232
|
+
return null;
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function writeLine(paths, key, directory, message) {
|
|
237
|
+
const filename = path.join(paths.lines, key);
|
|
238
|
+
if (message === null) {
|
|
239
|
+
await fs.rm(filename, { force: true });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const temporary = `${filename}.${process.pid}.tmp`;
|
|
243
|
+
await fs.writeFile(temporary, `${directory}\n${JSON.stringify({ systemMessage: message })}\n`, { encoding: "utf8", mode: 0o600 });
|
|
244
|
+
await fs.rename(temporary, filename);
|
|
245
|
+
}
|
|
246
|
+
async function forget(paths, key) {
|
|
247
|
+
await Promise.all([paths.requests, paths.lines, paths.shown].map((directory) => fs.rm(path.join(directory, key), { force: true })));
|
|
248
|
+
}
|
|
249
|
+
// Refreshes the summary line for each repository the hook asked about. The
|
|
250
|
+
// hook reads the result on a later turn, so a slow or failed request never
|
|
251
|
+
// delays the agent.
|
|
252
|
+
export async function refreshTurnSummaries(config, refreshedAt, nowMs, fetcher = fetch, signal) {
|
|
253
|
+
const paths = turnSummaryPaths(config.dataDirectory);
|
|
254
|
+
let keys;
|
|
255
|
+
try {
|
|
256
|
+
keys = await fs.readdir(paths.requests);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
260
|
+
return 0;
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
let refreshed = 0;
|
|
264
|
+
for (const key of keys.sort()) {
|
|
265
|
+
signal?.throwIfAborted();
|
|
266
|
+
if (!REQUEST_KEY_PATTERN.test(key))
|
|
267
|
+
continue;
|
|
268
|
+
const filename = path.join(paths.requests, key);
|
|
269
|
+
const stats = await fs.stat(filename).catch((error) => {
|
|
270
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
271
|
+
return null;
|
|
272
|
+
throw error;
|
|
273
|
+
});
|
|
274
|
+
if (stats === null)
|
|
275
|
+
continue;
|
|
276
|
+
const directory = await readRequest(filename);
|
|
277
|
+
if (nowMs - stats.mtimeMs > REQUEST_RETENTION_MS ||
|
|
278
|
+
directory === null ||
|
|
279
|
+
!isLaunchDirectory(directory)) {
|
|
280
|
+
await forget(paths, key);
|
|
281
|
+
refreshedAt.delete(key);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const previous = refreshedAt.get(key);
|
|
285
|
+
if (previous !== undefined &&
|
|
286
|
+
previous.directory === directory &&
|
|
287
|
+
nowMs - previous.atMs < REFRESH_INTERVAL_MS) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (refreshed === MAX_REFRESHES_PER_PASS)
|
|
291
|
+
break;
|
|
292
|
+
refreshedAt.set(key, { directory, atMs: nowMs });
|
|
293
|
+
refreshed += 1;
|
|
294
|
+
const summary = await fetchSummary(config, directory, fetcher, signal);
|
|
295
|
+
await writeLine(paths, key, directory, turnSummaryMessage(summary));
|
|
296
|
+
}
|
|
297
|
+
return refreshed;
|
|
298
|
+
}
|
|
299
|
+
function tomlBasicString(value) {
|
|
300
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
301
|
+
}
|
|
302
|
+
// Codex runs a hook only after the developer trusts it in /hooks, and records
|
|
303
|
+
// that trust as a hash of the normalized hook. Recomputing the same hash tells
|
|
304
|
+
// whether Codex will run this exact entry; any other result keeps the reminder.
|
|
305
|
+
function codexHookHash(command) {
|
|
306
|
+
const identity = {
|
|
307
|
+
event_name: "stop",
|
|
308
|
+
hooks: [
|
|
309
|
+
{
|
|
310
|
+
async: false,
|
|
311
|
+
command,
|
|
312
|
+
timeout: HOOK_TIMEOUT_SECONDS,
|
|
313
|
+
type: "command",
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
};
|
|
317
|
+
return `sha256:${createHash("sha256").update(JSON.stringify(identity)).digest("hex")}`;
|
|
318
|
+
}
|
|
319
|
+
function codexTrustedHash(configToml, key) {
|
|
320
|
+
const lines = configToml.split(/\r?\n/);
|
|
321
|
+
const header = `[hooks.state.${tomlBasicString(key)}]`;
|
|
322
|
+
const start = lines.findIndex((line) => line.trim() === header);
|
|
323
|
+
if (start < 0)
|
|
324
|
+
return null;
|
|
325
|
+
let trustedHash = null;
|
|
326
|
+
for (const line of lines.slice(start + 1)) {
|
|
327
|
+
const trimmed = line.trim();
|
|
328
|
+
if (trimmed.startsWith("["))
|
|
329
|
+
break;
|
|
330
|
+
if (/^enabled\s*=\s*false\b/.test(trimmed))
|
|
331
|
+
return null;
|
|
332
|
+
const match = /^trusted_hash\s*=\s*"(sha256:[0-9a-f]{64})"/.exec(trimmed);
|
|
333
|
+
if (match?.[1] !== undefined)
|
|
334
|
+
trustedHash = match[1];
|
|
335
|
+
}
|
|
336
|
+
return trustedHash;
|
|
337
|
+
}
|
|
338
|
+
export async function codexTurnSummaryApproval(config) {
|
|
339
|
+
if ("claudeHome" in config)
|
|
340
|
+
return "approved";
|
|
341
|
+
const filename = agentHookFile(config);
|
|
342
|
+
const { file } = await readHookFile(filename);
|
|
343
|
+
const command = hookCommand(turnSummaryPaths(config.dataDirectory));
|
|
344
|
+
const stop = file.hooks?.Stop ?? [];
|
|
345
|
+
for (const [groupIndex, group] of stop.entries()) {
|
|
346
|
+
const handlerIndex = group.hooks?.findIndex((hook) => hook.command === command) ?? -1;
|
|
347
|
+
if (handlerIndex < 0)
|
|
348
|
+
continue;
|
|
349
|
+
let configToml;
|
|
350
|
+
try {
|
|
351
|
+
configToml = await fs.readFile(path.join(config.codexHome, "config.toml"), "utf8");
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
355
|
+
return "needs_approval";
|
|
356
|
+
throw new Error("Could not read the Codex config.", { cause: error });
|
|
357
|
+
}
|
|
358
|
+
const trusted = codexTrustedHash(configToml, `${filename}:stop:${groupIndex}:${handlerIndex}`);
|
|
359
|
+
return trusted === codexHookHash(command) ? "approved" : "needs_approval";
|
|
360
|
+
}
|
|
361
|
+
return "not_installed";
|
|
362
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iteration-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"zod": "4.6.4",
|
|
21
|
-
"iteration-trace-contracts": "0.
|
|
21
|
+
"iteration-trace-contracts": "0.6.0"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "pnpm --filter iteration-trace-contracts build && tsc -p tsconfig.json"
|