@bli-cockpit/cli 0.1.9 → 0.1.12

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.
@@ -6,6 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { makeSourceAdapterIdentity, } from "./common.js";
9
+ import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
9
10
  const execFileAsync = promisify(execFile);
10
11
  const DEFAULT_SINCE_MINUTES = 24 * 60;
11
12
  const DEFAULT_SESSION_LIMIT = 50;
@@ -127,6 +128,9 @@ export async function collectRawEvidencePack(context, options) {
127
128
  local_path: entry.local_path,
128
129
  kind: entry.kind,
129
130
  codex_session_id: entry.codex_session_id ?? null,
131
+ ...(entry.artifact_metadata
132
+ ? { artifact_metadata: entry.artifact_metadata }
133
+ : {}),
130
134
  })),
131
135
  reused,
132
136
  };
@@ -197,7 +201,7 @@ async function collectCodexJsonlFiles(collection, options) {
197
201
  .map((filePath) => ({ filePath, codexSessionId: null }));
198
202
  for (const candidate of candidates) {
199
203
  const codexSessionId = candidate.codexSessionId ?? shortHash(candidate.filePath);
200
- await collectOneEvidenceFile(collection, {
204
+ const transcriptAccepted = await collectOneEvidenceFile(collection, {
201
205
  filePath: candidate.filePath,
202
206
  kind: "codex_jsonl",
203
207
  sessionId: codexSessionId,
@@ -205,6 +209,15 @@ async function collectCodexJsonlFiles(collection, options) {
205
209
  redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
206
210
  contentAddress: (hash16) => `codex/${safeKeySegment(codexSessionId)}/${hash16}.jsonl`,
207
211
  });
212
+ if (transcriptAccepted) {
213
+ await collectAgentImagesFromTranscript(collection, {
214
+ filePath: candidate.filePath,
215
+ source: "codex",
216
+ sessionId: codexSessionId,
217
+ kind: "codex_image_attachment",
218
+ contentAddress: (hash16, extension) => `codex/${safeKeySegment(codexSessionId)}/images/${hash16}.${extension}`,
219
+ });
220
+ }
208
221
  }
209
222
  }
210
223
  async function collectClaudeJsonlFiles(collection, sessions) {
@@ -226,7 +239,7 @@ async function collectClaudeJsonlFiles(collection, sessions) {
226
239
  // skipped entry is recorded here.
227
240
  }
228
241
  else {
229
- await collectOneEvidenceFile(collection, {
242
+ const mainAccepted = await collectOneEvidenceFile(collection, {
230
243
  filePath: session.local_path,
231
244
  kind: "claude_jsonl",
232
245
  sessionId,
@@ -238,10 +251,28 @@ async function collectClaudeJsonlFiles(collection, sessions) {
238
251
  redactedSummary: "Raw Claude Code JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
239
252
  contentAddress: (hash16) => `claude/${safeKeySegment(sessionId)}/${hash16}.jsonl`,
240
253
  });
254
+ if (mainAccepted) {
255
+ await collectAgentImagesFromTranscript(collection, {
256
+ filePath: session.local_path,
257
+ source: "claude_code",
258
+ sessionId,
259
+ kind: "claude_image_attachment",
260
+ contentAddress: (hash16, extension) => `claude/${safeKeySegment(sessionId)}/images/${hash16}.${extension}`,
261
+ });
262
+ }
263
+ }
264
+ if (session.skip_main && !session.main_file_oversized) {
265
+ await collectAgentImagesFromTranscript(collection, {
266
+ filePath: session.local_path,
267
+ source: "claude_code",
268
+ sessionId,
269
+ kind: "claude_image_attachment",
270
+ contentAddress: (hash16, extension) => `claude/${safeKeySegment(sessionId)}/images/${hash16}.${extension}`,
271
+ });
241
272
  }
242
273
  for (const sidecar of session.sidecar_files) {
243
274
  const stem = path.basename(sidecar.local_path).replace(/\.jsonl$/i, "");
244
- await collectOneEvidenceFile(collection, {
275
+ const sidecarAccepted = await collectOneEvidenceFile(collection, {
245
276
  filePath: sidecar.local_path,
246
277
  kind: "claude_jsonl_sidecar",
247
278
  sessionId,
@@ -250,9 +281,89 @@ async function collectClaudeJsonlFiles(collection, sessions) {
250
281
  redactedSummary: "Raw Claude Code subagent transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
251
282
  contentAddress: (hash16) => `claude/${safeKeySegment(sessionId)}/subagents/${safeKeySegment(stem)}-${hash16}.jsonl`,
252
283
  });
284
+ if (sidecarAccepted) {
285
+ await collectAgentImagesFromTranscript(collection, {
286
+ filePath: sidecar.local_path,
287
+ source: "claude_code",
288
+ sessionId,
289
+ sidecarId: stem,
290
+ kind: "claude_image_attachment",
291
+ contentAddress: (hash16, extension) => `claude/${safeKeySegment(sessionId)}/subagents/${safeKeySegment(stem)}/images/${hash16}.${extension}`,
292
+ });
293
+ }
253
294
  }
254
295
  }
255
296
  }
297
+ async function collectAgentImagesFromTranscript(collection, options) {
298
+ const result = await collectAgentImageEvidenceFromJsonlFile({
299
+ filePath: options.filePath,
300
+ source: options.source,
301
+ sessionId: options.sessionId,
302
+ sidecarId: options.sidecarId,
303
+ });
304
+ for (const skipped of result.skipped) {
305
+ collection.skipped.push({
306
+ kind: options.kind,
307
+ label: skipped.label,
308
+ reason: skipped.reason,
309
+ });
310
+ }
311
+ for (const image of result.images) {
312
+ await collectOneAgentImageFile(collection, {
313
+ image,
314
+ kind: options.kind,
315
+ sessionId: options.sessionId,
316
+ contentAddress: options.contentAddress,
317
+ });
318
+ }
319
+ }
320
+ async function collectOneAgentImageFile(collection, options) {
321
+ const raw = options.image.bytes;
322
+ const contentHash = sha256(raw);
323
+ const metadata = {
324
+ ...options.image.metadata,
325
+ content_hash_sha256: contentHash,
326
+ byte_size: raw.byteLength,
327
+ };
328
+ if (collection.skipContentHashes.has(contentHash)) {
329
+ collection.reused.push({
330
+ kind: options.kind,
331
+ label: options.image.label,
332
+ content_hash_sha256: contentHash,
333
+ codex_session_id: options.sessionId,
334
+ artifact_metadata: metadata,
335
+ });
336
+ return;
337
+ }
338
+ const deferReason = admitToBudget(collection.budget, raw.byteLength);
339
+ if (deferReason) {
340
+ collection.skipped.push({
341
+ kind: options.kind,
342
+ label: options.image.label,
343
+ reason: deferReason,
344
+ });
345
+ return;
346
+ }
347
+ collection.index.value += 1;
348
+ const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-agent-image-${contentHash.slice(0, 16)}.${options.image.extension}`);
349
+ const destination = path.join(collection.filesDir, path.basename(relativePath));
350
+ await fs.writeFile(destination, raw, { mode: 0o600 });
351
+ await chmodPrivate(destination, 0o600);
352
+ collection.entries.push(evidenceEntry({
353
+ kind: options.kind,
354
+ packId: collection.packId,
355
+ operatorId: collection.context.operatorId,
356
+ workContextId: collection.context.workContextId,
357
+ localPath: destination,
358
+ relativePath,
359
+ mediaType: metadata.media_type,
360
+ redactedSummary: "Raw image explicitly attached to an agent session, preserved in private durable storage.",
361
+ bytes: raw,
362
+ codexSessionId: options.sessionId,
363
+ contentAddress: options.contentAddress(contentHash.slice(0, 16), options.image.extension),
364
+ artifactMetadata: metadata,
365
+ }));
366
+ }
256
367
  /**
257
368
  * Reads, secret-guards, content-addresses, budget-checks, and copies one
258
369
  * attributed transcript into the pack. The secret guard runs again here even
@@ -268,7 +379,7 @@ async function collectOneEvidenceFile(collection, options) {
268
379
  label: fileName,
269
380
  reason: "secret_like_file_name",
270
381
  });
271
- return;
382
+ return false;
272
383
  }
273
384
  let raw;
274
385
  try {
@@ -280,7 +391,7 @@ async function collectOneEvidenceFile(collection, options) {
280
391
  label: fileName,
281
392
  reason: "file_read_failed",
282
393
  });
283
- return;
394
+ return false;
284
395
  }
285
396
  if (options.maxFileBytes && raw.byteLength > options.maxFileBytes) {
286
397
  collection.skipped.push({
@@ -288,7 +399,7 @@ async function collectOneEvidenceFile(collection, options) {
288
399
  label: fileName,
289
400
  reason: "file_too_large",
290
401
  });
291
- return;
402
+ return false;
292
403
  }
293
404
  if (containsSecretLikeContent(raw.toString("utf8"))) {
294
405
  collection.skipped.push({
@@ -296,7 +407,7 @@ async function collectOneEvidenceFile(collection, options) {
296
407
  label: fileName,
297
408
  reason: "secret_like_content_guard",
298
409
  });
299
- return;
410
+ return false;
300
411
  }
301
412
  const contentHash = sha256(raw);
302
413
  if (collection.skipContentHashes.has(contentHash)) {
@@ -306,7 +417,7 @@ async function collectOneEvidenceFile(collection, options) {
306
417
  content_hash_sha256: contentHash,
307
418
  codex_session_id: options.sessionId,
308
419
  });
309
- return;
420
+ return true;
310
421
  }
311
422
  const deferReason = admitToBudget(collection.budget, raw.byteLength);
312
423
  if (deferReason) {
@@ -315,7 +426,7 @@ async function collectOneEvidenceFile(collection, options) {
315
426
  label: fileName,
316
427
  reason: deferReason,
317
428
  });
318
- return;
429
+ return false;
319
430
  }
320
431
  collection.index.value += 1;
321
432
  const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${fileName}`);
@@ -335,6 +446,7 @@ async function collectOneEvidenceFile(collection, options) {
335
446
  codexSessionId: options.sessionId,
336
447
  contentAddress: options.contentAddress(contentHash.slice(0, 16)),
337
448
  }));
449
+ return true;
338
450
  }
339
451
  /**
340
452
  * Decrements the per-sync budget when a file fits, or returns a deferred-skip
@@ -475,6 +587,7 @@ function makeManifest(options) {
475
587
  claude_transcripts: "preserved_private_durable_remote",
476
588
  tool_payloads: "preserved_private_durable_remote",
477
589
  git_diffs: "preserved_private_durable_remote_env_secret_paths_excluded",
590
+ agent_image_attachments: "preserved_private_durable_remote_explicit_agent_session_attachment_only",
478
591
  env_files: "never_read",
479
592
  stdout: "manifest_only_no_raw_content",
480
593
  },
@@ -485,22 +598,35 @@ function makeManifest(options) {
485
598
  }
486
599
  function evidenceEntry(options) {
487
600
  const digest = sha256(options.bytes);
601
+ const objectKey = remoteObjectKey({
602
+ operatorId: options.operatorId,
603
+ workContextId: options.workContextId,
604
+ packId: options.packId,
605
+ relativePath: options.relativePath,
606
+ contentAddress: options.contentAddress,
607
+ });
488
608
  return {
489
609
  kind: options.kind,
490
610
  local_path: options.localPath,
491
611
  relative_path: options.relativePath,
492
- object_key: remoteObjectKey({
493
- operatorId: options.operatorId,
494
- workContextId: options.workContextId,
495
- packId: options.packId,
496
- relativePath: options.relativePath,
497
- contentAddress: options.contentAddress,
498
- }),
612
+ object_key: objectKey,
499
613
  content_hash_sha256: digest,
500
614
  byte_size: options.bytes.byteLength,
501
615
  media_type: options.mediaType,
502
616
  redacted_summary: options.redactedSummary,
503
617
  codex_session_id: options.codexSessionId ?? null,
618
+ ...(options.artifactMetadata
619
+ ? {
620
+ artifact_metadata: {
621
+ ...options.artifactMetadata,
622
+ raw_evidence_pointer_id: objectKey,
623
+ storage_bucket: RAW_EVIDENCE_BUCKET,
624
+ object_key: objectKey,
625
+ content_hash_sha256: digest,
626
+ byte_size: options.bytes.byteLength,
627
+ },
628
+ }
629
+ : {}),
504
630
  };
505
631
  }
506
632
  function redactManifestEntry(entry) {
@@ -512,6 +638,9 @@ function redactManifestEntry(entry) {
512
638
  byte_size: entry.byte_size,
513
639
  media_type: entry.media_type,
514
640
  redacted_summary: entry.redacted_summary,
641
+ ...(entry.artifact_metadata
642
+ ? { artifact_metadata: entry.artifact_metadata }
643
+ : {}),
515
644
  };
516
645
  }
517
646
  function pointerFromEntry(entry) {
package/dist/autostart.js CHANGED
@@ -36,6 +36,10 @@ export async function installAutostartAgent(options) {
36
36
  const plistPath = plistPathFor(homeDir);
37
37
  const stdoutPath = path.join(paths.state_dir, "sync.log");
38
38
  const stderrPath = path.join(paths.state_dir, "sync.err.log");
39
+ // launchd will not reliably watch a path that does not exist at load time, so
40
+ // only feed it the transcript dirs that are present right now. A missing dir
41
+ // is fine — the StartInterval floor still covers it.
42
+ const watchPaths = await existingWatchPaths(homeDir);
39
43
  await mkdir(path.dirname(plistPath), { recursive: true });
40
44
  await mkdir(paths.state_dir, { recursive: true });
41
45
  await writeFile(plistPath, renderPlist({
@@ -44,6 +48,7 @@ export async function installAutostartAgent(options) {
44
48
  intervalSeconds,
45
49
  stdoutPath,
46
50
  stderrPath,
51
+ watchPaths,
47
52
  }), "utf8");
48
53
  // Unload first so a changed plist is actually picked up; a not-yet-loaded
49
54
  // agent makes unload fail harmlessly, so the error is ignored.
@@ -103,10 +108,30 @@ export async function autostartStatus(options) {
103
108
  plist_path: plistPath,
104
109
  };
105
110
  }
111
+ /**
112
+ * The transcript directories whose changes should re-trigger a sync, in the
113
+ * order they appear in WatchPaths. Resolved from the same homeDir as the plist
114
+ * and log paths so `--home` redirects them together.
115
+ */
116
+ function watchPathCandidates(homeDir) {
117
+ return [
118
+ path.join(homeDir, ".claude", "projects"),
119
+ path.join(homeDir, ".codex", "sessions"),
120
+ ];
121
+ }
122
+ /** Subset of the transcript dirs that exist now (see the WatchPaths comment). */
123
+ async function existingWatchPaths(homeDir) {
124
+ const candidates = watchPathCandidates(homeDir);
125
+ const present = await Promise.all(candidates.map((dir) => fileExists(dir)));
126
+ return candidates.filter((_, i) => present[i]);
127
+ }
106
128
  function renderPlist(options) {
107
129
  // The repo path is shell-quoted because it lands inside a `/bin/zsh -lc "…"`
108
130
  // command string; the whole command is then XML-escaped for the <string>.
109
- const command = `npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit sync --repo ${shellQuote(options.workDir)} --dashboard-url ${shellQuote(options.dashboardUrl)} --json`;
131
+ const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
132
+ ? ""
133
+ : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
134
+ const command = `cockpit sync --repo ${shellQuote(options.workDir)}${dashboardArg} --json`;
110
135
  return [
111
136
  '<?xml version="1.0" encoding="UTF-8"?>',
112
137
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -124,6 +149,12 @@ function renderPlist(options) {
124
149
  ` <integer>${options.intervalSeconds}</integer>`,
125
150
  " <key>RunAtLoad</key>",
126
151
  " <true/>",
152
+ // WatchPaths makes a transcript write fire `cockpit sync` within seconds, so
153
+ // captures land near-real-time; StartInterval above is the safety-net floor.
154
+ // It fires often, but launchd single-flights the job, sync holds its own
155
+ // lock, and growing-transcript re-upload is damped — so cost stays bounded
156
+ // and no extra debounce is needed.
157
+ ...watchPathsBlock(options.watchPaths),
127
158
  " <key>EnvironmentVariables</key>",
128
159
  " <dict>",
129
160
  " <key>PATH</key>",
@@ -138,6 +169,21 @@ function renderPlist(options) {
138
169
  "",
139
170
  ].join("\n");
140
171
  }
172
+ /**
173
+ * Renders the `<key>WatchPaths</key><array>…</array>` lines, or nothing when no
174
+ * transcript dir exists yet (an empty array would tell launchd to watch
175
+ * everything-and-nothing; omitting the key leaves StartInterval as the floor).
176
+ */
177
+ function watchPathsBlock(watchPaths) {
178
+ if (watchPaths.length === 0)
179
+ return [];
180
+ return [
181
+ " <key>WatchPaths</key>",
182
+ " <array>",
183
+ ...watchPaths.map((dir) => ` <string>${xmlEscape(dir)}</string>`),
184
+ " </array>",
185
+ ];
186
+ }
141
187
  function shellQuote(value) {
142
188
  return `'${value.replace(/'/g, "'\\''")}'`;
143
189
  }