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 +1 -1
- package/dist/auto-connect.js +2 -2
- package/dist/background-service.d.ts +7 -0
- package/dist/background-service.js +37 -0
- package/dist/cli.js +28 -29
- package/dist/history-import.js +6 -6
- package/dist/status-view.d.ts +1 -0
- package/dist/status-view.js +10 -5
- package/dist/systemd-user.d.ts +7 -0
- package/dist/systemd-user.js +137 -0
- package/dist/turn-summary.d.ts +2 -1
- package/dist/turn-summary.js +65 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ npm install --global iteration-cli
|
|
|
7
7
|
iteration connect
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
The collectors
|
|
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
|
|
package/dist/auto-connect.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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,
|
|
15
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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));
|
package/dist/history-import.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
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
|
|
5
|
+
function backgroundControl(source) {
|
|
6
6
|
return {
|
|
7
|
-
isRunning: () =>
|
|
8
|
-
stop: () =>
|
|
9
|
-
start: (directory) =>
|
|
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 =
|
|
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);
|
package/dist/status-view.d.ts
CHANGED
package/dist/status-view.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
136
|
-
: "Existing local progress remains on this
|
|
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
|
|
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
|
+
}
|
package/dist/turn-summary.d.ts
CHANGED
|
@@ -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
|
|
21
|
+
export type TurnSummaryApproval = "approved" | "needs_approval" | "not_installed";
|
|
22
|
+
export declare function codexTurnSummaryApproval(config: CollectorConfig): Promise<TurnSummaryApproval>;
|
package/dist/turn-summary.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
299
|
-
return
|
|
300
|
-
|
|
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
|
}
|