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 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 codex
8
- iteration connect claude
7
+ iteration connect
9
8
  ```
10
9
 
11
- The collectors currently run on macOS with Node.js 22 or later. Connect either source or both on the same Mac. Each source has its own background process, local checkpoint, and upload queue. Connecting imports existing local sessions and collects new activity. Run `iteration` to see connected sources, `iteration status codex` or `iteration status claude` for details, and `iteration help` for other commands.
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
  }>>;
@@ -273,6 +273,7 @@ export async function authenticateAndEnrollCollector(endpoint, deviceNameValue,
273
273
  return {
274
274
  token: result.token,
275
275
  deviceId: result.deviceId,
276
+ organizationId: result.organizationId,
276
277
  organizationName: result.organizationName,
277
278
  };
278
279
  }
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
- " --data-directory DIR Use a different Iteration state directory",
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 See connected sources or connect Codex",
79
+ " iteration connect Connect detected agents and import sessions",
75
80
  "",
76
81
  "Commands:",
77
- " iteration connect codex Connect Codex directly",
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 codex --help for advanced setup options.",
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 codex or iteration connect claude in a terminal.\n");
126
+ process.stdout.write("Run iteration connect in a terminal.\n");
114
127
  return;
115
128
  }
116
- await initialize("codex", {});
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
- 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.\n`);
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
- process.stdout.write(`${collectorSourceLabel(source)} is collecting again. Run iteration to check progress.${repairedState ? " Incomplete setup was repaired." : ""}\n`);
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 === "" || (command === "connect" && source === undefined)) {
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
- return initialize(selectedSource, values);
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
  }
@@ -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 {};
@@ -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
- `Run iteration connect ${source} to import your existing sessions.`,
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 ${source} to 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
@@ -3,6 +3,7 @@ type CollectorConnection = Readonly<{
3
3
  endpoint: string;
4
4
  token: string;
5
5
  dataDirectory: string;
6
+ organizationId?: string;
6
7
  organizationName?: string;
7
8
  }>;
8
9
  export type CollectorConfig = (CollectorConnection & Readonly<{
package/dist/storage.js CHANGED
@@ -16,6 +16,7 @@ const connectionSchema = {
16
16
  endpoint: z.string(),
17
17
  token: z.string(),
18
18
  dataDirectory: z.string(),
19
+ organizationId: z.uuid().optional(),
19
20
  organizationName: z.string().min(1).max(160).optional(),
20
21
  };
21
22
  const configSchema = z.union([
@@ -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.5.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.5.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"