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