@indigoai-us/hq-cloud 5.44.0 → 5.46.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/src/cli/sync.ts CHANGED
@@ -382,6 +382,73 @@ export function resolveAutoPruneCap(): number {
382
382
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
383
383
  }
384
384
 
385
+ /** Max time to wait on the best-effort new-files notification POST. */
386
+ const NOTIFY_FILE_ADDED_TIMEOUT_MS = 5000;
387
+
388
+ /**
389
+ * Best-effort report of the files that were new to this drive during the sync,
390
+ * so the HQ Sync app can show a persistent cross-session "new files" history.
391
+ *
392
+ * POSTs to `${apiUrl}/v1/notify/file-added`, which writes per-recipient
393
+ * FILE_EVENT rows for the calling user (the one the files are new for). Fully
394
+ * non-fatal: any error, non-2xx, or timeout is swallowed — the durable signal
395
+ * is the synced file itself; this is only a notification mirror. Bounded by a
396
+ * 5s timeout so a hung endpoint can't stall sync completion. No-op when there
397
+ * are no new files.
398
+ */
399
+ async function reportNewFilesToNotify(
400
+ vaultConfig: VaultServiceConfig,
401
+ companyUid: string,
402
+ companySlug: string,
403
+ files: Array<{ path: string; bytes: number; addedBy: string | null }>,
404
+ ): Promise<void> {
405
+ if (files.length === 0) return;
406
+ try {
407
+ const token =
408
+ typeof vaultConfig.authToken === "function"
409
+ ? await vaultConfig.authToken()
410
+ : vaultConfig.authToken;
411
+ const base = vaultConfig.apiUrl.replace(/\/+$/, "");
412
+ const controller = new AbortController();
413
+ const timer = setTimeout(
414
+ () => controller.abort(),
415
+ NOTIFY_FILE_ADDED_TIMEOUT_MS,
416
+ );
417
+ try {
418
+ await fetch(`${base}/v1/notify/file-added`, {
419
+ method: "POST",
420
+ headers: {
421
+ Authorization: `Bearer ${token}`,
422
+ "Content-Type": "application/json",
423
+ },
424
+ body: JSON.stringify({
425
+ companyUid,
426
+ companySlug,
427
+ files: files.map((f) => ({
428
+ path: f.path,
429
+ bytes: f.bytes,
430
+ ...(f.addedBy ? { addedBy: f.addedBy } : {}),
431
+ })),
432
+ }),
433
+ signal: controller.signal,
434
+ });
435
+ } finally {
436
+ clearTimeout(timer);
437
+ }
438
+ } catch (err) {
439
+ // Best-effort: never let notification reporting affect the sync result.
440
+ try {
441
+ console.error(
442
+ `[hq-sync] new-files notify report failed (non-fatal): ${
443
+ err instanceof Error ? err.message : String(err)
444
+ }`,
445
+ );
446
+ } catch {
447
+ // swallow — logging must never break sync
448
+ }
449
+ }
450
+ }
451
+
385
452
  /**
386
453
  * Sync (pull) all allowed files from the entity vault.
387
454
  */
@@ -932,6 +999,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
932
999
  }
933
1000
  emit({ type: "new-files", files: enrichedNewFiles });
934
1001
 
1002
+ // Report new files to the notification service so they persist as a
1003
+ // cross-session "new files" history in the HQ Sync app (POST
1004
+ // /v1/notify/file-added → per-recipient FILE_EVENT rows for THIS user, who is
1005
+ // the one the files are new for). Best-effort and bounded: a failure or a
1006
+ // hung request must never delay or break the sync — the durable signal is the
1007
+ // synced file itself, this is only a notification mirror.
1008
+ await reportNewFilesToNotify(vaultConfig, ctx.uid, ctx.slug, enrichedNewFiles);
1009
+
935
1010
  // Codex P1 (PR #24 follow-up): scope-gate tombstone candidates with
936
1011
  // a per-object HEAD before unlinking. `listRemoteFiles` is STS-scoped
937
1012
  // (guest sessions with `allowedPrefixes`, role downgrade, custom
package/src/index.ts CHANGED
@@ -146,6 +146,8 @@ export type {
146
146
  TelemetryOptInResponse,
147
147
  UsageBatch,
148
148
  UsageIngestResult,
149
+ SkillInvocationBatch,
150
+ SkillInvocationIngestResult,
149
151
  // US-004 — browse-vs-sync membership sync-mode + ACL surface
150
152
  SyncMode,
151
153
  MembershipSyncConfig,
@@ -163,6 +165,20 @@ export type {
163
165
  TelemetryClientSurface,
164
166
  } from "./telemetry.js";
165
167
 
168
+ // Skill-invocation telemetry collector (`/v1/skill-invocations`). Reads the
169
+ // same Claude Code session logs as the token collector but extracts which
170
+ // skill / slash-command was invoked. Independent cursor; same opt-in gate.
171
+ export {
172
+ collectAndSendSkillTelemetry,
173
+ extractSkillEvents,
174
+ } from "./skill-telemetry.js";
175
+ export type {
176
+ CollectSkillTelemetryOptions,
177
+ CollectSkillTelemetryResult,
178
+ SkillEvent,
179
+ SkillTelemetryClientSurface,
180
+ } from "./skill-telemetry.js";
181
+
166
182
  // STS child vending (VLT-8)
167
183
  export type {
168
184
  TaskAction,
@@ -0,0 +1,279 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { promises as fs } from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { extractSkillEvents, collectAndSendSkillTelemetry } from "./skill-telemetry.js";
6
+ import type { SkillInvocationBatch } from "./vault-client.js";
7
+
8
+ describe("extractSkillEvents", () => {
9
+ it("extracts a user-typed slash command with args", () => {
10
+ const row = {
11
+ type: "user",
12
+ sessionId: "sess-1",
13
+ timestamp: "2026-06-03T10:10:07.220Z",
14
+ cwd: "/home/ec2-user/hq",
15
+ uuid: "u-1",
16
+ message: {
17
+ role: "user",
18
+ content:
19
+ "<command-message>personal:worktree</command-message> <command-name>/personal:worktree</command-name> <command-args>pull latest</command-args>",
20
+ },
21
+ };
22
+ const events = extractSkillEvents(row);
23
+ expect(events).toEqual([
24
+ {
25
+ skill: "personal:worktree",
26
+ source: "typed",
27
+ sessionId: "sess-1",
28
+ timestamp: "2026-06-03T10:10:07.220Z",
29
+ cwd: "/home/ec2-user/hq",
30
+ uuid: "u-1",
31
+ hasArgs: true,
32
+ },
33
+ ]);
34
+ });
35
+
36
+ it("marks hasArgs false when command-args is absent or empty", () => {
37
+ const row = {
38
+ type: "user",
39
+ message: { role: "user", content: "<command-name>/deploy</command-name>" },
40
+ };
41
+ expect(extractSkillEvents(row)[0]).toMatchObject({ skill: "deploy", source: "typed", hasArgs: false });
42
+ });
43
+
44
+ it("extracts a model-invoked Skill tool_use using the block id for dedup", () => {
45
+ const row = {
46
+ type: "assistant",
47
+ sessionId: "sess-2",
48
+ timestamp: "2026-06-03T14:00:00.000Z",
49
+ cwd: "/home/ec2-user/hq",
50
+ uuid: "row-uuid",
51
+ message: {
52
+ role: "assistant",
53
+ content: [
54
+ { type: "text", text: "ok" },
55
+ { type: "tool_use", id: "toolu_abc", name: "Skill", input: { skill: "indigo:hello-world" } },
56
+ ],
57
+ },
58
+ };
59
+ expect(extractSkillEvents(row)).toEqual([
60
+ {
61
+ skill: "indigo:hello-world",
62
+ source: "model",
63
+ sessionId: "sess-2",
64
+ timestamp: "2026-06-03T14:00:00.000Z",
65
+ cwd: "/home/ec2-user/hq",
66
+ uuid: "toolu_abc",
67
+ hasArgs: false,
68
+ },
69
+ ]);
70
+ });
71
+
72
+ it("sets hasArgs true for a model invocation with args, without capturing the args text", () => {
73
+ const row = {
74
+ type: "assistant",
75
+ message: {
76
+ role: "assistant",
77
+ content: [{ type: "tool_use", name: "Skill", input: { skill: "code-review", args: "high" } }],
78
+ },
79
+ };
80
+ const ev = extractSkillEvents(row)[0];
81
+ expect(ev).toMatchObject({ skill: "code-review", source: "model", hasArgs: true });
82
+ // Privacy: raw args must never appear on the extracted event.
83
+ expect(JSON.stringify(ev)).not.toContain("high");
84
+ });
85
+
86
+ it("ignores non-Skill tool_use blocks and non-invocation rows", () => {
87
+ expect(
88
+ extractSkillEvents({
89
+ type: "assistant",
90
+ message: { role: "assistant", content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] },
91
+ }),
92
+ ).toEqual([]);
93
+ expect(extractSkillEvents({ type: "user", message: { content: "just a normal message" } })).toEqual([]);
94
+ expect(extractSkillEvents(null)).toEqual([]);
95
+ expect(extractSkillEvents("nope")).toEqual([]);
96
+ });
97
+ });
98
+
99
+ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
100
+ function stubClient(captured: SkillInvocationBatch[]) {
101
+ return {
102
+ async getTelemetryOptIn() {
103
+ return { enabled: true, updatedAt: null };
104
+ },
105
+ async postSkillInvocations(batch: SkillInvocationBatch) {
106
+ captured.push(batch);
107
+ return { ok: true, written: batch.events.length, skipped: [] };
108
+ },
109
+ };
110
+ }
111
+
112
+ function row(obj: Record<string, unknown>): string {
113
+ return JSON.stringify(obj);
114
+ }
115
+
116
+ it("emits only invocations whose cwd equals hqRoot", async () => {
117
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
118
+ const projects = path.join(tmp, "projects");
119
+ const hqRoot = "/home/ec2-user/hq";
120
+
121
+ // Project A — sessions run from the HQ root.
122
+ const dirA = path.join(projects, "-home-ec2-user-hq");
123
+ await fs.mkdir(dirA, { recursive: true });
124
+ await fs.writeFile(
125
+ path.join(dirA, "a.jsonl"),
126
+ [
127
+ row({ type: "user", sessionId: "s1", timestamp: "2026-06-03T10:00:00Z", cwd: hqRoot, uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
128
+ row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-03T10:01:00Z", cwd: hqRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:hello-world" } }] } }),
129
+ ].join("\n") + "\n",
130
+ "utf-8",
131
+ );
132
+
133
+ // Project B — a different repo on the same machine. Must be excluded.
134
+ const dirB = path.join(projects, "-home-ec2-user-other");
135
+ await fs.mkdir(dirB, { recursive: true });
136
+ await fs.writeFile(
137
+ path.join(dirB, "b.jsonl"),
138
+ row({ type: "user", sessionId: "s2", timestamp: "2026-06-03T10:02:00Z", cwd: "/home/ec2-user/other", uuid: "u2", message: { role: "user", content: "<command-name>/secret-skill</command-name>" } }) + "\n",
139
+ "utf-8",
140
+ );
141
+
142
+ const captured: SkillInvocationBatch[] = [];
143
+ const result = await collectAndSendSkillTelemetry({
144
+ client: stubClient(captured),
145
+ machineId: "m-test",
146
+ installerVersion: "test",
147
+ hqRoot,
148
+ claudeProjectsRoot: projects,
149
+ cursorPath: path.join(tmp, "cursor.json"),
150
+ });
151
+
152
+ const skills = captured.flatMap((b) => b.events.map((e) => e.skill));
153
+ expect(result.eventsSent).toBe(2);
154
+ expect(skills.sort()).toEqual(["deploy", "indigo:hello-world"]);
155
+ // The other-repo invocation never leaves the machine.
156
+ expect(skills).not.toContain("secret-skill");
157
+
158
+ await fs.rm(tmp, { recursive: true, force: true });
159
+ });
160
+
161
+ it("commits the cursor to EOF on a clean run — a rerun re-sends nothing", async () => {
162
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
163
+ const projects = path.join(tmp, "projects");
164
+ const dir = path.join(projects, "-p");
165
+ await fs.mkdir(dir, { recursive: true });
166
+ await fs.writeFile(
167
+ path.join(dir, "s.jsonl"),
168
+ row({ type: "user", sessionId: "s1", timestamp: "2026-06-04T09:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n",
169
+ "utf-8",
170
+ );
171
+ const cursorPath = path.join(tmp, "cursor.json");
172
+ const captured: SkillInvocationBatch[] = [];
173
+ const client = stubClient(captured);
174
+
175
+ const r1 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
176
+ expect(r1.eventsSent).toBe(1);
177
+ const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
178
+ expect(r2.eventsSent).toBe(0); // cursor at EOF → nothing re-sent
179
+
180
+ await fs.rm(tmp, { recursive: true, force: true });
181
+ });
182
+
183
+ it("does NOT advance the cursor when the POST fails — re-sends on the next run", async () => {
184
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
185
+ const projects = path.join(tmp, "projects");
186
+ const dir = path.join(projects, "-p");
187
+ await fs.mkdir(dir, { recursive: true });
188
+ await fs.writeFile(
189
+ path.join(dir, "s.jsonl"),
190
+ row({ type: "user", sessionId: "s1", timestamp: "2026-06-04T09:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n",
191
+ "utf-8",
192
+ );
193
+ const cursorPath = path.join(tmp, "cursor.json");
194
+ const captured: SkillInvocationBatch[] = [];
195
+ let failNext = true;
196
+ const client = {
197
+ async getTelemetryOptIn() {
198
+ return { enabled: true, updatedAt: null };
199
+ },
200
+ async postSkillInvocations(batch: SkillInvocationBatch) {
201
+ if (failNext) {
202
+ failNext = false;
203
+ throw new Error("network down");
204
+ }
205
+ captured.push(batch);
206
+ return { ok: true, written: batch.events.length, skipped: [] };
207
+ },
208
+ };
209
+
210
+ const r1 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
211
+ expect(r1.eventsSent).toBe(0); // post threw → nothing committed
212
+ expect(captured).toHaveLength(0);
213
+
214
+ const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
215
+ expect(r2.eventsSent).toBe(1); // re-scanned from offset 0 and delivered
216
+ expect(captured.flatMap((b) => b.events.map((e) => e.skill))).toEqual(["deploy"]);
217
+
218
+ await fs.rm(tmp, { recursive: true, force: true });
219
+ });
220
+
221
+ it("picks up only newly-appended events on a later run (incremental offset)", async () => {
222
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
223
+ const projects = path.join(tmp, "projects");
224
+ const dir = path.join(projects, "-p");
225
+ await fs.mkdir(dir, { recursive: true });
226
+ const file = path.join(dir, "s.jsonl");
227
+ await fs.writeFile(
228
+ file,
229
+ row({ type: "user", sessionId: "s1", timestamp: "2026-06-04T09:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n",
230
+ "utf-8",
231
+ );
232
+ const cursorPath = path.join(tmp, "cursor.json");
233
+ const captured: SkillInvocationBatch[] = [];
234
+ const client = stubClient(captured);
235
+
236
+ await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
237
+
238
+ // New invocation appended after the first clean run.
239
+ await fs.appendFile(
240
+ file,
241
+ row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-04T09:05:00Z", cwd: "/x", message: { role: "assistant", content: [{ type: "tool_use", id: "t9", name: "Skill", input: { skill: "land" } }] } }) + "\n",
242
+ "utf-8",
243
+ );
244
+
245
+ const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
246
+ expect(r2.eventsSent).toBe(1);
247
+ // Only the appended event — not the original — is re-sent.
248
+ const lastBatch = captured[captured.length - 1];
249
+ expect(lastBatch.events.map((e) => e.skill)).toEqual(["land"]);
250
+
251
+ await fs.rm(tmp, { recursive: true, force: true });
252
+ });
253
+
254
+ it("captures every project when hqRoot is omitted", async () => {
255
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
256
+ const projects = path.join(tmp, "projects");
257
+ const dir = path.join(projects, "-proj");
258
+ await fs.mkdir(dir, { recursive: true });
259
+ await fs.writeFile(
260
+ path.join(dir, "c.jsonl"),
261
+ row({ type: "user", sessionId: "s3", timestamp: "2026-06-03T10:03:00Z", cwd: "/anywhere", uuid: "u3", message: { role: "user", content: "<command-name>/anywhere-skill</command-name>" } }) + "\n",
262
+ "utf-8",
263
+ );
264
+
265
+ const captured: SkillInvocationBatch[] = [];
266
+ const result = await collectAndSendSkillTelemetry({
267
+ client: stubClient(captured),
268
+ machineId: "m-test",
269
+ installerVersion: "test",
270
+ claudeProjectsRoot: projects,
271
+ cursorPath: path.join(tmp, "cursor.json"),
272
+ });
273
+
274
+ expect(result.eventsSent).toBe(1);
275
+ expect(captured[0].events[0].skill).toBe("anywhere-skill");
276
+
277
+ await fs.rm(tmp, { recursive: true, force: true });
278
+ });
279
+ });