iteration-cli 0.4.4 → 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 +8 -2
- package/dist/auto-connect.d.ts +30 -0
- package/dist/auto-connect.js +220 -0
- package/dist/claude-transcript.d.ts +6 -0
- package/dist/claude-transcript.js +255 -0
- package/dist/clerk-device-auth.d.ts +3 -1
- package/dist/clerk-device-auth.js +7 -4
- package/dist/cli.js +269 -66
- package/dist/collector.d.ts +1 -1
- package/dist/collector.js +32 -14
- package/dist/history-import.d.ts +1 -1
- package/dist/history-import.js +18 -15
- package/dist/launch-agent.d.ts +5 -4
- package/dist/launch-agent.js +26 -23
- package/dist/rollout.js +122 -135
- package/dist/service.d.ts +2 -1
- package/dist/service.js +59 -5
- package/dist/status-view.d.ts +4 -2
- package/dist/status-view.js +44 -37
- package/dist/storage.d.ts +22 -10
- package/dist/storage.js +144 -51
- 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,10 +4,16 @@ Install one CLI for Iteration's supported coding-agent sources:
|
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install --global iteration-cli
|
|
7
|
-
iteration
|
|
7
|
+
iteration connect
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
The
|
|
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.
|
|
11
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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.
|
|
11
17
|
|
|
12
18
|
If you previously installed the Codex-specific package, remove it with the package manager you used:
|
|
13
19
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type TraceEvent } from "iteration-trace-contracts";
|
|
2
|
+
export type ClaudeParseResult = Readonly<{
|
|
3
|
+
events: readonly TraceEvent[];
|
|
4
|
+
gaps: readonly string[];
|
|
5
|
+
}>;
|
|
6
|
+
export declare function parseClaudeLine(line: string, filename: string, byteOffset: number): ClaudeParseResult;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { parseTraceEventBatch, } from "iteration-trace-contracts";
|
|
5
|
+
const CONTEXT_RECORD_TYPES = new Set([
|
|
6
|
+
"attachment",
|
|
7
|
+
"system",
|
|
8
|
+
"last-prompt",
|
|
9
|
+
"mode",
|
|
10
|
+
"permission-mode",
|
|
11
|
+
"atis-latch",
|
|
12
|
+
"ai-title",
|
|
13
|
+
"file-history-snapshot",
|
|
14
|
+
"file-history-delta",
|
|
15
|
+
"cost-state",
|
|
16
|
+
"queue-operation",
|
|
17
|
+
]);
|
|
18
|
+
function object(value) {
|
|
19
|
+
const parsed = z.record(z.string(), z.json()).safeParse(value);
|
|
20
|
+
return parsed.success ? parsed.data : null;
|
|
21
|
+
}
|
|
22
|
+
function string(value) {
|
|
23
|
+
const parsed = z.string().safeParse(value);
|
|
24
|
+
return parsed.success ? parsed.data : null;
|
|
25
|
+
}
|
|
26
|
+
function digest(value) {
|
|
27
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
28
|
+
}
|
|
29
|
+
const URL_NAMESPACE = Buffer.from("6ba7b8119dad11d180b400c04fd430c8", "hex");
|
|
30
|
+
function childThreadId(parentId, agentId) {
|
|
31
|
+
const bytes = createHash("sha1")
|
|
32
|
+
.update(URL_NAMESPACE)
|
|
33
|
+
.update(`https://github.com/Thytu/Iteration/claude/${parentId}/agent/${agentId}`)
|
|
34
|
+
.digest()
|
|
35
|
+
.subarray(0, 16);
|
|
36
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
37
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
38
|
+
const hex = bytes.toString("hex");
|
|
39
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
40
|
+
}
|
|
41
|
+
const SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
42
|
+
function claudeSessionIdentity(filename) {
|
|
43
|
+
if (!filename.endsWith(".jsonl"))
|
|
44
|
+
return null;
|
|
45
|
+
const basename = path.basename(filename, ".jsonl");
|
|
46
|
+
if (path.basename(path.dirname(filename)) === "subagents") {
|
|
47
|
+
const parent = path.basename(path.dirname(path.dirname(filename)));
|
|
48
|
+
if (!basename.startsWith("agent-") || !SESSION_UUID.test(parent))
|
|
49
|
+
return null;
|
|
50
|
+
const agentId = basename.slice("agent-".length);
|
|
51
|
+
if (agentId.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
const threadId = childThreadId(parent, agentId);
|
|
54
|
+
const sessionId = `claude:${threadId}`;
|
|
55
|
+
const runId = `${parent}/agent/${agentId}`;
|
|
56
|
+
if (sessionId.length > 200 || runId.length > 200)
|
|
57
|
+
return null;
|
|
58
|
+
return {
|
|
59
|
+
sessionId,
|
|
60
|
+
parentSessionId: `claude:${parent}`,
|
|
61
|
+
agentId,
|
|
62
|
+
runId,
|
|
63
|
+
parentRunId: parent,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const sessionId = `claude:${basename}`;
|
|
67
|
+
return sessionId.length <= 200 && SESSION_UUID.test(basename)
|
|
68
|
+
? {
|
|
69
|
+
sessionId,
|
|
70
|
+
parentSessionId: null,
|
|
71
|
+
agentId: null,
|
|
72
|
+
runId: basename,
|
|
73
|
+
parentRunId: null,
|
|
74
|
+
}
|
|
75
|
+
: null;
|
|
76
|
+
}
|
|
77
|
+
function timestamp(record) {
|
|
78
|
+
const value = string(record.timestamp);
|
|
79
|
+
return value !== null &&
|
|
80
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) &&
|
|
81
|
+
!Number.isNaN(Date.parse(value))
|
|
82
|
+
? value
|
|
83
|
+
: null;
|
|
84
|
+
}
|
|
85
|
+
function event(identity, record, byteOffset, index, eventType, payload, callId = null, stableKey) {
|
|
86
|
+
return {
|
|
87
|
+
eventId: `${String(index).padStart(4, "0")}:${digest(`${identity.sessionId}:${stableKey ?? string(record.uuid) ?? `${byteOffset}:${JSON.stringify(record)}`}`)}`,
|
|
88
|
+
sessionId: identity.sessionId,
|
|
89
|
+
sequence: byteOffset,
|
|
90
|
+
callId,
|
|
91
|
+
projectId: null,
|
|
92
|
+
occurredAt: timestamp(record),
|
|
93
|
+
eventType,
|
|
94
|
+
payload,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function contentText(value) {
|
|
98
|
+
const literal = string(value);
|
|
99
|
+
if (literal !== null)
|
|
100
|
+
return literal;
|
|
101
|
+
if (!Array.isArray(value))
|
|
102
|
+
return null;
|
|
103
|
+
const parts = value.flatMap((part) => {
|
|
104
|
+
const block = object(part);
|
|
105
|
+
const text = block === null ? null : string(block.text);
|
|
106
|
+
return text === null ? [] : [text];
|
|
107
|
+
});
|
|
108
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
109
|
+
}
|
|
110
|
+
export function parseClaudeLine(line, filename, byteOffset) {
|
|
111
|
+
const identity = claudeSessionIdentity(filename);
|
|
112
|
+
if (identity === null)
|
|
113
|
+
return { events: [], gaps: ["invalid_claude_session_path"] };
|
|
114
|
+
let decoded;
|
|
115
|
+
try {
|
|
116
|
+
decoded = JSON.parse(line);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { events: [], gaps: ["malformed_jsonl_record"] };
|
|
120
|
+
}
|
|
121
|
+
const record = object(decoded);
|
|
122
|
+
if (record === null)
|
|
123
|
+
return { events: [], gaps: ["record_is_not_an_object"] };
|
|
124
|
+
const recordType = string(record.type);
|
|
125
|
+
const sourceSessionId = string(record.sessionId);
|
|
126
|
+
if ((identity.agentId !== null && sourceSessionId === null) ||
|
|
127
|
+
(sourceSessionId !== null &&
|
|
128
|
+
`claude:${sourceSessionId}` !==
|
|
129
|
+
(identity.parentSessionId ?? identity.sessionId))) {
|
|
130
|
+
return { events: [], gaps: ["session_id_does_not_match_path"] };
|
|
131
|
+
}
|
|
132
|
+
if (identity.agentId !== null && string(record.agentId) !== identity.agentId)
|
|
133
|
+
return { events: [], gaps: ["agent_id_does_not_match_path"] };
|
|
134
|
+
const gaps = [];
|
|
135
|
+
if (record.timestamp !== undefined && timestamp(record) === null)
|
|
136
|
+
gaps.push("invalid_source_timestamp");
|
|
137
|
+
const events = [];
|
|
138
|
+
const meta = {
|
|
139
|
+
id: identity.runId,
|
|
140
|
+
parent_thread_id: identity.parentRunId,
|
|
141
|
+
cwd: string(record.cwd),
|
|
142
|
+
git: { branch: string(record.gitBranch) },
|
|
143
|
+
version: string(record.version),
|
|
144
|
+
agentId: identity.agentId,
|
|
145
|
+
};
|
|
146
|
+
events.push({
|
|
147
|
+
...event(identity, record, 0, 0, "session_meta", meta, null, "session_meta"),
|
|
148
|
+
run: {
|
|
149
|
+
kind: "identity",
|
|
150
|
+
id: identity.runId,
|
|
151
|
+
parentId: identity.parentRunId,
|
|
152
|
+
ownStart: true,
|
|
153
|
+
startOrdinal: null,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
if (recordType === "user") {
|
|
157
|
+
const message = object(record.message);
|
|
158
|
+
const content = message === null ? null : string(message.content);
|
|
159
|
+
if (message === null)
|
|
160
|
+
gaps.push("user_without_message");
|
|
161
|
+
else if (content !== null) {
|
|
162
|
+
events.push(event(identity, record, byteOffset, 1, "user_message", {
|
|
163
|
+
text: content,
|
|
164
|
+
record,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
else if (Array.isArray(message.content)) {
|
|
168
|
+
for (const [index, value] of message.content.entries()) {
|
|
169
|
+
const block = object(value);
|
|
170
|
+
if (block === null) {
|
|
171
|
+
gaps.push("invalid_user_content_block");
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (block.type === "tool_result") {
|
|
175
|
+
const callId = string(block.tool_use_id);
|
|
176
|
+
if (callId === null) {
|
|
177
|
+
gaps.push("tool_result_without_call_id");
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
events.push(event(identity, record, byteOffset, index + 1, "tool_result", {
|
|
181
|
+
text: contentText(block.content),
|
|
182
|
+
record,
|
|
183
|
+
}, callId));
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
events.push(event(identity, record, byteOffset, index + 1, "user_message", {
|
|
187
|
+
text: string(block.text) ?? contentText(block.content),
|
|
188
|
+
record,
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else
|
|
194
|
+
gaps.push("unsupported_user_content");
|
|
195
|
+
}
|
|
196
|
+
else if (recordType === "assistant") {
|
|
197
|
+
const message = object(record.message);
|
|
198
|
+
if (message === null || !Array.isArray(message.content))
|
|
199
|
+
gaps.push("assistant_without_content_blocks");
|
|
200
|
+
else {
|
|
201
|
+
for (const [index, value] of message.content.entries()) {
|
|
202
|
+
const block = object(value);
|
|
203
|
+
if (block === null) {
|
|
204
|
+
gaps.push("invalid_assistant_content_block");
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const blockType = string(block.type);
|
|
208
|
+
const eventType = blockType === "text"
|
|
209
|
+
? "assistant_message"
|
|
210
|
+
: blockType === "thinking" || blockType === "redacted_thinking"
|
|
211
|
+
? "reasoning"
|
|
212
|
+
: blockType === "tool_use"
|
|
213
|
+
? "tool_call"
|
|
214
|
+
: null;
|
|
215
|
+
if (eventType === null) {
|
|
216
|
+
gaps.push(`unsupported_assistant_block:${blockType ?? "unknown"}`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const callId = eventType === "tool_call" ? string(block.id) : null;
|
|
220
|
+
if (eventType === "tool_call" && callId === null) {
|
|
221
|
+
gaps.push("tool_call_without_call_id");
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
events.push(event(identity, record, byteOffset, index + 1, eventType, {
|
|
225
|
+
text: string(block.text) ?? string(block.thinking),
|
|
226
|
+
name: string(block.name),
|
|
227
|
+
record,
|
|
228
|
+
}, callId));
|
|
229
|
+
}
|
|
230
|
+
if (object(message.usage) !== null && string(message.id) !== null) {
|
|
231
|
+
events.push(event(identity, record, byteOffset, 9999, "token_usage_record", { model: string(message.model), usage: message.usage }, null, `usage:${message.id}`));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else if (recordType !== null && CONTEXT_RECORD_TYPES.has(recordType)) {
|
|
236
|
+
const attachment = object(record.attachment);
|
|
237
|
+
events.push(event(identity, record, byteOffset, 1, "source_record", {
|
|
238
|
+
sourceType: recordType,
|
|
239
|
+
subtype: string(record.subtype) ??
|
|
240
|
+
(attachment === null ? null : string(attachment.type)),
|
|
241
|
+
record,
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
else
|
|
245
|
+
gaps.push(`unsupported_claude_record:${recordType ?? "unknown"}`);
|
|
246
|
+
const valid = [];
|
|
247
|
+
for (const candidate of events) {
|
|
248
|
+
const checked = parseTraceEventBatch({ events: [candidate] });
|
|
249
|
+
if (checked.success)
|
|
250
|
+
valid.push(candidate);
|
|
251
|
+
else
|
|
252
|
+
gaps.push(`invalid_normalized_event:${candidate.eventType}`);
|
|
253
|
+
}
|
|
254
|
+
return { events: valid, gaps };
|
|
255
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
import { type CollectorSource } from "iteration-trace-contracts";
|
|
2
|
+
export declare function authenticateAndEnrollCollector(endpoint: URL, deviceNameValue: string, organizationId: string | null, source?: CollectorSource): Promise<Readonly<{
|
|
2
3
|
token: string;
|
|
3
4
|
deviceId: string;
|
|
5
|
+
organizationId: string;
|
|
4
6
|
organizationName: string;
|
|
5
7
|
}>>;
|
|
@@ -186,8 +186,10 @@ async function clerkDeviceSignIn(configuration) {
|
|
|
186
186
|
await openBrowser(verificationUrl);
|
|
187
187
|
return pollForAccessToken(endpoints.tokenUrl, configuration.clientId, challenge.device_code, challenge.expires_in, challenge.interval);
|
|
188
188
|
}
|
|
189
|
-
async function enrollDevice(endpoint, accessToken, deviceName, organizationId) {
|
|
189
|
+
async function enrollDevice(endpoint, accessToken, deviceName, organizationId, source) {
|
|
190
190
|
const body = { deviceName };
|
|
191
|
+
if (source === "claude")
|
|
192
|
+
body.source = source;
|
|
191
193
|
if (organizationId !== null)
|
|
192
194
|
body.organizationId = organizationId;
|
|
193
195
|
const response = await fetch(apiRoute(endpoint, "./collector-auth/enroll"), {
|
|
@@ -248,7 +250,7 @@ async function chooseOrganization(organizations, hasMore) {
|
|
|
248
250
|
prompt.close();
|
|
249
251
|
}
|
|
250
252
|
}
|
|
251
|
-
export async function authenticateAndEnrollCollector(endpoint, deviceNameValue, organizationId) {
|
|
253
|
+
export async function authenticateAndEnrollCollector(endpoint, deviceNameValue, organizationId, source = "codex") {
|
|
252
254
|
const deviceName = z
|
|
253
255
|
.string()
|
|
254
256
|
.trim()
|
|
@@ -260,10 +262,10 @@ export async function authenticateAndEnrollCollector(endpoint, deviceNameValue,
|
|
|
260
262
|
}
|
|
261
263
|
const configuration = await loadOAuthConfiguration(endpoint);
|
|
262
264
|
const clerkAccessToken = await clerkDeviceSignIn(configuration);
|
|
263
|
-
let result = await enrollDevice(endpoint, clerkAccessToken, deviceName.data, organizationId);
|
|
265
|
+
let result = await enrollDevice(endpoint, clerkAccessToken, deviceName.data, organizationId, source);
|
|
264
266
|
if (result.status === "choose_organization") {
|
|
265
267
|
const selectedOrganizationId = await chooseOrganization(result.organizations, result.hasMore);
|
|
266
|
-
result = await enrollDevice(endpoint, clerkAccessToken, deviceName.data, selectedOrganizationId);
|
|
268
|
+
result = await enrollDevice(endpoint, clerkAccessToken, deviceName.data, selectedOrganizationId, source);
|
|
267
269
|
}
|
|
268
270
|
if (result.status !== "enrolled") {
|
|
269
271
|
throw new Error("The collector could not be assigned to that workspace.");
|
|
@@ -271,6 +273,7 @@ export async function authenticateAndEnrollCollector(endpoint, deviceNameValue,
|
|
|
271
273
|
return {
|
|
272
274
|
token: result.token,
|
|
273
275
|
deviceId: result.deviceId,
|
|
276
|
+
organizationId: result.organizationId,
|
|
274
277
|
organizationName: result.organizationName,
|
|
275
278
|
};
|
|
276
279
|
}
|