@faithfulalabi/agent-lens 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/bin/agent-lens.js +40 -0
  4. package/bin/package.json +4 -0
  5. package/dist/src/archive/cron-log.js +41 -0
  6. package/dist/src/archive/discover.js +89 -0
  7. package/dist/src/archive/index.js +7 -0
  8. package/dist/src/archive/lock.js +119 -0
  9. package/dist/src/archive/log.js +15 -0
  10. package/dist/src/archive/mirror.js +366 -0
  11. package/dist/src/archive/paths.js +159 -0
  12. package/dist/src/archive/read.js +133 -0
  13. package/dist/src/archive/report.js +272 -0
  14. package/dist/src/archive/seal.js +76 -0
  15. package/dist/src/archive/sidecar.js +39 -0
  16. package/dist/src/cli/args.js +47 -0
  17. package/dist/src/cli/commands/archive.js +65 -0
  18. package/dist/src/cli/commands/doctor.js +191 -0
  19. package/dist/src/cli/commands/prune.js +159 -0
  20. package/dist/src/cli/commands/rebuild.js +98 -0
  21. package/dist/src/cli/commands/schedule.js +325 -0
  22. package/dist/src/cli/commands/start.js +83 -0
  23. package/dist/src/cli/commands/warm.js +96 -0
  24. package/dist/src/cli/index.js +102 -0
  25. package/dist/src/content/resolve.js +163 -0
  26. package/dist/src/corpus/env.js +32 -0
  27. package/dist/src/corpus/paths.js +70 -0
  28. package/dist/src/corpus/scan.js +85 -0
  29. package/dist/src/corpus/watch.js +189 -0
  30. package/dist/src/db/freshness.js +82 -0
  31. package/dist/src/db/open.js +74 -0
  32. package/dist/src/db/read.js +266 -0
  33. package/dist/src/db/schema.js +312 -0
  34. package/dist/src/db/sidecars.js +216 -0
  35. package/dist/src/db/spill-index.js +68 -0
  36. package/dist/src/db/write.js +279 -0
  37. package/dist/src/project/pipeline.js +307 -0
  38. package/dist/src/project/subagents.js +41 -0
  39. package/dist/src/project/tools.js +94 -0
  40. package/dist/src/server/api.js +249 -0
  41. package/dist/src/server/app.js +28 -0
  42. package/dist/src/server/config.js +24 -0
  43. package/dist/src/server/drift-report.js +35 -0
  44. package/dist/src/server/index.js +1 -0
  45. package/dist/src/server/live.js +109 -0
  46. package/dist/src/server/middleware/host-guard.js +42 -0
  47. package/dist/src/server/middleware/token-auth.js +19 -0
  48. package/dist/src/server/start.js +150 -0
  49. package/dist/src/server/static-ui.js +97 -0
  50. package/dist/src/server/stream.js +50 -0
  51. package/dist/src/server/warm.js +48 -0
  52. package/dist/src/shared/api.js +1 -0
  53. package/dist/src/shared/entities.js +1 -0
  54. package/dist/src/shared/index.js +2 -0
  55. package/dist/src/shared/pricing.js +68 -0
  56. package/dist/src/shared/token.js +39 -0
  57. package/dist/src/transcript/accessors.js +28 -0
  58. package/dist/src/transcript/agents.js +44 -0
  59. package/dist/src/transcript/blocks.js +75 -0
  60. package/dist/src/transcript/drift.js +42 -0
  61. package/dist/src/transcript/human.js +65 -0
  62. package/dist/src/transcript/line.js +251 -0
  63. package/dist/src/transcript/raw-types.js +1 -0
  64. package/dist/src/transcript/spill.js +122 -0
  65. package/dist/src/transcript/usage.js +63 -0
  66. package/dist/src/transcript/version.js +1 -0
  67. package/package.json +70 -0
  68. package/ui/dist/assets/index-CKKoKUCq.js +254 -0
  69. package/ui/dist/assets/index-Chza4fL6.css +1 -0
  70. package/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  71. package/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  72. package/ui/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  73. package/ui/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  74. package/ui/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  75. package/ui/dist/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  76. package/ui/dist/index.html +21 -0
@@ -0,0 +1,159 @@
1
+ import { lstatSync, rmSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { sep } from 'node:path';
4
+ import { assertNotUnderRoot, assertUnderRoot, buildDoctorReport, DATA_DIR_LABEL, discover, resolveDataDir, resolveTranscriptRoot, TRANSCRIPT_ROOT_LABEL, } from '../../archive/index.js';
5
+ import { classifyCorpusPath, rowIdOf, sessionDirOf } from '../../corpus/paths.js';
6
+ import { EXIT_INCOMPLETE, EXIT_OK, parseStringFlag } from './archive.js';
7
+ import { COVERAGE_GAP_STATEMENT, DURABILITY_STATEMENT, formatRetention } from './doctor.js';
8
+ const KNOWN_FLAGS = new Set(['--dataDir', '--transcriptRoot', '--settingsPath']);
9
+ const WHOLE_ARCHIVE_WORD = 'delete';
10
+ function diskPathOf(entry) {
11
+ return entry.sealed ? `${entry.archivePath}.zst` : entry.archivePath;
12
+ }
13
+ function bytesOf(path) {
14
+ try {
15
+ return lstatSync(path).size;
16
+ }
17
+ catch {
18
+ return 0;
19
+ }
20
+ }
21
+ export function parsePruneArgs(args) {
22
+ let id;
23
+ for (let i = 0; i < args.length; i += 1) {
24
+ const arg = args[i];
25
+ if (arg.startsWith('-')) {
26
+ const eq = arg.indexOf('=');
27
+ const name = eq === -1 ? arg : arg.slice(0, eq);
28
+ if (!KNOWN_FLAGS.has(name)) {
29
+ return {
30
+ ok: false,
31
+ message: `unrecognised option ${arg} — prune takes [session-id] ` +
32
+ '[--dataDir=…] [--transcriptRoot=…] [--settingsPath=…] and nothing else',
33
+ };
34
+ }
35
+ if (eq === -1)
36
+ i += 1;
37
+ continue;
38
+ }
39
+ if (id !== undefined) {
40
+ return {
41
+ ok: false,
42
+ message: `unexpected argument ${arg} — prune takes at most one session id`,
43
+ };
44
+ }
45
+ id = arg;
46
+ }
47
+ return id === undefined ? { ok: true } : { ok: true, id };
48
+ }
49
+ export function resolvePruneTarget(report, id) {
50
+ if (id === undefined) {
51
+ return {
52
+ ok: true,
53
+ target: {
54
+ label: 'the whole archive',
55
+ confirmWord: WHOLE_ARCHIVE_WORD,
56
+ paths: [report.archiveRoot],
57
+ files: report.bytes.hotFiles + report.bytes.sealedFiles,
58
+ bytes: report.bytes.totalBytes,
59
+ archiveOnly: report.coverage.archiveOnly,
60
+ },
61
+ };
62
+ }
63
+ const entries = discover(report.sourceRoot, report.archiveRoot);
64
+ const kindOf = (entry) => classifyCorpusPath(entry.relPath);
65
+ const match = entries.find((e) => kindOf(e) === 'session' && rowIdOf(e.relPath) === id);
66
+ if (match === undefined) {
67
+ const sidecar = entries.find((e) => kindOf(e) === 'sidecar' && rowIdOf(e.relPath) === id);
68
+ if (sidecar !== undefined) {
69
+ const parent = entries.find((e) => kindOf(e) === 'session' &&
70
+ sidecar.archivePath.startsWith(sessionDirOf(e.archivePath) + sep));
71
+ const named = parent === undefined ? 'its parent session' : `session ${rowIdOf(parent.relPath)}`;
72
+ return {
73
+ ok: false,
74
+ message: `${id} is a sub-agent transcript — prune ${named}, which removes it too`,
75
+ };
76
+ }
77
+ return { ok: false, message: `no archived session ${id} under ${report.archiveRoot}` };
78
+ }
79
+ const dir = sessionDirOf(match.archivePath);
80
+ const owned = entries.filter((e) => e.presence !== 'source-only' &&
81
+ (e.archivePath === match.archivePath || e.archivePath.startsWith(dir + sep)));
82
+ return {
83
+ ok: true,
84
+ target: {
85
+ label: `session ${id}`,
86
+ confirmWord: id,
87
+ paths: [diskPathOf(match), dir],
88
+ files: owned.length,
89
+ bytes: owned.reduce((sum, e) => sum + bytesOf(diskPathOf(e)), 0),
90
+ archiveOnly: owned.filter((e) => e.presence === 'archive-only').length,
91
+ },
92
+ };
93
+ }
94
+ export function formatPruneConfirm(report, target) {
95
+ const lines = [
96
+ 'agent-lens prune — permanent, immediate, and there is no undo.',
97
+ '',
98
+ ` archive ${report.archiveRoot}`,
99
+ ` deleting ${target.label} — ${target.files} files, ${target.bytes} bytes`,
100
+ ];
101
+ for (const path of target.paths)
102
+ lines.push(` ${path}`);
103
+ lines.push(` of those ${target.archiveOnly} have no live source left — the archive is the only copy left`, '', DURABILITY_STATEMENT, COVERAGE_GAP_STATEMENT, '', 'Claude Code deletes its own transcripts on a rolling window and agent-lens does not', `control it: ${formatRetention(report.retention)}. Everything past that window exists`, 'only here. No soft-delete, no trash, no recovery.', '', `Type \`${target.confirmWord}\` to confirm; anything else aborts.`);
104
+ return lines.join('\n');
105
+ }
106
+ async function readConfirmLine() {
107
+ const rl = createInterface({ input: process.stdin, terminal: false });
108
+ try {
109
+ const { value, done } = await rl[Symbol.asyncIterator]().next();
110
+ return done === true ? '' : value;
111
+ }
112
+ finally {
113
+ rl.close();
114
+ process.stdin.pause();
115
+ }
116
+ }
117
+ export async function prune(args = [], options = {}) {
118
+ try {
119
+ const parsed = parsePruneArgs(args);
120
+ if (!parsed.ok) {
121
+ console.error(`agent-lens prune: ${parsed.message}`);
122
+ return EXIT_INCOMPLETE;
123
+ }
124
+ const dataDirFlag = parseStringFlag(args, 'dataDir');
125
+ const transcriptRootFlag = parseStringFlag(args, 'transcriptRoot');
126
+ const report = buildDoctorReport({
127
+ dataDir: dataDirFlag,
128
+ transcriptRoot: transcriptRootFlag,
129
+ settingsPath: parseStringFlag(args, 'settingsPath'),
130
+ });
131
+ const resolved = resolvePruneTarget(report, parsed.id);
132
+ if (!resolved.ok) {
133
+ console.error(`agent-lens prune: ${resolved.message}`);
134
+ return EXIT_INCOMPLETE;
135
+ }
136
+ const { target } = resolved;
137
+ const dataDir = resolveDataDir(dataDirFlag);
138
+ const transcriptRoot = resolveTranscriptRoot(transcriptRootFlag);
139
+ for (const path of target.paths) {
140
+ assertUnderRoot(path, dataDir, DATA_DIR_LABEL);
141
+ assertNotUnderRoot(path, transcriptRoot, TRANSCRIPT_ROOT_LABEL);
142
+ }
143
+ console.log(formatPruneConfirm(report, target));
144
+ const answer = await (options.confirm ?? readConfirmLine)();
145
+ if (answer !== target.confirmWord) {
146
+ console.log('agent-lens prune: declined — nothing was deleted');
147
+ return EXIT_OK;
148
+ }
149
+ for (const path of target.paths)
150
+ rmSync(path, { recursive: true, force: true });
151
+ console.log(`agent-lens prune: deleted ${target.label} — ${target.files} files, ${target.bytes} bytes`);
152
+ console.log(' cache.db is untouched — `agent-lens rebuild` clears what is now gone');
153
+ return EXIT_OK;
154
+ }
155
+ catch (error) {
156
+ console.error(`agent-lens prune: ${String(error.message ?? error)}`);
157
+ return EXIT_INCOMPLETE;
158
+ }
159
+ }
@@ -0,0 +1,98 @@
1
+ import { existsSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { acquireLock, resolveArchiveRoot, resolveDataDir, resolveTranscriptRoot, } from '../../archive/index.js';
4
+ import { createArchiveReader } from '../../archive/read.js';
5
+ import { createProjectionEnv } from '../../corpus/env.js';
6
+ import { ensureProjectedFold } from '../../db/freshness.js';
7
+ import { CACHE_DB_FILE, CACHE_LOCK_FILE, DbLockedError, openDb } from '../../db/open.js';
8
+ import { readEventCount } from '../../db/read.js';
9
+ import { deleteSessionProjection, projectSession } from '../../db/write.js';
10
+ import { EXIT_INCOMPLETE, EXIT_OK, parseStringFlag } from './archive.js';
11
+ export function parseSessionId(args) {
12
+ for (let i = 0; i < args.length; i += 1) {
13
+ const arg = args[i];
14
+ if (arg.startsWith('-')) {
15
+ if (arg === '--dataDir' || arg === '--transcriptRoot')
16
+ i += 1;
17
+ continue;
18
+ }
19
+ return arg;
20
+ }
21
+ return undefined;
22
+ }
23
+ function rebuildCache(dataDir) {
24
+ const lockPath = join(dataDir, CACHE_LOCK_FILE);
25
+ const lock = acquireLock(dataDir, undefined, lockPath);
26
+ if (lock.state.state === 'held') {
27
+ const pid = lock.state.holder_pid ?? 'unknown';
28
+ console.error(`agent-lens is already running (pid ${pid}) — stop it before rebuilding the cache`);
29
+ return EXIT_INCOMPLETE;
30
+ }
31
+ try {
32
+ const cachePath = join(dataDir, CACHE_DB_FILE);
33
+ const removed = [];
34
+ for (const suffix of ['', '-wal', '-shm']) {
35
+ const target = cachePath + suffix;
36
+ if (existsSync(target))
37
+ removed.push(target);
38
+ rmSync(target, { force: true });
39
+ }
40
+ if (removed.length === 0) {
41
+ console.log(`agent-lens rebuild: no cache at ${cachePath} — the next start builds one`);
42
+ return EXIT_OK;
43
+ }
44
+ console.log(`agent-lens rebuild: removed ${removed.length} file(s)`);
45
+ for (const path of removed)
46
+ console.log(` ${path}`);
47
+ console.log(' the next `agent-lens start` re-indexes the archive and reprojects on read');
48
+ return EXIT_OK;
49
+ }
50
+ finally {
51
+ lock.release();
52
+ }
53
+ }
54
+ function rebuildSession(dataDir, transcriptRoot, id) {
55
+ const opened = openDb({ dataDir });
56
+ try {
57
+ const env = createProjectionEnv(createArchiveReader(), {
58
+ archiveRoot: resolveArchiveRoot(dataDir),
59
+ transcriptRoot,
60
+ });
61
+ const started = Date.now();
62
+ const gate = ensureProjectedFold(opened.db, id, env);
63
+ if (gate.outcome === 'unindexed') {
64
+ console.error(`agent-lens rebuild: ${id} is not indexed — run \`agent-lens start\` first`);
65
+ return EXIT_INCOMPLETE;
66
+ }
67
+ if (gate.outcome === 'failed' || gate.fold === undefined) {
68
+ console.error(`agent-lens rebuild: ${id} has no readable archived bytes`);
69
+ return EXIT_INCOMPLETE;
70
+ }
71
+ if (gate.outcome === 'hit') {
72
+ deleteSessionProjection(opened.db, id);
73
+ projectSession(opened.db, id, env, gate.fold);
74
+ }
75
+ const events = readEventCount(opened.db, id);
76
+ console.log(`agent-lens rebuild: ${id} — ${events} events, took_ms ${Date.now() - started}`);
77
+ return EXIT_OK;
78
+ }
79
+ finally {
80
+ opened.close();
81
+ }
82
+ }
83
+ export async function rebuild(args = []) {
84
+ try {
85
+ const dataDir = resolveDataDir(parseStringFlag(args, 'dataDir'));
86
+ const transcriptRoot = resolveTranscriptRoot(parseStringFlag(args, 'transcriptRoot'));
87
+ const id = parseSessionId(args);
88
+ return id === undefined ? rebuildCache(dataDir) : rebuildSession(dataDir, transcriptRoot, id);
89
+ }
90
+ catch (error) {
91
+ if (error instanceof DbLockedError) {
92
+ console.error(`${error.message} — stop it, or reproject from the running UI instead`);
93
+ return EXIT_INCOMPLETE;
94
+ }
95
+ console.error(`agent-lens rebuild: ${String(error.message ?? error)}`);
96
+ return EXIT_INCOMPLETE;
97
+ }
98
+ }
@@ -0,0 +1,325 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { assertNotUnderRoot, lstatSafe, resolveCronLogPath, resolveDataDir, resolvePlistPath, resolveScheduleWrapperPath, resolveTranscriptRoot, TRANSCRIPT_ROOT_LABEL, } from '../../archive/paths.js';
7
+ import { parseStringFlag } from './archive.js';
8
+ export const SCHEDULE_LABEL = 'com.agent-lens.archive';
9
+ export const LEGACY_LABEL = 'com.faithful.agent-lens.archive';
10
+ const START_INTERVAL_SECONDS = 900;
11
+ export const CRON_STAMP_FORMAT = '+%Y-%m-%dT%H:%M:%S%z';
12
+ export const WAKE_TIME_CAVEAT = 'a wall-clock schedule does not fire while the machine is asleep — treat the interval as a bound on wake time, not on elapsed time';
13
+ const ACTIONS = 'install | status | disable';
14
+ const NON_MACOS_POINTER = 'the recurring job uses launchd, which is macOS-only. Elsewhere, run `agent-lens archive` ' +
15
+ 'every ~15 minutes yourself — a systemd timer or cron entry — per the README section ' +
16
+ '"Keeping the archive current".';
17
+ function realLaunchctl(args) {
18
+ const result = spawnSync('launchctl', args, { encoding: 'utf8' });
19
+ return {
20
+ status: result.status,
21
+ stdout: result.stdout ?? '',
22
+ stderr: result.error === undefined ? (result.stderr ?? '') : String(result.error.message),
23
+ };
24
+ }
25
+ function realDeps() {
26
+ return {
27
+ platform: process.platform,
28
+ execPath: process.execPath,
29
+ homeDir: homedir(),
30
+ uid: process.getuid?.() ?? 0,
31
+ moduleUrl: import.meta.url,
32
+ launchctl: realLaunchctl,
33
+ now: Date.now,
34
+ };
35
+ }
36
+ export function parseScheduleAction(args) {
37
+ for (let i = 0; i < args.length; i += 1) {
38
+ const arg = args[i];
39
+ if (arg.startsWith('-')) {
40
+ if (arg === '--dataDir')
41
+ i += 1;
42
+ continue;
43
+ }
44
+ return arg;
45
+ }
46
+ return undefined;
47
+ }
48
+ function resolvePackageRoot(fromDir) {
49
+ let dir = fromDir;
50
+ for (;;) {
51
+ if (existsSync(join(dir, 'package.json')))
52
+ return dir;
53
+ const parent = dirname(dir);
54
+ if (parent === dir) {
55
+ throw new Error(`no package.json above ${fromDir}; cannot locate the package root`);
56
+ }
57
+ dir = parent;
58
+ }
59
+ }
60
+ export function resolveArchiveInvocation(moduleUrl) {
61
+ const modulePath = fileURLToPath(moduleUrl);
62
+ return {
63
+ packageRoot: resolvePackageRoot(dirname(modulePath)),
64
+ kind: modulePath.endsWith('.ts') ? 'source' : 'built',
65
+ };
66
+ }
67
+ function shQuote(value) {
68
+ return `'${value.replace(/'/g, `'\\''`)}'`;
69
+ }
70
+ export function buildWrapperScript(opts) {
71
+ const { kind, packageRoot } = opts.invocation;
72
+ const entry = kind === 'source' ? '$ROOT/src/cli/index.ts' : '$ROOT/bin/agent-lens.js';
73
+ const run = kind === 'source'
74
+ ? '"$NODE" --import tsx "$ROOT/src/cli/index.ts" archive --dataDir "$DATA_DIR"'
75
+ : '"$NODE" "$ROOT/bin/agent-lens.js" archive --dataDir "$DATA_DIR"';
76
+ return `#!/bin/sh
77
+ # agent-lens archive — unattended pass, invoked by the launchd agent
78
+ # ${SCHEDULE_LABEL} every ${START_INTERVAL_SECONDS / 60} minutes.
79
+ #
80
+ # GENERATED by \`agent-lens schedule\` — do not edit. Turning the job on again
81
+ # rewrites this file in full, which is what keeps it current with the package
82
+ # that shipped it.
83
+ #
84
+ # Coverage is wake-time-bounded: ${WAKE_TIME_CAVEAT}.
85
+ #
86
+ # Exit codes are the archive command's own and are load-bearing:
87
+ # 0 = clean pass 1 = usage error / crash 3 = archive-side errors
88
+ # 2 is RESERVED product-wide and must never appear here.
89
+ set -u
90
+
91
+ NODE=${shQuote(opts.nodePath)}
92
+ ROOT=${shQuote(packageRoot)}
93
+ DATA_DIR=${shQuote(opts.dataDir)}
94
+ LOG=${shQuote(opts.cronLogPath)}
95
+ MAX_LINES=5000
96
+
97
+ # launchd hands over a minimal PATH. The node binary is addressed absolutely so
98
+ # the pass cannot hit an \`env: node\` lookup failure, and the export keeps
99
+ # anything the pass shells out to on a sane PATH too.
100
+ PATH="$(dirname "$NODE"):/usr/bin:/bin:/usr/sbin:/sbin"
101
+ export PATH
102
+
103
+ mkdir -p "$(dirname "$LOG")"
104
+
105
+ stamp() { date "${CRON_STAMP_FORMAT}"; }
106
+ say() { printf '%s %s\\n' "$(stamp)" "$1" >>"$LOG"; }
107
+
108
+ # Preconditions are logged loudly rather than failing silently: a job that
109
+ # quietly does nothing is indistinguishable from one that is working.
110
+ [ -d "$ROOT" ] || { say "FATAL package root missing: $ROOT"; exit 1; }
111
+ [ -x "$NODE" ] || { say "FATAL node missing: $NODE"; exit 1; }
112
+ [ -e "${entry}" ] || { say "FATAL entry missing: ${entry}"; exit 1; }
113
+ [ -d "$ROOT/node_modules" ] || { say "FATAL node_modules missing — run npm install in $ROOT"; exit 1; }
114
+
115
+ cd "$ROOT" || { say "FATAL cannot cd to $ROOT"; exit 1; }
116
+
117
+ OUT=$(${run} 2>&1)
118
+ CODE=$?
119
+
120
+ case "$CODE" in
121
+ 0) say "ok $OUT" ;;
122
+ 3) say "ERR3 archive-side errors — $OUT" ;;
123
+ 2) say "BUG exit 2 is reserved product-wide and must never come from archive — $OUT" ;;
124
+ *) say "ERR$CODE $OUT" ;;
125
+ esac
126
+
127
+ # Bounded log: keep the most recent MAX_LINES so months of 15-minute passes
128
+ # cannot fill the disk the archive depends on.
129
+ if [ -f "$LOG" ]; then
130
+ LINES=$(wc -l <"$LOG" 2>/dev/null || echo 0)
131
+ if [ "$LINES" -gt "$MAX_LINES" ]; then
132
+ tail -n "$MAX_LINES" "$LOG" >"$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG"
133
+ fi
134
+ fi
135
+
136
+ exit "$CODE"
137
+ `;
138
+ }
139
+ function xmlEscape(value) {
140
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
141
+ }
142
+ export function buildPlist(opts) {
143
+ const outLog = xmlEscape(join(opts.dataDir, 'logs', 'launchd.out.log'));
144
+ const errLog = xmlEscape(join(opts.dataDir, 'logs', 'launchd.err.log'));
145
+ return `<?xml version="1.0" encoding="UTF-8"?>
146
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
147
+ <plist version="1.0">
148
+ <dict>
149
+ <key>Label</key>
150
+ <string>${SCHEDULE_LABEL}</string>
151
+
152
+ <!-- Coverage is wake-time-bounded: ${xmlEscape(WAKE_TIME_CAVEAT)}. -->
153
+
154
+ <!-- /bin/sh rather than the script directly: launchd exec failures are then
155
+ reported against a known-good binary, so a broken shebang or a lost
156
+ +x bit shows up as a script error instead of a silent no-op. -->
157
+ <key>ProgramArguments</key>
158
+ <array>
159
+ <string>/bin/sh</string>
160
+ <string>${xmlEscape(opts.wrapperPath)}</string>
161
+ </array>
162
+
163
+ <key>StartInterval</key>
164
+ <integer>${START_INTERVAL_SECONDS}</integer>
165
+
166
+ <!-- Catch up immediately at login rather than waiting out the first interval:
167
+ the gap after a reboot is exactly when expiry is most likely to have run. -->
168
+ <key>RunAtLoad</key>
169
+ <true/>
170
+
171
+ <!-- launchd-level failures ONLY (exec errors, Full Disk Access denials).
172
+ The pass's own results go to logs/cron.log via the wrapper. -->
173
+ <key>StandardOutPath</key>
174
+ <string>${outLog}</string>
175
+ <key>StandardErrorPath</key>
176
+ <string>${errLog}</string>
177
+
178
+ <key>ProcessType</key>
179
+ <string>Background</string>
180
+
181
+ <!-- Deliberately NOT set: KeepAlive. This is a periodic batch job, not a
182
+ daemon; KeepAlive would restart it in a tight loop after every exit. -->
183
+ </dict>
184
+ </plist>
185
+ `;
186
+ }
187
+ function ensureDirOutsideCorpus(dir, mode, transcriptRoot) {
188
+ assertNotUnderRoot(dir, transcriptRoot, TRANSCRIPT_ROOT_LABEL);
189
+ mkdirSync(dir, { recursive: true, mode });
190
+ }
191
+ function atomicWrite(path, text, mode, transcriptRoot) {
192
+ assertNotUnderRoot(path, transcriptRoot, TRANSCRIPT_ROOT_LABEL);
193
+ const tmp = `${path}.tmp.${process.pid}`;
194
+ writeFileSync(tmp, text, { mode });
195
+ chmodSync(tmp, mode);
196
+ renameSync(tmp, path);
197
+ }
198
+ function removeIfPresent(path, transcriptRoot) {
199
+ if (lstatSafe(path) === undefined)
200
+ return false;
201
+ assertNotUnderRoot(path, transcriptRoot, TRANSCRIPT_ROOT_LABEL);
202
+ unlinkSync(path);
203
+ return true;
204
+ }
205
+ function gui(deps, label) {
206
+ return `gui/${deps.uid}/${label}`;
207
+ }
208
+ function install(dataDirFlag, deps) {
209
+ if (deps.platform !== 'darwin') {
210
+ console.error(`agent-lens schedule: nothing was set up — ${NON_MACOS_POINTER}`);
211
+ return 1;
212
+ }
213
+ const dataDir = resolveDataDir(dataDirFlag);
214
+ const transcriptRoot = resolveTranscriptRoot();
215
+ const wrapperPath = resolveScheduleWrapperPath(dataDir);
216
+ const plistPath = resolvePlistPath(SCHEDULE_LABEL, deps.homeDir);
217
+ const cronLogPath = resolveCronLogPath(dataDir);
218
+ const lines = [];
219
+ const legacyPlist = resolvePlistPath(LEGACY_LABEL, deps.homeDir);
220
+ const legacyWrapper = join(deps.homeDir, '.agent-lens', 'archive-cron.sh');
221
+ if (lstatSafe(legacyPlist) !== undefined || lstatSafe(legacyWrapper) !== undefined) {
222
+ deps.launchctl(['bootout', gui(deps, LEGACY_LABEL)]);
223
+ removeIfPresent(legacyPlist, transcriptRoot);
224
+ removeIfPresent(legacyWrapper, transcriptRoot);
225
+ lines.push(`migrated the legacy ${LEGACY_LABEL} job out — one job, one label`);
226
+ }
227
+ const invocation = resolveArchiveInvocation(deps.moduleUrl);
228
+ ensureDirOutsideCorpus(dirname(wrapperPath), 0o700, transcriptRoot);
229
+ ensureDirOutsideCorpus(dirname(cronLogPath), 0o700, transcriptRoot);
230
+ ensureDirOutsideCorpus(dirname(plistPath), 0o755, transcriptRoot);
231
+ atomicWrite(wrapperPath, buildWrapperScript({ nodePath: deps.execPath, invocation, dataDir, cronLogPath }), 0o700, transcriptRoot);
232
+ atomicWrite(plistPath, buildPlist({ wrapperPath, dataDir }), 0o644, transcriptRoot);
233
+ deps.launchctl(['bootout', gui(deps, SCHEDULE_LABEL)]);
234
+ const bootstrap = deps.launchctl(['bootstrap', `gui/${deps.uid}`, plistPath]);
235
+ if (bootstrap.status !== 0) {
236
+ deps.launchctl(['unload', '-w', plistPath]);
237
+ const load = deps.launchctl(['load', '-w', plistPath]);
238
+ if (load.status !== 0) {
239
+ const detail = (load.stderr || bootstrap.stderr).trim();
240
+ console.error(`agent-lens schedule: launchctl could not load the job — ${detail}`);
241
+ return 1;
242
+ }
243
+ }
244
+ lines.push(`recurring archive job on — label ${SCHEDULE_LABEL}, every ` +
245
+ `${START_INTERVAL_SECONDS / 60} minutes (${invocation.kind} layout)`, ` wrapper ${wrapperPath}`, ` passes ${cronLogPath}`, `note: ${WAKE_TIME_CAVEAT}`);
246
+ console.log(lines.join('\n'));
247
+ return 0;
248
+ }
249
+ async function status(dataDirFlag, deps) {
250
+ if (deps.platform !== 'darwin') {
251
+ console.log(`agent-lens schedule: ${NON_MACOS_POINTER}`);
252
+ console.log(`note: ${WAKE_TIME_CAVEAT}`);
253
+ return 0;
254
+ }
255
+ const plistPath = resolvePlistPath(SCHEDULE_LABEL, deps.homeDir);
256
+ const lines = [];
257
+ if (lstatSafe(plistPath)?.isFile() === true) {
258
+ lines.push(`recurring archive job: installed — ${plistPath}`);
259
+ const print = deps.launchctl(['print', gui(deps, SCHEDULE_LABEL)]);
260
+ lines.push(print.status === 0
261
+ ? ` loaded in launchd (${SCHEDULE_LABEL})`
262
+ : ` NOT loaded in launchd — run \`agent-lens schedule install\` to load it`);
263
+ }
264
+ else {
265
+ lines.push('recurring archive job: not installed');
266
+ lines.push(' turn it on with: agent-lens schedule install');
267
+ }
268
+ const [{ formatLastPassSection }, { readCronLogStatus }] = await Promise.all([
269
+ import('./doctor.js'),
270
+ import('../../archive/cron-log.js'),
271
+ ]);
272
+ lines.push(...formatLastPassSection(readCronLogStatus(dataDirFlag), deps.now()));
273
+ lines.push('', `note: ${WAKE_TIME_CAVEAT}`);
274
+ console.log(lines.join('\n'));
275
+ return 0;
276
+ }
277
+ function disable(dataDirFlag, deps) {
278
+ if (deps.platform !== 'darwin') {
279
+ console.error(`agent-lens schedule: nothing was removed — ${NON_MACOS_POINTER}`);
280
+ return 1;
281
+ }
282
+ const transcriptRoot = resolveTranscriptRoot();
283
+ const plistPath = resolvePlistPath(SCHEDULE_LABEL, deps.homeDir);
284
+ const wrapperPath = resolveScheduleWrapperPath(resolveDataDir(dataDirFlag));
285
+ const bootout = deps.launchctl(['bootout', gui(deps, SCHEDULE_LABEL)]);
286
+ if (bootout.status !== 0 && lstatSafe(plistPath) !== undefined) {
287
+ deps.launchctl(['unload', '-w', plistPath]);
288
+ }
289
+ const removed = [];
290
+ if (removeIfPresent(plistPath, transcriptRoot))
291
+ removed.push(plistPath);
292
+ if (removeIfPresent(wrapperPath, transcriptRoot))
293
+ removed.push(wrapperPath);
294
+ try {
295
+ rmdirSync(dirname(wrapperPath));
296
+ }
297
+ catch {
298
+ }
299
+ if (removed.length === 0) {
300
+ console.log('recurring archive job: not installed — nothing to remove');
301
+ }
302
+ else {
303
+ console.log(['recurring archive job off — removed:', ...removed.map((path) => ` ${path}`)].join('\n') +
304
+ '\n cron.log and the archive itself are untouched');
305
+ }
306
+ return 0;
307
+ }
308
+ export async function schedule(args = [], deps = realDeps()) {
309
+ const action = parseScheduleAction(args);
310
+ const dataDir = parseStringFlag(args, 'dataDir');
311
+ switch (action) {
312
+ case 'install':
313
+ return install(dataDir, deps);
314
+ case 'status':
315
+ return status(dataDir, deps);
316
+ case 'disable':
317
+ return disable(dataDir, deps);
318
+ case undefined:
319
+ console.error(`agent-lens schedule: an action is required — ${ACTIONS}`);
320
+ return 1;
321
+ default:
322
+ console.error(`agent-lens schedule: unknown action ${action} — expected ${ACTIONS}`);
323
+ return 1;
324
+ }
325
+ }
@@ -0,0 +1,83 @@
1
+ import { discover, resolveArchiveRoot, resolveTranscriptRoot, } from '../../archive/index.js';
2
+ import { startServer } from '../../server/index.js';
3
+ export function emptyArchiveNotice(dataDir, transcriptRoot) {
4
+ const archiveRoot = resolveArchiveRoot(dataDir);
5
+ const root = resolveTranscriptRoot(transcriptRoot);
6
+ const entries = discover(root, archiveRoot);
7
+ if (entries.some((entry) => entry.presence !== 'source-only'))
8
+ return undefined;
9
+ return (`agent-lens: ${archiveRoot} is empty, so the session list will be too — ` +
10
+ `${entries.length} file(s) of transcripts under ${root} ` +
11
+ 'are what `agent-lens archive` mirrors into it. Nothing is indexed until it runs.');
12
+ }
13
+ export function parsePort(args) {
14
+ for (let i = 0; i < args.length; i++) {
15
+ const arg = args[i];
16
+ if (arg === '--port') {
17
+ const value = args[i + 1];
18
+ if (value === undefined) {
19
+ throw new Error('--port requires a value');
20
+ }
21
+ return parsePortValue(value);
22
+ }
23
+ if (arg.startsWith('--port=')) {
24
+ return parsePortValue(arg.slice('--port='.length));
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+ function parsePortValue(value) {
30
+ const port = Number(value);
31
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
32
+ throw new Error(`invalid --port value: ${value}`);
33
+ }
34
+ return port;
35
+ }
36
+ export function parseHost(args) {
37
+ for (let i = 0; i < args.length; i++) {
38
+ const arg = args[i];
39
+ if (arg === '--host') {
40
+ const value = args[i + 1];
41
+ if (value === undefined) {
42
+ throw new Error('--host requires a value');
43
+ }
44
+ return parseHostValue(value);
45
+ }
46
+ if (arg.startsWith('--host=')) {
47
+ return parseHostValue(arg.slice('--host='.length));
48
+ }
49
+ }
50
+ return undefined;
51
+ }
52
+ function parseHostValue(value) {
53
+ if (value === '') {
54
+ throw new Error('--host requires a value');
55
+ }
56
+ return value;
57
+ }
58
+ export async function start(args = []) {
59
+ const port = parsePort(args);
60
+ const host = parseHost(args);
61
+ const options = {};
62
+ if (port !== undefined)
63
+ options.port = port;
64
+ if (host !== undefined)
65
+ options.host = host;
66
+ const notice = emptyArchiveNotice();
67
+ if (notice !== undefined)
68
+ console.log(notice);
69
+ const handle = await startServer(options);
70
+ await new Promise((resolve) => {
71
+ let closing = false;
72
+ const shutdown = () => {
73
+ if (closing)
74
+ return;
75
+ closing = true;
76
+ void handle.close().then(resolve);
77
+ };
78
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
79
+ process.once(signal, shutdown);
80
+ }
81
+ console.log(`agent-lens listening on http://${host ?? '127.0.0.1'}:${handle.port}`);
82
+ });
83
+ }