iteration-cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ npm install --global iteration-cli
7
7
  iteration connect
8
8
  ```
9
9
 
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.
10
+ The collectors run on macOS or Linux with Node.js 22 or later. Linux needs systemd. The CLI automatically enables background collection after logout and installs a user service that starts at boot and restarts on failure. If server policy does not let the user enable this, an administrator must allow background services for the account. The setting applies to the whole Linux account and remains enabled after `iteration disconnect`; the CLI does not turn off other user services. `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.
11
11
 
12
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.
13
13
 
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import * as z from "zod";
6
6
  import { COLLECTOR_SOURCES, parseCollectorEnrollmentResponse, } from "iteration-trace-contracts";
7
- import { installLaunchAgent } from "./launch-agent.js";
7
+ import { installBackgroundService } from "./background-service.js";
8
8
  import { installTurnSummary } from "./turn-summary.js";
9
9
  import { assertFreshCollectorDirectory, collectorPaths, defaultCollectorDirectory, ensurePrivateDirectory, loadConfig, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
10
10
  const consentSchema = z
@@ -159,7 +159,7 @@ export async function activateSource(source, home, endpoint, enrollment, state)
159
159
  await saveConfig(config, paths);
160
160
  try {
161
161
  await saveState(state, paths);
162
- await installLaunchAgent(directory, source);
162
+ await installBackgroundService(directory, source);
163
163
  }
164
164
  catch (error) {
165
165
  throw new Error(`${source} was connected, but local startup failed. Run iteration start ${source} to repair it.`, { cause: error });
@@ -0,0 +1,7 @@
1
+ import type { CollectorSource } from "iteration-trace-contracts";
2
+ export declare function requireCollectorPlatform(): void;
3
+ export declare function prepareBackgroundPersistence(): Promise<void>;
4
+ export declare function collectorLogPath(source: CollectorSource): string;
5
+ export declare function installBackgroundService(directory: string, source: CollectorSource): Promise<string>;
6
+ export declare function uninstallBackgroundService(source: CollectorSource): Promise<void>;
7
+ export declare function backgroundServiceStatus(source: CollectorSource): Promise<boolean>;
@@ -0,0 +1,37 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
4
+ import { installSystemdUserService, ensureSystemdUserLinger, systemdLogPath, systemdUserServiceStatus, uninstallSystemdUserService, } from "./systemd-user.js";
5
+ export function requireCollectorPlatform() {
6
+ if (process.platform !== "darwin" && process.platform !== "linux") {
7
+ throw new Error("Automatic startup is supported on macOS and Linux only.");
8
+ }
9
+ }
10
+ export async function prepareBackgroundPersistence() {
11
+ requireCollectorPlatform();
12
+ if (process.platform === "linux")
13
+ await ensureSystemdUserLinger();
14
+ }
15
+ export function collectorLogPath(source) {
16
+ if (process.platform === "linux")
17
+ return systemdLogPath(source);
18
+ return path.join(os.homedir(), "Library", "Logs", `iteration-${source}-collector.error.log`);
19
+ }
20
+ export async function installBackgroundService(directory, source) {
21
+ requireCollectorPlatform();
22
+ return process.platform === "darwin"
23
+ ? installLaunchAgent(directory, source)
24
+ : installSystemdUserService(directory, source);
25
+ }
26
+ export async function uninstallBackgroundService(source) {
27
+ requireCollectorPlatform();
28
+ return process.platform === "darwin"
29
+ ? uninstallLaunchAgent(source)
30
+ : uninstallSystemdUserService(source);
31
+ }
32
+ export async function backgroundServiceStatus(source) {
33
+ requireCollectorPlatform();
34
+ return process.platform === "darwin"
35
+ ? launchAgentStatus(source)
36
+ : systemdUserServiceStatus(source);
37
+ }
package/dist/cli.js CHANGED
@@ -11,8 +11,8 @@ import { activateSource, connectedConfig, detectedSources, expandSource, loadMac
11
11
  import { importSourceHistory } from "./history-import.js";
12
12
  import { runCollector } from "./service.js";
13
13
  import { renderDetailedStatus, renderHome, safeWorkspaceName, } from "./status-view.js";
14
- import { installTurnSummary, turnSummaryApprovalHint, uninstallTurnSummary, } from "./turn-summary.js";
15
- import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
14
+ import { installTurnSummary, codexTurnSummaryApproval, uninstallTurnSummary, } from "./turn-summary.js";
15
+ import { backgroundServiceStatus, collectorLogPath, installBackgroundService, prepareBackgroundPersistence, requireCollectorPlatform, uninstallBackgroundService, } from "./background-service.js";
16
16
  import { assertFreshCollectorDirectory, collectorSource, collectorSourceDirectory, collectorPaths, defaultCollectorDirectory, ensureStartableState, healthSnapshot, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
17
17
  const DEFAULT_INGEST_ENDPOINT = "https://useiteration.com/api/traces/ingest";
18
18
  function argumentsForCli() {
@@ -55,14 +55,14 @@ function usage(command) {
55
55
  if (command === "connect") {
56
56
  process.stdout.write([
57
57
  "Connect a coding agent to Iteration",
58
- " iteration connect Connect all detected agents on this Mac",
58
+ " iteration connect Connect all detected agents on this computer",
59
59
  " iteration connect codex",
60
60
  " iteration connect claude",
61
61
  "",
62
62
  "Existing local sessions and new activity are collected automatically.",
63
63
  "",
64
64
  "Options for advanced setups:",
65
- " --device-name NAME Name this Mac in Iteration",
65
+ " --device-name NAME Name this computer in Iteration",
66
66
  " --organization-id UUID Select a workspace without a prompt",
67
67
  " --codex-home DIR Use a different Codex data directory",
68
68
  " --claude-home DIR Use a different Claude data directory",
@@ -118,7 +118,7 @@ async function defaultCommand() {
118
118
  });
119
119
  const missing = discovered.filter((source) => (source === "codex" ? codex : claude).kind === "not_connected");
120
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`);
121
+ process.stdout.write(`\n${missing.map(collectorSourceLabel).join(" and ")} found on this computer but not connected. Run iteration connect to include ${missing.length === 1 ? "it" : "them"}.\n`);
122
122
  }
123
123
  return;
124
124
  }
@@ -135,16 +135,17 @@ function dataDirectory(source, values) {
135
135
  async function turnSummaryNotice(config) {
136
136
  try {
137
137
  await installTurnSummary(config);
138
- return ` A one-line friction summary now appears after agent turns in your repositories.${turnSummaryApprovalHint(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.";
139
142
  }
140
143
  catch (error) {
141
144
  return ` The turn summary could not be installed: ${error instanceof Error ? error.message : "unknown error"} Run iteration start ${collectorSource(config)} to retry it.`;
142
145
  }
143
146
  }
144
147
  async function initialize(source, values) {
145
- if (process.platform !== "darwin") {
146
- throw new Error("Automatic startup is currently supported on macOS only.");
147
- }
148
+ requireCollectorPlatform();
148
149
  const endpoint = optionalString(values, "endpoint") ?? DEFAULT_INGEST_ENDPOINT;
149
150
  const endpointUrl = validateCollectorEndpoint(endpoint);
150
151
  const deviceName = optionalString(values, "device-name") ?? os.hostname();
@@ -160,6 +161,7 @@ async function initialize(source, values) {
160
161
  path.join(os.homedir(), ".claude")));
161
162
  await assertFreshCollectorDirectory(paths, source);
162
163
  const preparedState = await prepareSourceInitialState(source, sourceHome, true);
164
+ await prepareBackgroundPersistence();
163
165
  const enrollment = await authenticateAndEnrollCollector(endpointUrl, deviceName, organizationId, source);
164
166
  const connection = {
165
167
  endpoint: endpointUrl.toString(),
@@ -179,7 +181,7 @@ async function initialize(source, values) {
179
181
  throw new Error(`Collector access was approved, but local setup did not finish. Run iteration start ${source} to repair it.`, { cause: error });
180
182
  }
181
183
  try {
182
- await installLaunchAgent(directory, source);
184
+ await installBackgroundService(directory, source);
183
185
  }
184
186
  catch (error) {
185
187
  throw new Error(`Collector access was approved, but background startup failed. Run iteration start ${source} to retry it.`, { cause: error });
@@ -191,8 +193,8 @@ async function initialize(source, values) {
191
193
  async function approveAllSources(sources, workspaceName, yes) {
192
194
  const names = sources.map(collectorSourceLabel).join(" and ");
193
195
  const safeName = safeWorkspaceName(workspaceName);
194
- process.stdout.write(`Detected ${names || "no saved agent sessions"} on this Mac.\n` +
195
- `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`);
196
+ process.stdout.write(`Detected ${names || "no saved agent sessions"} on this computer.\n` +
197
+ `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.${process.platform === "linux" ? " Collection will continue after you log out." : ""}\n`);
196
198
  if (yes)
197
199
  return;
198
200
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -214,9 +216,7 @@ async function approveAllSources(sources, workspaceName, yes) {
214
216
  }
215
217
  }
216
218
  async function connectAll(values) {
217
- if (process.platform !== "darwin") {
218
- throw new Error("Automatic startup is currently supported on macOS only.");
219
- }
219
+ requireCollectorPlatform();
220
220
  if (values["data-directory"] !== undefined) {
221
221
  throw new Error("--data-directory requires a source-specific connect command.");
222
222
  }
@@ -234,7 +234,7 @@ async function connectAll(values) {
234
234
  const detected = await detectedSources(sourceHomes);
235
235
  const existing = COLLECTOR_SOURCES.flatMap((source, index) => configs[index] ? [{ source, config: configs[index] }] : []);
236
236
  if (detected.length === 0 && existing.length === 0) {
237
- process.stdout.write("No supported local coding agents found yet. Run iteration connect after using Codex or Claude Code on this Mac.\n");
237
+ process.stdout.write("No supported local coding agents found yet. Run iteration connect after using Codex or Claude Code on this computer.\n");
238
238
  return;
239
239
  }
240
240
  const anchorSource = existing[0]?.source ?? detected[0];
@@ -272,6 +272,7 @@ async function connectAll(values) {
272
272
  previousConsent.sourceHomes.claude !== sourceHomes.claude) {
273
273
  await approveAllSources(detected, existingScopes[0]?.scope.organizationName ?? null, values.yes === true);
274
274
  }
275
+ await prepareBackgroundPersistence();
275
276
  let anchor = anchorExisting ?? (await initialize(anchorSource, values));
276
277
  let organizationId = requestedOrganizationId ??
277
278
  existingScopes[0]?.scope.organizationId ??
@@ -307,16 +308,14 @@ async function connectAll(values) {
307
308
  });
308
309
  for (const item of existing) {
309
310
  await ensureStartableState(item.config, collectorPaths(item.config.dataDirectory));
310
- await installLaunchAgent(item.config.dataDirectory, item.source);
311
+ await installBackgroundService(item.config.dataDirectory, item.source);
311
312
  const notice = await turnSummaryNotice(item.config);
312
313
  process.stdout.write(`${collectorSourceLabel(item.source)} is still connected.${notice}\n`);
313
314
  }
314
- process.stdout.write("Detected agents are connected. New supported agents on this Mac will connect automatically.\n");
315
+ process.stdout.write("Detected agents are connected. New supported agents on this computer will connect automatically.\n");
315
316
  }
316
317
  async function importHistory(source, values) {
317
- if (process.platform !== "darwin") {
318
- throw new Error("Automatic startup is currently supported on macOS only.");
319
- }
318
+ requireCollectorPlatform();
320
319
  const directory = dataDirectory(source, values);
321
320
  const paths = collectorPaths(directory);
322
321
  const config = await loadConfig(paths);
@@ -332,16 +331,14 @@ async function importHistory(source, values) {
332
331
  process.stdout.write(`History scan scheduled for ${result.size.files} local session files (${result.size.bytes} bytes). Scanning and uploads run in bounded batches and may take time. Older sessions appear under the date Iteration receives them. Use iteration status ${source} to see local scan progress and pending uploads.${result.backgroundRunning ? "" : ` The collector was not running; run iteration start ${source} to begin scanning.`}\n`);
333
332
  }
334
333
  async function start(source, values) {
335
- if (process.platform !== "darwin") {
336
- throw new Error("Automatic startup is currently supported on macOS only.");
337
- }
334
+ requireCollectorPlatform();
338
335
  const directory = dataDirectory(source, values);
339
336
  const paths = collectorPaths(directory);
340
337
  const config = await loadConfig(paths);
341
338
  if (collectorSource(config) !== source)
342
339
  throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
343
340
  const repairedState = await ensureStartableState(config, paths);
344
- await installLaunchAgent(directory, source);
341
+ await installBackgroundService(directory, source);
345
342
  const notice = await turnSummaryNotice(config);
346
343
  process.stdout.write(`${collectorSourceLabel(source)} is collecting again. Run iteration to check progress.${repairedState ? " Incomplete setup was repaired." : ""}${notice}\n`);
347
344
  }
@@ -360,12 +357,13 @@ async function statusSnapshot(source, values) {
360
357
  const config = await loadConfig(paths);
361
358
  if (collectorSource(config) !== source)
362
359
  throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
363
- const [running, size, ids, scan, localHealth] = await Promise.all([
364
- launchAgentStatus(source),
360
+ const [running, size, ids, scan, localHealth, approval] = await Promise.all([
361
+ backgroundServiceStatus(source),
365
362
  outboxSize(paths),
366
363
  pendingEventIds(paths),
367
364
  historyScanProgress(collectorSourceDirectory(config), paths, source),
368
365
  healthSnapshot(paths),
366
+ codexTurnSummaryApproval(config),
369
367
  ]);
370
368
  const pendingEvents = size === 0 ? 0 : ids.size;
371
369
  const pendingBytes = ids.size === 0 ? 0 : size;
@@ -378,8 +376,9 @@ async function statusSnapshot(source, values) {
378
376
  pendingBytes,
379
377
  health: localHealth.health,
380
378
  healthAvailable: localHealth.kind === "available",
379
+ turnSummaryNeedsApproval: approval === "needs_approval",
381
380
  tracesUrl: new URL("/app/sessions", config.endpoint).toString(),
382
- logPath: path.join(os.homedir(), "Library", "Logs", `iteration-${source}-collector.error.log`),
381
+ logPath: collectorLogPath(source),
383
382
  };
384
383
  }
385
384
  async function status(source, values) {
@@ -439,7 +438,7 @@ async function main() {
439
438
  if (command === "start" && selectedSource !== null)
440
439
  return start(selectedSource, values);
441
440
  if (command === "disconnect" && selectedSource !== null) {
442
- await uninstallLaunchAgent(selectedSource);
441
+ await uninstallBackgroundService(selectedSource);
443
442
  const paths = collectorPaths(dataDirectory(selectedSource, values));
444
443
  if (await fileExists(paths.config)) {
445
444
  await uninstallTurnSummary(await loadConfig(paths));
@@ -1,16 +1,16 @@
1
1
  import path from "node:path";
2
- import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
2
+ import { backgroundServiceStatus, installBackgroundService, uninstallBackgroundService, } from "./background-service.js";
3
3
  import { waitForCollectorLock } from "./service.js";
4
4
  import { collectorSource, collectorSourceDirectory, historyImportSize, loadState, resetHistoryCursors, } from "./storage.js";
5
- function macControl(source) {
5
+ function backgroundControl(source) {
6
6
  return {
7
- isRunning: () => launchAgentStatus(source),
8
- stop: () => uninstallLaunchAgent(source),
9
- start: (directory) => installLaunchAgent(directory, source),
7
+ isRunning: () => backgroundServiceStatus(source),
8
+ stop: () => uninstallBackgroundService(source),
9
+ start: (directory) => installBackgroundService(directory, source),
10
10
  acquireLock: waitForCollectorLock,
11
11
  };
12
12
  }
13
- export async function importSourceHistory(config, paths, confirmed, control = macControl(collectorSource(config))) {
13
+ export async function importSourceHistory(config, paths, confirmed, control = backgroundControl(collectorSource(config))) {
14
14
  const source = collectorSource(config);
15
15
  const directory = collectorSourceDirectory(config);
16
16
  const state = await loadState(paths);
@@ -22,6 +22,7 @@ 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
  }>;
@@ -1,4 +1,5 @@
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
  }
@@ -43,7 +44,7 @@ function relativeTime(atMs, nowMs) {
43
44
  return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ago`;
44
45
  }
45
46
  function pendingDescription(status) {
46
- return `${status.pendingEvents} event${status.pendingEvents === 1 ? "" : "s"} (${readableBytes(status.pendingBytes)}) waiting on this Mac`;
47
+ return `${status.pendingEvents} event${status.pendingEvents === 1 ? "" : "s"} (${readableBytes(status.pendingBytes)}) waiting on this computer`;
47
48
  }
48
49
  function progressDescription(scan, source) {
49
50
  if (scan.kind === "waiting")
@@ -82,7 +83,7 @@ function failureDescription(status, source) {
82
83
  if (failure.status === 401 || failure.status === 403) {
83
84
  return {
84
85
  heading: `${name} upload access needs attention`,
85
- explanation: `Iteration denied this collector (${code}). Queued data stays on this Mac. Contact Iteration support to reconnect it safely.`,
86
+ explanation: `Iteration denied this collector (${code}). Queued data stays on this computer. Contact Iteration support to reconnect it safely.`,
86
87
  };
87
88
  }
88
89
  if (failure.status !== null &&
@@ -132,8 +133,8 @@ export function renderHome(status, nowMs = Date.now(), source = "codex") {
132
133
  `${name} connection needs repair`,
133
134
  "Local collector data exists, but its connection details are missing.",
134
135
  status.pendingBytes > 0
135
- ? `${readableBytes(status.pendingBytes)} of queued data remains on this Mac.`
136
- : "Existing local progress remains on this Mac.",
136
+ ? `${readableBytes(status.pendingBytes)} of queued data remains on this computer.`
137
+ : "Existing local progress remains on this computer.",
137
138
  `Run iteration status ${source} for recovery details.`,
138
139
  ].join("\n");
139
140
  }
@@ -183,7 +184,7 @@ export function renderHome(status, nowMs = Date.now(), source = "codex") {
183
184
  lines.push(`${name} is uploading captured sessions`, `${pendingDescription(status)}.`);
184
185
  }
185
186
  else if (status.scan.totalBytes === 0) {
186
- lines.push(`${name} is connected`, `Waiting for ${name} sessions on this Mac.`);
187
+ lines.push(`${name} is connected`, `Waiting for ${name} sessions on this computer.`);
187
188
  }
188
189
  else if (status.scan.kind === "scanning" &&
189
190
  status.scan.scannedBytes < status.scan.totalBytes) {
@@ -197,6 +198,8 @@ 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
  }
@@ -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}`);
@@ -0,0 +1,7 @@
1
+ import type { CollectorSource } from "iteration-trace-contracts";
2
+ export declare function ensureSystemdUserLinger(): Promise<void>;
3
+ export declare function systemdLogPath(source: CollectorSource): string;
4
+ export declare function systemdUserUnit(cliPath?: string, nodePath?: string, dataDirectory?: string, source?: CollectorSource, outputPath?: string, errorPath?: string): string;
5
+ export declare function installSystemdUserService(dataDirectory: string, source: CollectorSource): Promise<string>;
6
+ export declare function uninstallSystemdUserService(source: CollectorSource): Promise<void>;
7
+ export declare function systemdUserServiceStatus(source: CollectorSource): Promise<boolean>;
@@ -0,0 +1,137 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ const execFile = promisify(execFileCallback);
8
+ const SYSTEMCTL_TIMEOUT_MS = 10_000;
9
+ const LOGINCTL_TIMEOUT_MS = 10_000;
10
+ function currentUserId() {
11
+ const userId = process.getuid?.();
12
+ if (userId === undefined)
13
+ throw new Error("Could not determine the current Linux user id.");
14
+ return String(userId);
15
+ }
16
+ async function lingerState(userId) {
17
+ const { stdout } = await execFile("loginctl", ["show-user", userId, "--property=Linger", "--value"], { timeout: LOGINCTL_TIMEOUT_MS });
18
+ const state = stdout.trim();
19
+ if (state === "yes" || state === "no")
20
+ return state;
21
+ throw new Error(`Could not read Linux background service setting: ${state || "empty response"}.`);
22
+ }
23
+ export async function ensureSystemdUserLinger() {
24
+ const userId = currentUserId();
25
+ let state = "no";
26
+ let queryFailure;
27
+ try {
28
+ state = await lingerState(userId);
29
+ }
30
+ catch (error) {
31
+ queryFailure = error;
32
+ }
33
+ if (state === "yes")
34
+ return;
35
+ try {
36
+ await execFile("loginctl", ["--no-ask-password", "enable-linger", userId], {
37
+ timeout: LOGINCTL_TIMEOUT_MS,
38
+ });
39
+ }
40
+ catch (userError) {
41
+ try {
42
+ await execFile("sudo", ["-n", "loginctl", "enable-linger", userId], {
43
+ timeout: LOGINCTL_TIMEOUT_MS,
44
+ });
45
+ }
46
+ catch (adminError) {
47
+ throw new Error("This server does not allow Iteration to keep collecting after you log out. Ask an administrator to allow background services for your account, then run iteration connect again.", {
48
+ cause: new AggregateError(queryFailure
49
+ ? [queryFailure, userError, adminError]
50
+ : [userError, adminError], "Could not enable Linux background collection."),
51
+ });
52
+ }
53
+ }
54
+ try {
55
+ if ((await lingerState(userId)) === "yes")
56
+ return;
57
+ }
58
+ catch (error) {
59
+ throw new Error("Could not verify Linux background collection after logout.", {
60
+ cause: error,
61
+ });
62
+ }
63
+ throw new Error("Linux did not enable background collection after logout.");
64
+ }
65
+ function unitName(source) {
66
+ return `iteration-${source}-collector.service`;
67
+ }
68
+ function unitPath(source) {
69
+ return path.join(os.homedir(), ".config", "systemd", "user", unitName(source));
70
+ }
71
+ export function systemdLogPath(source) {
72
+ return path.join(os.homedir(), ".config", "iteration", "logs", `iteration-${source}-collector.error.log`);
73
+ }
74
+ function unitValue(value) {
75
+ if (/[\r\n\0]/.test(value))
76
+ throw new Error("Collector paths cannot contain line breaks.");
77
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
78
+ }
79
+ export function systemdUserUnit(cliPath = fileURLToPath(new URL("./cli.js", import.meta.url)), nodePath = process.execPath, dataDirectory = path.join(os.homedir(), ".config", "iteration", "codex-collector"), source = "codex", outputPath = path.join(os.homedir(), ".config", "iteration", "logs", `iteration-${source}-collector.log`), errorPath = systemdLogPath(source)) {
80
+ return [
81
+ "[Unit]",
82
+ `Description=Iteration ${source} collector`,
83
+ "[Service]",
84
+ "Type=exec",
85
+ `ExecStart=${[nodePath, cliPath, "run", source, "--data-directory", dataDirectory].map(unitValue).join(" ")}`,
86
+ "Restart=always",
87
+ "RestartSec=5",
88
+ `StandardOutput=append:${unitValue(outputPath)}`,
89
+ `StandardError=append:${unitValue(errorPath)}`,
90
+ "[Install]",
91
+ "WantedBy=default.target",
92
+ "",
93
+ ].join("\n");
94
+ }
95
+ async function systemctl(...args) {
96
+ const { stdout } = await execFile("systemctl", ["--user", ...args], {
97
+ timeout: SYSTEMCTL_TIMEOUT_MS,
98
+ });
99
+ return stdout.trim();
100
+ }
101
+ export async function installSystemdUserService(dataDirectory, source) {
102
+ await ensureSystemdUserLinger();
103
+ const file = unitPath(source);
104
+ const logDirectory = path.dirname(systemdLogPath(source));
105
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
106
+ await fs.mkdir(logDirectory, { recursive: true, mode: 0o700 });
107
+ await fs.writeFile(file, systemdUserUnit(undefined, undefined, dataDirectory, source), {
108
+ encoding: "utf8",
109
+ mode: 0o644,
110
+ });
111
+ await systemctl("daemon-reload");
112
+ await systemctl("enable", unitName(source));
113
+ await systemctl("restart", unitName(source));
114
+ return file;
115
+ }
116
+ export async function uninstallSystemdUserService(source) {
117
+ const file = unitPath(source);
118
+ try {
119
+ await fs.access(file);
120
+ }
121
+ catch (error) {
122
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
123
+ return;
124
+ throw error;
125
+ }
126
+ await systemctl("disable", "--now", unitName(source));
127
+ await fs.rm(file);
128
+ await systemctl("daemon-reload");
129
+ }
130
+ export async function systemdUserServiceStatus(source) {
131
+ const state = await systemctl("show", "--property=ActiveState", "--value", unitName(source));
132
+ if (state === "active")
133
+ return true;
134
+ if (state === "inactive" || state === "failed")
135
+ return false;
136
+ throw new Error(`Could not determine ${source} collector service state: ${state || "empty response"}.`);
137
+ }
@@ -18,4 +18,5 @@ export declare function refreshTurnSummaries(config: CollectorConfig, refreshedA
18
18
  directory: string;
19
19
  atMs: number;
20
20
  }>>, nowMs: number, fetcher?: typeof fetch, signal?: AbortSignal): Promise<number>;
21
- export declare function turnSummaryApprovalHint(config: CollectorConfig): string;
21
+ export type TurnSummaryApproval = "approved" | "needs_approval" | "not_installed";
22
+ export declare function codexTurnSummaryApproval(config: CollectorConfig): Promise<TurnSummaryApproval>;
@@ -1,8 +1,9 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { promises as fs } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { isLaunchDirectory, parseCollectorFrictionSummary, } from "iteration-trace-contracts";
4
5
  import * as z from "zod";
5
- import { collectorSource, ensurePrivateDirectory, } from "./storage.js";
6
+ import { ensurePrivateDirectory } from "./storage.js";
6
7
  const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
7
8
  const REQUEST_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
8
9
  const MAX_REFRESHES_PER_PASS = 20;
@@ -295,8 +296,67 @@ export async function refreshTurnSummaries(config, refreshedAt, nowMs, fetcher =
295
296
  }
296
297
  return refreshed;
297
298
  }
298
- export function turnSummaryApprovalHint(config) {
299
- return collectorSource(config) === "codex"
300
- ? " Approve Iteration's turn summary once with /hooks in Codex."
301
- : "";
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";
302
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",