@sayknow-cli/coding-agent 0.5.11 → 0.5.13

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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.13] - 2026-09-15
6
+ ### Fixed
7
+
8
+ - Session-import's cross-process idempotency test no longer depends on Bun child stdout surviving a contended file lock. The child writes its result to a file as well as stdout, so a silent empty pipe on Linux CI cannot fail a successful import.
9
+
10
+
11
+ ## [0.5.12] - 2026-09-15
12
+ ### Added
13
+
14
+ - `skc ultragoal succession offer|adopt|status` gives an approved Ultragoal an explicit, audited path from the repository it was planned in to the repository its implementation belongs to (ported from upstream #5353). The source run fences the selected unfinished goals immediately; the target adopts a fresh pending plan with verbatim brief/objectives and unresolved obligations as provenance, not inherited completion authority.
15
+ - Custom OpenAI-compatible providers in `models.yml` auto-populate `/model` from their live `/v1/models` catalog without a manual `discovery:` block, and auto-classify each discovered model's wire API family (ported from upstream #5187). Explicit `discovery` config and the local `openaiCompat` proxy lane are unchanged.
16
+
17
+ ### Fixed
18
+
19
+ - Session import redaction now bounds the URL-credential scheme to 16 characters so large credential-free transcripts scan in linear time instead of quadratic (ported from upstream #5346). Markdown-wrapped `https://user:pass@host` credentials remain redacted.
20
+ - File-lock acquisition timeouts are a typed `FileLockAcquireError` with the lock path, retry count, and holder, instead of an untyped string. Empty `.lock` directories left by a crash between `mkdir` and writing `info` are reclaimable by GC (the SKC-shaped subset of upstream #5378/#5381; this tree has no `.lock.pending.<pid>.<uuid>` staging).
21
+ - `writeGuardedJsonAtomic` honors `lockHeld`, so callers already inside `withWorkflowStateLock` (succession offer/adopt, start/checkpoint) no longer self-deadlock.
22
+ - Under tmux, a successful sixel DA1 no longer turns INLINE transcript images back on. Ghostty answers that query with ";4" even though it never paints sixel, so every screenshot was smuggled through DCS passthrough onto the outer image plane and stacked over the chat. The probe now enables overlay sixel (the pet) only; inline sixel stays off unless tmux itself owns `terminal-features=sixel`.
23
+
24
+
5
25
  ## [0.5.11] - 2026-09-10
6
26
 
7
27
  ### Fixed
@@ -3,6 +3,14 @@ export interface FileLockOptions {
3
3
  retries?: number;
4
4
  retryDelayMs?: number;
5
5
  }
6
+ export declare class FileLockAcquireError extends Error {
7
+ readonly filePath: string;
8
+ readonly lockPath: string;
9
+ readonly attempts: number;
10
+ readonly holder: string;
11
+ readonly code = "acquire_timeout";
12
+ constructor(filePath: string, lockPath: string, attempts: number, holder: string);
13
+ }
6
14
  /**
7
15
  * Returns the OS-provided process start timestamp for PID-reuse detection.
8
16
  * `ps` is available on the supported Unix hosts (macOS and Linux), unlike
@@ -97,6 +97,7 @@ export declare const ModelsConfigFile: ConfigFile<{
97
97
  auth?: "none" | "apiKey" | "oauth" | undefined;
98
98
  discovery?: {
99
99
  type: "ollama" | "lm-studio" | "omlx" | "sglang" | "llama.cpp" | "openai-models-list";
100
+ apiByModelPrefix?: Record<string, string> | undefined;
100
101
  } | undefined;
101
102
  requestTransform?: {
102
103
  profile?: "openai-proxy" | undefined;
@@ -265,6 +265,7 @@ export declare const ProviderDiscoverySchema: z.ZodObject<{
265
265
  "llama.cpp": "llama.cpp";
266
266
  "openai-models-list": "openai-models-list";
267
267
  }>;
268
+ apiByModelPrefix: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
268
269
  }, z.core.$strip>;
269
270
  export declare const ProviderAuthSchema: z.ZodEnum<{
270
271
  none: "none";
@@ -375,6 +376,7 @@ export declare const ModelsConfigSchema: z.ZodObject<{
375
376
  "llama.cpp": "llama.cpp";
376
377
  "openai-models-list": "openai-models-list";
377
378
  }>;
379
+ apiByModelPrefix: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
378
380
  }, z.core.$strip>>;
379
381
  requestTransform: z.ZodOptional<z.ZodObject<{
380
382
  profile: z.ZodOptional<z.ZodEnum<{
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export declare const IMPORT_REDACTED_PLACEHOLDER = "[REDACTED]";
10
10
  /** Bumped when patterns change; persisted in import provenance. */
11
- export declare const IMPORT_SANITIZER_VERSION = 3;
11
+ export declare const IMPORT_SANITIZER_VERSION = 4;
12
12
  export interface ImportRedactionResult {
13
13
  value: string;
14
14
  redacted: number;
@@ -264,7 +264,19 @@ export declare function recordUltragoalNudgeIfBudgetRemaining(input: {
264
264
  reason: string;
265
265
  currentGoalObjective?: string;
266
266
  }): Promise<UltragoalNudgeOutcome>;
267
- export declare function writePlan(cwd: string, plan: UltragoalPlan, sessionId?: string | null): Promise<void>;
267
+ export declare function writePlan(cwd: string, plan: UltragoalPlan, sessionId?: string | null, options?: {
268
+ lockHeld?: boolean;
269
+ }): Promise<void>;
270
+ /**
271
+ * The single exclusion that decides who owns an Ultragoal run.
272
+ *
273
+ * Establishing ownership is a read-decide-write sequence: a start reads the plan,
274
+ * checks the outgoing succession fence, then commits `active`. Every caller that
275
+ * decides ownership — source admission, succession offer, and successor
276
+ * publication — runs that decision inside this lock, keyed on the plan.
277
+ */
278
+ export declare function withUltragoalPlanOwnership<T>(cwd: string, sessionId: string, fn: () => Promise<T>): Promise<T>;
279
+ export declare function resolveUltragoalOwnershipSession(cwd: string, sessionId?: string | null): Promise<string>;
268
280
  export declare function nonEmptyString(value: unknown): string | null;
269
281
  export declare function stringArray(value: unknown): string[] | null;
270
282
  export declare function hashPipelineMetadata(metadata: Omit<UltragoalPipelineMetadata, "metadataHash">): string;
@@ -0,0 +1,251 @@
1
+ import { type RepositoryBinding } from "./repository-binding";
2
+ import { type UltragoalCommandResult, type UltragoalGoalStatus, type UltragoalPlan, type UltragoalSkcGoalMode } from "./ultragoal-runtime";
3
+ export declare const ULTRAGOAL_SUCCESSION_OPERATION_SCHEMA: "skc.ultragoal_succession_operation.v1";
4
+ export declare const ULTRAGOAL_SUCCESSION_OFFER_SCHEMA: "skc.ultragoal_succession_offer.v1";
5
+ export declare const ULTRAGOAL_SUCCESSION_FENCE_SCHEMA: "skc.ultragoal_succession_fence.v1";
6
+ export declare const ULTRAGOAL_SUCCESSION_ADOPTION_SCHEMA: "skc.ultragoal_succession_adoption.v1";
7
+ export declare const ULTRAGOAL_SUCCESSION_CLAIM_SCHEMA: "skc.ultragoal_succession_claim.v1";
8
+ export declare const SUCCESSION_ADOPTED_EVENT: "succession_adopted";
9
+ export type UltragoalSuccessionErrorCode = "source_plan_missing" | "source_not_quiescent" | "invalid_selection" | "unsafe_target" | "authorization_required" | "divergent_operation" | "goal_handed_off" | "adoption_unpublished" | "publication_conflict" | "published_plan_missing" | "fence_missing" | "fence_mismatch" | "source_changed" | "offer_untrusted" | "offer_path_escape" | "target_mismatch" | "target_occupied" | "duplicate_adoption";
10
+ export declare class UltragoalSuccessionError extends Error {
11
+ readonly code: UltragoalSuccessionErrorCode;
12
+ constructor(code: UltragoalSuccessionErrorCode, message: string);
13
+ }
14
+ export interface UltragoalSuccessionArtifactDigests {
15
+ briefSha256: string;
16
+ goalsSha256: string;
17
+ ledgerSha256: string;
18
+ }
19
+ export interface UltragoalSuccessionCarriedGoal {
20
+ sourceGoalId: string;
21
+ title: string;
22
+ /** The source objective, verbatim. Never a summary. */
23
+ objective: string;
24
+ /** Historical provenance only; never a receipt. */
25
+ sourceStatusAtOffer: UltragoalGoalStatus;
26
+ unresolvedObligations: string[];
27
+ /**
28
+ * Source dependency groups this goal belonged to, carried as an obligation
29
+ * with explicit id mapping. The group's *metadata* (batch hashes, receipts)
30
+ * is deliberately not carried — that would be inherited validation authority.
31
+ * What is carried is the requirement that these goals be validated together.
32
+ */
33
+ dependencyGroups: UltragoalSuccessionDependencyGroup[];
34
+ }
35
+ export type UltragoalSuccessionDependencyGroupKind = "validation-batch" | "review-blocker";
36
+ export interface UltragoalSuccessionDependencyGroup {
37
+ kind: UltragoalSuccessionDependencyGroupKind;
38
+ groupId: string;
39
+ memberSourceGoalIds: string[];
40
+ /** For validation batches: the source goal that closed the batch. */
41
+ finalSourceGoalId?: string;
42
+ }
43
+ /**
44
+ * Per-selected-goal snapshot of exactly what was offered.
45
+ *
46
+ * Admission compares *this*, not whole-file equality. The fence deliberately
47
+ * leaves unselected goals schedulable, so ordinary source progress moves
48
+ * `goals.json`/`ledger.jsonl` bytes; requiring whole-file equality would
49
+ * permanently strand the selected work through drift that never touched it
50
+ * (issue #5353 review item 3). Whole-file digests remain recorded as provenance
51
+ * and any observed drift is written into the adoption record.
52
+ */
53
+ export interface UltragoalSuccessionSelectionSnapshot {
54
+ /** The brief carries global constraints, so it must not change. */
55
+ briefSha256: string;
56
+ goals: Array<{
57
+ sourceGoalId: string;
58
+ recordSha256: string;
59
+ obligationsSha256: string;
60
+ }>;
61
+ snapshotSha256: string;
62
+ }
63
+ export interface UltragoalSuccessionOffer {
64
+ schema: typeof ULTRAGOAL_SUCCESSION_OFFER_SCHEMA;
65
+ operationId: string;
66
+ createdAt: string;
67
+ source: {
68
+ sessionId: string;
69
+ repository: RepositoryBinding;
70
+ /** Whole-file digests at offer time. Provenance; not the admission test. */
71
+ artifacts: UltragoalSuccessionArtifactDigests;
72
+ /** The authoritative admission test for "did the offered work change". */
73
+ selectionSnapshot: UltragoalSuccessionSelectionSnapshot;
74
+ skcGoalMode: UltragoalSkcGoalMode;
75
+ };
76
+ target: {
77
+ repository: RepositoryBinding;
78
+ };
79
+ selection: {
80
+ goalIds: string[];
81
+ };
82
+ carryover: {
83
+ /** The source brief, verbatim. */
84
+ brief: string;
85
+ goals: UltragoalSuccessionCarriedGoal[];
86
+ };
87
+ authorization: {
88
+ statement: string;
89
+ authorizedBy: string;
90
+ };
91
+ provenanceNotice: string;
92
+ }
93
+ export interface UltragoalSuccessionFence {
94
+ schema: typeof ULTRAGOAL_SUCCESSION_FENCE_SCHEMA;
95
+ operationId: string;
96
+ createdAt: string;
97
+ sourceSessionId: string;
98
+ sourceRepository: RepositoryBinding;
99
+ targetRepository: RepositoryBinding;
100
+ selectedGoalIds: string[];
101
+ sourceArtifacts: UltragoalSuccessionArtifactDigests;
102
+ offerPath: string;
103
+ offerSha256: string;
104
+ }
105
+ export interface UltragoalSuccessionGoalMapping {
106
+ sourceGoalId: string;
107
+ targetGoalId: string;
108
+ }
109
+ export interface UltragoalSuccessionAdoption {
110
+ schema: typeof ULTRAGOAL_SUCCESSION_ADOPTION_SCHEMA;
111
+ operationId: string;
112
+ status: "pending" | "published";
113
+ claimedAt: string;
114
+ publishedAt?: string;
115
+ targetSessionId: string;
116
+ targetRepository: RepositoryBinding;
117
+ source: {
118
+ sessionId: string;
119
+ repository: RepositoryBinding;
120
+ artifacts: UltragoalSuccessionArtifactDigests;
121
+ /** Whole-file digests observed at adoption; may differ from the offer. */
122
+ artifactsAtAdoption: UltragoalSuccessionArtifactDigests;
123
+ /** Whole-file drift on goals the offer did not select, recorded honestly. */
124
+ unselectedDrift: string[];
125
+ verifiedAt: string;
126
+ };
127
+ goalMap: UltragoalSuccessionGoalMapping[];
128
+ /**
129
+ * Content digest of the plan this operation is entitled to publish, recorded
130
+ * before publication. A pending replay may only proceed when the target plan
131
+ * is absent or matches this exactly; anything else is someone else's work.
132
+ */
133
+ expectedPlanDigest: string;
134
+ /** Pinned so the published plan is a deterministic function of the claim. */
135
+ plannedAt: string;
136
+ offerPath: string;
137
+ offerSha256: string;
138
+ fencePath: string;
139
+ claimPath: string;
140
+ authorization: {
141
+ statement: string;
142
+ authorizedBy: string;
143
+ };
144
+ provenanceNotice: string;
145
+ }
146
+ /**
147
+ * Repository-wide adoption claim.
148
+ *
149
+ * The session-scoped adoption record cannot be the exclusion primitive: two
150
+ * simultaneous sessions in one repository each create their own file and both
151
+ * win. This claim is a single O_EXCL file per operation for the whole
152
+ * repository, so exclusion is atomic rather than scan-then-write.
153
+ */
154
+ export interface UltragoalSuccessionRepositoryClaim {
155
+ schema: typeof ULTRAGOAL_SUCCESSION_CLAIM_SCHEMA;
156
+ operationId: string;
157
+ targetSessionId: string;
158
+ targetWorktreeRoot: string;
159
+ targetCommonDir: string | null;
160
+ claimedAt: string;
161
+ }
162
+ export declare function ultragoalSuccessionDir(cwd: string, sessionId: string): string;
163
+ export declare function ultragoalSuccessionFencePath(cwd: string, sessionId: string): string;
164
+ export declare function ultragoalSuccessionAdoptionPath(cwd: string, sessionId: string): string;
165
+ export declare function ultragoalSuccessionOfferPath(cwd: string, sessionId: string, operationId: string): string;
166
+ /**
167
+ * Repository-wide (deliberately NOT session-scoped) adoption claim.
168
+ *
169
+ * Session directories cannot express "this repository has already adopted this
170
+ * operation", so the exclusion primitive lives beside them under the shared
171
+ * ultragoal root that `getUltragoalPaths` already uses for session-less state.
172
+ */
173
+ export declare function ultragoalSuccessionClaimPath(cwd: string, operationId: string): string;
174
+ /** Read the durable outgoing ownership fence for a source session, if any. */
175
+ export declare function readUltragoalSuccessionFence(cwd: string, sessionId: string): Promise<UltragoalSuccessionFence | null>;
176
+ /** Read the durable adoption claim for a target session, if any. */
177
+ export declare function readUltragoalSuccessionAdoption(cwd: string, sessionId: string): Promise<UltragoalSuccessionAdoption | null>;
178
+ /**
179
+ * Source admission guard: a goal that has been handed off to a successor run is
180
+ * no longer this run's to schedule or checkpoint.
181
+ *
182
+ * Goals outside the fenced selection stay fully schedulable — the fence defines
183
+ * one owner per goal, not a freeze of the whole source run.
184
+ */
185
+ export declare function assertUltragoalGoalNotFenced(cwd: string, sessionId: string | null | undefined, goalId: string): Promise<void>;
186
+ /**
187
+ * Target admission guard: an adoption that has not finished publishing does not
188
+ * yet own anything.
189
+ *
190
+ * Publication writes `goals.json` before it marks the claim published, so a
191
+ * crash leaves a *visible plan with an unpublished claim*. A visible plan is not
192
+ * admission evidence — the claim is. Without this, the target executes goals
193
+ * whose adoption was never completed, and the run's own completion receipts
194
+ * would rest on a transaction that never committed.
195
+ *
196
+ * Callers must invoke this before any mutation so a refusal leaves `goals.json`
197
+ * and `ledger.jsonl` untouched.
198
+ */
199
+ export declare function assertUltragoalAdoptionPublished(cwd: string, sessionId: string | null | undefined): Promise<void>;
200
+ export interface UltragoalSuccessionOfferInput {
201
+ cwd: string;
202
+ sessionId?: string | null;
203
+ targetRepositoryPath: string;
204
+ goalIds: readonly string[];
205
+ authorization: string;
206
+ authorizedBy: string;
207
+ }
208
+ export interface UltragoalSuccessionOfferResult {
209
+ operationId: string;
210
+ offerPath: string;
211
+ fencePath: string;
212
+ offer: UltragoalSuccessionOffer;
213
+ /** True when an identical prior operation was reconciled instead of re-recorded. */
214
+ reconciled: boolean;
215
+ }
216
+ /**
217
+ * Record an explicit successor offer and fence the selected goals off from the
218
+ * source run. Writes nothing to the source brief, goals or ledger.
219
+ */
220
+ export declare function offerUltragoalSuccession(input: UltragoalSuccessionOfferInput): Promise<UltragoalSuccessionOfferResult>;
221
+ export interface UltragoalSuccessionAdoptInput {
222
+ cwd: string;
223
+ sessionId?: string | null;
224
+ offerPath: string;
225
+ skcGoalMode?: UltragoalSkcGoalMode;
226
+ }
227
+ export interface UltragoalSuccessionAdoptResult {
228
+ operationId: string;
229
+ adoptionPath: string;
230
+ plan: UltragoalPlan;
231
+ goalMap: UltragoalSuccessionGoalMapping[];
232
+ reconciled: boolean;
233
+ }
234
+ /**
235
+ * Adopt an explicit successor offer into this repository/session.
236
+ *
237
+ * Fails closed on a forged or stale offer, a changed source, a missing source
238
+ * fence, an unnamed target, an occupied target, and any duplicate or divergent
239
+ * adoption anywhere in this repository. A retry reconciles the exact recorded
240
+ * operation or fails closed; it never resumes a different one.
241
+ */
242
+ export declare function adoptUltragoalSuccession(input: UltragoalSuccessionAdoptInput): Promise<UltragoalSuccessionAdoptResult>;
243
+ /**
244
+ * `skc ultragoal succession <offer|adopt|status>`.
245
+ *
246
+ * Kept out of `RECONCILE_COMMANDS` on purpose: the reconcile pass appends a
247
+ * `reconcile_failed` ledger row on failure, which would move the very source
248
+ * digests this feature records. The target's workflow state and HUD reconcile on
249
+ * the next ordinary `skc ultragoal status`.
250
+ */
251
+ export declare function runUltragoalSuccessionCommand(args: readonly string[], cwd: string): Promise<UltragoalCommandResult>;
@@ -0,0 +1,6 @@
1
+ /** Shared placeholder semantics for model-authored workflow content. */
2
+ export declare const WORKFLOW_PLACEHOLDER_CORRECTION = "provide a specific, non-placeholder question or objective instead of empty, whitespace, unused, TODO, TBD, N/A, N-A, NA, none, placeholder, or stub";
3
+ /** Exact, case-insensitive placeholder detection; meaningful sentences remain valid. */
4
+ export declare function isWorkflowPlaceholderText(value: unknown): boolean;
5
+ /** Detect a legacy formatted deep-interview ask whose visible body is a placeholder. */
6
+ export declare function isLegacyDeepInterviewPlaceholder(value: unknown): boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/coding-agent",
4
- "version": "0.5.11",
4
+ "version": "0.5.13",
5
5
  "description": "Sayknow-CLI CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -54,12 +54,12 @@
54
54
  "@agentclientprotocol/sdk": "1.3.0",
55
55
  "@babel/parser": "^7.29.3",
56
56
  "@mozilla/readability": "^0.6.0",
57
- "@sayknow-cli/stats": "0.5.11",
58
- "@sayknow-cli/agent-core": "0.5.11",
59
- "@sayknow-cli/ai": "0.5.11",
60
- "@sayknow-cli/natives": "0.5.11",
61
- "@sayknow-cli/tui": "0.5.11",
62
- "@sayknow-cli/utils": "0.5.11",
57
+ "@sayknow-cli/stats": "0.5.13",
58
+ "@sayknow-cli/agent-core": "0.5.13",
59
+ "@sayknow-cli/ai": "0.5.13",
60
+ "@sayknow-cli/natives": "0.5.13",
61
+ "@sayknow-cli/tui": "0.5.13",
62
+ "@sayknow-cli/utils": "0.5.13",
63
63
  "@puppeteer/browsers": "^2.13.0",
64
64
  "@types/turndown": "5.0.6",
65
65
  "@xterm/headless": "^6.0.0",
@@ -58,9 +58,33 @@ function keptMalformedRecord(lockDir: string): GcRecord {
58
58
  };
59
59
  }
60
60
 
61
+ function emptyLockDirRecord(lockDir: string): GcRecord {
62
+ return {
63
+ store: "file_locks",
64
+ id: lockDir,
65
+ path: lockDir,
66
+ pid_status: "none",
67
+ status: "stale",
68
+ stale: true,
69
+ removable: true,
70
+ action: "none",
71
+ reason: "empty_file_lock_dir",
72
+ };
73
+ }
74
+
61
75
  async function collectLockRecord(lockDir: string, ctx: GcContext): Promise<GcRecord> {
62
76
  const info = await readFileLockInfoForGc(lockDir);
63
- if (!info) return keptMalformedRecord(lockDir);
77
+ if (!info) {
78
+ // mkdir-before-info crash leftover: an empty `.lock` dir has no owner token
79
+ // and is safe to reclaim. Any other info-less shape stays fail-closed.
80
+ try {
81
+ const entries = await fs.readdir(lockDir);
82
+ if (entries.length === 0) return emptyLockDirRecord(lockDir);
83
+ } catch (error) {
84
+ if (!isEnoent(error)) throw error;
85
+ }
86
+ return keptMalformedRecord(lockDir);
87
+ }
64
88
 
65
89
  const probeResult = ctx.probe(info.pid);
66
90
  const pidStatus = gcPidStatusLabel(probeResult);
@@ -165,7 +189,20 @@ export const fileLocksGcAdapter: GcStoreAdapter = {
165
189
  async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
166
190
  const lockDir = record.path ?? record.id;
167
191
  const info = await readFileLockInfoForGc(lockDir);
168
- if (!info) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
192
+ if (!info) {
193
+ if (record.reason !== "empty_file_lock_dir") {
194
+ return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
195
+ }
196
+ try {
197
+ const entries = await fs.readdir(lockDir);
198
+ if (entries.length !== 0) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
199
+ await fs.rmdir(lockDir);
200
+ return { removed: true };
201
+ } catch (error) {
202
+ if (isEnoent(error)) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
203
+ return { removed: false, error: errorMessage(error) };
204
+ }
205
+ }
169
206
 
170
207
  const probeResult = ctx.probe(info.pid);
171
208
  if (probeResult.status !== "dead") {
@@ -14,6 +14,21 @@ const DEFAULT_OPTIONS: Required<FileLockOptions> = {
14
14
  retries: 50,
15
15
  retryDelayMs: 100,
16
16
  };
17
+ export class FileLockAcquireError extends Error {
18
+ readonly code = "acquire_timeout";
19
+
20
+ constructor(
21
+ readonly filePath: string,
22
+ readonly lockPath: string,
23
+ readonly attempts: number,
24
+ readonly holder: string,
25
+ ) {
26
+ super(
27
+ `Failed to acquire lock for ${filePath} after ${attempts} attempts: ${holder} (${lockPath}); a live owner is never displaced`,
28
+ );
29
+ this.name = "FileLockAcquireError";
30
+ }
31
+ }
17
32
 
18
33
  type LockInfo = FileLockOwnerToken;
19
34
 
@@ -243,6 +258,12 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken): Promise
243
258
  const outcome = await removeFileLockDirForGc(lockPath, owner);
244
259
  if (outcome !== "removed") throw new Error(`Failed to release file lock: ${outcome}.`);
245
260
  }
261
+ async function lockHolderDescription(lockPath: string): Promise<string> {
262
+ const info = await readLockInfo(lockPath);
263
+ if (!info) return "unknown holder";
264
+ return `pid ${info.pid}`;
265
+ }
266
+
246
267
  async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
247
268
  const opts = { ...DEFAULT_OPTIONS, ...options };
248
269
  const lockPath = getLockPath(filePath);
@@ -255,7 +276,7 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
255
276
  if (await removeStaleLockForAcquire(lockPath, stale)) continue;
256
277
  await Bun.sleep(opts.retryDelayMs);
257
278
  }
258
- throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
279
+ throw new FileLockAcquireError(filePath, lockPath, opts.retries, await lockHolderDescription(lockPath));
259
280
  }
260
281
 
261
282
  /**