@hraness/dawg 0.0.0-stage → 0.3.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 (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Reverse deltas for session history.
3
+ *
4
+ * Every event stores a `rewind`: how to turn the composition it produced back
5
+ * into the one it replaced, instead of the whole previous composition.
6
+ * Objects diff per key, arrays of `{id}` objects diff per id and position,
7
+ * and anything else is replaced wholesale. Applying the rewinds of the newest
8
+ * events in reverse order recovers any older composition, which is what undo
9
+ * and the daemon's rebase need. A single-note edit costs about 150 bytes
10
+ * instead of a full copy of the score, so the record stays small and the
11
+ * session never becomes un-editable.
12
+ */
13
+ export type Rewind =
14
+ /** The value in the earlier composition (also used for a key that was removed). */
15
+ | { set: unknown }
16
+ /** The key did not exist in the earlier composition. */
17
+ | { del: true }
18
+ /** Per-key rewinds for a plain object. */
19
+ | { obj: Record<string, Rewind> }
20
+ /**
21
+ * An array of `{id}` objects: remove the items at `drop` (indices into the
22
+ * later array), then place `put` values at their earlier indices.
23
+ */
24
+ | { arr: { drop: number[]; put: [number, unknown][] } };
25
+
26
+ const MAX_DEPTH = 16;
27
+
28
+ /** Applying this rewind changes nothing. */
29
+ export const IDENTITY_REWIND: Rewind = Object.freeze({ obj: {} }) as Rewind;
30
+
31
+ export function isIdentityRewind(value: Rewind | undefined): boolean {
32
+ return (
33
+ value !== undefined && "obj" in value && Object.keys(value.obj).length === 0
34
+ );
35
+ }
36
+
37
+ /** The rewind that turns `after` back into `before`; identity when equal. */
38
+ export function diffRewind(before: unknown, after: unknown): Rewind {
39
+ return diff(before, after, 0) ?? IDENTITY_REWIND;
40
+ }
41
+
42
+ function diff(
43
+ before: unknown,
44
+ after: unknown,
45
+ depth: number,
46
+ ): Rewind | undefined {
47
+ if (depth >= MAX_DEPTH)
48
+ return deepEqual(before, after) ? undefined : { set: before };
49
+ if (isPlainObject(before) && isPlainObject(after)) {
50
+ const obj: Record<string, Rewind> = {};
51
+ for (const key of Object.keys(before)) {
52
+ if (!(key in after) || after[key] === undefined)
53
+ obj[key] = { set: before[key] };
54
+ else {
55
+ const child = diff(before[key], after[key], depth + 1);
56
+ if (child) obj[key] = child;
57
+ }
58
+ }
59
+ for (const key of Object.keys(after))
60
+ if (!(key in before) || before[key] === undefined)
61
+ obj[key] = { del: true };
62
+ return Object.keys(obj).length === 0 ? undefined : { obj };
63
+ }
64
+ if (isKeyedArray(before) && isKeyedArray(after)) {
65
+ const beforeIndex = new Map<string, number>();
66
+ before.forEach((item, index) => beforeIndex.set(item.id, index));
67
+ // Greedy common subsequence by id: an item is kept when it is unchanged
68
+ // and keeps its relative order. Edits touch few items, so this is near
69
+ // optimal and always correct; a full reorder degrades to a full copy.
70
+ const kept = new Set<number>();
71
+ const drop: number[] = [];
72
+ let last = -1;
73
+ after.forEach((item, index) => {
74
+ const at = beforeIndex.get(item.id);
75
+ if (at !== undefined && at > last && deepEqual(before[at], item)) {
76
+ kept.add(at);
77
+ last = at;
78
+ } else drop.push(index);
79
+ });
80
+ const put: [number, unknown][] = [];
81
+ before.forEach((item, index) => {
82
+ if (!kept.has(index)) put.push([index, item]);
83
+ });
84
+ return drop.length === 0 && put.length === 0
85
+ ? undefined
86
+ : { arr: { drop, put } };
87
+ }
88
+ return deepEqual(before, after) ? undefined : { set: before };
89
+ }
90
+
91
+ /** The earlier value: `rewind` applied to `after`. Throws on a shape mismatch. */
92
+ export function applyRewind(after: unknown, rewind: Rewind): unknown {
93
+ if ("set" in rewind) return rewind.set;
94
+ if ("del" in rewind) return undefined;
95
+ if ("obj" in rewind) {
96
+ // The identity rewind applies to any value, not only objects.
97
+ if (isIdentityRewind(rewind)) return after;
98
+ if (!isPlainObject(after)) throw new Error("rewind expects an object");
99
+ const result: Record<string, unknown> = { ...after };
100
+ for (const [key, child] of Object.entries(rewind.obj)) {
101
+ if ("del" in child) delete result[key];
102
+ else result[key] = applyRewind(result[key], child);
103
+ }
104
+ return result;
105
+ }
106
+ if (!Array.isArray(after)) throw new Error("rewind expects an array");
107
+ const dropped = new Set(rewind.arr.drop);
108
+ const kept: unknown[] = [];
109
+ after.forEach((item, index) => {
110
+ if (!dropped.has(index)) kept.push(item);
111
+ });
112
+ const put = new Map(rewind.arr.put);
113
+ const length = kept.length + put.size;
114
+ const result: unknown[] = [];
115
+ let next = 0;
116
+ for (let index = 0; index < length; index += 1) {
117
+ if (put.has(index)) result.push(put.get(index));
118
+ else {
119
+ if (next >= kept.length) throw new Error("rewind indices are invalid");
120
+ result.push(kept[next]);
121
+ next += 1;
122
+ }
123
+ }
124
+ if (next !== kept.length) throw new Error("rewind indices are invalid");
125
+ return result;
126
+ }
127
+
128
+ /**
129
+ * The composition before `events[index]`, by rewinding `composition` (the
130
+ * one after the last event) through every later event. Undefined when any
131
+ * event in that range has no usable rewind.
132
+ */
133
+ export function rewindComposition(
134
+ composition: unknown,
135
+ events: readonly { rewind?: Rewind | undefined }[],
136
+ index: number,
137
+ ): unknown {
138
+ if (!Number.isSafeInteger(index) || index < 0 || index >= events.length)
139
+ return undefined;
140
+ let value = composition;
141
+ try {
142
+ for (let at = events.length - 1; at >= index; at -= 1) {
143
+ const rewind = events[at]!.rewind;
144
+ if (rewind === undefined) return undefined;
145
+ value = applyRewind(value, rewind);
146
+ }
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ return value;
151
+ }
152
+
153
+ /** Validates a stored rewind from `unknown`; `undefined` when absent. */
154
+ export function parseRewind(value: unknown): Rewind | undefined {
155
+ if (value === undefined) return undefined;
156
+ return parse(value, 0);
157
+ }
158
+
159
+ function parse(value: unknown, depth: number): Rewind {
160
+ if (!isPlainObject(value) || depth > MAX_DEPTH)
161
+ throw new Error("session event rewind is invalid");
162
+ const keys = Object.keys(value);
163
+ if (keys.length !== 1) throw new Error("session event rewind is invalid");
164
+ if ("set" in value) {
165
+ if (value.set === undefined)
166
+ throw new Error("session event rewind is invalid");
167
+ return { set: value.set };
168
+ }
169
+ if ("del" in value) {
170
+ if (value.del !== true) throw new Error("session event rewind is invalid");
171
+ return { del: true };
172
+ }
173
+ if ("obj" in value) {
174
+ if (!isPlainObject(value.obj))
175
+ throw new Error("session event rewind is invalid");
176
+ const obj: Record<string, Rewind> = {};
177
+ for (const [key, child] of Object.entries(value.obj))
178
+ obj[key] = parse(child, depth + 1);
179
+ return { obj };
180
+ }
181
+ if ("arr" in value && isPlainObject(value.arr)) {
182
+ const { drop, put } = value.arr;
183
+ if (
184
+ !Array.isArray(drop) ||
185
+ !Array.isArray(put) ||
186
+ !drop.every((index) => Number.isSafeInteger(index) && index >= 0) ||
187
+ !put.every(
188
+ (entry) =>
189
+ Array.isArray(entry) &&
190
+ entry.length === 2 &&
191
+ Number.isSafeInteger(entry[0]) &&
192
+ entry[0] >= 0 &&
193
+ entry[1] !== undefined,
194
+ )
195
+ )
196
+ throw new Error("session event rewind is invalid");
197
+ return {
198
+ arr: {
199
+ drop: drop as number[],
200
+ put: put.map((entry) => [entry[0], entry[1]] as [number, unknown]),
201
+ },
202
+ };
203
+ }
204
+ throw new Error("session event rewind is invalid");
205
+ }
206
+
207
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
208
+ return typeof value === "object" && value !== null && !Array.isArray(value);
209
+ }
210
+
211
+ function isKeyedArray(value: unknown): value is { id: string }[] {
212
+ if (!Array.isArray(value)) return false;
213
+ const seen = new Set<string>();
214
+ for (const item of value) {
215
+ if (!isPlainObject(item) || typeof item.id !== "string") return false;
216
+ if (seen.has(item.id)) return false;
217
+ seen.add(item.id);
218
+ }
219
+ return true;
220
+ }
221
+
222
+ function deepEqual(a: unknown, b: unknown): boolean {
223
+ if (a === b) return true;
224
+ if (
225
+ typeof a !== "object" ||
226
+ typeof b !== "object" ||
227
+ a === null ||
228
+ b === null
229
+ )
230
+ return false;
231
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
232
+ if (Array.isArray(a)) {
233
+ const other = b as unknown[];
234
+ if (a.length !== other.length) return false;
235
+ for (let index = 0; index < a.length; index += 1)
236
+ if (!deepEqual(a[index], other[index])) return false;
237
+ return true;
238
+ }
239
+ const left = a as Record<string, unknown>;
240
+ const right = b as Record<string, unknown>;
241
+ const keys = Object.keys(left).filter((key) => left[key] !== undefined);
242
+ const otherKeys = Object.keys(right).filter(
243
+ (key) => right[key] !== undefined,
244
+ );
245
+ if (keys.length !== otherKeys.length) return false;
246
+ for (const key of keys)
247
+ if (!(key in right) || !deepEqual(left[key], right[key])) return false;
248
+ return true;
249
+ }
@@ -0,0 +1,180 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ readCurrentSessionId,
5
+ sessionPaths,
6
+ loadSession,
7
+ stateDir,
8
+ } from "./store.ts";
9
+ import { daemonLockPath } from "./protocol.ts";
10
+
11
+ export type SessionSummary = {
12
+ sessionId: string;
13
+ revision: number;
14
+ updatedAt: string;
15
+ current: boolean;
16
+ daemonPid: number | null;
17
+ tracks: number | null;
18
+ name: string;
19
+ nameSource: "auto" | "user";
20
+ forkOf?: { sessionId: string; revision: number };
21
+ error?: string;
22
+ };
23
+
24
+ /** Lists sessions under `<workspace>/.dawg/sessions`, newest first. */
25
+ export async function listSessions(
26
+ workspace: string,
27
+ ): Promise<SessionSummary[]> {
28
+ let names: string[];
29
+ try {
30
+ names = await readdir(join(stateDir(workspace), "sessions"));
31
+ } catch {
32
+ return [];
33
+ }
34
+ const current = await readCurrentSessionId(workspace).catch(() => undefined);
35
+ const summaries: SessionSummary[] = [];
36
+ for (const name of names
37
+ .filter((entry) => entry.endsWith(".json"))
38
+ .slice(0, 512)) {
39
+ const sessionId = name.slice(0, -".json".length);
40
+ let paths;
41
+ try {
42
+ paths = sessionPaths(workspace, sessionId);
43
+ } catch {
44
+ continue;
45
+ }
46
+ const daemonPid = await liveDaemonPid(daemonLockPath(paths));
47
+ try {
48
+ const record = await loadSession<{ tracks?: unknown }>(paths);
49
+ summaries.push({
50
+ sessionId,
51
+ revision: record.revision,
52
+ updatedAt: record.updatedAt,
53
+ current: sessionId === current,
54
+ daemonPid,
55
+ tracks: Array.isArray(record.composition?.tracks)
56
+ ? record.composition.tracks.length
57
+ : null,
58
+ name: record.meta.name,
59
+ nameSource: record.meta.nameSource,
60
+ ...(record.meta.forkOf ? { forkOf: record.meta.forkOf } : {}),
61
+ });
62
+ } catch (error) {
63
+ summaries.push({
64
+ sessionId,
65
+ revision: -1,
66
+ updatedAt: "",
67
+ current: sessionId === current,
68
+ daemonPid,
69
+ tracks: null,
70
+ name: "",
71
+ nameSource: "auto",
72
+ error: error instanceof Error ? error.message : String(error),
73
+ });
74
+ }
75
+ }
76
+ return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
77
+ }
78
+
79
+ export async function printSessions(
80
+ workspace: string,
81
+ out: { write(text: string): unknown },
82
+ ): Promise<void> {
83
+ const sessions = await listSessions(workspace);
84
+ if (sessions.length === 0) {
85
+ out.write("no sessions in .dawg (run `dawg` to create one)\n");
86
+ return;
87
+ }
88
+ for (const session of sessions) {
89
+ const marker = session.current ? "*" : " ";
90
+ const detail = session.error
91
+ ? `unreadable · ${session.error}`
92
+ : `rev ${session.revision} · ${session.tracks ?? "?"} tracks · updated ${session.updatedAt}`;
93
+ const daemon = session.daemonPid ? ` · dawgd pid ${session.daemonPid}` : "";
94
+ out.write(`${marker} ${formatSessionLine(session, sessions)}${daemon}\n`);
95
+ }
96
+ }
97
+
98
+ /** One `/sessions` row: name, short id, tracks, revision, age, fork parent. */
99
+ export function formatSessionLine(
100
+ session: SessionSummary,
101
+ all: readonly SessionSummary[] = [],
102
+ now = Date.now(),
103
+ ): string {
104
+ const id = session.sessionId.slice(0, 8);
105
+ if (session.error) return `${id} unreadable · ${session.error}`;
106
+ const parent = session.forkOf
107
+ ? all.find((entry) => entry.sessionId === session.forkOf!.sessionId)
108
+ : undefined;
109
+ const fork = session.forkOf
110
+ ? ` · fork of ${parent?.name || session.forkOf.sessionId.slice(0, 8)}@${session.forkOf.revision}`
111
+ : "";
112
+ return `${session.name.padEnd(24)} ${id} ${session.tracks ?? "?"} tracks · rev ${session.revision} · ${relativeTime(session.updatedAt, now)}${fork}`;
113
+ }
114
+
115
+ export function relativeTime(iso: string, now = Date.now()): string {
116
+ const at = Date.parse(iso);
117
+ if (!Number.isFinite(at)) return "unknown";
118
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
119
+ if (seconds < 60) return "just now";
120
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
121
+ if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h ago`;
122
+ return `${Math.floor(seconds / 86_400)}d ago`;
123
+ }
124
+
125
+ export type SessionResolution =
126
+ | { status: "found"; session: SessionSummary }
127
+ | { status: "missing" }
128
+ | { status: "ambiguous"; candidates: SessionSummary[] };
129
+
130
+ /**
131
+ * Resolves `--session <name|id>`: an exact id, then an exact name
132
+ * (case-insensitive), then a unique id prefix of at least 4 characters.
133
+ */
134
+ export function resolveSession(
135
+ sessions: readonly SessionSummary[],
136
+ query: string,
137
+ ): SessionResolution {
138
+ const readable = sessions.filter((session) => !session.error);
139
+ const byId = readable.find((session) => session.sessionId === query);
140
+ if (byId) return { status: "found", session: byId };
141
+ const lower = query.trim().toLowerCase();
142
+ const byName = readable.filter(
143
+ (session) => session.name.toLowerCase() === lower,
144
+ );
145
+ if (byName.length === 1) return { status: "found", session: byName[0]! };
146
+ if (byName.length > 1) return { status: "ambiguous", candidates: byName };
147
+ if (query.length >= 4) {
148
+ const byPrefix = readable.filter((session) =>
149
+ session.sessionId.startsWith(query),
150
+ );
151
+ if (byPrefix.length === 1)
152
+ return { status: "found", session: byPrefix[0]! };
153
+ if (byPrefix.length > 1)
154
+ return { status: "ambiguous", candidates: byPrefix };
155
+ }
156
+ return { status: "missing" };
157
+ }
158
+
159
+ export function ambiguousSessionMessage(
160
+ query: string,
161
+ candidates: readonly SessionSummary[],
162
+ ): string {
163
+ return [
164
+ `session "${query}" is ambiguous; use an id:`,
165
+ ...candidates.map((session) => ` ${formatSessionLine(session)}`),
166
+ ].join("\n");
167
+ }
168
+
169
+ async function liveDaemonPid(lockPath: string): Promise<number | null> {
170
+ try {
171
+ const text = await readFile(join(lockPath, "owner"), "utf8");
172
+ if (text.length > 256) return null;
173
+ const pid = (JSON.parse(text) as { pid?: unknown }).pid;
174
+ if (!Number.isSafeInteger(pid) || (pid as number) <= 0) return null;
175
+ process.kill(pid as number, 0);
176
+ return pid as number;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
@@ -0,0 +1,92 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ type LockOwner = { pid: number; token: string };
6
+
7
+ /** Never expire an active writer's lock merely because a write takes time. */
8
+ export async function acquireSessionLock(
9
+ path: string,
10
+ timeoutMs = 3_000,
11
+ ): Promise<() => Promise<void>> {
12
+ const owner: LockOwner = { pid: process.pid, token: randomUUID() };
13
+ const started = Date.now();
14
+ while (true) {
15
+ try {
16
+ await mkdir(path);
17
+ } catch (error) {
18
+ if (!hasCode(error, "EEXIST")) throw error;
19
+ await reclaimDeadOwner(path);
20
+ if (Date.now() - started >= timeoutMs)
21
+ throw new Error("timed out waiting for the dawg session lock");
22
+ await new Promise((resolve) => setTimeout(resolve, 8));
23
+ continue;
24
+ }
25
+ try {
26
+ await writeFile(join(path, "owner"), JSON.stringify(owner), {
27
+ encoding: "utf8",
28
+ flag: "wx",
29
+ });
30
+ } catch (error) {
31
+ await rm(path, { recursive: true, force: true });
32
+ throw error;
33
+ }
34
+ return async () => {
35
+ const current = await readOwner(path);
36
+ if (current?.pid === owner.pid && current.token === owner.token)
37
+ await rm(path, { recursive: true, force: true });
38
+ };
39
+ }
40
+ }
41
+
42
+ async function reclaimDeadOwner(path: string): Promise<void> {
43
+ // Serialize reclaimers separately. Otherwise two readers of a dead PID can
44
+ // remove a new writer's lock after the first reclaimer removes the old one.
45
+ const reclaimPath = `${path}.reclaim`;
46
+ try {
47
+ await mkdir(reclaimPath);
48
+ } catch (error) {
49
+ if (hasCode(error, "EEXIST")) return;
50
+ throw error;
51
+ }
52
+ try {
53
+ const owner = await readOwner(path);
54
+ if (!owner) return; // A writer may still be publishing its owner record.
55
+ try {
56
+ process.kill(owner.pid, 0);
57
+ } catch (error) {
58
+ if (hasCode(error, "ESRCH"))
59
+ await rm(path, { recursive: true, force: true });
60
+ }
61
+ } finally {
62
+ await rm(reclaimPath, { recursive: true, force: true });
63
+ }
64
+ }
65
+
66
+ async function readOwner(path: string): Promise<LockOwner | undefined> {
67
+ try {
68
+ const text = await readFile(join(path, "owner"), "utf8");
69
+ if (text.length > 256) return undefined;
70
+ const value: unknown = JSON.parse(text);
71
+ // Read locks left by earlier versions (Track 0.1), which stored only a PID.
72
+ if (Number.isSafeInteger(value) && (value as number) > 0)
73
+ return { pid: value as number, token: "legacy" };
74
+ if (
75
+ typeof value === "object" &&
76
+ value !== null &&
77
+ "pid" in value &&
78
+ Number.isSafeInteger(value.pid) &&
79
+ (value.pid as number) > 0 &&
80
+ "token" in value &&
81
+ typeof value.token === "string"
82
+ )
83
+ return { pid: value.pid as number, token: value.token };
84
+ } catch {
85
+ // Missing, unreadable, or incomplete owner records cannot prove death.
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ function hasCode(error: unknown, code: string): boolean {
91
+ return error instanceof Error && "code" in error && error.code === code;
92
+ }