@indigoai-us/hq-cloud 6.0.3 → 6.2.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/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +18 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/reindex.d.ts +4 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +336 -30
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.d.ts +3 -3
- package/dist/cli/reindex.test.js +36 -11
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +36 -0
- package/dist/cli/rescue-core.d.ts.map +1 -0
- package/dist/cli/rescue-core.js +1536 -0
- package/dist/cli/rescue-core.js.map +1 -0
- package/dist/cli/rescue-drift-reconcile.test.js +33 -10
- package/dist/cli/rescue-drift-reconcile.test.js.map +1 -1
- package/dist/cli/rescue-mtime-preserve.test.js +36 -12
- package/dist/cli/rescue-mtime-preserve.test.js.map +1 -1
- package/dist/cli/rescue.d.ts +4 -10
- package/dist/cli/rescue.d.ts.map +1 -1
- package/dist/cli/rescue.js +14 -37
- package/dist/cli/rescue.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +9 -8
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/rescue.test.js +1 -10
- package/dist/cli/rescue.test.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/conflict-index.d.ts +40 -0
- package/dist/lib/conflict-index.d.ts.map +1 -1
- package/dist/lib/conflict-index.js +121 -0
- package/dist/lib/conflict-index.js.map +1 -1
- package/dist/lib/conflict.test.js +145 -1
- package/dist/lib/conflict.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +27 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +117 -4
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +159 -7
- package/dist/skill-telemetry.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.ts +18 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/reindex.test.ts +45 -12
- package/src/cli/reindex.ts +345 -36
- package/src/cli/rescue-core.ts +1650 -0
- package/src/cli/rescue-drift-reconcile.test.ts +33 -12
- package/src/cli/rescue-mtime-preserve.test.ts +36 -15
- package/src/cli/rescue.reindex.test.ts +9 -8
- package/src/cli/rescue.test.ts +1 -11
- package/src/cli/rescue.ts +15 -40
- package/src/index.ts +2 -2
- package/src/lib/conflict-index.ts +146 -0
- package/src/lib/conflict.test.ts +171 -0
- package/src/skill-telemetry.test.ts +220 -7
- package/src/skill-telemetry.ts +129 -4
- package/scripts/reindex.sh +0 -318
- package/scripts/replace-rescue.sh +0 -1522
|
@@ -2,7 +2,12 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
extractSkillEvents,
|
|
7
|
+
extractCodexSkillEvents,
|
|
8
|
+
parseCodexSessionMeta,
|
|
9
|
+
collectAndSendSkillTelemetry,
|
|
10
|
+
} from "./skill-telemetry.js";
|
|
6
11
|
import type { SkillInvocationBatch } from "./vault-client.js";
|
|
7
12
|
|
|
8
13
|
describe("extractSkillEvents", () => {
|
|
@@ -96,6 +101,78 @@ describe("extractSkillEvents", () => {
|
|
|
96
101
|
});
|
|
97
102
|
});
|
|
98
103
|
|
|
104
|
+
describe("extractCodexSkillEvents (Codex CLI rollouts)", () => {
|
|
105
|
+
const ctx = { sessionId: "019ea34e-4a2b-7241-b8e3-1023abb57c6b", cwd: "/home/ec2-user/hq" };
|
|
106
|
+
|
|
107
|
+
it("extracts a typed slash skill from a user_message, attributing session ctx", () => {
|
|
108
|
+
const row = {
|
|
109
|
+
timestamp: "2026-06-07T18:17:57.722Z",
|
|
110
|
+
type: "event_msg",
|
|
111
|
+
payload: { type: "user_message", message: "/indigo:hello-world", images: [], local_images: [], text_elements: [] },
|
|
112
|
+
};
|
|
113
|
+
expect(extractCodexSkillEvents(row, ctx)).toEqual([
|
|
114
|
+
{
|
|
115
|
+
skill: "indigo:hello-world",
|
|
116
|
+
source: "typed",
|
|
117
|
+
sessionId: ctx.sessionId,
|
|
118
|
+
timestamp: "2026-06-07T18:17:57.722Z",
|
|
119
|
+
cwd: ctx.cwd,
|
|
120
|
+
uuid: `codex:${ctx.sessionId}:2026-06-07T18:17:57.722Z:indigo:hello-world`,
|
|
121
|
+
hasArgs: false,
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("sets hasArgs true when the command has trailing args, without leaking them", () => {
|
|
127
|
+
const row = {
|
|
128
|
+
timestamp: "2026-06-07T18:18:00.000Z",
|
|
129
|
+
type: "event_msg",
|
|
130
|
+
payload: { type: "user_message", message: "/indigo:signals refresh secret-arg-value" },
|
|
131
|
+
};
|
|
132
|
+
const ev = extractCodexSkillEvents(row, ctx)[0];
|
|
133
|
+
expect(ev).toMatchObject({ skill: "indigo:signals", source: "typed", hasArgs: true });
|
|
134
|
+
expect(JSON.stringify(ev)).not.toContain("secret-arg-value");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("rejects file-path-like and non-command user messages", () => {
|
|
138
|
+
const mk = (message: string) => ({ type: "event_msg", payload: { type: "user_message", message } });
|
|
139
|
+
expect(extractCodexSkillEvents(mk("/etc/hosts please read this"), ctx)).toEqual([]);
|
|
140
|
+
expect(extractCodexSkillEvents(mk("just a normal prompt"), ctx)).toEqual([]);
|
|
141
|
+
expect(extractCodexSkillEvents(mk(""), ctx)).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("ignores non-user_message rows (session_meta, lifecycle, assistant)", () => {
|
|
145
|
+
expect(
|
|
146
|
+
extractCodexSkillEvents({ type: "session_meta", payload: { id: "x", cwd: "/y" } }, ctx),
|
|
147
|
+
).toEqual([]);
|
|
148
|
+
expect(
|
|
149
|
+
extractCodexSkillEvents({ type: "event_msg", payload: { type: "task_started" } }, ctx),
|
|
150
|
+
).toEqual([]);
|
|
151
|
+
// The duplicate `response_item` (role:user) Codex also writes must NOT
|
|
152
|
+
// double-count — only the `event_msg` user_message is the signal.
|
|
153
|
+
expect(
|
|
154
|
+
extractCodexSkillEvents(
|
|
155
|
+
{ type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "/indigo:hello-world" }] } },
|
|
156
|
+
ctx,
|
|
157
|
+
),
|
|
158
|
+
).toEqual([]);
|
|
159
|
+
expect(extractCodexSkillEvents(null, ctx)).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("omits the synthesized uuid when session ctx is missing (safe-fail)", () => {
|
|
163
|
+
const row = { timestamp: "2026-06-07T18:17:57.722Z", type: "event_msg", payload: { type: "user_message", message: "/deploy" } };
|
|
164
|
+
expect(extractCodexSkillEvents(row, {})[0]).toMatchObject({ skill: "deploy", source: "typed", uuid: undefined });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("parseCodexSessionMeta reads id + cwd, and returns null for other rows", () => {
|
|
168
|
+
expect(
|
|
169
|
+
parseCodexSessionMeta({ type: "session_meta", payload: { id: "sess-x", cwd: "/home/ec2-user/hq", originator: "codex_exec" } }),
|
|
170
|
+
).toEqual({ sessionId: "sess-x", cwd: "/home/ec2-user/hq" });
|
|
171
|
+
expect(parseCodexSessionMeta({ type: "event_msg", payload: {} })).toBeNull();
|
|
172
|
+
expect(parseCodexSessionMeta(null)).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
99
176
|
describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
100
177
|
function stubClient(captured: SkillInvocationBatch[]) {
|
|
101
178
|
return {
|
|
@@ -146,6 +223,7 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
|
146
223
|
installerVersion: "test",
|
|
147
224
|
hqRoot,
|
|
148
225
|
claudeProjectsRoot: projects,
|
|
226
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
149
227
|
cursorPath: path.join(tmp, "cursor.json"),
|
|
150
228
|
});
|
|
151
229
|
|
|
@@ -172,9 +250,9 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
|
172
250
|
const captured: SkillInvocationBatch[] = [];
|
|
173
251
|
const client = stubClient(captured);
|
|
174
252
|
|
|
175
|
-
const r1 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
253
|
+
const r1 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
|
|
176
254
|
expect(r1.eventsSent).toBe(1);
|
|
177
|
-
const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
255
|
+
const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
|
|
178
256
|
expect(r2.eventsSent).toBe(0); // cursor at EOF → nothing re-sent
|
|
179
257
|
|
|
180
258
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
@@ -207,11 +285,11 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
|
207
285
|
},
|
|
208
286
|
};
|
|
209
287
|
|
|
210
|
-
const r1 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
288
|
+
const r1 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
|
|
211
289
|
expect(r1.eventsSent).toBe(0); // post threw → nothing committed
|
|
212
290
|
expect(captured).toHaveLength(0);
|
|
213
291
|
|
|
214
|
-
const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
292
|
+
const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
|
|
215
293
|
expect(r2.eventsSent).toBe(1); // re-scanned from offset 0 and delivered
|
|
216
294
|
expect(captured.flatMap((b) => b.events.map((e) => e.skill))).toEqual(["deploy"]);
|
|
217
295
|
|
|
@@ -233,7 +311,7 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
|
233
311
|
const captured: SkillInvocationBatch[] = [];
|
|
234
312
|
const client = stubClient(captured);
|
|
235
313
|
|
|
236
|
-
await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
314
|
+
await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
|
|
237
315
|
|
|
238
316
|
// New invocation appended after the first clean run.
|
|
239
317
|
await fs.appendFile(
|
|
@@ -242,7 +320,7 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
|
242
320
|
"utf-8",
|
|
243
321
|
);
|
|
244
322
|
|
|
245
|
-
const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
323
|
+
const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
|
|
246
324
|
expect(r2.eventsSent).toBe(1);
|
|
247
325
|
// Only the appended event — not the original — is re-sent.
|
|
248
326
|
const lastBatch = captured[captured.length - 1];
|
|
@@ -268,6 +346,7 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
|
268
346
|
machineId: "m-test",
|
|
269
347
|
installerVersion: "test",
|
|
270
348
|
claudeProjectsRoot: projects,
|
|
349
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
271
350
|
cursorPath: path.join(tmp, "cursor.json"),
|
|
272
351
|
});
|
|
273
352
|
|
|
@@ -276,4 +355,138 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
|
276
355
|
|
|
277
356
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
278
357
|
});
|
|
358
|
+
|
|
359
|
+
// ── Codex CLI rollouts ────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
// Build a realistic Codex rollout: a leading session_meta, the environment
|
|
362
|
+
// boilerplate Codex writes (which must be ignored), then the typed commands.
|
|
363
|
+
function codexRollout(sessionId: string, cwd: string, messages: string[]): string {
|
|
364
|
+
const lines = [
|
|
365
|
+
row({ timestamp: "2026-06-07T18:16:56.480Z", type: "session_meta", payload: { id: sessionId, cwd, originator: "codex_exec", cli_version: "0.137.0", source: "exec" } }),
|
|
366
|
+
row({ timestamp: "2026-06-07T18:16:56.718Z", type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "<environment_context>\n <cwd>" + cwd + "</cwd>\n</environment_context>" }] } }),
|
|
367
|
+
...messages.flatMap((message, i) => [
|
|
368
|
+
// Codex writes BOTH a response_item (role:user) and an event_msg for the
|
|
369
|
+
// same input; only the event_msg must be counted (no double-count).
|
|
370
|
+
row({ timestamp: `2026-06-07T18:17:0${i}.000Z`, type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: message }] } }),
|
|
371
|
+
row({ timestamp: `2026-06-07T18:17:0${i}.000Z`, type: "event_msg", payload: { type: "user_message", message, images: [], local_images: [], text_elements: [] } }),
|
|
372
|
+
]),
|
|
373
|
+
];
|
|
374
|
+
return lines.join("\n") + "\n";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
it("captures Codex typed slash skills scoped to hqRoot, excluding other cwds + dupes", async () => {
|
|
378
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-codex-"));
|
|
379
|
+
const codex = path.join(tmp, "sessions", "2026", "06", "07");
|
|
380
|
+
await fs.mkdir(codex, { recursive: true });
|
|
381
|
+
const hqRoot = "/home/ec2-user/hq";
|
|
382
|
+
|
|
383
|
+
await fs.writeFile(
|
|
384
|
+
path.join(codex, "rollout-2026-06-07T18-16-56-aaaa.jsonl"),
|
|
385
|
+
codexRollout("sess-codex-1", hqRoot, ["/indigo:hello-world", "/indigo:signals refresh"]),
|
|
386
|
+
"utf-8",
|
|
387
|
+
);
|
|
388
|
+
// A second rollout from a different repo — must be excluded by hqRoot scope.
|
|
389
|
+
await fs.writeFile(
|
|
390
|
+
path.join(codex, "rollout-2026-06-07T18-20-00-bbbb.jsonl"),
|
|
391
|
+
codexRollout("sess-codex-2", "/home/ec2-user/other", ["/secret-skill"]),
|
|
392
|
+
"utf-8",
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const captured: SkillInvocationBatch[] = [];
|
|
396
|
+
const result = await collectAndSendSkillTelemetry({
|
|
397
|
+
client: stubClient(captured),
|
|
398
|
+
machineId: "m",
|
|
399
|
+
installerVersion: "t",
|
|
400
|
+
hqRoot,
|
|
401
|
+
claudeProjectsRoot: path.join(tmp, "no-claude"),
|
|
402
|
+
codexSessionsRoot: path.join(tmp, "sessions"),
|
|
403
|
+
cursorPath: path.join(tmp, "cursor.json"),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const events = captured.flatMap((b) => b.events);
|
|
407
|
+
// No double-count from the duplicate response_item rows.
|
|
408
|
+
expect(result.eventsSent).toBe(2);
|
|
409
|
+
expect(events.map((e) => e.skill).sort()).toEqual(["indigo:hello-world", "indigo:signals"]);
|
|
410
|
+
// Codex invocations are recorded as the typed source.
|
|
411
|
+
expect(events.every((e) => e.source === "typed")).toBe(true);
|
|
412
|
+
// The other-repo Codex invocation never leaves the machine.
|
|
413
|
+
expect(events.map((e) => e.skill)).not.toContain("secret-skill");
|
|
414
|
+
|
|
415
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("captures Claude and Codex skills together in a single run", async () => {
|
|
419
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-both-"));
|
|
420
|
+
const projects = path.join(tmp, "projects", "-home-ec2-user-hq");
|
|
421
|
+
const codex = path.join(tmp, "sessions", "2026", "06", "07");
|
|
422
|
+
await fs.mkdir(projects, { recursive: true });
|
|
423
|
+
await fs.mkdir(codex, { recursive: true });
|
|
424
|
+
const hqRoot = "/home/ec2-user/hq";
|
|
425
|
+
|
|
426
|
+
await fs.writeFile(
|
|
427
|
+
path.join(projects, "a.jsonl"),
|
|
428
|
+
row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-07T10:00:00Z", cwd: hqRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:crm-management" } }] } }) + "\n",
|
|
429
|
+
"utf-8",
|
|
430
|
+
);
|
|
431
|
+
await fs.writeFile(
|
|
432
|
+
path.join(codex, "rollout-x.jsonl"),
|
|
433
|
+
codexRollout("sess-codex-1", hqRoot, ["/indigo:hello-world"]),
|
|
434
|
+
"utf-8",
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const captured: SkillInvocationBatch[] = [];
|
|
438
|
+
const result = await collectAndSendSkillTelemetry({
|
|
439
|
+
client: stubClient(captured),
|
|
440
|
+
machineId: "m",
|
|
441
|
+
installerVersion: "t",
|
|
442
|
+
hqRoot,
|
|
443
|
+
claudeProjectsRoot: path.join(tmp, "projects"),
|
|
444
|
+
codexSessionsRoot: path.join(tmp, "sessions"),
|
|
445
|
+
cursorPath: path.join(tmp, "cursor.json"),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
expect(result.eventsSent).toBe(2);
|
|
449
|
+
const events = captured.flatMap((b) => b.events);
|
|
450
|
+
expect(events.find((e) => e.skill === "indigo:crm-management")?.source).toBe("model");
|
|
451
|
+
expect(events.find((e) => e.skill === "indigo:hello-world")?.source).toBe("typed");
|
|
452
|
+
|
|
453
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("reads Codex session ctx from the top on an incremental (cursor mid-file) run", async () => {
|
|
457
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-incr-"));
|
|
458
|
+
const codex = path.join(tmp, "sessions", "2026", "06", "07");
|
|
459
|
+
await fs.mkdir(codex, { recursive: true });
|
|
460
|
+
const hqRoot = "/home/ec2-user/hq";
|
|
461
|
+
const file = path.join(codex, "rollout-incr.jsonl");
|
|
462
|
+
|
|
463
|
+
await fs.writeFile(file, codexRollout("sess-incr", hqRoot, ["/indigo:hello-world"]), "utf-8");
|
|
464
|
+
const cursorPath = path.join(tmp, "cursor.json");
|
|
465
|
+
const captured: SkillInvocationBatch[] = [];
|
|
466
|
+
const client = stubClient(captured);
|
|
467
|
+
const base = { client, machineId: "m", installerVersion: "t", hqRoot, claudeProjectsRoot: path.join(tmp, "no-claude"), codexSessionsRoot: path.join(tmp, "sessions"), cursorPath };
|
|
468
|
+
|
|
469
|
+
const r1 = await collectAndSendSkillTelemetry(base);
|
|
470
|
+
expect(r1.eventsSent).toBe(1);
|
|
471
|
+
|
|
472
|
+
// Append a new typed command AFTER the session_meta line. The cursor now
|
|
473
|
+
// sits mid-file, so the new event's cwd/sessionId must still come from the
|
|
474
|
+
// session_meta at the top — proving readCodexSessionContext reads offset 0.
|
|
475
|
+
await fs.appendFile(
|
|
476
|
+
file,
|
|
477
|
+
row({ timestamp: "2026-06-07T18:30:00.000Z", type: "event_msg", payload: { type: "user_message", message: "/indigo:action-items" } }) + "\n",
|
|
478
|
+
"utf-8",
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const r2 = await collectAndSendSkillTelemetry(base);
|
|
482
|
+
expect(r2.eventsSent).toBe(1);
|
|
483
|
+
const last = captured[captured.length - 1].events[0];
|
|
484
|
+
expect(last.skill).toBe("indigo:action-items");
|
|
485
|
+
// cwd survived → it passed the hqRoot scope filter (would be dropped if the
|
|
486
|
+
// session_meta weren't re-read from the top on this mid-file run).
|
|
487
|
+
expect(last.cwd).toBe(hqRoot);
|
|
488
|
+
expect(last.sessionId).toBe("sess-incr");
|
|
489
|
+
|
|
490
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
491
|
+
});
|
|
279
492
|
});
|
package/src/skill-telemetry.ts
CHANGED
|
@@ -23,6 +23,15 @@
|
|
|
23
23
|
* `name === "Skill"` and `input.skill` names the skill.
|
|
24
24
|
* The two are mutually exclusive per invocation, so there is no double-count.
|
|
25
25
|
*
|
|
26
|
+
* Codex CLI is captured too, from its own rollout logs at
|
|
27
|
+
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`. Codex records cwd +
|
|
28
|
+
* sessionId ONCE in a leading `session_meta` line (not on every row), and a
|
|
29
|
+
* typed slash command — including an HQ skill, e.g. `/indigo:hello-world` — is
|
|
30
|
+
* logged verbatim as a later `event_msg` `user_message` (Codex does not expand
|
|
31
|
+
* it). That parallels Claude's typed path exactly; Codex has no discrete "skill
|
|
32
|
+
* tool_use" event, so only the typed source is captured for it. Both runtimes
|
|
33
|
+
* funnel into the same wire shape, scope filter, batcher, and per-file cursor.
|
|
34
|
+
*
|
|
26
35
|
* Privacy: raw `<command-args>` / `input.args` content is NEVER sent to the
|
|
27
36
|
* cloud — only a `hasArgs` boolean. This matches the message-stripping posture
|
|
28
37
|
* of `./telemetry.ts::sanitizeRow`, which deliberately drops all prompt/tool
|
|
@@ -68,6 +77,8 @@ export interface CollectSkillTelemetryOptions {
|
|
|
68
77
|
hqRoot?: string;
|
|
69
78
|
/** Override `~/.claude/projects` for tests. */
|
|
70
79
|
claudeProjectsRoot?: string;
|
|
80
|
+
/** Override `~/.codex/sessions` (the Codex CLI rollout root) for tests. */
|
|
81
|
+
codexSessionsRoot?: string;
|
|
71
82
|
/** Override `~/.hq/skill-telemetry-cursor.json` for tests. */
|
|
72
83
|
cursorPath?: string;
|
|
73
84
|
/** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
|
|
@@ -230,6 +241,74 @@ export function extractSkillEvents(row: unknown): SkillEvent[] {
|
|
|
230
241
|
return [];
|
|
231
242
|
}
|
|
232
243
|
|
|
244
|
+
// ── Codex rollout extractor ───────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/** Match a leading slash command in a Codex `user_message`: a single token
|
|
247
|
+
* (no internal "/", so file paths like "/etc/hosts" are rejected) optionally
|
|
248
|
+
* followed by whitespace + args. Group 1 = skill/command, group 2 = args. */
|
|
249
|
+
const CODEX_CMD = /^\/([^/\s]+)(?:\s+([\s\S]*))?$/;
|
|
250
|
+
|
|
251
|
+
/** Parse cwd + sessionId from a Codex `session_meta` rollout line. Returns null
|
|
252
|
+
* for any other line type. */
|
|
253
|
+
export function parseCodexSessionMeta(
|
|
254
|
+
row: unknown,
|
|
255
|
+
): { sessionId?: string; cwd?: string } | null {
|
|
256
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
|
257
|
+
const obj = row as Record<string, unknown>;
|
|
258
|
+
if (obj.type !== "session_meta") return null;
|
|
259
|
+
const payload =
|
|
260
|
+
obj.payload && typeof obj.payload === "object" && !Array.isArray(obj.payload)
|
|
261
|
+
? (obj.payload as Record<string, unknown>)
|
|
262
|
+
: {};
|
|
263
|
+
const sessionId = typeof payload.id === "string" ? payload.id : undefined;
|
|
264
|
+
const cwd = typeof payload.cwd === "string" ? payload.cwd : undefined;
|
|
265
|
+
return { sessionId, cwd };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extract a typed skill/slash-command invocation from a Codex `event_msg`
|
|
270
|
+
* `user_message` row. Session context (cwd, sessionId) lives in the file's
|
|
271
|
+
* leading `session_meta` line and is threaded in via `ctx`. Returns 0 or 1
|
|
272
|
+
* event (a Codex user_message carries at most one command).
|
|
273
|
+
*/
|
|
274
|
+
export function extractCodexSkillEvents(
|
|
275
|
+
row: unknown,
|
|
276
|
+
ctx: { sessionId?: string; cwd?: string },
|
|
277
|
+
): SkillEvent[] {
|
|
278
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
|
279
|
+
const obj = row as Record<string, unknown>;
|
|
280
|
+
if (obj.type !== "event_msg") return [];
|
|
281
|
+
const payload =
|
|
282
|
+
obj.payload && typeof obj.payload === "object" && !Array.isArray(obj.payload)
|
|
283
|
+
? (obj.payload as Record<string, unknown>)
|
|
284
|
+
: undefined;
|
|
285
|
+
if (!payload || payload.type !== "user_message") return [];
|
|
286
|
+
const message = typeof payload.message === "string" ? payload.message : "";
|
|
287
|
+
const m = CODEX_CMD.exec(message.trim());
|
|
288
|
+
if (!m) return [];
|
|
289
|
+
const skill = m[1].trim();
|
|
290
|
+
if (!skill) return [];
|
|
291
|
+
const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
|
|
292
|
+
// Codex events carry no per-event id, so synthesize a stable, content-derived
|
|
293
|
+
// uuid (sessionId + timestamp + skill are unique per invocation) for
|
|
294
|
+
// idempotent re-delivery — the server dedups on the composite eventKey.
|
|
295
|
+
const uuid =
|
|
296
|
+
ctx.sessionId !== undefined && timestamp !== undefined
|
|
297
|
+
? `codex:${ctx.sessionId}:${timestamp}:${skill}`
|
|
298
|
+
: undefined;
|
|
299
|
+
return [
|
|
300
|
+
{
|
|
301
|
+
skill,
|
|
302
|
+
source: "typed",
|
|
303
|
+
sessionId: ctx.sessionId,
|
|
304
|
+
timestamp,
|
|
305
|
+
cwd: ctx.cwd,
|
|
306
|
+
uuid,
|
|
307
|
+
hasArgs: Boolean(m[2] && m[2].trim()),
|
|
308
|
+
},
|
|
309
|
+
];
|
|
310
|
+
}
|
|
311
|
+
|
|
233
312
|
/** Shape the event for the wire. Drops raw args unless explicitly enabled. */
|
|
234
313
|
function toWireRow(ev: SkillEvent): Record<string, unknown> {
|
|
235
314
|
const row: Record<string, unknown> = {
|
|
@@ -273,6 +352,34 @@ async function listJsonlFiles(root: string): Promise<string[]> {
|
|
|
273
352
|
return out;
|
|
274
353
|
}
|
|
275
354
|
|
|
355
|
+
// Codex's `session_meta` is always the first, small line of a rollout. Read a
|
|
356
|
+
// bounded prefix from the top so a mid-file (cursor-resumed) scan still has
|
|
357
|
+
// session context without slurping a multi-MiB transcript.
|
|
358
|
+
const CODEX_META_PREFIX_BYTES = 64 * 1024;
|
|
359
|
+
|
|
360
|
+
/** Read cwd + sessionId from a Codex rollout's leading `session_meta` line.
|
|
361
|
+
* Always reads from offset 0 (independent of the byte cursor). Best-effort:
|
|
362
|
+
* any error → empty context (events then fail the hqRoot scope filter, which
|
|
363
|
+
* is the safe default). */
|
|
364
|
+
async function readCodexSessionContext(
|
|
365
|
+
filePath: string,
|
|
366
|
+
): Promise<{ sessionId?: string; cwd?: string }> {
|
|
367
|
+
try {
|
|
368
|
+
const fh = await fs.open(filePath, "r");
|
|
369
|
+
try {
|
|
370
|
+
const buf = Buffer.alloc(CODEX_META_PREFIX_BYTES);
|
|
371
|
+
const { bytesRead } = await fh.read(buf, 0, CODEX_META_PREFIX_BYTES, 0);
|
|
372
|
+
const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n", 1)[0]?.trim();
|
|
373
|
+
if (!firstLine) return {};
|
|
374
|
+
return parseCodexSessionMeta(JSON.parse(firstLine)) ?? {};
|
|
375
|
+
} finally {
|
|
376
|
+
await fh.close();
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
276
383
|
const MAX_BATCH_BYTES = 1_000_000;
|
|
277
384
|
|
|
278
385
|
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
@@ -301,6 +408,8 @@ export async function collectAndSendSkillTelemetry(
|
|
|
301
408
|
const home = os.homedir();
|
|
302
409
|
const claudeProjectsRoot =
|
|
303
410
|
opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
|
|
411
|
+
const codexSessionsRoot =
|
|
412
|
+
opts.codexSessionsRoot ?? path.join(home, ".codex", "sessions");
|
|
304
413
|
const cursorPath =
|
|
305
414
|
opts.cursorPath ?? path.join(home, ".hq", "skill-telemetry-cursor.json");
|
|
306
415
|
const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
|
|
@@ -327,9 +436,16 @@ export async function collectAndSendSkillTelemetry(
|
|
|
327
436
|
return { enabled: false, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
|
|
328
437
|
}
|
|
329
438
|
|
|
330
|
-
// 2. Cursor + file enumeration.
|
|
439
|
+
// 2. Cursor + file enumeration. Both runtimes share one cursor (keyed by
|
|
440
|
+
// absolute path — the roots never collide) and one batcher. Each file is
|
|
441
|
+
// tagged with its runtime so the scan picks the right line extractor.
|
|
331
442
|
const cursor = await loadCursor(cursorPath);
|
|
332
|
-
const
|
|
443
|
+
const claudeFiles = await listJsonlFiles(claudeProjectsRoot);
|
|
444
|
+
const codexFiles = await listJsonlFiles(codexSessionsRoot);
|
|
445
|
+
const files: { filePath: string; kind: "claude" | "codex" }[] = [
|
|
446
|
+
...claudeFiles.map((f) => ({ filePath: f, kind: "claude" as const })),
|
|
447
|
+
...codexFiles.map((f) => ({ filePath: f, kind: "codex" as const })),
|
|
448
|
+
];
|
|
333
449
|
|
|
334
450
|
// 3. Scan every file from its stored offset, collecting events tagged with
|
|
335
451
|
// the byte offset of the line they came from (for per-batch commit).
|
|
@@ -348,7 +464,7 @@ export async function collectAndSendSkillTelemetry(
|
|
|
348
464
|
const rotationResets: Record<string, CursorEntry> = {};
|
|
349
465
|
const sourced: Sourced[] = [];
|
|
350
466
|
|
|
351
|
-
for (const filePath of files) {
|
|
467
|
+
for (const { filePath, kind } of files) {
|
|
352
468
|
let stat;
|
|
353
469
|
try {
|
|
354
470
|
stat = await fs.stat(filePath);
|
|
@@ -392,6 +508,11 @@ export async function collectAndSendSkillTelemetry(
|
|
|
392
508
|
continue;
|
|
393
509
|
}
|
|
394
510
|
|
|
511
|
+
// Codex events lack per-row cwd/sessionId — they live in the file's leading
|
|
512
|
+
// `session_meta` line, which we read from the top regardless of the cursor.
|
|
513
|
+
const codexCtx =
|
|
514
|
+
kind === "codex" ? await readCodexSessionContext(filePath) : undefined;
|
|
515
|
+
|
|
395
516
|
// Compute the absolute end-byte offset of each line in the read region.
|
|
396
517
|
const segments = content.split("\n");
|
|
397
518
|
let cumulative = offset;
|
|
@@ -408,7 +529,11 @@ export async function collectAndSendSkillTelemetry(
|
|
|
408
529
|
} catch {
|
|
409
530
|
continue;
|
|
410
531
|
}
|
|
411
|
-
|
|
532
|
+
const events =
|
|
533
|
+
kind === "codex"
|
|
534
|
+
? extractCodexSkillEvents(parsed, codexCtx ?? {})
|
|
535
|
+
: extractSkillEvents(parsed);
|
|
536
|
+
for (const ev of events) {
|
|
412
537
|
// Scope filter: only emit invocations made from the HQ project.
|
|
413
538
|
if (scopeCwd !== undefined && (ev.cwd === undefined || normalizePath(ev.cwd) !== scopeCwd)) {
|
|
414
539
|
continue;
|