@aibtc/mcp-server 1.74.0 → 1.76.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.
package/README.md CHANGED
@@ -141,8 +141,8 @@ npx @aibtc/mcp-server@latest --install --profile full # writes AIBTC_TOOLS=all
141
141
  | `identity` | ERC-8004 identity and reputation, message signing |
142
142
  | `social` | Nostr, AIBTC inbox |
143
143
  | `earn` | Bounty board (create, accept, pay, my views) |
144
- | `legion` | AIBTC News Legion |
145
- | `markets` | Stacks prediction market, At Stake |
144
+ | `legion` | AIBTC News Legion, At Stake side legions (El Salvador) |
145
+ | `markets` | Stacks prediction market, At Stake market |
146
146
  | `dev` | Scaffolding, OpenRouter, settings, inference marketplace, arXiv |
147
147
 
148
148
  The server's instructions list the groups that are off, so the agent can tell you which one to enable. The OpenRouter `bridge` starts from the full set and narrows it with its own `--read-only`/`--allow`/`--block` flags.
@@ -8,7 +8,7 @@
8
8
  * builder produced it.
9
9
  */
10
10
  import * as btc from "@scure/btc-signer";
11
- import type { MempoolApi } from "./mempool-api.js";
11
+ import { type MempoolApi } from "./mempool-api.js";
12
12
  import type { Network } from "../config/networks.js";
13
13
  /** The wallet a Bitcoin spend is billed to. */
14
14
  export interface BtcSpender {
@@ -32,8 +32,9 @@ export declare function btcTxOutflowSats(api: MempoolApi, txHex: string, spender
32
32
  export declare function psbtOutflowSats(tx: btc.Transaction, signedInputs: number[], spender: BtcSpender, network: Network): bigint;
33
33
  /**
34
34
  * Broadcast a signed transaction that spends from this wallet, metered by the
35
- * spending limit: refused before broadcast when it would exceed the cap,
36
- * recorded once broadcast.
35
+ * spending limit: booked before broadcast (refused when it would exceed the
36
+ * cap), given back only when the node rejects it. A network error leaves it
37
+ * unknown whether the transaction was relayed, so the booking stands.
37
38
  */
38
39
  export declare function meteredBtcBroadcast(api: MempoolApi, txHex: string, spender: BtcSpender, network: Network): Promise<string>;
39
40
  //# sourceMappingURL=btc-spend.d.ts.map
@@ -10,6 +10,7 @@
10
10
  import * as btc from "@scure/btc-signer";
11
11
  import { hex } from "@scure/base";
12
12
  import { getBtcNetwork } from "../transactions/bitcoin-builder.js";
13
+ import { BroadcastRejectedError } from "./mempool-api.js";
13
14
  import { getSpendLimiter } from "./spend-limiter.js";
14
15
  function ownAddresses(spender) {
15
16
  return [spender.btcAddress, spender.taprootAddress].filter((a) => !!a);
@@ -86,14 +87,21 @@ export function psbtOutflowSats(tx, signedInputs, spender, network) {
86
87
  }
87
88
  /**
88
89
  * Broadcast a signed transaction that spends from this wallet, metered by the
89
- * spending limit: refused before broadcast when it would exceed the cap,
90
- * recorded once broadcast.
90
+ * spending limit: booked before broadcast (refused when it would exceed the
91
+ * cap), given back only when the node rejects it. A network error leaves it
92
+ * unknown whether the transaction was relayed, so the booking stands.
91
93
  */
92
94
  export async function meteredBtcBroadcast(api, txHex, spender, network) {
93
95
  const amount = await btcTxOutflowSats(api, txHex, spender, network);
94
- await getSpendLimiter().check("sats", amount, spender.address);
95
- const txid = await api.broadcastTransaction(txHex);
96
- await getSpendLimiter().record("sats", amount, spender.address);
97
- return txid;
96
+ const limiter = getSpendLimiter();
97
+ const reservation = await limiter.reserve([{ unit: "sats", amount }], spender.address);
98
+ try {
99
+ return await api.broadcastTransaction(txHex);
100
+ }
101
+ catch (error) {
102
+ if (error instanceof BroadcastRejectedError)
103
+ await limiter.release(reservation);
104
+ throw error;
105
+ }
98
106
  }
99
107
  //# sourceMappingURL=btc-spend.js.map
@@ -11,6 +11,10 @@ import type { Network } from "../config/networks.js";
11
11
  /**
12
12
  * UTXO (Unspent Transaction Output) from mempool.space API
13
13
  */
14
+ /** mempool.space answered the broadcast with a 4xx: the transaction was refused, not relayed. */
15
+ export declare class BroadcastRejectedError extends Error {
16
+ constructor(message: string);
17
+ }
14
18
  export interface UTXO {
15
19
  /**
16
20
  * Transaction ID containing this UTXO
@@ -7,6 +7,16 @@
7
7
  *
8
8
  * Documentation: https://mempool.space/docs/api
9
9
  */
10
+ /**
11
+ * UTXO (Unspent Transaction Output) from mempool.space API
12
+ */
13
+ /** mempool.space answered the broadcast with a 4xx: the transaction was refused, not relayed. */
14
+ export class BroadcastRejectedError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "BroadcastRejectedError";
18
+ }
19
+ }
10
20
  /**
11
21
  * Get the mempool.space API base URL for a network
12
22
  */
@@ -157,7 +167,11 @@ export class MempoolApi {
157
167
  });
158
168
  if (!response.ok) {
159
169
  const errorText = await response.text().catch(() => "Unknown error");
160
- throw new Error(`Failed to broadcast transaction: ${response.status} ${response.statusText} - ${errorText}`);
170
+ const message = `Failed to broadcast transaction: ${response.status} ${response.statusText} - ${errorText}`;
171
+ // A 4xx is the node refusing the transaction: it was not relayed.
172
+ if (response.status < 500)
173
+ throw new BroadcastRejectedError(message);
174
+ throw new Error(message);
161
175
  }
162
176
  // Response is the txid as plain text
163
177
  const txid = await response.text();
@@ -91,6 +91,15 @@ export declare class SpendLimitError extends Error {
91
91
  readonly scope: "session" | "day";
92
92
  constructor(message: string, unit: SpendUnit, attempted: number, remaining: number, scope: "session" | "day");
93
93
  }
94
+ /** What `reserve()` booked, for `release()` to give back. */
95
+ export interface SpendReservation {
96
+ addr: string;
97
+ day: string;
98
+ spends: Array<{
99
+ unit: SpendUnit;
100
+ amount: number;
101
+ }>;
102
+ }
94
103
  declare class SpendLimiter {
95
104
  private static instance;
96
105
  private stateFile;
@@ -105,12 +114,47 @@ declare class SpendLimiter {
105
114
  /** Reset the in-memory session ledger for a wallet (call on unlock/lock/switch). */
106
115
  resetSession(addr: string): void;
107
116
  /**
108
- * Throw SpendLimitError if `amount` would push the session OR day total over
109
- * its cap. Does NOT record — call recordSpend after a successful broadcast.
117
+ * Book `spends` against the session and day ledgers, or throw
118
+ * SpendLimitError if any of them would push a total over its cap. Call this
119
+ * BEFORE signing, and `release()` the result if the spend then fails before
120
+ * it leaves this process.
121
+ *
122
+ * The cap check and the booking are one step under the shared file lease.
123
+ * A separate check and record let two processes both pass the check against
124
+ * the same day total and then both spend (#683). Booked here, a second
125
+ * process sees this spend in the total before it signs. A process that dies
126
+ * between reserve and broadcast leaves the spend booked: over-counting is
127
+ * the safe direction, and the skills engine does the same.
128
+ *
129
+ * All units are checked before any is booked, so a contract call moving STX
130
+ * and sBTC is booked whole or not at all.
131
+ */
132
+ reserve(spends: Array<{
133
+ unit: SpendUnit;
134
+ amount: bigint;
135
+ }>, addr: string): Promise<SpendReservation>;
136
+ /**
137
+ * Give back a reservation whose spend never left this process (signing or
138
+ * broadcast failed, or a fallback path books its own). Without the lease
139
+ * the day ledger is left as is: an unlocked write could drop another
140
+ * process's booking, while skipping only over-counts.
141
+ *
142
+ * Idempotent: a reservation is given back at most once, so a caller that
143
+ * releases before a fallback and again when that fallback throws does not
144
+ * subtract twice.
145
+ *
146
+ * Never throws. Callers release on their way to rethrowing the real failure
147
+ * or into a fallback; a ledger write error here must not replace either.
148
+ * Failing to give budget back only over-counts.
149
+ */
150
+ release(reservation: SpendReservation): Promise<void>;
151
+ /**
152
+ * Serialize ledger updates inside this process. The file lease excludes the
153
+ * OTHER process (the skills engine's direct x402 path shares
154
+ * spend-state.json so one wallet has one daily cap); this chain keeps two
155
+ * calls in this process from interleaving their read-modify-writes.
110
156
  */
111
- check(unit: SpendUnit, amount: bigint, addr: string): Promise<void>;
112
- /** Record a completed spend against both the session and day ledgers. */
113
- record(unit: SpendUnit, amount: bigint, addr: string): Promise<void>;
157
+ private serialized;
114
158
  /** Remaining session/day budget for status reporting. */
115
159
  status(addr: string): Promise<{
116
160
  enabled: boolean;
@@ -182,6 +182,21 @@ export class SpendLimitError extends Error {
182
182
  this.name = "SpendLimitError";
183
183
  }
184
184
  }
185
+ /**
186
+ * Drop every day but `today`, for every wallet, so the file does not grow
187
+ * unbounded. Runs only under the lease: it walks EVERY address, so an
188
+ * unlocked write could lose another wallet's day too.
189
+ */
190
+ function pruneOtherDays(state, today) {
191
+ for (const a of Object.keys(state)) {
192
+ for (const day of Object.keys(state[a])) {
193
+ if (day !== today)
194
+ delete state[a][day];
195
+ }
196
+ if (Object.keys(state[a]).length === 0)
197
+ delete state[a];
198
+ }
199
+ }
185
200
  class SpendLimiter {
186
201
  static instance;
187
202
  stateFile = DEFAULT_STATE_FILE;
@@ -226,80 +241,129 @@ class SpendLimiter {
226
241
  this.session.delete(addr);
227
242
  }
228
243
  /**
229
- * Throw SpendLimitError if `amount` would push the session OR day total over
230
- * its cap. Does NOT record — call recordSpend after a successful broadcast.
244
+ * Book `spends` against the session and day ledgers, or throw
245
+ * SpendLimitError if any of them would push a total over its cap. Call this
246
+ * BEFORE signing, and `release()` the result if the spend then fails before
247
+ * it leaves this process.
248
+ *
249
+ * The cap check and the booking are one step under the shared file lease.
250
+ * A separate check and record let two processes both pass the check against
251
+ * the same day total and then both spend (#683). Booked here, a second
252
+ * process sees this spend in the total before it signs. A process that dies
253
+ * between reserve and broadcast leaves the spend booked: over-counting is
254
+ * the safe direction, and the skills engine does the same.
255
+ *
256
+ * All units are checked before any is booked, so a contract call moving STX
257
+ * and sBTC is booked whole or not at all.
231
258
  */
232
- async check(unit, amount, addr) {
233
- if (!isEnabled())
234
- return;
235
- if (amount <= 0n)
236
- return;
237
- const amt = Number(amount);
238
- const { daily, session } = capsFor(unit);
239
- const sessionSpent = this.getSession(addr)[unit];
240
- if (sessionSpent + amt > session) {
241
- const remaining = Math.max(0, session - sessionSpent);
242
- throw new SpendLimitError(this.message(unit, amt, remaining, "session", session), unit, amt, remaining, "session");
243
- }
244
- const state = await this.readState();
245
- const daySpent = state[addr]?.[todayKey()]?.[unit] ?? 0;
246
- if (daySpent + amt > daily) {
247
- const remaining = Math.max(0, daily - daySpent);
248
- throw new SpendLimitError(this.message(unit, amt, remaining, "day", daily), unit, amt, remaining, "day");
259
+ async reserve(spends, addr) {
260
+ const booked = spends
261
+ .filter((s) => s.amount > 0n)
262
+ .map((s) => ({ unit: s.unit, amount: Number(s.amount) }));
263
+ if (!isEnabled() || booked.length === 0)
264
+ return { addr, day: todayKey(), spends: [] };
265
+ const totals = { ustx: 0, sats: 0 };
266
+ for (const { unit, amount } of booked)
267
+ totals[unit] += amount;
268
+ const run = await this.serialized(() => withSharedStateLock(this.stateFile, async () => {
269
+ // Read the day under the lease: waiting for it can cross UTC midnight,
270
+ // and pruning against a stale day would delete the new one.
271
+ const day = todayKey();
272
+ const session = this.getSession(addr);
273
+ const state = await this.readState();
274
+ for (const unit of ["ustx", "sats"]) {
275
+ const amt = totals[unit];
276
+ if (amt === 0)
277
+ continue;
278
+ const { daily, session: sessionCap } = capsFor(unit);
279
+ if (session[unit] + amt > sessionCap) {
280
+ const remaining = Math.max(0, sessionCap - session[unit]);
281
+ throw new SpendLimitError(this.message(unit, amt, remaining, "session", sessionCap), unit, amt, remaining, "session");
282
+ }
283
+ const daySpent = state[addr]?.[day]?.[unit] ?? 0;
284
+ if (daySpent + amt > daily) {
285
+ const remaining = Math.max(0, daily - daySpent);
286
+ throw new SpendLimitError(this.message(unit, amt, remaining, "day", daily), unit, amt, remaining, "day");
287
+ }
288
+ }
289
+ pruneOtherDays(state, day);
290
+ if (!state[addr])
291
+ state[addr] = {};
292
+ if (!state[addr][day])
293
+ state[addr][day] = { ustx: 0, sats: 0 };
294
+ for (const unit of ["ustx", "sats"]) {
295
+ state[addr][day][unit] += totals[unit];
296
+ }
297
+ await this.writeState(state);
298
+ session.ustx += totals.ustx;
299
+ session.sats += totals.sats;
300
+ return day;
301
+ }));
302
+ if (!run.held) {
303
+ throw new Error(`Spend refused: could not take the spending-limit lock, so the daily cap cannot be ` +
304
+ `checked safely: ${run.unavailable}`);
249
305
  }
306
+ return { addr, day: run.value, spends: booked };
250
307
  }
251
- /** Record a completed spend against both the session and day ledgers. */
252
- async record(unit, amount, addr) {
253
- if (!isEnabled())
254
- return;
255
- if (amount <= 0n)
308
+ /**
309
+ * Give back a reservation whose spend never left this process (signing or
310
+ * broadcast failed, or a fallback path books its own). Without the lease
311
+ * the day ledger is left as is: an unlocked write could drop another
312
+ * process's booking, while skipping only over-counts.
313
+ *
314
+ * Idempotent: a reservation is given back at most once, so a caller that
315
+ * releases before a fallback and again when that fallback throws does not
316
+ * subtract twice.
317
+ *
318
+ * Never throws. Callers release on their way to rethrowing the real failure
319
+ * or into a fallback; a ledger write error here must not replace either.
320
+ * Failing to give budget back only over-counts.
321
+ */
322
+ async release(reservation) {
323
+ if (reservation.spends.length === 0)
256
324
  return;
257
- const amt = Number(amount);
258
- this.getSession(addr)[unit] += amt;
259
- // Two locks, for two different races.
260
- //
261
- // The promise chain serializes concurrent record() calls INSIDE this
262
- // process. The file lease excludes the OTHER process writing this file:
263
- // the skills engine's direct x402 path shares spend-state.json so one
264
- // wallet has one daily cap. Without the lease, its writes and ours
265
- // overwrite each other and the day total under-counts, which is the
266
- // direction that permits spending past the cap.
267
- //
268
- // Note the prune below walks EVERY address, so an unlocked concurrent
269
- // write can lose another wallet's day too, not just this one's.
270
- const write = this.writeLock.then(async () => {
271
- const run = await withSharedStateLock(this.stateFile, async () => {
325
+ const { addr, day } = reservation;
326
+ const totals = { ustx: 0, sats: 0 };
327
+ for (const { unit, amount } of reservation.spends)
328
+ totals[unit] += amount;
329
+ reservation.spends = [];
330
+ let run;
331
+ try {
332
+ run = await this.serialized(() => withSharedStateLock(this.stateFile, async () => {
272
333
  const state = await this.readState();
273
- const today = todayKey();
274
- // Prune stale days so the file does not grow unbounded.
275
- for (const a of Object.keys(state)) {
276
- for (const day of Object.keys(state[a])) {
277
- if (day !== today)
278
- delete state[a][day];
279
- }
280
- if (Object.keys(state[a]).length === 0)
281
- delete state[a];
334
+ const ledger = state[addr]?.[day];
335
+ if (ledger) {
336
+ ledger.ustx = Math.max(0, ledger.ustx - totals.ustx);
337
+ ledger.sats = Math.max(0, ledger.sats - totals.sats);
338
+ await this.writeState(state);
282
339
  }
283
- if (!state[addr])
284
- state[addr] = {};
285
- if (!state[addr][today])
286
- state[addr][today] = { ustx: 0, sats: 0 };
287
- state[addr][today][unit] += amt;
288
- await this.writeState(state);
289
- });
290
- // The spend already happened on chain by the time record() is called, so
291
- // an unavailable lease must not throw: that would turn a broadcast
292
- // transaction into an unrecorded one. Write anyway and say so, because a
293
- // silent under-count is the failure this whole lease exists to prevent.
294
- if (!run.held) {
295
- console.error(`[spend-limit] Recorded ${amt} ${unit} for ${addr} WITHOUT the shared lock: ` +
296
- `${run.unavailable} The daily total may under-count if another tool wrote ` +
297
- `concurrently.`);
298
- }
299
- });
300
- // A failed write must not poison the chain for every later record().
301
- this.writeLock = write.catch(() => { });
302
- await write;
340
+ const session = this.getSession(addr);
341
+ session.ustx = Math.max(0, session.ustx - totals.ustx);
342
+ session.sats = Math.max(0, session.sats - totals.sats);
343
+ }));
344
+ }
345
+ catch (error) {
346
+ console.error(`[spend-limit] Could not release ${totals.ustx} uSTX / ${totals.sats} sats for ${addr}: ` +
347
+ `${error instanceof Error ? error.message : String(error)}. The day total stays ` +
348
+ `over-counted by that amount.`);
349
+ return;
350
+ }
351
+ if (!run.held) {
352
+ console.error(`[spend-limit] Could not release ${totals.ustx} uSTX / ${totals.sats} sats for ${addr}: ` +
353
+ `${run.unavailable} The day total stays over-counted by that amount.`);
354
+ }
355
+ }
356
+ /**
357
+ * Serialize ledger updates inside this process. The file lease excludes the
358
+ * OTHER process (the skills engine's direct x402 path shares
359
+ * spend-state.json so one wallet has one daily cap); this chain keeps two
360
+ * calls in this process from interleaving their read-modify-writes.
361
+ */
362
+ serialized(fn) {
363
+ const run = this.writeLock.then(fn);
364
+ // A failed update must not poison the chain for every later one.
365
+ this.writeLock = run.then(() => { }, () => { });
366
+ return run;
303
367
  }
304
368
  /** Remaining session/day budget for status reporting. */
305
369
  async status(addr) {
@@ -23,11 +23,13 @@
23
23
  * though nothing here is x402: renaming it on this side would silently return
24
24
  * both tools to the race above while each believed it held a lock.
25
25
  *
26
- * A STALE LEASE IS NEVER RECLAIMED AUTOMATICALLY. Deleting a lock this process
27
- * did not create needs an atomic "remove only if unchanged since I looked",
28
- * which the filesystem does not offer; guessing wrong there is precisely the
29
- * concurrent write the lock exists to prevent. A dead holder is reported with
30
- * its pid and left for an operator, who can see whether it is really dead.
26
+ * A STALE LEASE IS RECLAIMED ONLY WHEN ITS HOLDER IS PROVABLY DEAD, by the
27
+ * same protocol the skills engine uses: a second mkdir lock (`<lock>.reclaim`)
28
+ * serializes the removal, and staleness and owner are re-read while holding
29
+ * it. A dead holder cannot release or heartbeat, so the lease inspected is the
30
+ * lease removed. Without this, one SIGKILLed holder would refuse every spend
31
+ * until an operator stepped in. A stale lease whose holder may still be alive
32
+ * is reported with its pid and left alone.
31
33
  */
32
34
  /** Mutable so tests do not have to wait real seconds. */
33
35
  declare const timing: {
@@ -47,26 +49,25 @@ export declare const _stateLockTesting: {
47
49
  * redirects the state file redirects the lock with it.
48
50
  */
49
51
  declare function lockDirFor(stateFile: string): string;
50
- export interface LockedRun<T> {
51
- /** False when the body ran WITHOUT the lease. */
52
- held: boolean;
53
- /** Set when `held` is false: why the lease could not be taken. */
54
- unavailable?: string;
52
+ export type LockedRun<T> = {
53
+ held: true;
55
54
  value: T;
56
55
  }
56
+ /** The lease could not be taken, so `fn` did NOT run. */
57
+ | {
58
+ held: false;
59
+ unavailable: string;
60
+ };
57
61
  /**
58
- * Run `fn` under the shared lease for `stateFile`.
59
- *
60
- * `fn` RUNS EITHER WAY. This guards a read-modify-write that records something
61
- * which has ALREADY HAPPENED on chain, so refusing to run it would turn a
62
- * broadcast transaction into an unrecorded one — strictly worse than the race
63
- * it is trying to avoid. The caller is told which case it got and is expected
64
- * to say so loudly rather than let an under-count pass silently.
62
+ * Run `fn` under the shared lease for `stateFile`, or not at all.
65
63
  *
66
- * That is the deliberate difference from the skills engine, which takes this
67
- * same lease and DOES refuse: there the lock guards a payment not yet signed,
68
- * so refusing costs nothing but a retry. Authorising a future spend and
69
- * recording a past one fail in opposite directions.
64
+ * Every write to the spend ledger happens before the money moves: a
65
+ * reservation authorizes a spend not yet signed, and a release gives back one
66
+ * that never left. Neither is worth writing without the lease. A reservation
67
+ * that cannot take it is refused, which costs a retry; a release that cannot
68
+ * take it is skipped, which leaves the ledger over-counting. Both fail in the
69
+ * direction that keeps the cap. The skills engine refuses on the same lease
70
+ * for the same reason.
70
71
  */
71
72
  export declare function withSharedStateLock<T>(stateFile: string, fn: () => Promise<T>): Promise<LockedRun<T>>;
72
73
  export {};
@@ -23,15 +23,17 @@
23
23
  * though nothing here is x402: renaming it on this side would silently return
24
24
  * both tools to the race above while each believed it held a lock.
25
25
  *
26
- * A STALE LEASE IS NEVER RECLAIMED AUTOMATICALLY. Deleting a lock this process
27
- * did not create needs an atomic "remove only if unchanged since I looked",
28
- * which the filesystem does not offer; guessing wrong there is precisely the
29
- * concurrent write the lock exists to prevent. A dead holder is reported with
30
- * its pid and left for an operator, who can see whether it is really dead.
26
+ * A STALE LEASE IS RECLAIMED ONLY WHEN ITS HOLDER IS PROVABLY DEAD, by the
27
+ * same protocol the skills engine uses: a second mkdir lock (`<lock>.reclaim`)
28
+ * serializes the removal, and staleness and owner are re-read while holding
29
+ * it. A dead holder cannot release or heartbeat, so the lease inspected is the
30
+ * lease removed. Without this, one SIGKILLed holder would refuse every spend
31
+ * until an operator stepped in. A stale lease whose holder may still be alive
32
+ * is reported with its pid and left alone.
31
33
  */
32
34
  import os from "os";
33
35
  import path from "path";
34
- import { mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync, } from "fs";
36
+ import { mkdirSync, readFileSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync, } from "fs";
35
37
  import { randomBytes } from "crypto";
36
38
  /** No heartbeat for this long means the holder is probably dead. */
37
39
  const LOCK_STALE_MS = 60_000;
@@ -116,6 +118,70 @@ function ownedBy(dir, token) {
116
118
  return false;
117
119
  }
118
120
  }
121
+ /** "<pid> <token>" from the lease's owner file, or null if it cannot be read. */
122
+ function readOwner(dir) {
123
+ try {
124
+ const match = /^(\d+) (\S+)$/.exec(readFileSync(path.join(dir, "owner"), "utf8"));
125
+ return match ? { pid: Number(match[1]), token: match[2] } : null;
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ /**
132
+ * True only when the holder is provably not running on this host: the pid
133
+ * does not exist (ESRCH), or it is our own pid with a token this process does
134
+ * not hold (a previous incarnation, e.g. a restarted container reusing pid 1).
135
+ * EPERM and pid reuse by an unrelated process read as alive, which errs
136
+ * toward refusing.
137
+ */
138
+ function holderIsDead(owner) {
139
+ if (owner.pid === process.pid) {
140
+ return ![...heldLocks.values()].includes(owner.token);
141
+ }
142
+ try {
143
+ process.kill(owner.pid, 0);
144
+ return false;
145
+ }
146
+ catch (error) {
147
+ return error.code === "ESRCH";
148
+ }
149
+ }
150
+ /**
151
+ * Remove a stale lease whose holder is dead. Byte-compatible with
152
+ * `reclaimDeadLock` in the skills engine's x402-guards.ts. An ownerless lease
153
+ * (created, then the creator died before writing its owner) is reclaimable
154
+ * once stale, since a live creator writes the owner immediately.
155
+ */
156
+ function reclaimDeadLock(dir) {
157
+ const reclaimDir = `${dir}.reclaim`;
158
+ try {
159
+ mkdirSync(reclaimDir);
160
+ }
161
+ catch {
162
+ return false; // another process is reclaiming; keep waiting
163
+ }
164
+ try {
165
+ if (!lockIsStale(dir))
166
+ return false;
167
+ const owner = readOwner(dir);
168
+ if (owner && !holderIsDead(owner))
169
+ return false;
170
+ rmSync(dir, { recursive: true, force: true });
171
+ return true;
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ finally {
177
+ try {
178
+ rmdirSync(reclaimDir);
179
+ }
180
+ catch {
181
+ // already gone
182
+ }
183
+ }
184
+ }
119
185
  function describeHolder(dir) {
120
186
  try {
121
187
  const [pid] = readFileSync(path.join(dir, "owner"), "utf8").split(" ");
@@ -170,47 +236,63 @@ function installExitHooks() {
170
236
  });
171
237
  }
172
238
  }
239
+ function reclaimInProgress(dir) {
240
+ try {
241
+ statSync(`${dir}.reclaim`);
242
+ return true;
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ }
173
248
  async function acquire(dir, token) {
174
249
  const deadline = Date.now() + timing.maxWaitMs;
175
250
  for (;;) {
176
251
  if (tryLock(dir, token))
177
252
  return null;
178
253
  if (lockIsStale(dir)) {
179
- return {
180
- reason: `the lock at ${dir} has had no heartbeat for over ${Math.round(timing.staleMs / 1000)}s ` +
181
- `(${describeHolder(dir)}). If that process is dead, remove the directory; do not remove ` +
182
- `it while a write may still be in progress.`,
183
- };
254
+ if (reclaimDeadLock(dir))
255
+ continue;
256
+ if (lockIsStale(`${dir}.reclaim`)) {
257
+ return {
258
+ reason: `a lock reclaim at ${dir}.reclaim was interrupted over ` +
259
+ `${Math.round(timing.staleMs / 1000)}s ago. Remove that directory (not the lock itself).`,
260
+ };
261
+ }
262
+ if (lockIsStale(dir) && !reclaimInProgress(dir)) {
263
+ return {
264
+ reason: `the lock at ${dir} has had no heartbeat for over ${Math.round(timing.staleMs / 1000)}s ` +
265
+ `(${describeHolder(dir)}) and its holder may still be running. If that process is ` +
266
+ `dead, remove the directory; do not remove it while a write may still be in progress.`,
267
+ };
268
+ }
184
269
  }
185
270
  if (Date.now() >= deadline) {
186
271
  return {
187
272
  reason: `another writer held the lock at ${dir} for the whole ` +
188
- `${Math.round(timing.maxWaitMs / 1000)}s wait (${describeHolder(dir)}).`,
273
+ `${Math.round(timing.maxWaitMs / 1000)}s wait (${describeHolder(dir)}). Retry once it finishes.`,
189
274
  };
190
275
  }
191
276
  await new Promise((r) => setTimeout(r, timing.retryMs));
192
277
  }
193
278
  }
194
279
  /**
195
- * Run `fn` under the shared lease for `stateFile`.
196
- *
197
- * `fn` RUNS EITHER WAY. This guards a read-modify-write that records something
198
- * which has ALREADY HAPPENED on chain, so refusing to run it would turn a
199
- * broadcast transaction into an unrecorded one — strictly worse than the race
200
- * it is trying to avoid. The caller is told which case it got and is expected
201
- * to say so loudly rather than let an under-count pass silently.
280
+ * Run `fn` under the shared lease for `stateFile`, or not at all.
202
281
  *
203
- * That is the deliberate difference from the skills engine, which takes this
204
- * same lease and DOES refuse: there the lock guards a payment not yet signed,
205
- * so refusing costs nothing but a retry. Authorising a future spend and
206
- * recording a past one fail in opposite directions.
282
+ * Every write to the spend ledger happens before the money moves: a
283
+ * reservation authorizes a spend not yet signed, and a release gives back one
284
+ * that never left. Neither is worth writing without the lease. A reservation
285
+ * that cannot take it is refused, which costs a retry; a release that cannot
286
+ * take it is skipped, which leaves the ledger over-counting. Both fail in the
287
+ * direction that keeps the cap. The skills engine refuses on the same lease
288
+ * for the same reason.
207
289
  */
208
290
  export async function withSharedStateLock(stateFile, fn) {
209
291
  const dir = lockDirFor(stateFile);
210
292
  const token = randomBytes(12).toString("hex");
211
293
  const unavailable = await acquire(dir, token);
212
294
  if (unavailable) {
213
- return { held: false, unavailable: unavailable.reason, value: await fn() };
295
+ return { held: false, unavailable: unavailable.reason };
214
296
  }
215
297
  installExitHooks();
216
298
  heldLocks.set(dir, token);