@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faithful Alabi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # agent-lens
2
+
3
+ A local-first tracing platform for coding agents. Run one command, open a local UI, and read back
4
+ everything a Claude Code session did — prompts, tool calls, sub-agents, inputs and outputs — so you
5
+ can see where a session went and steer the next one better.
6
+
7
+ It reads the transcripts Claude Code already writes to disk. There is nothing to add to your
8
+ harness and nothing to configure.
9
+
10
+ ## Quickstart
11
+
12
+ ```bash
13
+ npx @faithfulalabi/agent-lens
14
+ ```
15
+
16
+ That starts the local server and the UI and prints the URL. Open it. Sessions you have already run
17
+ are there; new ones show up as they happen.
18
+
19
+ Requires Node.js `>=24`.
20
+
21
+ ## The durability contract — read this before you delete anything
22
+
23
+ Three directories, three completely different promises.
24
+
25
+ | Path | What it is | What deleting it costs |
26
+ | ------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
27
+ | `~/.claude/projects` | The **source**. Claude Code owns it, writes it, and expires files from it on its own schedule. agent-lens only reads. | Not your call — Claude Code is already doing it. That is the whole reason this tool exists. |
28
+ | `~/.agent-lens/archive` | The **system of record**. A verbatim mirror: the same bytes at a different path, append-only, never written back. | **Everything past the source's cliff.** There is no other copy. |
29
+ | `~/.agent-lens/cache.db` | A **disposable** cache — search indexes and projections, all derived. | Nothing. It rebuilds from the archive. |
30
+
31
+ > **rm cache.db loses nothing. rm -rf ~/.agent-lens/archive loses data permanently.**
32
+
33
+ The inversion is the point. The directory that looks canonical is the one being erased, and the
34
+ unremarkable one in your home directory is the one holding the only surviving copy.
35
+
36
+ ### The limitation, stated plainly
37
+
38
+ > **agent-lens can only archive what exists while it runs — a gap in uptime is a gap in the record.**
39
+
40
+ A coverage figure of 100% is 100% _of the survivors_. Anything Claude Code expired before the
41
+ archive existed, or during a long gap in it, is gone and no tool can bring it back.
42
+
43
+ What the archive does hold, it holds forever. The shape is consistent even though the numbers are
44
+ not: the source thins out with age and then stops — past a cliff a few weeks back there is nothing
45
+ left in it at all — while the archive keeps going. Every session older than that cliff exists only
46
+ in the archive.
47
+
48
+ Do not take a number from this page. Your corpus is not the author's, and both move day to day.
49
+ `agent-lens doctor` prints yours, including how many files have no live source left and are held
50
+ only by the archive.
51
+
52
+ ## Keeping the archive current
53
+
54
+ ```bash
55
+ agent-lens schedule install
56
+ ```
57
+
58
+ On macOS that sets up a launchd job that runs `agent-lens archive` every 15 minutes and logs each
59
+ pass where `agent-lens doctor` reads it. Run it again any time — it replaces its own job rather
60
+ than duplicating it. `agent-lens schedule status` reports the job and its last successful pass;
61
+ `agent-lens schedule disable` turns it off cleanly, leaving the archive and its logs untouched. On
62
+ other platforms, run `agent-lens archive` every ~15 minutes yourself via a systemd timer or cron.
63
+
64
+ One caveat worth knowing before you rely on an interval: a wall-clock schedule does not fire while
65
+ the machine is asleep. Treat the interval as a bound on _wake_ time, not on elapsed time.
66
+
67
+ The pass is safe to run often: an advisory lock means a second concurrent pass copies nothing and
68
+ exits 0, and an unchanged corpus copies zero bytes. When a source has been rewritten, the archived
69
+ bytes are **kept** and the file is marked `diverged` rather than overwritten. `agent-lens archive
70
+ --verify` is the full-file integrity audit — **not for the scheduled pass**; run it by hand or on a
71
+ weekly schedule.
72
+
73
+ ## `agent-lens doctor` — what is protected, and what is not
74
+
75
+ `doctor` reads both trees and reports archive coverage, integrity, total archive bytes split hot vs
76
+ sealed, every diverged file, and Claude Code's own retention setting. It **writes nothing** — not
77
+ the archive, not the log, not the lock, and never anything belonging to your harness. It reports
78
+ retention; it does not repair it.
79
+
80
+ ```bash
81
+ agent-lens doctor --verify
82
+ ```
83
+
84
+ | Flag | Meaning |
85
+ | ------------------------ | --------------------------------------------------------------------------------------------- |
86
+ | `--json` | emit the full report as one JSON line |
87
+ | `--dataDir <dir>` | override `~/.agent-lens` |
88
+ | `--transcriptRoot <dir>` | override `~/.claude/projects` |
89
+ | `--settingsPath <file>` | override where the harness retention setting is read from (also `AGENT_LENS_CLAUDE_SETTINGS`) |
90
+ | `--verify` | recompute the full prefix hash — the real audit, not the sampled one |
91
+
92
+ Read the integrity line carefully. It prints three counts that sum to the number of archived files:
93
+ **verified**, **diverged** and **unverifiable**. A file is unverifiable when nothing exists to check
94
+ it against — its source has already expired, or it is sealed — and there is no stored per-file hash
95
+ yet. Those files are never counted as verified. On a machine that has been off for a month, expect
96
+ unverifiable to be the large one. That is the truthful answer, not a failure.
97
+
98
+ `doctor` also reports the time since the archive job's last successful pass, read from the job's
99
+ own log (`~/.agent-lens/logs/cron.log`) and never from a file mtime — a job that has not run is a
100
+ different, more urgent fact than one that ran and found nothing new to copy.
101
+
102
+ ## Starting the server
103
+
104
+ ```bash
105
+ agent-lens start
106
+ ```
107
+
108
+ | Flag | Meaning |
109
+ | --------------- | ------------------------------------------------------------------------------------------------------------- |
110
+ | `--port <n>` | bind port (auto-increments on collision) |
111
+ | `--host <host>` | bind host. Defaults to loopback; anything else prints a network-exposure warning and is your decision to make |
112
+
113
+ The server binds `127.0.0.1` by default and every `/api/*` request carries a token. See
114
+ [SECURITY.md](SECURITY.md) for the trust boundary, including the one property that surprises
115
+ people: on a shared machine, the agent being traced can read the trace API too.
116
+
117
+ ## Contributing
118
+
119
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup, the render loop against a real corpus, and the
120
+ one-door rule every change to transcript reading has to satisfy.
121
+
122
+ ## License
123
+
124
+ MIT.
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // The published entry point. It runs the BUILT CLI in-process: no transpiler, no
3
+ // source tree, no child process.
4
+ //
5
+ // In-process is load-bearing, not a tidy-up. The previous shim launched a child
6
+ // with a transpiler forking underneath it: a three-process chain that swallowed
7
+ // signals, recorded at src/cli/__tests__/start-signals.test.ts:7-12. Calling
8
+ // `main` here puts SIGINT/SIGTERM/SIGHUP straight onto the handlers
9
+ // `commands/start` registers, so `agent-lens start` survives a real kill.
10
+ //
11
+ // The tokens this file must never contain are therefore spelled nowhere in it,
12
+ // prose included — the render gate's own precedent for a substring pin, since a
13
+ // comment satisfies `toContain` just as well as code does. The list lives in
14
+ // src/packaging.test.ts, under "resolves the built CLI in-process".
15
+ //
16
+ // The UI still resolves: `resolveUiDir` in src/server/static-ui walks up to the
17
+ // nearest package.json instead of using a fixed relative path, precisely because
18
+ // the built server sits one directory deeper than the source one.
19
+
20
+ const MIN_NODE_MAJOR = 24;
21
+ const major = Number.parseInt(process.versions.node, 10);
22
+
23
+ if (!Number.isInteger(major) || major < MIN_NODE_MAJOR) {
24
+ // Ahead of the import, never after it. The CLI graph reaches node:sqlite
25
+ // (src/db/open) and zstd (src/archive/seal), so on an older Node the first
26
+ // symptom would be ERR_UNKNOWN_BUILTIN_MODULE from deep inside the DB layer.
27
+ // npm will not catch it either: `engine-strict` defaults to false, which makes
28
+ // the `engines` field a warning that installs anyway.
29
+ process.stderr.write(
30
+ `agent-lens needs Node 24 or newer; this is Node ${process.versions.node}.\n`,
31
+ );
32
+ process.exitCode = 1;
33
+ } else {
34
+ // Dynamic, so the guard above runs first — a static import would hoist past it.
35
+ const { main } = await import('../dist/src/cli/index.js');
36
+ // Setting the code and returning, rather than terminating the process here:
37
+ // the abrupt form truncates piped stdout mid-write. Exit codes 0/1/3 from
38
+ // `commands/archive` flow through unchanged either way.
39
+ process.exitCode = await main(process.argv.slice(2));
40
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "module",
3
+ "main": "agent-lens.js"
4
+ }
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { lstatSafe, resolveCronLogPath } from './paths.js';
3
+ const ENTRY = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2})(\d{2})\s+(\S+)\s*(.*)$/;
4
+ export function parseCronLog(text) {
5
+ const entries = [];
6
+ for (const line of text.split('\n')) {
7
+ const match = ENTRY.exec(line);
8
+ if (match === null)
9
+ continue;
10
+ const [, y, mo, d, h, mi, s, sign, offH, offM, status, summary] = match;
11
+ const offsetMs = (sign === '-' ? -1 : 1) * (Number(offH) * 60 + Number(offM)) * 60_000;
12
+ entries.push({
13
+ epochMs: Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) - offsetMs,
14
+ status: status,
15
+ summary: summary?.trim() ?? '',
16
+ });
17
+ }
18
+ return entries;
19
+ }
20
+ export function readCronLogStatus(dataDir) {
21
+ const path = resolveCronLogPath(dataDir);
22
+ if (lstatSafe(path)?.isFile() !== true)
23
+ return { state: 'absent', path };
24
+ let text;
25
+ try {
26
+ text = readFileSync(path, 'utf8');
27
+ }
28
+ catch {
29
+ return { state: 'empty', path };
30
+ }
31
+ const entries = parseCronLog(text);
32
+ const lastEntry = entries.at(-1);
33
+ if (lastEntry === undefined)
34
+ return { state: 'empty', path };
35
+ return {
36
+ state: 'found',
37
+ path,
38
+ lastEntry,
39
+ lastOk: entries.findLast((entry) => entry.status === 'ok'),
40
+ };
41
+ }
@@ -0,0 +1,89 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { relativeUnder } from './paths.js';
4
+ const SESSION_SUBDIRS = ['subagents', 'tool-results'];
5
+ const ALLOWED_SUFFIXES = ['.jsonl', '.meta.json', '.txt'];
6
+ const SEALED_SUFFIX = '.zst';
7
+ function readDirSafe(dir, wantDirs) {
8
+ try {
9
+ return readdirSync(dir, { withFileTypes: true })
10
+ .filter((entry) => (wantDirs ? entry.isDirectory() : entry.isFile()))
11
+ .map((entry) => entry.name);
12
+ }
13
+ catch {
14
+ return [];
15
+ }
16
+ }
17
+ function isAllowed(name) {
18
+ return ALLOWED_SUFFIXES.some((suffix) => name.endsWith(suffix));
19
+ }
20
+ function walkRecursive(dir, stripSealed, out) {
21
+ for (const name of readDirSafe(dir, false)) {
22
+ if (isAllowed(stripSealed ? stripSealedSuffix(name).name : name)) {
23
+ out.push(join(dir, name));
24
+ }
25
+ }
26
+ for (const child of readDirSafe(dir, true)) {
27
+ walkRecursive(join(dir, child), stripSealed, out);
28
+ }
29
+ }
30
+ function stripSealedSuffix(name) {
31
+ return name.endsWith(SEALED_SUFFIX)
32
+ ? { name: name.slice(0, -SEALED_SUFFIX.length), sealed: true }
33
+ : { name, sealed: false };
34
+ }
35
+ function walkRoot(root, stripSealed) {
36
+ const found = [];
37
+ for (const slug of readDirSafe(root, true)) {
38
+ const slugDir = join(root, slug);
39
+ for (const name of readDirSafe(slugDir, false)) {
40
+ const stem = stripSealed ? stripSealedSuffix(name).name : name;
41
+ if (stem.endsWith('.jsonl'))
42
+ found.push(join(slugDir, name));
43
+ }
44
+ for (const session of readDirSafe(slugDir, true)) {
45
+ for (const sub of SESSION_SUBDIRS) {
46
+ walkRecursive(join(slugDir, session, sub), stripSealed, found);
47
+ }
48
+ }
49
+ }
50
+ return found;
51
+ }
52
+ export function discover(sourceRoot, archiveRoot) {
53
+ const entries = new Map();
54
+ for (const sourcePath of walkRoot(sourceRoot, false)) {
55
+ const relPath = relativeUnder(sourceRoot, sourcePath);
56
+ if (relPath === undefined)
57
+ continue;
58
+ entries.set(relPath, {
59
+ relPath,
60
+ sourcePath,
61
+ archivePath: join(archiveRoot, relPath),
62
+ presence: 'source-only',
63
+ sealed: false,
64
+ });
65
+ }
66
+ for (const found of walkRoot(archiveRoot, true)) {
67
+ const { sealed } = stripSealedSuffix(found);
68
+ const archivePath = sealed ? found.slice(0, -SEALED_SUFFIX.length) : found;
69
+ const relPath = relativeUnder(archiveRoot, archivePath);
70
+ if (relPath === undefined)
71
+ continue;
72
+ const sourcePath = join(sourceRoot, relPath);
73
+ const existing = entries.get(relPath);
74
+ if (existing === undefined) {
75
+ entries.set(relPath, {
76
+ relPath,
77
+ sourcePath,
78
+ archivePath,
79
+ presence: 'archive-only',
80
+ sealed,
81
+ });
82
+ }
83
+ else {
84
+ existing.presence = 'both';
85
+ existing.sealed ||= sealed;
86
+ }
87
+ }
88
+ return [...entries.values()].sort((a, b) => (a.relPath < b.relPath ? -1 : 1));
89
+ }
@@ -0,0 +1,7 @@
1
+ export { archiveOnce, createMirrorContext, mirrorFile } from './mirror.js';
2
+ export { discover } from './discover.js';
3
+ export { buildDoctorReport, SEALED_LEGACY_REASON } from './report.js';
4
+ export { acquireLock } from './lock.js';
5
+ export { parseCronLog, readCronLogStatus } from './cron-log.js';
6
+ export { resolveArchiveLogPath, resolveArchiveRoot, resolveCronLogPath, resolveDataDir, resolveLockPath, resolveTranscriptRoot, } from './paths.js';
7
+ export { assertNotUnderRoot, assertUnderRoot, DATA_DIR_LABEL, TRANSCRIPT_ROOT_LABEL, } from './paths.js';
@@ -0,0 +1,119 @@
1
+ import { closeSync, openSync, readFileSync, unlinkSync, writeSync } from 'node:fs';
2
+ import { hostname as osHostname } from 'node:os';
3
+ import { dirname } from 'node:path';
4
+ import { assertUnderRoot, DATA_DIR_LABEL, ensureDirUnder, resolveLockPath, statSafe, } from './paths.js';
5
+ export const MAX_LOCK_AGE_MS = 60 * 60 * 1000;
6
+ function defaultIsAlive(pid) {
7
+ try {
8
+ process.kill(pid, 0);
9
+ return 'alive';
10
+ }
11
+ catch (error) {
12
+ return error.code === 'EPERM' ? 'permission-denied' : 'dead';
13
+ }
14
+ }
15
+ function parseRecord(raw) {
16
+ try {
17
+ const parsed = JSON.parse(raw);
18
+ if (typeof parsed !== 'object' || parsed === null)
19
+ return undefined;
20
+ const { pid, started_at, hostname } = parsed;
21
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
22
+ return undefined;
23
+ return {
24
+ pid,
25
+ started_at: typeof started_at === 'number' ? started_at : 0,
26
+ hostname: typeof hostname === 'string' ? hostname : '',
27
+ };
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ function tryCreate(lockPath, record) {
34
+ let fd;
35
+ try {
36
+ fd = openSync(lockPath, 'wx', 0o600);
37
+ }
38
+ catch (error) {
39
+ if (error.code === 'EEXIST')
40
+ return false;
41
+ throw error;
42
+ }
43
+ try {
44
+ writeSync(fd, JSON.stringify(record));
45
+ }
46
+ finally {
47
+ closeSync(fd);
48
+ }
49
+ return true;
50
+ }
51
+ export function releaseLock(lockPath, pid) {
52
+ try {
53
+ const record = parseRecord(readFileSync(lockPath, 'utf8'));
54
+ if (record?.pid === pid)
55
+ unlinkSync(lockPath);
56
+ }
57
+ catch {
58
+ }
59
+ }
60
+ export function acquireLock(dataDir, identity = {}, lockPath = resolveLockPath(dataDir)) {
61
+ const pid = identity.pid ?? process.pid;
62
+ const hostname = identity.hostname ?? osHostname();
63
+ const now = identity.now ?? Date.now();
64
+ const isAlive = identity.isAlive ?? defaultIsAlive;
65
+ ensureDirUnder(dirname(lockPath), dataDir, DATA_DIR_LABEL);
66
+ assertUnderRoot(lockPath, dataDir, DATA_DIR_LABEL);
67
+ const mine = { pid, started_at: now, hostname };
68
+ if (tryCreate(lockPath, mine)) {
69
+ return {
70
+ state: { state: 'acquired', holder_pid: pid },
71
+ release: () => releaseLock(lockPath, pid),
72
+ };
73
+ }
74
+ let raw;
75
+ try {
76
+ raw = readFileSync(lockPath, 'utf8');
77
+ }
78
+ catch {
79
+ return tryCreate(lockPath, mine)
80
+ ? { state: { state: 'acquired', holder_pid: pid }, release: () => releaseLock(lockPath, pid) }
81
+ : { state: { state: 'held' }, release: () => { } };
82
+ }
83
+ const record = parseRecord(raw);
84
+ const held = (extra) => ({
85
+ state: { state: 'held', ...extra },
86
+ release: () => { },
87
+ });
88
+ if (record === undefined) {
89
+ const mtime = statSafe(lockPath)?.mtimeMs ?? 0;
90
+ const age = now - mtime;
91
+ if (age <= MAX_LOCK_AGE_MS)
92
+ return held({ age_ms: age });
93
+ return reclaim(lockPath, mine, 'unparseable', undefined, age);
94
+ }
95
+ const age = now - record.started_at;
96
+ const liveness = isAlive(record.pid);
97
+ if (liveness === 'permission-denied')
98
+ return held({ holder_pid: record.pid, age_ms: age });
99
+ if (liveness === 'alive' && record.hostname === hostname) {
100
+ return held({ holder_pid: record.pid, age_ms: age });
101
+ }
102
+ if (liveness === 'dead')
103
+ return reclaim(lockPath, mine, 'esrch', record.pid, age);
104
+ return reclaim(lockPath, mine, 'foreign-host', record.pid, age);
105
+ }
106
+ function reclaim(lockPath, mine, reason, holderPid, ageMs) {
107
+ try {
108
+ unlinkSync(lockPath);
109
+ }
110
+ catch {
111
+ }
112
+ if (!tryCreate(lockPath, mine)) {
113
+ return { state: { state: 'held', holder_pid: holderPid, age_ms: ageMs }, release: () => { } };
114
+ }
115
+ return {
116
+ state: { state: 'reclaimed', holder_pid: holderPid, age_ms: ageMs, reclaim_reason: reason },
117
+ release: () => releaseLock(lockPath, mine.pid),
118
+ };
119
+ }
@@ -0,0 +1,15 @@
1
+ import { appendOwnedLine, resolveArchiveLogPath } from './paths.js';
2
+ export function isQuiet(record) {
3
+ return (record.bytes_copied === 0 &&
4
+ record.diverged.length === 0 &&
5
+ record.newly_expired.length === 0 &&
6
+ record.errors.length === 0 &&
7
+ record.lock.state === 'acquired' &&
8
+ record.sealed.length === 0);
9
+ }
10
+ export function appendArchiveLog(dataDir, record) {
11
+ if (isQuiet(record))
12
+ return false;
13
+ appendOwnedLine(resolveArchiveLogPath(dataDir), `${JSON.stringify(record)}\n`, dataDir);
14
+ return true;
15
+ }