@markjaquith/agency 2.69.1 → 2.71.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.
@@ -18,7 +18,6 @@ interface IntegrationResult {
18
18
  | "opencode-plugin"
19
19
  | "opencode-tui"
20
20
  | "opencode-tui-plugin"
21
- | "pi-extension"
22
21
  readonly path: string
23
22
  readonly state: string
24
23
  readonly diagnostic: string
@@ -33,7 +32,6 @@ const integrationNames = {
33
32
  "opencode-plugin": "OpenCode workbase plugin",
34
33
  "opencode-tui": "OpenCode TUI config",
35
34
  "opencode-tui-plugin": "OpenCode /agency-debug",
36
- "pi-extension": "Pi workbase extension",
37
35
  } as const
38
36
 
39
37
  const logHumanResult = (
@@ -92,9 +90,9 @@ Usage: agency integration <subcommand>
92
90
 
93
91
  Inspect or explicitly synchronize managed agent integration files. OpenCode
94
92
  launches load the managed instructions and project config at runtime. Managed
95
- OpenCode and Pi extensions provide whole-workbase context and writable-checkout
96
- skills, and the /agency-debug TUI diagnostic reports OpenCode integration state
97
- without changing Agency write authority.
93
+ The managed OpenCode extension provides whole-workbase context and
94
+ writable-checkout skills, and the /agency-debug TUI diagnostic reports OpenCode
95
+ integration state without changing Agency write authority.
98
96
 
99
97
  Subcommands:
100
98
  status Report file state, access diagnostics, and safe remediation
@@ -16,10 +16,6 @@ import {
16
16
  canUpdateManagedWorkbaseOpencodeTuiPlugin,
17
17
  managedWorkbaseOpencodeTuiPlugin,
18
18
  } from "../workbase/opencode-tui-plugin-file"
19
- import {
20
- canUpdateManagedWorkbasePiExtension,
21
- managedWorkbasePiExtension,
22
- } from "../workbase/pi-extension-file"
23
19
  import { IntegrationService } from "./IntegrationService"
24
20
 
25
21
  const write = async (root: string, path: string, content: string) => {
@@ -69,7 +65,6 @@ describe("IntegrationService", () => {
69
65
  "missing",
70
66
  "missing",
71
67
  "missing",
72
- "missing",
73
68
  ])
74
69
  expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
75
70
 
@@ -86,18 +81,12 @@ describe("IntegrationService", () => {
86
81
  ".opencode/tui/agency-debug.ts",
87
82
  managedWorkbaseOpencodeTuiPlugin,
88
83
  )
89
- await write(
90
- root,
91
- ".pi/extensions/agency-workbase.ts",
92
- managedWorkbasePiExtension,
93
- )
94
84
  expect((await status(root)).files.map(({ state }) => state)).toEqual([
95
85
  "managed",
96
86
  "managed",
97
87
  "managed",
98
88
  "managed",
99
89
  "managed",
100
- "managed",
101
90
  ])
102
91
  })
103
92
 
@@ -115,7 +104,6 @@ describe("IntegrationService", () => {
115
104
  "missing",
116
105
  "missing",
117
106
  "missing",
118
- "missing",
119
107
  ])
120
108
  })
121
109
 
@@ -220,131 +208,6 @@ describe("IntegrationService", () => {
220
208
  ).toBe(false)
221
209
  })
222
210
 
223
- test("generates a Pi extension with workbase context and checkout skills", async () => {
224
- expect(managedWorkbasePiExtension).toContain(
225
- 'import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"',
226
- )
227
- expect(managedWorkbasePiExtension).toContain('pi.on("resources_discover"')
228
- expect(managedWorkbasePiExtension).toContain('pi.on("before_agent_start"')
229
- expect(managedWorkbasePiExtension).toContain(
230
- 'join(checkout, ".agents", "skills")',
231
- )
232
- expect(managedWorkbasePiExtension).toContain(
233
- 'join(checkout, CONFIG_DIR_NAME, "skills")',
234
- )
235
- expect(managedWorkbasePiExtension).toContain(
236
- "The complete Agency workbase is available",
237
- )
238
- expect(managedWorkbasePiExtension).toContain(
239
- "Agency context remains the authority for writes",
240
- )
241
- expect(
242
- canUpdateManagedWorkbasePiExtension(managedWorkbasePiExtension),
243
- ).toBe(true)
244
- expect(
245
- canUpdateManagedWorkbasePiExtension(
246
- managedWorkbasePiExtension.replace("resources_discover", "discover"),
247
- ),
248
- ).toBe(false)
249
-
250
- const taskDirectory = join(root, "tasks/example")
251
- const checkoutPath = join(taskDirectory, "code/agency")
252
- await write(root, ".agency/AGENTS.md", "# Managed Agency instructions\n")
253
- await write(
254
- root,
255
- "tasks/example/TASK.md",
256
- "---\nrepo: agency\nstatus: working\n---\n",
257
- )
258
- await write(
259
- root,
260
- "tasks/example/code/agency/.agents/skills/example/SKILL.md",
261
- "---\nname: example\ndescription: Example.\n---\n",
262
- )
263
- await write(
264
- root,
265
- "tasks/example/code/agency/.pi/skills/pi-example/SKILL.md",
266
- "---\nname: pi-example\ndescription: Pi example.\n---\n",
267
- )
268
- const path = join(root, ".pi/extensions/agency-workbase.ts")
269
- await write(
270
- root,
271
- ".pi/extensions/agency-workbase.ts",
272
- managedWorkbasePiExtension.replace(
273
- 'import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"',
274
- 'const CONFIG_DIR_NAME = ".pi"\ntype ExtensionAPI = any',
275
- ),
276
- )
277
-
278
- const contextResponse = JSON.stringify({
279
- version: 1,
280
- ok: true,
281
- result: {
282
- workbase: { root },
283
- target: {
284
- kind: "task",
285
- taskId: "example",
286
- path: join(taskDirectory, "TASK.md"),
287
- },
288
- authority: {
289
- mode: "execution",
290
- writable: { checkoutPath },
291
- },
292
- documents: { task: { data: { status: "working" } } },
293
- validation: { valid: true, warnings: [] },
294
- },
295
- })
296
- const handlers = new Map<string, (...args: any[]) => any>()
297
- const pi = {
298
- exec: async () => ({ code: 0, stdout: contextResponse, stderr: "" }),
299
- on: (name: string, handler: (...args: any[]) => any) => {
300
- handlers.set(name, handler)
301
- },
302
- }
303
- const previousTarget = process.env.AGENCY_TARGET
304
- const previousWorkbase = process.env.AGENCY_WORKBASE
305
- const previousCheckout = process.env.AGENCY_WRITABLE_CHECKOUT
306
- delete process.env.AGENCY_WORKBASE
307
- delete process.env.AGENCY_WRITABLE_CHECKOUT
308
- process.env.AGENCY_TARGET = "execution-unit:task/example"
309
- try {
310
- const generated = await import(
311
- `${pathToFileURL(path).href}?pi-extension=${Date.now()}`
312
- )
313
- generated.default(pi)
314
- const resources = await handlers.get("resources_discover")?.({
315
- cwd: taskDirectory,
316
- reason: "startup",
317
- })
318
- expect(resources.skillPaths).toEqual([
319
- join(checkoutPath, ".agents/skills"),
320
- join(checkoutPath, ".pi/skills"),
321
- ])
322
-
323
- const prompt = await handlers.get("before_agent_start")?.(
324
- { systemPrompt: "Base prompt" },
325
- { cwd: taskDirectory },
326
- )
327
- expect(prompt.systemPrompt).toContain("# Managed Agency instructions")
328
- expect(prompt.systemPrompt).toContain(
329
- `The complete Agency workbase is available at ${root}`,
330
- )
331
- expect(prompt.systemPrompt).toContain(
332
- "active worker for execution-unit:task/example",
333
- )
334
- expect(prompt.systemPrompt).toContain(
335
- `${checkoutPath} as the default implementation directory`,
336
- )
337
- } finally {
338
- if (previousTarget === undefined) delete process.env.AGENCY_TARGET
339
- else process.env.AGENCY_TARGET = previousTarget
340
- if (previousWorkbase === undefined) delete process.env.AGENCY_WORKBASE
341
- else process.env.AGENCY_WORKBASE = previousWorkbase
342
- if (previousCheckout === undefined)
343
- delete process.env.AGENCY_WRITABLE_CHECKOUT
344
- else process.env.AGENCY_WRITABLE_CHECKOUT = previousCheckout
345
- }
346
- })
347
-
348
211
  const testWorkerIdentity = async ({
349
212
  target,
350
213
  launchTarget,
@@ -870,7 +733,6 @@ describe("IntegrationService", () => {
870
733
  { name: "opencode-plugin", state: "managed", changed: true },
871
734
  { name: "opencode-tui", state: "managed", changed: true },
872
735
  { name: "opencode-tui-plugin", state: "managed", changed: true },
873
- { name: "pi-extension", state: "managed", changed: true },
874
736
  ])
875
737
  expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
876
738
  customRootAgents,
@@ -895,9 +757,6 @@ describe("IntegrationService", () => {
895
757
  expect(
896
758
  await Bun.file(join(root, ".opencode/tui/agency-debug.ts")).text(),
897
759
  ).toBe(managedWorkbaseOpencodeTuiPlugin)
898
- expect(
899
- await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).text(),
900
- ).toBe(managedWorkbasePiExtension)
901
760
 
902
761
  await unlink(join(root, ".agency/AGENTS.md"))
903
762
  const second = await sync(root)
@@ -962,6 +821,30 @@ describe("IntegrationService", () => {
962
821
  })
963
822
  })
964
823
 
824
+ test("removes a checksum-valid workbase-local Pi extension", async () => {
825
+ const body = "export default function legacyAgencyExtension() {}\n"
826
+ const checksum = createHash("sha256").update(body).digest("hex")
827
+ const path = join(root, ".pi/extensions/agency-workbase.ts")
828
+ await write(
829
+ root,
830
+ ".pi/extensions/agency-workbase.ts",
831
+ `// agency-managed: sha256=${checksum}\n\n${body}`,
832
+ )
833
+
834
+ await sync(root)
835
+ expect(await Bun.file(path).exists()).toBe(false)
836
+ expect(await Bun.file(join(root, ".pi")).exists()).toBe(false)
837
+ })
838
+
839
+ test("preserves a customized workbase-local Pi extension", async () => {
840
+ const custom = "export default function customPiExtension() {}\n"
841
+ const path = join(root, ".pi/extensions/agency-workbase.ts")
842
+ await write(root, ".pi/extensions/agency-workbase.ts", custom)
843
+
844
+ await sync(root)
845
+ expect(await Bun.file(path).text()).toBe(custom)
846
+ })
847
+
965
848
  test("migrates a checksum-valid plugin from the legacy singular path", async () => {
966
849
  const legacyPath = join(
967
850
  root,
@@ -991,21 +874,6 @@ describe("IntegrationService", () => {
991
874
  expect(await Bun.file(legacyPath).exists()).toBe(false)
992
875
  })
993
876
 
994
- test("preserves a user-owned Pi extension", async () => {
995
- const custom = "export default function customPiExtension() {}\n"
996
- await write(root, ".pi/extensions/agency-workbase.ts", custom)
997
-
998
- const result = await sync(root)
999
- expect(result.files[5]).toMatchObject({
1000
- name: "pi-extension",
1001
- state: "customized",
1002
- changed: false,
1003
- })
1004
- expect(
1005
- await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).text(),
1006
- ).toBe(custom)
1007
- })
1008
-
1009
877
  test("preserves user-owned TUI config and diagnostic plugin", async () => {
1010
878
  const customConfig = '{"theme":"custom"}\n'
1011
879
  const customPlugin =
@@ -1,6 +1,6 @@
1
1
  import { Effect } from "effect"
2
2
  import { createHash } from "node:crypto"
3
- import { join } from "node:path"
3
+ import { dirname, join } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
5
  import { WorkbaseService } from "./WorkbaseService"
6
6
  import {
@@ -23,10 +23,19 @@ import {
23
23
  canUpdateManagedWorkbaseOpencodeTuiPlugin,
24
24
  managedWorkbaseOpencodeTuiPlugin,
25
25
  } from "../workbase/opencode-tui-plugin-file"
26
- import {
27
- canUpdateManagedWorkbasePiExtension,
28
- managedWorkbasePiExtension,
29
- } from "../workbase/pi-extension-file"
26
+
27
+ const managedHeaderPattern =
28
+ /^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
29
+
30
+ const canRemoveLegacyPiExtension = (content: string) => {
31
+ const match = content.match(managedHeaderPattern)
32
+ if (!match?.[1]) return false
33
+ return (
34
+ createHash("sha256")
35
+ .update(content.slice(match[0].length))
36
+ .digest("hex") === match[1]
37
+ )
38
+ }
30
39
 
31
40
  type IntegrationFileState = "managed" | "customized" | "missing" | "drifted"
32
41
 
@@ -37,7 +46,6 @@ interface IntegrationFileStatus {
37
46
  | "opencode-plugin"
38
47
  | "opencode-tui"
39
48
  | "opencode-tui-plugin"
40
- | "pi-extension"
41
49
  readonly path: string
42
50
  readonly state: IntegrationFileState
43
51
  readonly diagnostic: string
@@ -136,27 +144,6 @@ const describe = (
136
144
  "Run 'agency integration sync' to install /agency-debug.",
137
145
  }
138
146
  }
139
- if (name === "pi-extension") {
140
- return state === "managed"
141
- ? {
142
- diagnostic:
143
- "Agency's managed Pi extension provides whole-workbase context and exposes writable-checkout skills.",
144
- remediation: null,
145
- }
146
- : state === "customized"
147
- ? {
148
- diagnostic:
149
- "A user-owned Pi workbase extension is present and was preserved.",
150
- remediation: null,
151
- }
152
- : {
153
- diagnostic:
154
- "The managed Pi workbase extension needs synchronization.",
155
- remediation:
156
- "Run 'agency integration sync' to provide workbase context and expose writable-checkout skills in Pi.",
157
- }
158
- }
159
-
160
147
  return state === "missing" || state === "drifted"
161
148
  ? {
162
149
  diagnostic: "Managed workbase instructions need synchronization.",
@@ -215,12 +202,6 @@ const inspect = (root: string) =>
215
202
  "agency-repository-skills.ts",
216
203
  )
217
204
  const tuiPluginPath = join(opencodeDirectory, "tui", "agency-debug.ts")
218
- const piExtensionPath = join(
219
- root,
220
- ".pi",
221
- "extensions",
222
- "agency-workbase.ts",
223
- )
224
205
  const files: IntegrationFileStatus[] = []
225
206
 
226
207
  files.push(
@@ -325,22 +306,6 @@ const inspect = (root: string) =>
325
306
  files.push(fileStatus("opencode-tui-plugin", tuiPluginPath, "missing"))
326
307
  }
327
308
 
328
- if ((yield* fs.readSymlinkTarget(piExtensionPath)) !== null) {
329
- files.push(fileStatus("pi-extension", piExtensionPath, "customized"))
330
- } else if (yield* fs.exists(piExtensionPath)) {
331
- files.push(
332
- classify(
333
- "pi-extension",
334
- piExtensionPath,
335
- yield* fs.readFile(piExtensionPath),
336
- managedWorkbasePiExtension,
337
- canUpdateManagedWorkbasePiExtension,
338
- ),
339
- )
340
- } else {
341
- files.push(fileStatus("pi-extension", piExtensionPath, "missing"))
342
- }
343
-
344
309
  return files
345
310
  })
346
311
 
@@ -418,6 +383,16 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
418
383
  yield* canRemoveLegacyOpencodeCommand(root)
419
384
  const removeLegacyOpencodePlugin =
420
385
  yield* canRemoveLegacyOpencodePlugin(root)
386
+ const legacyPiExtension = join(
387
+ root,
388
+ ".pi",
389
+ "extensions",
390
+ "agency-workbase.ts",
391
+ )
392
+ const removeLegacyPiExtension =
393
+ (yield* fs.readSymlinkTarget(legacyPiExtension)) === null &&
394
+ (yield* fs.exists(legacyPiExtension)) &&
395
+ canRemoveLegacyPiExtension(yield* fs.readFile(legacyPiExtension))
421
396
  const files: IntegrationSyncFile[] = []
422
397
 
423
398
  for (const status of statuses) {
@@ -442,9 +417,6 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
442
417
  status.path,
443
418
  managedWorkbaseOpencodeTuiPlugin,
444
419
  )
445
- } else {
446
- yield* fs.createDirectory(join(root, ".pi", "extensions"))
447
- yield* fs.writeFile(status.path, managedWorkbasePiExtension)
448
420
  }
449
421
  }
450
422
  files.push({
@@ -472,6 +444,13 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
472
444
  join(root, ".opencode", "command", "agency.md"),
473
445
  )
474
446
  }
447
+ if (removeLegacyPiExtension) {
448
+ yield* fs.deleteFile(legacyPiExtension)
449
+ yield* fs.deleteDirectoryIfEmpty(dirname(legacyPiExtension))
450
+ yield* fs.deleteDirectoryIfEmpty(
451
+ dirname(dirname(legacyPiExtension)),
452
+ )
453
+ }
475
454
 
476
455
  return { root, files }
477
456
  }),
@@ -0,0 +1,94 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { rm } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { cleanupTempDir, createTempDir } from "./test-utils"
5
+ import { exportUsageEvents, recordUsageEvent } from "./usage-log"
6
+
7
+ describe("usage logging", () => {
8
+ const tempDirs: string[] = []
9
+
10
+ afterEach(() => Promise.all(tempDirs.splice(0).map(cleanupTempDir)))
11
+
12
+ test("stores versioned privacy-safe events in session order", async () => {
13
+ const state = await createTempDir()
14
+ tempDirs.push(state)
15
+ const env = {
16
+ XDG_STATE_HOME: state,
17
+ AGENCY_SESSION_ID: "session-1",
18
+ } as NodeJS.ProcessEnv
19
+ for (const commandPath of ["worktree/prepare", "context"]) {
20
+ await recordUsageEvent(
21
+ {
22
+ commandPath,
23
+ flagNames: ["json", "task", "json"],
24
+ durationMs: 12.4,
25
+ outcome: "success",
26
+ exitStatus: 0,
27
+ },
28
+ "1.2.3",
29
+ env,
30
+ )
31
+ }
32
+
33
+ expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
34
+ true,
35
+ )
36
+ expect(await exportUsageEvents(env)).toEqual([
37
+ expect.objectContaining({
38
+ version: 1,
39
+ sessionId: "session-1",
40
+ sessionSequence: 1,
41
+ agencyVersion: "1.2.3",
42
+ commandPath: "worktree/prepare",
43
+ flagNames: ["json", "task"],
44
+ durationMs: 12,
45
+ outcome: "success",
46
+ exitStatus: 0,
47
+ }),
48
+ expect.objectContaining({
49
+ sessionSequence: 2,
50
+ commandPath: "context",
51
+ }),
52
+ ])
53
+ })
54
+
55
+ test("supports opt-out and ignores unavailable storage", async () => {
56
+ const state = await createTempDir()
57
+ tempDirs.push(state)
58
+ const disabled = {
59
+ XDG_STATE_HOME: state,
60
+ AGENCY_NO_USAGE_LOG: "true",
61
+ } as NodeJS.ProcessEnv
62
+ await recordUsageEvent(
63
+ {
64
+ commandPath: "status",
65
+ flagNames: [],
66
+ durationMs: 1,
67
+ outcome: "failure",
68
+ exitStatus: 1,
69
+ },
70
+ "1.2.3",
71
+ disabled,
72
+ )
73
+ expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
74
+ false,
75
+ )
76
+
77
+ const blocked = join(state, "blocked")
78
+ await Bun.write(blocked, "not a directory")
79
+ await expect(
80
+ recordUsageEvent(
81
+ {
82
+ commandPath: "status",
83
+ flagNames: [],
84
+ durationMs: 1,
85
+ outcome: "failure",
86
+ exitStatus: 1,
87
+ },
88
+ "1.2.3",
89
+ { AGENCY_USAGE_DB: join(blocked, "usage.sqlite3") },
90
+ ),
91
+ ).resolves.toBeUndefined()
92
+ await rm(blocked)
93
+ })
94
+ })
@@ -0,0 +1,143 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { mkdir } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+
5
+ const USAGE_EVENT_VERSION = 1 as const
6
+ const DEFAULT_RETENTION_DAYS = 90
7
+
8
+ export interface UsageEvent {
9
+ readonly commandPath: string
10
+ readonly flagNames: readonly string[]
11
+ readonly durationMs: number
12
+ readonly exitStatus: number
13
+ readonly outcome: "success" | "failure"
14
+ }
15
+
16
+ const stateDirectory = (env: NodeJS.ProcessEnv) =>
17
+ env.XDG_STATE_HOME ?? join(env.HOME ?? ".", ".local", "state")
18
+
19
+ const usageDatabasePath = (env: NodeJS.ProcessEnv = process.env) =>
20
+ env.AGENCY_USAGE_DB ?? join(stateDirectory(env), "agency", "usage.sqlite3")
21
+
22
+ const enabled = (env: NodeJS.ProcessEnv) =>
23
+ !["1", "true", "yes"].includes((env.AGENCY_NO_USAGE_LOG ?? "").toLowerCase())
24
+
25
+ const retentionDays = (env: NodeJS.ProcessEnv) => {
26
+ const value = Number.parseInt(env.AGENCY_USAGE_RETENTION_DAYS ?? "", 10)
27
+ return Number.isSafeInteger(value) && value >= 0
28
+ ? value
29
+ : DEFAULT_RETENTION_DAYS
30
+ }
31
+
32
+ const openDatabase = async (env: NodeJS.ProcessEnv) => {
33
+ const path = usageDatabasePath(env)
34
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
35
+ const database = new Database(path, { create: true, strict: true })
36
+ database.run("PRAGMA journal_mode = WAL")
37
+ database.run("PRAGMA busy_timeout = 1000")
38
+ database.run(`
39
+ CREATE TABLE IF NOT EXISTS usage_events (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ event_version INTEGER NOT NULL,
42
+ session_id TEXT NOT NULL,
43
+ session_sequence INTEGER NOT NULL,
44
+ occurred_at TEXT NOT NULL,
45
+ agency_version TEXT NOT NULL,
46
+ command_path TEXT NOT NULL,
47
+ flag_names TEXT NOT NULL,
48
+ duration_ms INTEGER NOT NULL,
49
+ outcome TEXT NOT NULL,
50
+ exit_status INTEGER NOT NULL
51
+ )
52
+ `)
53
+ database.run(
54
+ "CREATE INDEX IF NOT EXISTS usage_events_session ON usage_events(session_id, session_sequence)",
55
+ )
56
+ database.run(
57
+ "CREATE INDEX IF NOT EXISTS usage_events_command ON usage_events(command_path, occurred_at)",
58
+ )
59
+ return database
60
+ }
61
+
62
+ export async function recordUsageEvent(
63
+ event: UsageEvent,
64
+ agencyVersion: string,
65
+ env: NodeJS.ProcessEnv = process.env,
66
+ ): Promise<void> {
67
+ if (!enabled(env)) return
68
+ let database: Database | undefined
69
+ try {
70
+ database = await openDatabase(env)
71
+ const sessionId = env.AGENCY_SESSION_ID || `process-${process.pid}`
72
+ database
73
+ .query(`
74
+ INSERT INTO usage_events (
75
+ event_version, session_id, session_sequence, occurred_at,
76
+ agency_version, command_path, flag_names, duration_ms,
77
+ outcome, exit_status
78
+ ) VALUES (
79
+ ?, ?,
80
+ (SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM usage_events WHERE session_id = ?),
81
+ ?, ?, ?, ?, ?, ?, ?
82
+ )
83
+ `)
84
+ .run(
85
+ USAGE_EVENT_VERSION,
86
+ sessionId,
87
+ sessionId,
88
+ new Date().toISOString(),
89
+ agencyVersion,
90
+ event.commandPath,
91
+ JSON.stringify([...new Set(event.flagNames)].sort()),
92
+ Math.max(0, Math.round(event.durationMs)),
93
+ event.outcome,
94
+ event.exitStatus,
95
+ )
96
+ if (Math.random() < 0.01) {
97
+ const days = retentionDays(env)
98
+ database
99
+ .query(
100
+ "DELETE FROM usage_events WHERE occurred_at < datetime('now', ?)",
101
+ )
102
+ .run(`-${days} days`)
103
+ }
104
+ } catch {
105
+ // Usage logging must never affect command behavior.
106
+ } finally {
107
+ database?.close()
108
+ }
109
+ }
110
+
111
+ export async function exportUsageEvents(
112
+ env: NodeJS.ProcessEnv = process.env,
113
+ ): Promise<readonly Record<string, unknown>[]> {
114
+ if (!enabled(env)) return []
115
+ let database: Database | undefined
116
+ try {
117
+ database = await openDatabase(env)
118
+ const rows = database
119
+ .query(`
120
+ SELECT event_version, session_id, session_sequence, occurred_at,
121
+ agency_version, command_path, flag_names, duration_ms,
122
+ outcome, exit_status
123
+ FROM usage_events ORDER BY occurred_at, id
124
+ `)
125
+ .all() as Record<string, string | number>[]
126
+ return rows.map((row) => ({
127
+ version: row.event_version,
128
+ sessionId: row.session_id,
129
+ sessionSequence: row.session_sequence,
130
+ occurredAt: row.occurred_at,
131
+ agencyVersion: row.agency_version,
132
+ commandPath: row.command_path,
133
+ flagNames: JSON.parse(String(row.flag_names)),
134
+ durationMs: row.duration_ms,
135
+ outcome: row.outcome,
136
+ exitStatus: row.exit_status,
137
+ }))
138
+ } catch {
139
+ return []
140
+ } finally {
141
+ database?.close()
142
+ }
143
+ }