@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.
Files changed (64) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +18 -0
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/cli/index.d.ts +2 -2
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +2 -2
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/reindex.d.ts +4 -11
  9. package/dist/cli/reindex.d.ts.map +1 -1
  10. package/dist/cli/reindex.js +336 -30
  11. package/dist/cli/reindex.js.map +1 -1
  12. package/dist/cli/reindex.test.d.ts +3 -3
  13. package/dist/cli/reindex.test.js +36 -11
  14. package/dist/cli/reindex.test.js.map +1 -1
  15. package/dist/cli/rescue-core.d.ts +36 -0
  16. package/dist/cli/rescue-core.d.ts.map +1 -0
  17. package/dist/cli/rescue-core.js +1536 -0
  18. package/dist/cli/rescue-core.js.map +1 -0
  19. package/dist/cli/rescue-drift-reconcile.test.js +33 -10
  20. package/dist/cli/rescue-drift-reconcile.test.js.map +1 -1
  21. package/dist/cli/rescue-mtime-preserve.test.js +36 -12
  22. package/dist/cli/rescue-mtime-preserve.test.js.map +1 -1
  23. package/dist/cli/rescue.d.ts +4 -10
  24. package/dist/cli/rescue.d.ts.map +1 -1
  25. package/dist/cli/rescue.js +14 -37
  26. package/dist/cli/rescue.js.map +1 -1
  27. package/dist/cli/rescue.reindex.test.js +9 -8
  28. package/dist/cli/rescue.reindex.test.js.map +1 -1
  29. package/dist/cli/rescue.test.js +1 -10
  30. package/dist/cli/rescue.test.js.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/conflict-index.d.ts +40 -0
  36. package/dist/lib/conflict-index.d.ts.map +1 -1
  37. package/dist/lib/conflict-index.js +121 -0
  38. package/dist/lib/conflict-index.js.map +1 -1
  39. package/dist/lib/conflict.test.js +145 -1
  40. package/dist/lib/conflict.test.js.map +1 -1
  41. package/dist/skill-telemetry.d.ts +27 -0
  42. package/dist/skill-telemetry.d.ts.map +1 -1
  43. package/dist/skill-telemetry.js +117 -4
  44. package/dist/skill-telemetry.js.map +1 -1
  45. package/dist/skill-telemetry.test.js +159 -7
  46. package/dist/skill-telemetry.test.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/bin/sync-runner.ts +18 -0
  49. package/src/cli/index.ts +2 -2
  50. package/src/cli/reindex.test.ts +45 -12
  51. package/src/cli/reindex.ts +345 -36
  52. package/src/cli/rescue-core.ts +1650 -0
  53. package/src/cli/rescue-drift-reconcile.test.ts +33 -12
  54. package/src/cli/rescue-mtime-preserve.test.ts +36 -15
  55. package/src/cli/rescue.reindex.test.ts +9 -8
  56. package/src/cli/rescue.test.ts +1 -11
  57. package/src/cli/rescue.ts +15 -40
  58. package/src/index.ts +2 -2
  59. package/src/lib/conflict-index.ts +146 -0
  60. package/src/lib/conflict.test.ts +171 -0
  61. package/src/skill-telemetry.test.ts +220 -7
  62. package/src/skill-telemetry.ts +129 -4
  63. package/scripts/reindex.sh +0 -318
  64. 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 { extractSkillEvents, collectAndSendSkillTelemetry } from "./skill-telemetry.js";
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
  });
@@ -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 files = await listJsonlFiles(claudeProjectsRoot);
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
- for (const ev of extractSkillEvents(parsed)) {
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;