@bli-cockpit/cli 0.1.8 → 0.1.11
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/README.md +7 -3
- package/dist/adapters/agent-image-evidence.js +97 -0
- package/dist/adapters/agent-image-records.js +147 -0
- package/dist/adapters/agent-image-validation.js +88 -0
- package/dist/adapters/raw-evidence.js +145 -16
- package/dist/autostart.js +197 -0
- package/dist/commands/local-args.js +383 -0
- package/dist/commands/local.js +135 -851
- package/dist/commands/session-sync.js +504 -0
- package/dist/evidence-upload-client.js +7 -0
- package/dist/local-state.js +1 -1
- package/dist/upload.js +152 -3
- package/package.json +2 -2
package/dist/commands/local.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { createCollectorServer } from "../server.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
10
|
-
import { readLocalCollectorConfig } from "../local-state.js";
|
|
5
|
+
import { parseLocalArgs, normalizeUrl } from "./local-args.js";
|
|
6
|
+
import { autostartStatus, installAutostartAgent, uninstallAutostartAgent } from "../autostart.js";
|
|
7
|
+
import { getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext } from "../local-state.js";
|
|
8
|
+
import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
|
|
9
|
+
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
11
10
|
import { acquireSyncLock } from "../sync-lock.js";
|
|
12
|
-
import { discoverGitWorktrees
|
|
11
|
+
import { discoverGitWorktrees } from "../repo-identity.js";
|
|
12
|
+
import { runAttributedWorktreeSync } from "./session-sync.js";
|
|
13
13
|
export const rootCommandNames = new Set([
|
|
14
14
|
"onboard",
|
|
15
15
|
"install",
|
|
@@ -21,6 +21,7 @@ export const rootCommandNames = new Set([
|
|
|
21
21
|
"status",
|
|
22
22
|
"sessions",
|
|
23
23
|
"serve",
|
|
24
|
+
"autostart",
|
|
24
25
|
]);
|
|
25
26
|
export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
26
27
|
if (isLocalHelpRequest(argv)) {
|
|
@@ -57,6 +58,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
57
58
|
return await runSessions(command, io);
|
|
58
59
|
case "serve":
|
|
59
60
|
return await runServe(command, io);
|
|
61
|
+
case "autostart":
|
|
62
|
+
return await runAutostart(command, io);
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
catch (error) {
|
|
@@ -78,6 +81,7 @@ export function localCommandHelp(command) {
|
|
|
78
81
|
" cockpit status [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
79
82
|
" cockpit sessions [--source codex|claude] [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
80
83
|
" cockpit serve [--port <port>] [--repo <path>]",
|
|
84
|
+
" cockpit autostart [install|uninstall|status] [--repo <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
|
|
81
85
|
].join("\n");
|
|
82
86
|
}
|
|
83
87
|
function localSubcommandHelp(command) {
|
|
@@ -169,6 +173,17 @@ function localSubcommandHelp(command) {
|
|
|
169
173
|
"Starts the local collector HTTP status server.",
|
|
170
174
|
],
|
|
171
175
|
],
|
|
176
|
+
[
|
|
177
|
+
"autostart",
|
|
178
|
+
[
|
|
179
|
+
"Usage: cockpit autostart [install|uninstall|status] [--repo <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
|
|
180
|
+
"",
|
|
181
|
+
"Installs a macOS launchd LaunchAgent that runs `cockpit sync` at login and",
|
|
182
|
+
"every 30 min (default), surviving reboots — so machines never drift to Stale.",
|
|
183
|
+
"Action defaults to `install`. `--repo` is the parent work folder to sync.",
|
|
184
|
+
"macOS-only for now; see docs/runbooks/cockpit-launchd-sync.md.",
|
|
185
|
+
],
|
|
186
|
+
],
|
|
172
187
|
]);
|
|
173
188
|
return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
|
|
174
189
|
}
|
|
@@ -178,284 +193,6 @@ function isLocalHelpRequest(argv) {
|
|
|
178
193
|
return false;
|
|
179
194
|
return argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h");
|
|
180
195
|
}
|
|
181
|
-
function parseLocalArgs(argv) {
|
|
182
|
-
const command = argv[0];
|
|
183
|
-
switch (command) {
|
|
184
|
-
case "onboard":
|
|
185
|
-
return parseOnboardArgs(argv.slice(1));
|
|
186
|
-
case "install":
|
|
187
|
-
return parseInstallArgs(argv.slice(1));
|
|
188
|
-
case "login":
|
|
189
|
-
case "pair":
|
|
190
|
-
return parseLoginArgs(argv.slice(1));
|
|
191
|
-
case "logout":
|
|
192
|
-
return parseLogoutArgs(argv.slice(1));
|
|
193
|
-
case "start":
|
|
194
|
-
return parseStartArgs(argv.slice(1));
|
|
195
|
-
case "sync":
|
|
196
|
-
return parseSyncArgs(argv.slice(1));
|
|
197
|
-
case "status":
|
|
198
|
-
return parseStatusArgs(argv.slice(1));
|
|
199
|
-
case "sessions":
|
|
200
|
-
return parseSessionsArgs(argv.slice(1));
|
|
201
|
-
case "serve":
|
|
202
|
-
return parseServeArgs(argv.slice(1));
|
|
203
|
-
default:
|
|
204
|
-
throw new Error(`Unknown local command: ${command ?? ""}`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
function parseOnboardArgs(args) {
|
|
208
|
-
const values = parseNamedArgs(args, {
|
|
209
|
-
allowedFlags: [
|
|
210
|
-
"--home",
|
|
211
|
-
"--repo",
|
|
212
|
-
"--dashboard-url",
|
|
213
|
-
"--email",
|
|
214
|
-
"--device-name",
|
|
215
|
-
"--ticket",
|
|
216
|
-
"--branch",
|
|
217
|
-
"--json",
|
|
218
|
-
"--poll-interval-ms",
|
|
219
|
-
"--timeout-ms",
|
|
220
|
-
"--max-depth",
|
|
221
|
-
"--max-repos",
|
|
222
|
-
],
|
|
223
|
-
valueFlags: [
|
|
224
|
-
"--home",
|
|
225
|
-
"--repo",
|
|
226
|
-
"--dashboard-url",
|
|
227
|
-
"--email",
|
|
228
|
-
"--device-name",
|
|
229
|
-
"--ticket",
|
|
230
|
-
"--branch",
|
|
231
|
-
"--poll-interval-ms",
|
|
232
|
-
"--timeout-ms",
|
|
233
|
-
"--max-depth",
|
|
234
|
-
"--max-repos",
|
|
235
|
-
],
|
|
236
|
-
});
|
|
237
|
-
assertNoPositionals(values.positionals, "onboard");
|
|
238
|
-
return {
|
|
239
|
-
kind: "onboard",
|
|
240
|
-
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
241
|
-
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
242
|
-
dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
|
|
243
|
-
claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
|
|
244
|
-
deviceName: optionalNonEmpty(values.flags.get("--device-name")),
|
|
245
|
-
activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
|
|
246
|
-
branch: optionalNonEmpty(values.flags.get("--branch")),
|
|
247
|
-
json: values.booleans.has("--json"),
|
|
248
|
-
pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
|
|
249
|
-
timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
|
|
250
|
-
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
251
|
-
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
function parseInstallArgs(args) {
|
|
255
|
-
const values = parseNamedArgs(args, {
|
|
256
|
-
allowedFlags: [
|
|
257
|
-
"--home",
|
|
258
|
-
"--repo",
|
|
259
|
-
"--dashboard-url",
|
|
260
|
-
"--supabase-url",
|
|
261
|
-
"--json",
|
|
262
|
-
],
|
|
263
|
-
valueFlags: ["--home", "--repo", "--dashboard-url", "--supabase-url"],
|
|
264
|
-
});
|
|
265
|
-
assertNoPositionals(values.positionals, "install");
|
|
266
|
-
return {
|
|
267
|
-
kind: "install",
|
|
268
|
-
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
269
|
-
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
270
|
-
dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
|
|
271
|
-
supabaseUrl: optionalNonEmpty(values.flags.get("--supabase-url")),
|
|
272
|
-
json: values.booleans.has("--json"),
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
function parseLoginArgs(args) {
|
|
276
|
-
const values = parseNamedArgs(args, {
|
|
277
|
-
allowedFlags: [
|
|
278
|
-
"--home",
|
|
279
|
-
"--dashboard-url",
|
|
280
|
-
"--email",
|
|
281
|
-
"--device-name",
|
|
282
|
-
"--json",
|
|
283
|
-
"--poll-interval-ms",
|
|
284
|
-
"--timeout-ms",
|
|
285
|
-
],
|
|
286
|
-
valueFlags: [
|
|
287
|
-
"--home",
|
|
288
|
-
"--dashboard-url",
|
|
289
|
-
"--email",
|
|
290
|
-
"--device-name",
|
|
291
|
-
"--poll-interval-ms",
|
|
292
|
-
"--timeout-ms",
|
|
293
|
-
],
|
|
294
|
-
});
|
|
295
|
-
assertNoPositionals(values.positionals, "login");
|
|
296
|
-
return {
|
|
297
|
-
kind: "login",
|
|
298
|
-
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
299
|
-
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
300
|
-
claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
|
|
301
|
-
deviceName: optionalNonEmpty(values.flags.get("--device-name")),
|
|
302
|
-
json: values.booleans.has("--json"),
|
|
303
|
-
pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
|
|
304
|
-
timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
function parseLogoutArgs(args) {
|
|
308
|
-
const values = parseNamedArgs(args, {
|
|
309
|
-
allowedFlags: ["--home", "--json"],
|
|
310
|
-
valueFlags: ["--home"],
|
|
311
|
-
});
|
|
312
|
-
assertNoPositionals(values.positionals, "logout");
|
|
313
|
-
return {
|
|
314
|
-
kind: "logout",
|
|
315
|
-
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
316
|
-
json: values.booleans.has("--json"),
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
function parseStartArgs(args) {
|
|
320
|
-
const values = parseNamedArgs(args, {
|
|
321
|
-
allowedFlags: [
|
|
322
|
-
"--home",
|
|
323
|
-
"--repo",
|
|
324
|
-
"--branch",
|
|
325
|
-
"--ticket",
|
|
326
|
-
"--operator-id",
|
|
327
|
-
"--session-id",
|
|
328
|
-
"--json",
|
|
329
|
-
"--max-depth",
|
|
330
|
-
"--max-repos",
|
|
331
|
-
],
|
|
332
|
-
valueFlags: [
|
|
333
|
-
"--home",
|
|
334
|
-
"--repo",
|
|
335
|
-
"--branch",
|
|
336
|
-
"--ticket",
|
|
337
|
-
"--operator-id",
|
|
338
|
-
"--session-id",
|
|
339
|
-
"--max-depth",
|
|
340
|
-
"--max-repos",
|
|
341
|
-
],
|
|
342
|
-
});
|
|
343
|
-
assertNoPositionals(values.positionals, "start");
|
|
344
|
-
return {
|
|
345
|
-
kind: "start",
|
|
346
|
-
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
347
|
-
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
348
|
-
branch: optionalNonEmpty(values.flags.get("--branch")),
|
|
349
|
-
activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
|
|
350
|
-
operatorId: optionalNonEmpty(values.flags.get("--operator-id")),
|
|
351
|
-
sessionId: optionalNonEmpty(values.flags.get("--session-id")),
|
|
352
|
-
json: values.booleans.has("--json"),
|
|
353
|
-
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
354
|
-
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
function parseSyncArgs(args) {
|
|
358
|
-
const values = parseNamedArgs(args, {
|
|
359
|
-
allowedFlags: ["--home", "--repo", "--dashboard-url", "--json", "--max-depth", "--max-repos"],
|
|
360
|
-
valueFlags: ["--home", "--repo", "--dashboard-url", "--max-depth", "--max-repos"],
|
|
361
|
-
});
|
|
362
|
-
assertNoPositionals(values.positionals, "sync");
|
|
363
|
-
return {
|
|
364
|
-
kind: "sync",
|
|
365
|
-
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
366
|
-
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
367
|
-
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
368
|
-
json: values.booleans.has("--json"),
|
|
369
|
-
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
370
|
-
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
function parseStatusArgs(args) {
|
|
374
|
-
const values = parseNamedArgs(args, {
|
|
375
|
-
allowedFlags: ["--home", "--repo", "--json", "--max-depth", "--max-repos"],
|
|
376
|
-
valueFlags: ["--home", "--repo", "--max-depth", "--max-repos"],
|
|
377
|
-
});
|
|
378
|
-
assertNoPositionals(values.positionals, "status");
|
|
379
|
-
return {
|
|
380
|
-
kind: "status",
|
|
381
|
-
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
382
|
-
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
383
|
-
json: values.booleans.has("--json"),
|
|
384
|
-
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
385
|
-
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
function parseSessionsArgs(args) {
|
|
389
|
-
const values = parseNamedArgs(args, {
|
|
390
|
-
allowedFlags: ["--home", "--repo", "--source", "--json", "--max-depth", "--max-repos"],
|
|
391
|
-
valueFlags: ["--home", "--repo", "--source", "--max-depth", "--max-repos"],
|
|
392
|
-
});
|
|
393
|
-
assertNoPositionals(values.positionals, "sessions");
|
|
394
|
-
const source = values.flags.get("--source");
|
|
395
|
-
if (source !== undefined && source !== "codex" && source !== "claude") {
|
|
396
|
-
throw new Error("--source must be 'codex' or 'claude'.");
|
|
397
|
-
}
|
|
398
|
-
return {
|
|
399
|
-
kind: "sessions",
|
|
400
|
-
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
401
|
-
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
402
|
-
source,
|
|
403
|
-
json: values.booleans.has("--json"),
|
|
404
|
-
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
405
|
-
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
function parseServeArgs(args) {
|
|
409
|
-
const values = parseNamedArgs(args, {
|
|
410
|
-
allowedFlags: ["--home", "--repo", "--port"],
|
|
411
|
-
valueFlags: ["--home", "--repo", "--port"],
|
|
412
|
-
});
|
|
413
|
-
assertNoPositionals(values.positionals, "serve");
|
|
414
|
-
const port = Number(values.flags.get("--port") ?? "4174");
|
|
415
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
416
|
-
throw new Error("--port must be a TCP port between 1 and 65535.");
|
|
417
|
-
}
|
|
418
|
-
return {
|
|
419
|
-
kind: "serve",
|
|
420
|
-
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
421
|
-
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
422
|
-
port,
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
function parseNamedArgs(args, options) {
|
|
426
|
-
const allowed = new Set(options.allowedFlags);
|
|
427
|
-
const valueFlags = new Set(options.valueFlags);
|
|
428
|
-
const flags = new Map();
|
|
429
|
-
const booleans = new Set();
|
|
430
|
-
const positionals = [];
|
|
431
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
432
|
-
const arg = args[index] ?? "";
|
|
433
|
-
rejectServiceRoleLikeArgument(arg);
|
|
434
|
-
if (!arg.startsWith("--")) {
|
|
435
|
-
positionals.push(arg);
|
|
436
|
-
continue;
|
|
437
|
-
}
|
|
438
|
-
const [flag, inlineValue] = arg.split("=", 2);
|
|
439
|
-
if (!allowed.has(flag))
|
|
440
|
-
throw new Error(`Unknown flag: ${flag}`);
|
|
441
|
-
if (valueFlags.has(flag)) {
|
|
442
|
-
const value = inlineValue ?? args[index + 1];
|
|
443
|
-
if (!value || value.startsWith("--")) {
|
|
444
|
-
throw new Error(`${flag} requires a value.`);
|
|
445
|
-
}
|
|
446
|
-
rejectServiceRoleLikeArgument(value);
|
|
447
|
-
flags.set(flag, value);
|
|
448
|
-
if (inlineValue === undefined)
|
|
449
|
-
index += 1;
|
|
450
|
-
}
|
|
451
|
-
else {
|
|
452
|
-
if (inlineValue !== undefined)
|
|
453
|
-
throw new Error(`${flag} does not accept a value.`);
|
|
454
|
-
booleans.add(flag);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
return { flags, booleans, positionals };
|
|
458
|
-
}
|
|
459
196
|
async function runInstall(command, io) {
|
|
460
197
|
const result = await installLocalCollector(command);
|
|
461
198
|
if (command.json) {
|
|
@@ -473,17 +210,14 @@ function isInteractiveStdin(io) {
|
|
|
473
210
|
return Boolean(io.stdin.isTTY);
|
|
474
211
|
}
|
|
475
212
|
/**
|
|
476
|
-
* Reads one line from stdin
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
* never block on input — they keep the existing behaviour (email optional; the
|
|
480
|
-
* approving admin's account owns the device). An empty answer or a non-email
|
|
481
|
-
* skips rather than failing, matching `optionalEmail`'s leniency.
|
|
213
|
+
* Reads one line from stdin after writing a prompt. Shared by the onboard email
|
|
214
|
+
* prompt and the autostart prompt; callers gate on `isInteractiveStdin` first so
|
|
215
|
+
* headless / piped / spawned runs never block on input.
|
|
482
216
|
*/
|
|
483
|
-
async function
|
|
484
|
-
io.stdout.write(
|
|
217
|
+
async function readLine(io, prompt) {
|
|
218
|
+
io.stdout.write(prompt);
|
|
485
219
|
io.stdin.setEncoding("utf8");
|
|
486
|
-
|
|
220
|
+
return new Promise((resolve) => {
|
|
487
221
|
const onData = (chunk) => {
|
|
488
222
|
io.stdin.removeListener("data", onData);
|
|
489
223
|
io.stdin.pause();
|
|
@@ -492,6 +226,15 @@ async function promptOnboardEmail(io) {
|
|
|
492
226
|
io.stdin.resume();
|
|
493
227
|
io.stdin.on("data", onData);
|
|
494
228
|
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Asks for the dashboard email so `cockpit onboard` (no flags) need not force a
|
|
232
|
+
* `--email`. Only called when stdin is a TTY and not in --json mode. An empty
|
|
233
|
+
* answer or a non-email skips rather than failing, matching `optionalEmail`'s
|
|
234
|
+
* leniency (the approving admin's account then owns the device).
|
|
235
|
+
*/
|
|
236
|
+
async function promptOnboardEmail(io) {
|
|
237
|
+
const raw = await readLine(io, "Dashboard email (press enter to skip): ");
|
|
495
238
|
const answer = raw.trim().toLowerCase();
|
|
496
239
|
if (!answer)
|
|
497
240
|
return undefined;
|
|
@@ -501,6 +244,38 @@ async function promptOnboardEmail(io) {
|
|
|
501
244
|
}
|
|
502
245
|
return answer;
|
|
503
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* After a successful onboard, offers to install the launchd autostart agent so a
|
|
249
|
+
* Mac mini keeps syncing without anyone re-running cockpit — this is the fix for
|
|
250
|
+
* interns drifting to Stale. Only in an interactive, non-JSON run with a real
|
|
251
|
+
* exec runner (`io.exec`): headless / piped / spawned onboards and tests that
|
|
252
|
+
* pass no exec skip it entirely. Declining leaves onboarding's success untouched.
|
|
253
|
+
*/
|
|
254
|
+
async function maybeOfferAutostart(command, io) {
|
|
255
|
+
if (command.json || !isInteractiveStdin(io) || !io.exec)
|
|
256
|
+
return;
|
|
257
|
+
const answer = (await readLine(io, "Keep Cockpit syncing in the background, even after restart? [Y/n] "))
|
|
258
|
+
.trim()
|
|
259
|
+
.toLowerCase();
|
|
260
|
+
if (answer === "n" || answer === "no") {
|
|
261
|
+
writeLine(io.stdout, "Skipped background autostart. Run `cockpit autostart install` anytime.");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const result = await installAutostartAgent({
|
|
265
|
+
homeDir: command.homeDir,
|
|
266
|
+
repoRoot: command.repoRoot,
|
|
267
|
+
dashboardUrl: command.dashboardUrl,
|
|
268
|
+
exec: io.exec,
|
|
269
|
+
});
|
|
270
|
+
if (result.status === "unsupported") {
|
|
271
|
+
writeLine(io.stdout, `Background autostart unsupported: ${result.message}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
writeLine(io.stdout, result.loaded
|
|
275
|
+
? "Background autostart installed; Cockpit syncs at login and every 30 min."
|
|
276
|
+
: "Background autostart installed, but launchctl load reported a problem; check `cockpit autostart status`.");
|
|
277
|
+
writeLine(io.stdout, `Plist: ${result.plist_path}`);
|
|
278
|
+
}
|
|
504
279
|
async function runOnboard(command, io) {
|
|
505
280
|
let install = null;
|
|
506
281
|
let pair = null;
|
|
@@ -574,6 +349,8 @@ async function runOnboard(command, io) {
|
|
|
574
349
|
codex_sessions: multi.codex_sessions,
|
|
575
350
|
}, null, 2));
|
|
576
351
|
}
|
|
352
|
+
if (multi.ok)
|
|
353
|
+
await maybeOfferAutostart(command, io);
|
|
577
354
|
return multi.ok ? 0 : 1;
|
|
578
355
|
}
|
|
579
356
|
const context = await startLocalWorkContext({
|
|
@@ -638,6 +415,7 @@ async function runOnboard(command, io) {
|
|
|
638
415
|
writeLine(io.stdout, `Upload state: ${status.upload_state}`);
|
|
639
416
|
writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
|
|
640
417
|
writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
|
|
418
|
+
await maybeOfferAutostart(command, io);
|
|
641
419
|
return 0;
|
|
642
420
|
}
|
|
643
421
|
catch (error) {
|
|
@@ -694,497 +472,6 @@ async function readOnboardSessionReuseCandidate(homeDir) {
|
|
|
694
472
|
function normalizeUrlForComparison(value) {
|
|
695
473
|
return value ? normalizeUrl(value) : null;
|
|
696
474
|
}
|
|
697
|
-
const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
|
|
698
|
-
const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
|
|
699
|
-
const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
700
|
-
/**
|
|
701
|
-
* Shared dual-source sync orchestration for single-repo and parent-folder
|
|
702
|
-
* modes. Codex AND Claude Code sessions are scanned and attributed once across
|
|
703
|
-
* every discovered worktree; each worktree syncs with only its own attributed
|
|
704
|
-
* transcripts (codex + claude main + sidecars), and the
|
|
705
|
-
* ambiguous/unattributed/skipped remainder is reported with reason labels and a
|
|
706
|
-
* `source` discriminator instead of being duplicated into every repo or
|
|
707
|
-
* silently dropped. The session row's upload state maps ONLY from the main-file
|
|
708
|
-
* outcome (D3); sidecar outcomes aggregate into CLI counts.
|
|
709
|
-
*/
|
|
710
|
-
async function runAttributedWorktreeSync(options) {
|
|
711
|
-
const now = new Date();
|
|
712
|
-
const homeDir = options.homeDir ?? os.homedir();
|
|
713
|
-
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
714
|
-
const claudeEnabled = await isClaudeCollectionEnabled(paths);
|
|
715
|
-
const codexAttribution = await scanAndAttributeCodexSessions({
|
|
716
|
-
sessionsDir: path.join(homeDir, ".codex", "sessions"),
|
|
717
|
-
worktrees: options.worktrees,
|
|
718
|
-
now,
|
|
719
|
-
});
|
|
720
|
-
// First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
|
|
721
|
-
// days so the first sync captures retroactive history instead of only 24h.
|
|
722
|
-
const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
|
|
723
|
-
const firstRunBackfill = claudeEnabled && !claudeCursorExists;
|
|
724
|
-
const claudeAttribution = claudeEnabled
|
|
725
|
-
? await scanAndAttributeClaudeSessions({
|
|
726
|
-
projectsDir: path.join(homeDir, ".claude", "projects"),
|
|
727
|
-
worktrees: options.worktrees,
|
|
728
|
-
now,
|
|
729
|
-
sinceMinutes: firstRunBackfill
|
|
730
|
-
? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
|
|
731
|
-
: undefined,
|
|
732
|
-
})
|
|
733
|
-
: emptyClaudeScan();
|
|
734
|
-
// Read the Claude cursor up front: damping decisions need prior upload state.
|
|
735
|
-
const claudeCursorBefore = claudeEnabled
|
|
736
|
-
? await readRawEvidenceCursor(paths, {
|
|
737
|
-
filename: CLAUDE_CURSOR_FILENAME,
|
|
738
|
-
}).catch(() => emptyRawEvidenceCursorState())
|
|
739
|
-
: emptyRawEvidenceCursorState();
|
|
740
|
-
// sessionId -> prior durable pointer for damped sessions (drives the
|
|
741
|
-
// growth_damped count + the skip_main decision).
|
|
742
|
-
const dampedClaudePointers = new Map();
|
|
743
|
-
// sessionId -> prior durable pointer for EVERY already-durable Claude session
|
|
744
|
-
// (superset of damped). A session that was durable before but had no fresh
|
|
745
|
-
// upload this sync (damped, spooled, budget-deferred) reports reused_existing
|
|
746
|
-
// with this pointer instead of not_uploaded, so the store row never flips.
|
|
747
|
-
const claudePriorDurablePointers = new Map();
|
|
748
|
-
for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
|
|
749
|
-
if (entry.uploaded_object_key) {
|
|
750
|
-
claudePriorDurablePointers.set(sessionId, entry.uploaded_object_key);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
// One shared budget for the whole sync (D7b is per-sync): a parent-folder
|
|
754
|
-
// sync over many worktrees honors a single byte/object cap rather than N×.
|
|
755
|
-
const rawEvidenceBudget = {
|
|
756
|
-
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
757
|
-
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
758
|
-
};
|
|
759
|
-
const outcomes = [];
|
|
760
|
-
let ok = true;
|
|
761
|
-
for (const worktree of options.worktrees) {
|
|
762
|
-
let context = null;
|
|
763
|
-
if (options.startContexts) {
|
|
764
|
-
context = await startLocalWorkContext({
|
|
765
|
-
homeDir: options.homeDir,
|
|
766
|
-
repoRoot: worktree.repo_root,
|
|
767
|
-
branch: options.branch,
|
|
768
|
-
activeTicketId: options.activeTicketId,
|
|
769
|
-
operatorId: options.operatorId,
|
|
770
|
-
sessionId: options.sessionId,
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
const claudeSessionFiles = claudeAttribution.results
|
|
774
|
-
.filter((result) => result.state === "attributed" &&
|
|
775
|
-
result.worktree?.worktree_fingerprint ===
|
|
776
|
-
worktree.worktree_fingerprint)
|
|
777
|
-
.map((result) => {
|
|
778
|
-
const damped = !result.main_file_oversized &&
|
|
779
|
-
shouldDampClaudeMain(result, claudeCursorBefore, now);
|
|
780
|
-
if (damped) {
|
|
781
|
-
dampedClaudePointers.set(result.claude_session_id, claudeCursorBefore.sessions[result.claude_session_id]
|
|
782
|
-
?.uploaded_object_key ?? null);
|
|
783
|
-
}
|
|
784
|
-
return {
|
|
785
|
-
local_path: result.file_path,
|
|
786
|
-
claude_session_id: result.claude_session_id,
|
|
787
|
-
main_file_oversized: result.main_file_oversized,
|
|
788
|
-
skip_main: damped,
|
|
789
|
-
sidecar_files: result.sidecar_files
|
|
790
|
-
.filter((sidecar) => !sidecar.skipped_reason)
|
|
791
|
-
.map((sidecar) => ({ local_path: sidecar.local_path })),
|
|
792
|
-
};
|
|
793
|
-
});
|
|
794
|
-
const syncOptions = {
|
|
795
|
-
homeDir: options.homeDir,
|
|
796
|
-
repoRoot: worktree.repo_root,
|
|
797
|
-
dashboardUrl: options.dashboardUrl,
|
|
798
|
-
codexSessionFiles: codexAttribution.results
|
|
799
|
-
.filter((result) => result.state === "attributed" &&
|
|
800
|
-
result.worktree?.worktree_fingerprint ===
|
|
801
|
-
worktree.worktree_fingerprint)
|
|
802
|
-
.map((result) => ({
|
|
803
|
-
local_path: result.file_path,
|
|
804
|
-
codex_session_id: result.codex_session_id,
|
|
805
|
-
})),
|
|
806
|
-
claudeSessionFiles,
|
|
807
|
-
rawEvidenceBudget,
|
|
808
|
-
fetch: options.fetchImpl,
|
|
809
|
-
};
|
|
810
|
-
let sync;
|
|
811
|
-
try {
|
|
812
|
-
sync = await syncLocalAmbientEnvelope(syncOptions);
|
|
813
|
-
}
|
|
814
|
-
catch (error) {
|
|
815
|
-
// A newly cloned repo has no work context yet. Capture is permissive
|
|
816
|
-
// and ticket binding comes later, so start general ambient capture for
|
|
817
|
-
// it instead of blocking every other repo's sync until someone runs
|
|
818
|
-
// `cockpit start` by hand.
|
|
819
|
-
if (error instanceof LocalUploadBlockedError &&
|
|
820
|
-
error.blocker === "missing_context") {
|
|
821
|
-
context = await startLocalWorkContext({
|
|
822
|
-
homeDir: options.homeDir,
|
|
823
|
-
repoRoot: worktree.repo_root,
|
|
824
|
-
branch: options.branch,
|
|
825
|
-
});
|
|
826
|
-
sync = await syncLocalAmbientEnvelope(syncOptions);
|
|
827
|
-
}
|
|
828
|
-
else {
|
|
829
|
-
throw error;
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
ok = ok && sync.status === "uploaded";
|
|
833
|
-
outcomes.push({ worktree, context, sync });
|
|
834
|
-
}
|
|
835
|
-
const sessions = buildAgentSessionReport({
|
|
836
|
-
codexResults: codexAttribution.results,
|
|
837
|
-
claudeResults: claudeAttribution.results,
|
|
838
|
-
outcomes,
|
|
839
|
-
now,
|
|
840
|
-
claudePriorDurablePointers,
|
|
841
|
-
});
|
|
842
|
-
// The sessions cursor is an optimization; a broken local state dir must not
|
|
843
|
-
// turn already-completed syncs into a CLI crash.
|
|
844
|
-
let codexStaleCount = 0;
|
|
845
|
-
let claudeStaleCount = 0;
|
|
846
|
-
try {
|
|
847
|
-
const codexCursor = await readRawEvidenceCursor(paths);
|
|
848
|
-
codexStaleCount = recordSourceObservations({
|
|
849
|
-
cursor: codexCursor,
|
|
850
|
-
results: codexAttribution.results,
|
|
851
|
-
sessions,
|
|
852
|
-
source: "codex",
|
|
853
|
-
sessionIdOf: (result) => result.codex_session_id,
|
|
854
|
-
now,
|
|
855
|
-
});
|
|
856
|
-
codexCursor.updated_at = now.toISOString();
|
|
857
|
-
await writeRawEvidenceCursor(paths, codexCursor);
|
|
858
|
-
}
|
|
859
|
-
catch {
|
|
860
|
-
// Best-effort: stale counts read 0 and observations re-record next sync.
|
|
861
|
-
}
|
|
862
|
-
if (claudeEnabled) {
|
|
863
|
-
try {
|
|
864
|
-
claudeStaleCount = recordSourceObservations({
|
|
865
|
-
cursor: claudeCursorBefore,
|
|
866
|
-
results: claudeAttribution.results,
|
|
867
|
-
sessions,
|
|
868
|
-
source: "claude_code",
|
|
869
|
-
sessionIdOf: (result) => result.claude_session_id,
|
|
870
|
-
now,
|
|
871
|
-
priorCursor: claudeCursorBefore,
|
|
872
|
-
});
|
|
873
|
-
claudeCursorBefore.updated_at = now.toISOString();
|
|
874
|
-
await writeRawEvidenceCursor(paths, claudeCursorBefore, {
|
|
875
|
-
filename: CLAUDE_CURSOR_FILENAME,
|
|
876
|
-
sessionsOnly: true,
|
|
877
|
-
});
|
|
878
|
-
}
|
|
879
|
-
catch {
|
|
880
|
-
// Best-effort: a broken Claude cursor must not fail the sync.
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
|
|
884
|
-
const report = firstUploaded
|
|
885
|
-
? await postCodexSessionReport({
|
|
886
|
-
homeDir: options.homeDir,
|
|
887
|
-
repoRoot: firstUploaded.worktree.repo_root,
|
|
888
|
-
dashboardUrl: options.dashboardUrl,
|
|
889
|
-
sessions,
|
|
890
|
-
fetch: options.fetchImpl,
|
|
891
|
-
now,
|
|
892
|
-
})
|
|
893
|
-
: {
|
|
894
|
-
posted: false,
|
|
895
|
-
reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
|
|
896
|
-
};
|
|
897
|
-
const summary = buildAgentSessionSummary({
|
|
898
|
-
codexAttribution,
|
|
899
|
-
claudeAttribution,
|
|
900
|
-
outcomes,
|
|
901
|
-
codexStaleCount,
|
|
902
|
-
claudeStaleCount,
|
|
903
|
-
firstRunBackfill,
|
|
904
|
-
growthDamped: dampedClaudePointers.size,
|
|
905
|
-
report,
|
|
906
|
-
});
|
|
907
|
-
return { ok, outcomes, codexAttribution, claudeAttribution, summary };
|
|
908
|
-
}
|
|
909
|
-
const ATTRIBUTION_STATE_RANK = {
|
|
910
|
-
attributed: 3,
|
|
911
|
-
ambiguous: 2,
|
|
912
|
-
unattributed: 1,
|
|
913
|
-
skipped: 0,
|
|
914
|
-
};
|
|
915
|
-
function normalizeCodexResult(result) {
|
|
916
|
-
return {
|
|
917
|
-
source: "codex",
|
|
918
|
-
session_id: result.codex_session_id,
|
|
919
|
-
state: result.state,
|
|
920
|
-
reason: result.reason,
|
|
921
|
-
signals: result.signals,
|
|
922
|
-
attribution_score: result.attribution_score,
|
|
923
|
-
path_score: result.path_score,
|
|
924
|
-
content_hash_sha256: result.content_hash_sha256,
|
|
925
|
-
byte_size: result.byte_size,
|
|
926
|
-
session_file_mtime: result.session_file_mtime,
|
|
927
|
-
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
928
|
-
worktree: result.worktree,
|
|
929
|
-
cwd_basename: result.cwd_basename,
|
|
930
|
-
cwd_hash: result.cwd_hash,
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
function normalizeClaudeResult(result) {
|
|
934
|
-
return {
|
|
935
|
-
source: "claude_code",
|
|
936
|
-
session_id: result.claude_session_id,
|
|
937
|
-
state: result.state,
|
|
938
|
-
reason: result.reason,
|
|
939
|
-
signals: result.signals,
|
|
940
|
-
attribution_score: result.attribution_score,
|
|
941
|
-
path_score: result.path_score,
|
|
942
|
-
content_hash_sha256: result.content_hash_sha256,
|
|
943
|
-
byte_size: result.byte_size,
|
|
944
|
-
session_file_mtime: result.session_file_mtime,
|
|
945
|
-
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
946
|
-
worktree: result.worktree,
|
|
947
|
-
cwd_basename: result.cwd_basename,
|
|
948
|
-
cwd_hash: result.cwd_hash,
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
/**
|
|
952
|
-
* Generalizes the per-session report across sources. Dedupe is per
|
|
953
|
-
* `(source, session_id)` so a Codex session and a Claude session that happen to
|
|
954
|
-
* share an id are never collapsed. Upload state maps ONLY from the main-file
|
|
955
|
-
* outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
|
|
956
|
-
* session's upload state (D3). Damped Claude sessions report `reused_existing`
|
|
957
|
-
* carrying their prior durable pointer.
|
|
958
|
-
*/
|
|
959
|
-
function buildAgentSessionReport(options) {
|
|
960
|
-
const normalized = [
|
|
961
|
-
...options.codexResults.map(normalizeCodexResult),
|
|
962
|
-
...options.claudeResults.map(normalizeClaudeResult),
|
|
963
|
-
];
|
|
964
|
-
const bestByKey = new Map();
|
|
965
|
-
for (const result of normalized) {
|
|
966
|
-
const key = `${result.source}:${result.session_id}`;
|
|
967
|
-
const existing = bestByKey.get(key);
|
|
968
|
-
if (!existing ||
|
|
969
|
-
(ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
|
|
970
|
-
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
|
|
971
|
-
((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
|
|
972
|
-
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
|
|
973
|
-
result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
|
|
974
|
-
bestByKey.set(key, result);
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
// Main-file outcomes only (D3): a sidecar making it must never mark a session
|
|
978
|
-
// uploaded when the main did not.
|
|
979
|
-
const uploadByKey = new Map();
|
|
980
|
-
for (const outcome of options.outcomes) {
|
|
981
|
-
if (outcome.sync.status !== "uploaded")
|
|
982
|
-
continue;
|
|
983
|
-
for (const upload of outcome.sync.raw_evidence_outcomes) {
|
|
984
|
-
if (!upload.codex_session_id || !upload.raw_evidence_pointer_id)
|
|
985
|
-
continue;
|
|
986
|
-
const source = upload.kind === "claude_jsonl"
|
|
987
|
-
? "claude_code"
|
|
988
|
-
: upload.kind === "codex_jsonl"
|
|
989
|
-
? "codex"
|
|
990
|
-
: null;
|
|
991
|
-
if (!source)
|
|
992
|
-
continue; // sidecars and other kinds do not set session state
|
|
993
|
-
uploadByKey.set(`${source}:${upload.codex_session_id}`, upload);
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
return [...bestByKey.values()].map((result) => {
|
|
997
|
-
const key = `${result.source}:${result.session_id}`;
|
|
998
|
-
const upload = uploadByKey.get(key);
|
|
999
|
-
// A previously-durable Claude session with no fresh main upload this sync
|
|
1000
|
-
// (damped / spooled / budget-deferred) reports reused_existing + its prior
|
|
1001
|
-
// pointer rather than not_uploaded, so the store row never flips.
|
|
1002
|
-
const priorDurablePointer = result.source === "claude_code"
|
|
1003
|
-
? (options.claudePriorDurablePointers.get(result.session_id) ?? null)
|
|
1004
|
-
: null;
|
|
1005
|
-
return {
|
|
1006
|
-
codex_session_id: result.session_id,
|
|
1007
|
-
source: result.source,
|
|
1008
|
-
observed_at: options.now.toISOString(),
|
|
1009
|
-
attribution_state: result.state,
|
|
1010
|
-
attribution_reason: result.reason,
|
|
1011
|
-
attribution_score: result.attribution_score,
|
|
1012
|
-
path_score: result.path_score,
|
|
1013
|
-
signals: result.signals,
|
|
1014
|
-
...(result.content_hash_sha256
|
|
1015
|
-
? { session_file_hash_sha256: result.content_hash_sha256 }
|
|
1016
|
-
: {}),
|
|
1017
|
-
session_file_byte_size: result.byte_size,
|
|
1018
|
-
session_file_mtime: result.session_file_mtime,
|
|
1019
|
-
...(result.worktree
|
|
1020
|
-
? {
|
|
1021
|
-
repo_fingerprint: result.worktree.repo_fingerprint,
|
|
1022
|
-
worktree_fingerprint: result.worktree.worktree_fingerprint,
|
|
1023
|
-
repo_label: result.worktree.repo_label,
|
|
1024
|
-
branch: result.worktree.branch,
|
|
1025
|
-
}
|
|
1026
|
-
: {}),
|
|
1027
|
-
...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
|
|
1028
|
-
...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
|
|
1029
|
-
...(upload
|
|
1030
|
-
? {
|
|
1031
|
-
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
1032
|
-
upload_state: upload.upload_state,
|
|
1033
|
-
}
|
|
1034
|
-
: priorDurablePointer
|
|
1035
|
-
? {
|
|
1036
|
-
raw_evidence_pointer_id: priorDurablePointer,
|
|
1037
|
-
upload_state: "reused_existing",
|
|
1038
|
-
}
|
|
1039
|
-
: result.state === "attributed"
|
|
1040
|
-
? { upload_state: "not_uploaded" }
|
|
1041
|
-
: {}),
|
|
1042
|
-
};
|
|
1043
|
-
});
|
|
1044
|
-
}
|
|
1045
|
-
/**
|
|
1046
|
-
* Records per-source session observations into its cursor and returns the stale
|
|
1047
|
-
* count. Damped/reused Claude sessions carry forward their prior upload
|
|
1048
|
-
* timestamp + byte size so the 6h damping window keeps counting from the real
|
|
1049
|
-
* last upload (otherwise a slowly-growing file would never re-upload — D21).
|
|
1050
|
-
*/
|
|
1051
|
-
function recordSourceObservations(options) {
|
|
1052
|
-
const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
|
|
1053
|
-
const stale = countStaleSessions(options.cursor, seen);
|
|
1054
|
-
for (const result of options.results) {
|
|
1055
|
-
const sessionId = options.sessionIdOf(result);
|
|
1056
|
-
const reported = options.sessions.find((session) => session.source === options.source &&
|
|
1057
|
-
session.codex_session_id === sessionId);
|
|
1058
|
-
const uploadedThisSync = reported?.upload_state === "uploaded";
|
|
1059
|
-
const durableThisSync = reported?.upload_state === "uploaded" ||
|
|
1060
|
-
reported?.upload_state === "reused_existing";
|
|
1061
|
-
const prior = options.priorCursor?.sessions[sessionId];
|
|
1062
|
-
// D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
|
|
1063
|
-
// upload-failed for a session that was ALREADY durable must NOT wipe the
|
|
1064
|
-
// prior durable state — otherwise damping is forfeited forever and the
|
|
1065
|
-
// store row oscillates uploaded -> not_uploaded hourly. Carry the prior
|
|
1066
|
-
// durable pointer/timestamp/size forward unless we durably uploaded anew.
|
|
1067
|
-
const uploadedObjectKey = durableThisSync
|
|
1068
|
-
? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
|
|
1069
|
-
: (prior?.uploaded_object_key ?? null);
|
|
1070
|
-
const uploadedAt = uploadedThisSync
|
|
1071
|
-
? options.now.toISOString()
|
|
1072
|
-
: (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
|
|
1073
|
-
const uploadedByteSize = uploadedThisSync
|
|
1074
|
-
? result.byte_size
|
|
1075
|
-
: (prior?.uploaded_byte_size ??
|
|
1076
|
-
(durableThisSync ? result.byte_size : null));
|
|
1077
|
-
const entry = {
|
|
1078
|
-
file_hash_sha256: result.content_hash_sha256,
|
|
1079
|
-
file_mtime_ms: result.session_file_mtime_ms,
|
|
1080
|
-
byte_size: result.byte_size,
|
|
1081
|
-
// Durable byte offset reflects how many bytes are durable remotely (the
|
|
1082
|
-
// last uploaded size), not the current file size.
|
|
1083
|
-
byte_offset: uploadedByteSize ?? 0,
|
|
1084
|
-
state: result.state,
|
|
1085
|
-
reason: result.reason,
|
|
1086
|
-
worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
|
|
1087
|
-
uploaded_object_key: uploadedObjectKey,
|
|
1088
|
-
uploaded_at: uploadedAt,
|
|
1089
|
-
uploaded_byte_size: uploadedByteSize,
|
|
1090
|
-
last_seen_at: options.now.toISOString(),
|
|
1091
|
-
};
|
|
1092
|
-
recordSessionObservation(options.cursor, sessionId, entry);
|
|
1093
|
-
}
|
|
1094
|
-
return stale;
|
|
1095
|
-
}
|
|
1096
|
-
function shouldDampClaudeMain(result, cursor, now) {
|
|
1097
|
-
const entry = cursor.sessions[result.claude_session_id];
|
|
1098
|
-
if (!entry ||
|
|
1099
|
-
!entry.uploaded_object_key ||
|
|
1100
|
-
!entry.uploaded_at ||
|
|
1101
|
-
entry.uploaded_byte_size == null) {
|
|
1102
|
-
return false;
|
|
1103
|
-
}
|
|
1104
|
-
const grew = result.byte_size > entry.uploaded_byte_size;
|
|
1105
|
-
if (!grew)
|
|
1106
|
-
return false; // unchanged content reuses via the object cursor
|
|
1107
|
-
const growth = result.byte_size - entry.uploaded_byte_size;
|
|
1108
|
-
const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
|
|
1109
|
-
return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
|
|
1110
|
-
Number.isFinite(ageMs) &&
|
|
1111
|
-
ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
|
|
1112
|
-
}
|
|
1113
|
-
function buildAgentSessionSummary(options) {
|
|
1114
|
-
const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
|
|
1115
|
-
const attributedClaude = options.claudeAttribution.results.filter((result) => result.state === "attributed");
|
|
1116
|
-
const sidecarsCollected = attributedClaude.reduce((total, result) => total +
|
|
1117
|
-
result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
|
|
1118
|
-
const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
|
|
1119
|
-
result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
|
|
1120
|
-
const codex = {
|
|
1121
|
-
scanned: options.codexAttribution.scanned_file_count,
|
|
1122
|
-
attributed: options.codexAttribution.counts.attributed,
|
|
1123
|
-
ambiguous: options.codexAttribution.counts.ambiguous,
|
|
1124
|
-
unattributed: options.codexAttribution.counts.unattributed,
|
|
1125
|
-
skipped: options.codexAttribution.counts.skipped,
|
|
1126
|
-
stale: options.codexStaleCount,
|
|
1127
|
-
};
|
|
1128
|
-
const claude = {
|
|
1129
|
-
scanned: options.claudeAttribution.scanned_session_count,
|
|
1130
|
-
attributed: options.claudeAttribution.counts.attributed,
|
|
1131
|
-
ambiguous: options.claudeAttribution.counts.ambiguous,
|
|
1132
|
-
unattributed: options.claudeAttribution.counts.unattributed,
|
|
1133
|
-
skipped: options.claudeAttribution.counts.skipped,
|
|
1134
|
-
stale: options.claudeStaleCount,
|
|
1135
|
-
sidecars_collected: sidecarsCollected,
|
|
1136
|
-
sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
|
|
1137
|
-
upload.upload_state === "reused_existing").length,
|
|
1138
|
-
sidecars_skipped: sidecarsSkipped,
|
|
1139
|
-
sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
|
|
1140
|
-
sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
|
|
1141
|
-
mains_oversized: options.claudeAttribution.counts.mains_oversized,
|
|
1142
|
-
oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
|
|
1143
|
-
project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
|
|
1144
|
-
sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
|
|
1145
|
-
growth_damped: options.growthDamped,
|
|
1146
|
-
first_run_backfill: options.firstRunBackfill,
|
|
1147
|
-
};
|
|
1148
|
-
return {
|
|
1149
|
-
scanned: codex.scanned,
|
|
1150
|
-
attributed: codex.attributed,
|
|
1151
|
-
ambiguous: codex.ambiguous,
|
|
1152
|
-
unattributed: codex.unattributed,
|
|
1153
|
-
skipped: codex.skipped,
|
|
1154
|
-
stale: codex.stale,
|
|
1155
|
-
report_posted: options.report.posted,
|
|
1156
|
-
report_reason: options.report.reason,
|
|
1157
|
-
codex,
|
|
1158
|
-
claude,
|
|
1159
|
-
files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
|
|
1160
|
-
files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
|
|
1161
|
-
};
|
|
1162
|
-
}
|
|
1163
|
-
function emptyClaudeScan() {
|
|
1164
|
-
return {
|
|
1165
|
-
results: [],
|
|
1166
|
-
scanned_session_count: 0,
|
|
1167
|
-
project_dirs_skipped: 0,
|
|
1168
|
-
counts: {
|
|
1169
|
-
attributed: 0,
|
|
1170
|
-
ambiguous: 0,
|
|
1171
|
-
unattributed: 0,
|
|
1172
|
-
skipped: 0,
|
|
1173
|
-
mains_oversized: 0,
|
|
1174
|
-
oversized_lines_skipped: 0,
|
|
1175
|
-
sessions_schema_drift: 0,
|
|
1176
|
-
sidecars_capped: 0,
|
|
1177
|
-
},
|
|
1178
|
-
};
|
|
1179
|
-
}
|
|
1180
|
-
async function isClaudeCollectionEnabled(paths) {
|
|
1181
|
-
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
1182
|
-
return config?.collect_claude_jsonl !== false;
|
|
1183
|
-
}
|
|
1184
|
-
async function fileExists(filePath) {
|
|
1185
|
-
const { stat } = await import("node:fs/promises");
|
|
1186
|
-
return stat(filePath).then(() => true, () => false);
|
|
1187
|
-
}
|
|
1188
475
|
function shortSha(value) {
|
|
1189
476
|
return value ? value.slice(0, 12) : "unknown";
|
|
1190
477
|
}
|
|
@@ -1685,74 +972,70 @@ async function runServe(command, io) {
|
|
|
1685
972
|
});
|
|
1686
973
|
return 0;
|
|
1687
974
|
}
|
|
1688
|
-
function
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
return
|
|
1704
|
-
if (!trimmed.includes("@")) {
|
|
1705
|
-
throw new Error("--email must be a valid email address.");
|
|
1706
|
-
}
|
|
1707
|
-
return trimmed;
|
|
1708
|
-
}
|
|
1709
|
-
function optionalPositiveInteger(value, flag) {
|
|
1710
|
-
if (value === undefined)
|
|
1711
|
-
return undefined;
|
|
1712
|
-
const parsed = Number(value);
|
|
1713
|
-
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1714
|
-
throw new Error(`${flag} must be a positive integer.`);
|
|
1715
|
-
}
|
|
1716
|
-
return parsed;
|
|
1717
|
-
}
|
|
1718
|
-
function normalizeUrl(value) {
|
|
1719
|
-
const trimmed = value.trim().replace(/\/+$/, "");
|
|
1720
|
-
if (!trimmed)
|
|
1721
|
-
throw new Error("URL value cannot be empty.");
|
|
1722
|
-
return trimmed;
|
|
1723
|
-
}
|
|
1724
|
-
function rejectServiceRoleLikeArgument(value) {
|
|
1725
|
-
if (!looksLikeServiceRoleSecret(value))
|
|
1726
|
-
return;
|
|
1727
|
-
throw new Error("Service-role credentials are not accepted by local collector commands.");
|
|
1728
|
-
}
|
|
1729
|
-
function looksLikeServiceRoleSecret(value) {
|
|
1730
|
-
if (serviceCredentialNamePattern().test(value))
|
|
1731
|
-
return true;
|
|
1732
|
-
const parts = value.split(".");
|
|
1733
|
-
if (parts.length !== 3)
|
|
1734
|
-
return false;
|
|
1735
|
-
try {
|
|
1736
|
-
const payload = Buffer.from(base64UrlToBase64(parts[1] ?? ""), "base64").toString("utf8");
|
|
1737
|
-
return serviceCredentialPayloadPattern().test(payload);
|
|
975
|
+
async function runAutostart(command, io) {
|
|
976
|
+
const exec = io.exec ?? defaultExec();
|
|
977
|
+
const result = command.action === "install"
|
|
978
|
+
? await installAutostartAgent({
|
|
979
|
+
homeDir: command.homeDir,
|
|
980
|
+
repoRoot: command.repoRoot,
|
|
981
|
+
dashboardUrl: command.dashboardUrl,
|
|
982
|
+
intervalSeconds: command.intervalSeconds,
|
|
983
|
+
exec,
|
|
984
|
+
})
|
|
985
|
+
: command.action === "uninstall"
|
|
986
|
+
? await uninstallAutostartAgent({ homeDir: command.homeDir, exec })
|
|
987
|
+
: await autostartStatus({ homeDir: command.homeDir, exec });
|
|
988
|
+
if (command.json) {
|
|
989
|
+
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
990
|
+
return result.status === "unsupported" ? 1 : 0;
|
|
1738
991
|
}
|
|
1739
|
-
|
|
1740
|
-
|
|
992
|
+
writeAutostartResult(io, result);
|
|
993
|
+
return result.status === "unsupported" ? 1 : 0;
|
|
994
|
+
}
|
|
995
|
+
function writeAutostartResult(io, result) {
|
|
996
|
+
switch (result.status) {
|
|
997
|
+
case "installed":
|
|
998
|
+
writeLine(io.stdout, result.loaded
|
|
999
|
+
? "Cockpit autostart installed and loaded."
|
|
1000
|
+
: "Cockpit autostart installed (launchctl load reported a problem).");
|
|
1001
|
+
writeLine(io.stdout, `Label: ${result.label}`);
|
|
1002
|
+
writeLine(io.stdout, `Plist: ${result.plist_path}`);
|
|
1003
|
+
writeLine(io.stdout, `Interval: every ${result.interval_seconds}s`);
|
|
1004
|
+
writeLine(io.stdout, `Repo: ${result.work_dir}`);
|
|
1005
|
+
writeLine(io.stdout, `Dashboard: ${result.dashboard_url}`);
|
|
1006
|
+
if (!result.loaded && result.message)
|
|
1007
|
+
writeLine(io.stderr, result.message);
|
|
1008
|
+
return;
|
|
1009
|
+
case "uninstalled":
|
|
1010
|
+
writeLine(io.stdout, "Cockpit autostart removed.");
|
|
1011
|
+
writeLine(io.stdout, `Plist: ${result.plist_path}`);
|
|
1012
|
+
return;
|
|
1013
|
+
case "absent":
|
|
1014
|
+
writeLine(io.stdout, "Cockpit autostart is not installed.");
|
|
1015
|
+
writeLine(io.stdout, `Plist: ${result.plist_path}`);
|
|
1016
|
+
return;
|
|
1017
|
+
case "loaded":
|
|
1018
|
+
writeLine(io.stdout, "Cockpit autostart is installed and loaded.");
|
|
1019
|
+
writeLine(io.stdout, `Plist: ${result.plist_path}`);
|
|
1020
|
+
return;
|
|
1021
|
+
case "not_loaded":
|
|
1022
|
+
writeLine(io.stdout, "Cockpit autostart plist exists but is not loaded; run `cockpit autostart install` to reload.");
|
|
1023
|
+
writeLine(io.stdout, `Plist: ${result.plist_path}`);
|
|
1024
|
+
return;
|
|
1025
|
+
case "unsupported":
|
|
1026
|
+
writeLine(io.stderr, `Autostart unsupported: ${result.message}`);
|
|
1027
|
+
return;
|
|
1741
1028
|
}
|
|
1742
1029
|
}
|
|
1743
|
-
function
|
|
1744
|
-
return new
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
}
|
|
1753
|
-
function base64UrlToBase64(value) {
|
|
1754
|
-
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
1755
|
-
return `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
|
|
1030
|
+
function defaultExec() {
|
|
1031
|
+
return (cmd, args) => new Promise((resolve) => {
|
|
1032
|
+
execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {
|
|
1033
|
+
// execFile's error `.code` is the exit code when numeric, but a string
|
|
1034
|
+
// (e.g. "ENOENT") for spawn failures — treat those as a generic 1.
|
|
1035
|
+
const code = err == null ? 0 : typeof err.code === "number" ? err.code : 1;
|
|
1036
|
+
resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1756
1039
|
}
|
|
1757
1040
|
function defaultIo() {
|
|
1758
1041
|
if (!globalThis.fetch) {
|
|
@@ -1764,6 +1047,7 @@ function defaultIo() {
|
|
|
1764
1047
|
stderr: process.stderr,
|
|
1765
1048
|
env: process.env,
|
|
1766
1049
|
fetch: globalThis.fetch.bind(globalThis),
|
|
1050
|
+
exec: defaultExec(),
|
|
1767
1051
|
};
|
|
1768
1052
|
}
|
|
1769
1053
|
function writeLine(stream, text) {
|