@tempoxyz/patron 0.0.28 → 0.0.30

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.
@@ -1,12 +1,14 @@
1
1
  import { createRequire as __patronCreateRequire } from 'node:module'
2
2
  const require = __patronCreateRequire(import.meta.url)
3
3
  import {
4
+ CLAUDE_HOOK_STATUS,
4
5
  FileTokenStore,
5
6
  LINK_TOKEN_STEP,
6
7
  STARTER_TOKENS,
7
8
  budgetTokens,
8
9
  buyTokens,
9
10
  codexHome,
11
+ codexNotifyCommand,
10
12
  codexPaths,
11
13
  hasPatronHandler,
12
14
  installCodex,
@@ -15,10 +17,13 @@ import {
15
17
  loadIssuer,
16
18
  needsRefill,
17
19
  parseUsd,
20
+ patronCommands,
18
21
  purchasableTokens,
19
22
  readJsonObject,
20
23
  redeem,
24
+ refillSize,
21
25
  removeCodex,
26
+ storedIssuer,
22
27
  tipTokens,
23
28
  upgradeCodex,
24
29
  withHooksKey,
@@ -26,41 +31,44 @@ import {
26
31
  withPatronStatus,
27
32
  withoutPatronHandlers,
28
33
  writeJsonObject
29
- } from "./chunk-EKFENOVR.js";
34
+ } from "./chunk-4SXIRXUL.js";
30
35
  import {
36
+ awaitRedemptions,
37
+ gatewayTurn,
31
38
  startRefill
32
- } from "./chunk-TV5VLFTD.js";
39
+ } from "./chunk-TC2WEQ25.js";
33
40
  import {
41
+ inferHome,
34
42
  resolveSettings,
35
43
  settingsFor
36
- } from "./chunk-OQXACACU.js";
44
+ } from "./chunk-NDBTADXZ.js";
37
45
  import {
38
- AGENT_IDS,
39
46
  LeaderboardResponse,
40
47
  PatronInfo,
41
- ensureHome,
42
- profileMessage,
43
- readConfig,
44
- readJson,
45
- readUsage,
46
- statePaths,
47
- updateUsage,
48
- writeConfig,
49
- writePrivateFile
50
- } from "./chunk-EV7OA6GK.js";
48
+ profileMessage
49
+ } from "./chunk-ROVOCDKE.js";
51
50
  import {
51
+ AGENT_IDS,
52
52
  DEFAULT_MONTHLY_BUDGET_USD,
53
53
  MAX_TOKENS_PER_PAGE,
54
54
  MIN_TOKENS_PER_PAGE,
55
55
  PACK_SIZE,
56
56
  TOKEN_PRICE_UNITS,
57
57
  clampTokensPerPage,
58
+ ensureHome,
58
59
  formatUsd,
59
60
  normalizePayee,
61
+ readConfig,
62
+ readJson,
63
+ readUsage,
64
+ statePaths,
60
65
  tokensToUnits,
61
66
  tryLock,
62
- withLock
63
- } from "./chunk-EISHX7Q3.js";
67
+ updateUsage,
68
+ withLock,
69
+ writeConfig,
70
+ writePrivateFile
71
+ } from "./chunk-AKOXBXV3.js";
64
72
  import "./chunk-GTMLTZRN.js";
65
73
 
66
74
  // src/index.ts
@@ -70,9 +78,9 @@ import { homedir as homedir2 } from "node:os";
70
78
 
71
79
  // src/agents.ts
72
80
  import { constants } from "node:fs";
73
- import { access as access2, realpath as realpath2 } from "node:fs/promises";
81
+ import { access as access2, readFile as readFile4, realpath as realpath2 } from "node:fs/promises";
74
82
  import { homedir } from "node:os";
75
- import { delimiter, join as join5 } from "node:path";
83
+ import { delimiter, join as join5, resolve } from "node:path";
76
84
 
77
85
  // src/amp.ts
78
86
  import { mkdir, readFile, rm } from "node:fs/promises";
@@ -134,13 +142,17 @@ async function installAmpPlugin(path, command) {
134
142
  return "installed";
135
143
  }
136
144
  async function upgradeAmpPlugin(path) {
145
+ const command = await ampPluginCommand(path);
146
+ if (command === void 0) return false;
147
+ return await installAmpPlugin(path, command) === "installed";
148
+ }
149
+ async function ampPluginCommand(path) {
137
150
  const current = await readText(path);
138
- if (current === void 0 || !current.startsWith(AMP_PLUGIN_MARKER)) return false;
151
+ if (current === void 0 || !current.startsWith(AMP_PLUGIN_MARKER)) return void 0;
139
152
  const literal = /^const COMMAND = (".*")$/m.exec(current)?.[1];
140
- if (!literal) return false;
153
+ if (!literal) return void 0;
141
154
  const command = JSON.parse(literal);
142
- if (typeof command !== "string") return false;
143
- return await installAmpPlugin(path, command) === "installed";
155
+ return typeof command === "string" ? command : void 0;
144
156
  }
145
157
  async function removeAmpPlugin(path) {
146
158
  const current = await readText(path);
@@ -161,7 +173,6 @@ async function readText(path) {
161
173
  var CLAUDE_MATCHER = "WebFetch|WebSearch";
162
174
  var HOOK_TIMEOUT_SECONDS = 30;
163
175
  var EVENT = "PostToolUse";
164
- var CLAUDE_HOOK_STATUS = "Patron: pay the site this read";
165
176
  function patronGroup(command) {
166
177
  return {
167
178
  hooks: [
@@ -176,8 +187,8 @@ function patronGroup(command) {
176
187
  matcher: CLAUDE_MATCHER
177
188
  };
178
189
  }
179
- function withPatronHook(settings, command) {
180
- return withPatronGroup(settings, EVENT, patronGroup(command));
190
+ function withPatronHook(settings, command, known) {
191
+ return withPatronGroup(settings, EVENT, patronGroup(command), known);
181
192
  }
182
193
  async function upgradeClaudeHook(path) {
183
194
  const settings = await readJsonObject(path);
@@ -186,8 +197,8 @@ async function upgradeClaudeHook(path) {
186
197
  await writeJsonObject(path, next);
187
198
  return true;
188
199
  }
189
- function withoutPatronHook(settings) {
190
- return withoutPatronHandlers(settings, EVENT);
200
+ function withoutPatronHook(settings, known) {
201
+ return withoutPatronHandlers(settings, EVENT, known);
191
202
  }
192
203
  function hasPatronHook(settings, command) {
193
204
  return hasPatronHandler(settings, EVENT, {
@@ -195,16 +206,17 @@ function hasPatronHook(settings, command) {
195
206
  ...command === void 0 ? {} : { command }
196
207
  });
197
208
  }
198
- async function installClaudeHook(path, command) {
209
+ async function installClaudeHook(path, command, known) {
199
210
  const settings = await readJsonObject(path);
200
- if (hasPatronHook(settings, command)) return "unchanged";
201
- await writeJsonObject(path, withPatronHook(settings, command));
211
+ if (hasPatronHook(settings, command) && patronCommands(settings, EVENT).length === 1)
212
+ return "unchanged";
213
+ await writeJsonObject(path, withPatronHook(settings, command, known));
202
214
  return "installed";
203
215
  }
204
- async function removeClaudeHook(path) {
216
+ async function removeClaudeHook(path, known) {
205
217
  const settings = await readJsonObject(path);
206
- if (!hasPatronHandler(settings, EVENT)) return "missing";
207
- await writeJsonObject(path, withoutPatronHook(settings));
218
+ if (!hasPatronHandler(settings, EVENT, known ? { known } : {})) return "missing";
219
+ await writeJsonObject(path, withoutPatronHook(settings, known));
208
220
  return "removed";
209
221
  }
210
222
 
@@ -216,42 +228,46 @@ var EVENT2 = "postToolUse";
216
228
  function cursorHooksPath(home) {
217
229
  return join2(home, ".cursor", "hooks.json");
218
230
  }
219
- function withCursorHook(config, command) {
220
- const stripped = withoutCursorHook(config);
231
+ function withCursorHook(config, command, known) {
232
+ const stripped = withoutCursorHook(config, known);
221
233
  const next = typeof stripped.version === "number" ? stripped : { version: 1, ...stripped };
222
234
  const hooks = isObject(next.hooks) ? next.hooks : {};
223
235
  const entries = Array.isArray(hooks[EVENT2]) ? hooks[EVENT2] : [];
224
236
  return withHooksKey(next, EVENT2, [...entries, cursorEntry(command)]);
225
237
  }
226
- function withoutCursorHook(config) {
238
+ function withoutCursorHook(config, known) {
227
239
  const entries = isObject(config.hooks) ? config.hooks[EVENT2] : void 0;
228
- if (!Array.isArray(entries) || !entries.some(isPatronHandler)) return { ...config };
240
+ const patron = (entry) => isPatronHandler(entry, known);
241
+ if (!Array.isArray(entries) || !entries.some(patron)) return { ...config };
229
242
  return withHooksKey(
230
243
  config,
231
244
  EVENT2,
232
- entries.filter((entry) => !isPatronHandler(entry))
245
+ entries.filter((entry) => !patron(entry))
233
246
  );
234
247
  }
235
248
  function cursorEntry(command) {
236
249
  return { command, matcher: CURSOR_MATCHER, timeout: HOOK_TIMEOUT_SECONDS2 };
237
250
  }
238
- function hasCursorHook(config, command) {
251
+ function hasCursorHook(config, command, known) {
239
252
  const entries = isObject(config.hooks) ? config.hooks[EVENT2] : void 0;
240
253
  if (!Array.isArray(entries)) return false;
241
- if (command === void 0) return entries.some(isPatronHandler);
254
+ if (command === void 0) return entries.some((entry) => isPatronHandler(entry, known));
242
255
  const wanted = JSON.stringify(cursorEntry(command));
243
- return entries.some((entry) => JSON.stringify(entry) === wanted);
256
+ const patron = entries.filter(
257
+ (entry) => isPatronHandler(entry, known) || JSON.stringify(entry) === wanted
258
+ );
259
+ return patron.length === 1 && JSON.stringify(patron[0]) === wanted;
244
260
  }
245
- async function installCursorHook(path, command) {
261
+ async function installCursorHook(path, command, known) {
246
262
  const config = await readJsonObject(path);
247
- if (hasCursorHook(config, command)) return "unchanged";
248
- await writeJsonObject(path, withCursorHook(config, command));
263
+ if (hasCursorHook(config, command, known)) return "unchanged";
264
+ await writeJsonObject(path, withCursorHook(config, command, known));
249
265
  return "installed";
250
266
  }
251
- async function removeCursorHook(path) {
267
+ async function removeCursorHook(path, known) {
252
268
  const config = await readJsonObject(path);
253
- if (!hasCursorHook(config)) return "missing";
254
- await writeJsonObject(path, withoutCursorHook(config));
269
+ if (!hasCursorHook(config, void 0, known)) return "missing";
270
+ await writeJsonObject(path, withoutCursorHook(config, known));
255
271
  return "removed";
256
272
  }
257
273
 
@@ -351,9 +367,9 @@ import { basename, dirname as dirname3, join as join4 } from "node:path";
351
367
  function stableBin(home) {
352
368
  return join4(home, "cli", "current", "dist", "bin.js");
353
369
  }
354
- async function installStableCli(home, script = process.argv[1]) {
370
+ async function installStableCli(home, script = process.argv[1], anySource = process.platform === "win32" && Boolean(process.env.PATRON_HOME)) {
355
371
  const resolved = script ? await realpath(script).catch(() => void 0) : void 0;
356
- if (!resolved || !/[/\\]_npx[/\\]/.test(resolved) || basename(resolved) !== "bin.js")
372
+ if (!resolved || !anySource && !/[/\\]_npx[/\\]/.test(resolved) || basename(resolved) !== "bin.js")
357
373
  return void 0;
358
374
  const packageDir = dirname3(dirname3(resolved));
359
375
  const { version } = JSON.parse(await readFile3(join4(packageDir, "package.json"), "utf8"));
@@ -408,32 +424,33 @@ var ADAPTERS = {
408
424
  install: async (command, { env, home }) => ({
409
425
  state: await installAmpPlugin(ampPluginPath(env, home), command)
410
426
  }),
427
+ // The plugin file starts with Patron's marker, so it needs no known commands.
411
428
  remove: ({ env, home }) => removeAmpPlugin(ampPluginPath(env, home)),
412
429
  upgrade: ({ env, home }) => upgradeAmpPlugin(ampPluginPath(env, home))
413
430
  },
414
431
  "claude-code": {
415
432
  files: (environment) => [claudeSettingsPath(environment)],
416
433
  detect: async (environment) => await exists(claudeDirectory(environment)) || await onPath(["claude"], environment.env.PATH ?? ""),
417
- install: async (command, environment) => ({
418
- state: await installClaudeHook(claudeSettingsPath(environment), command)
434
+ install: async (command, environment, known) => ({
435
+ state: await installClaudeHook(claudeSettingsPath(environment), command, known)
419
436
  }),
420
- remove: (environment) => removeClaudeHook(claudeSettingsPath(environment)),
437
+ remove: (environment, known) => removeClaudeHook(claudeSettingsPath(environment), known),
421
438
  upgrade: (environment) => upgradeClaudeHook(claudeSettingsPath(environment))
422
439
  },
423
440
  codex: {
424
441
  files: ({ env, home }) => Object.values(codexPaths(env, home)),
425
442
  detect: async ({ env, home }) => await exists(codexHome(env, home)) || await onPath(["codex"], env.PATH ?? ""),
426
- install: (command, { env, home }) => installCodex(codexPaths(env, home), command),
427
- remove: ({ env, home }) => removeCodex(codexPaths(env, home)),
443
+ install: (command, { env, home }, known) => installCodex(codexPaths(env, home), command, process.platform, known),
444
+ remove: ({ env, home }, known) => removeCodex(codexPaths(env, home), known),
428
445
  upgrade: ({ env, home }) => upgradeCodex(codexPaths(env, home))
429
446
  },
430
447
  cursor: {
431
448
  files: ({ home }) => [cursorHooksPath(home)],
432
449
  detect: async ({ env, home }) => await exists(join5(home, ".cursor")) || await onPath(["cursor-agent", "cursor"], env.PATH ?? ""),
433
- install: async (command, { home }) => ({
434
- state: await installCursorHook(cursorHooksPath(home), command)
450
+ install: async (command, { home }, known) => ({
451
+ state: await installCursorHook(cursorHooksPath(home), command, known)
435
452
  }),
436
- remove: ({ home }) => removeCursorHook(cursorHooksPath(home))
453
+ remove: ({ home }, known) => removeCursorHook(cursorHooksPath(home), known)
437
454
  }
438
455
  };
439
456
  function claudeDirectory({ env, home }) {
@@ -452,12 +469,13 @@ async function detectAgents(environment) {
452
469
  }
453
470
  async function installAgents(agents, command, environment) {
454
471
  const store = originalsStore(environment);
472
+ const known = await knownCommands(environment, [command]);
455
473
  return Promise.all(
456
474
  agents.map(async (agent) => {
457
475
  let recorded = [];
458
476
  try {
459
477
  recorded = await rememberOriginals(store, ADAPTERS[agent].files(environment));
460
- const { note, state } = await ADAPTERS[agent].install(command, environment);
478
+ const { note, state } = await ADAPTERS[agent].install(command, environment, known);
461
479
  if (state !== "installed") await forgetOriginals(store, recorded);
462
480
  return { agent, label: AGENT_LABELS[agent], state, ...note ? { note } : {} };
463
481
  } catch (error) {
@@ -477,10 +495,17 @@ async function upgradeAgents(agents, environment) {
477
495
  }
478
496
  async function removeAgents(environment) {
479
497
  const store = originalsStore(environment);
498
+ const patronHome = environment.env.PATRON_HOME || join5(environment.home, ".patron");
499
+ const stable = `${quote(process.execPath)} ${quote(stableBin(patronHome))} hook`;
500
+ const known = await knownCommands(environment, [
501
+ await hookCommand(environment.env, process.argv[1], process.execPath, patronHome),
502
+ stable,
503
+ withPatronHome(stable, environment.env.PATRON_HOME)
504
+ ]);
480
505
  return Promise.all(
481
506
  AGENT_IDS.map(async (agent) => {
482
507
  try {
483
- const state = await ADAPTERS[agent].remove(environment);
508
+ const state = await ADAPTERS[agent].remove(environment, known);
484
509
  if (state === "removed")
485
510
  await restoreOriginals(store, ADAPTERS[agent].files(environment)).catch(
486
511
  () => void 0
@@ -492,6 +517,32 @@ async function removeAgents(environment) {
492
517
  })
493
518
  );
494
519
  }
520
+ async function knownCommands({ env, home }, extra) {
521
+ const paths = codexPaths(env, home);
522
+ const found = await Promise.all([
523
+ readJsonObject(claudeSettingsPath({ env, home })).then(
524
+ (settings) => patronCommands(settings, "PostToolUse"),
525
+ () => []
526
+ ),
527
+ readJsonObject(paths.hooks).then(
528
+ (settings) => patronCommands(settings, "Stop"),
529
+ () => []
530
+ ),
531
+ readFile4(paths.config, "utf8").then(
532
+ (toml) => [codexNotifyCommand(toml)],
533
+ () => []
534
+ ),
535
+ ampPluginCommand(ampPluginPath(env, home)).then(
536
+ (command) => [command],
537
+ () => []
538
+ )
539
+ ]);
540
+ return new Set(
541
+ [...found.flat(), ...extra].filter(
542
+ (command) => typeof command === "string" && command !== ""
543
+ )
544
+ );
545
+ }
495
546
  function originalsStore({ env, home }) {
496
547
  return originalsPath(env.PATRON_HOME || join5(home, ".patron"));
497
548
  }
@@ -503,17 +554,45 @@ function failure(agent, error) {
503
554
  state: "failed"
504
555
  };
505
556
  }
506
- async function hookCommand(env = process.env, script = process.argv[1], node = process.execPath, patronHome = env.PATRON_HOME || join5(homedir(), ".patron")) {
557
+ async function hookCommand(env = process.env, script = process.argv[1], node = process.execPath, patronHome = env.PATRON_HOME || join5(homedir(), ".patron"), platform = process.platform) {
507
558
  if (env.PATRON_HOOK_COMMAND) return env.PATRON_HOOK_COMMAND;
559
+ const windows = platform === "win32";
560
+ const run2 = await runCommand(
561
+ script,
562
+ node,
563
+ patronHome,
564
+ windows && Boolean(env.PATRON_HOME),
565
+ platform
566
+ );
567
+ if (!env.PATRON_HOME || windows || await runsFromHome(run2.bin, patronHome))
568
+ return run2.command;
569
+ return withPatronHome(run2.command, env.PATRON_HOME);
570
+ }
571
+ async function runCommand(script, node, patronHome, preferCopy, platform) {
572
+ const run2 = (bin) => ({
573
+ bin,
574
+ command: `${quote(node, platform)} ${quote(bin, platform)} hook`
575
+ });
576
+ const stable = stableBin(patronHome);
577
+ if (preferCopy && await exists(stable)) return run2(stable);
508
578
  const resolved = script ? await realpath2(script).catch(() => void 0) : void 0;
509
579
  if (resolved && /[/\\]bin\.js$/.test(resolved) && !/[/\\]_npx[/\\]/.test(resolved))
510
- return `${quote(node)} ${quote(resolved)} hook`;
511
- if (await exists(stableBin(patronHome)))
512
- return `${quote(node)} ${quote(stableBin(patronHome))} hook`;
513
- return "npx -y @tempoxyz/patron hook";
580
+ return run2(resolved);
581
+ if (await exists(stable)) return run2(stable);
582
+ return { command: "npx -y @tempoxyz/patron hook" };
514
583
  }
515
- function quote(value) {
516
- return /^[\w./:@-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
584
+ async function runsFromHome(bin, patronHome) {
585
+ const inferred = inferHome(bin);
586
+ if (!inferred) return false;
587
+ const real = (path) => realpath2(path).catch(() => resolve(path));
588
+ return await real(inferred) === await real(patronHome);
589
+ }
590
+ function withPatronHome(command, patronHome) {
591
+ return patronHome ? `PATRON_HOME=${quote(resolve(patronHome))} ${command}` : command;
592
+ }
593
+ function quote(value, platform = process.platform) {
594
+ if (/^[\w./:@-]+$/.test(value)) return value;
595
+ return platform === "win32" ? `"${value}"` : `'${value.replaceAll("'", "'\\''")}'`;
517
596
  }
518
597
  async function exists(path) {
519
598
  return access2(path).then(
@@ -697,16 +776,24 @@ async function linkRefillFailedAt(home) {
697
776
  const value = await readJson(statePaths(home).linkRefill).catch(() => void 0);
698
777
  return typeof value?.failedAt === "number" ? value.failedAt : void 0;
699
778
  }
700
- var REFILL_LOCK_STALE_MS = 5 * 60 * 1e3;
701
779
  var defaultOpenWallet = async (settings, config, issuer, maxUnits) => {
702
780
  if (config.wallet === "link") {
703
- const { openLinkWallet } = await import("./wallet-6TDMXRWW.js");
781
+ const { openLinkWallet } = await import("./wallet-OTFEEKIG.js");
704
782
  return openLinkWallet(config, maxUnits);
705
783
  }
706
- const { openWallet } = await import("./wallet-6TDMXRWW.js");
784
+ const { openWallet } = await import("./wallet-OTFEEKIG.js");
707
785
  return openWallet(settings, config.wallet, issuer, maxUnits);
708
786
  };
787
+ var REFILL_LOCK_STALE_MS = 5 * 60 * 1e3;
709
788
  async function buyWithinBudget(settings, config, wanted, openWallet = defaultOpenWallet) {
789
+ await ensureHome(settings.home);
790
+ return withLock(
791
+ statePaths(settings.home).purchaseLock,
792
+ () => buyLocked(settings, config, wanted, openWallet),
793
+ { staleMs: REFILL_LOCK_STALE_MS, timeoutMs: REFILL_LOCK_STALE_MS }
794
+ );
795
+ }
796
+ async function buyLocked(settings, config, wanted, openWallet) {
710
797
  const usage = await readUsage(settings.home);
711
798
  const count = purchasableTokens(config, usage, wanted);
712
799
  if (count === 0) return 0;
@@ -729,10 +816,46 @@ async function buyWithinBudget(settings, config, wanted, openWallet = defaultOpe
729
816
  });
730
817
  return tokens.length;
731
818
  }
732
- async function refill(base, openWallet = defaultOpenWallet, now = Date.now) {
819
+ var REFILL_DELAY_MS = { max: 10 * 60 * 1e3, min: 60 * 1e3 };
820
+ async function delayedRefill(base, openWallet = defaultOpenWallet, sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)), random = Math.random) {
821
+ if (!await readConfig(base.home)) return 0;
822
+ const release = await tryLock(
823
+ statePaths(base.home).refillWait,
824
+ REFILL_DELAY_MS.max + REFILL_GATEWAY_WAIT_MS + REFILL_LOCK_STALE_MS
825
+ );
826
+ if (!release) return 0;
827
+ try {
828
+ await sleep(
829
+ REFILL_DELAY_MS.min + random() * (REFILL_DELAY_MS.max - REFILL_DELAY_MS.min)
830
+ );
831
+ return await refill(base, openWallet, Date.now, sleep);
832
+ } finally {
833
+ await release();
834
+ }
835
+ }
836
+ var REFILL_GATEWAY_WAIT_MS = 15 * 60 * 1e3;
837
+ async function refill(base, openWallet = defaultOpenWallet, now = Date.now, sleep) {
733
838
  const config = await readConfig(base.home);
734
839
  if (!config || config.paused) return 0;
735
840
  const settings = settingsFor(base, config);
841
+ const stale = (await storedIssuer(settings.home, settings.gatewayUrl))?.stale ?? true;
842
+ const low = needsRefill(await new FileTokenStore(settings.home).count()) && await linkBackedOff(settings.home, config, now()) === false && await readUsage(settings.home).then(
843
+ (usage) => refillSize(config, usage) > 0,
844
+ () => true
845
+ );
846
+ if (!stale && !low) return 0;
847
+ const bought = await gatewayTurn(
848
+ settings.home,
849
+ async () => {
850
+ if (stale)
851
+ await loadIssuer(settings.home, settings.gatewayUrl).catch(() => void 0);
852
+ return low ? refillLocked(settings, config, openWallet, now) : 0;
853
+ },
854
+ { maxWaitMs: REFILL_GATEWAY_WAIT_MS, ...sleep ? { sleep } : {} }
855
+ );
856
+ return bought ?? 0;
857
+ }
858
+ async function refillLocked(settings, config, openWallet, now) {
736
859
  const release = await tryLock(
737
860
  statePaths(settings.home).refillLock,
738
861
  REFILL_LOCK_STALE_MS
@@ -742,8 +865,7 @@ async function refill(base, openWallet = defaultOpenWallet, now = Date.now) {
742
865
  if (!needsRefill(await new FileTokenStore(settings.home).count())) return 0;
743
866
  if (config.wallet !== "link")
744
867
  return await buyWithinBudget(settings, config, PACK_SIZE, openWallet);
745
- const failedAt = await linkRefillFailedAt(settings.home);
746
- if (failedAt !== void 0 && now() - failedAt < LINK_REFILL_BACKOFF_MS) return 0;
868
+ if (await linkBackedOff(settings.home, config, now())) return 0;
747
869
  const path = statePaths(settings.home).linkRefill;
748
870
  try {
749
871
  const bought = await buyWithinBudget(settings, config, PACK_SIZE, openWallet);
@@ -757,6 +879,11 @@ async function refill(base, openWallet = defaultOpenWallet, now = Date.now) {
757
879
  await release();
758
880
  }
759
881
  }
882
+ async function linkBackedOff(home, config, now) {
883
+ if (config.wallet !== "link") return false;
884
+ const failedAt = await linkRefillFailedAt(home);
885
+ return failedAt !== void 0 && now - failedAt < LINK_REFILL_BACKOFF_MS;
886
+ }
760
887
 
761
888
  // src/render.ts
762
889
  function tokensUsd(tokens) {
@@ -846,6 +973,34 @@ PATRON_PRIVATE_KEY (testnet automation), PATRON_BUDGET_USD (non-interactive setu
846
973
  PATRON_WALLET=link (pay with Link through link-cli), PATRON_LINK_CLI (its path).`;
847
974
  var UsageError = class extends Error {
848
975
  };
976
+ var TIP_BATCH = 8;
977
+ var TIP_REDEEM_TIMEOUT_MS = 3e4;
978
+ var TIP_BATCH_LEASE_MS = 6e4;
979
+ async function inGatewayTurn(deps, fn) {
980
+ const result = await gatewayTurn(
981
+ deps.settings.home,
982
+ async () => ({ value: await fn() }),
983
+ {
984
+ ...waiting(deps),
985
+ onWait: (ms) => say(
986
+ deps,
987
+ styleText(
988
+ ` Waiting ${minutes(ms)} after your agents' last payment, so Patron can't link the two.`,
989
+ "dim",
990
+ deps.color
991
+ )
992
+ )
993
+ }
994
+ );
995
+ return result.value;
996
+ }
997
+ function waiting(deps) {
998
+ return deps.sleep ? { sleep: deps.sleep } : {};
999
+ }
1000
+ function minutes(ms) {
1001
+ const count = Math.max(1, Math.ceil(ms / 6e4));
1002
+ return count === 1 ? "about a minute" : `about ${count} minutes`;
1003
+ }
849
1004
  function defaults() {
850
1005
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
851
1006
  const color = supportsColor(process.stdout);
@@ -915,7 +1070,7 @@ async function run(argv, overrides = {}) {
915
1070
  await uninstall(deps);
916
1071
  return 0;
917
1072
  case "refill":
918
- await refill(deps.settings, deps.openWallet).catch(() => 0);
1073
+ await (args[0] === "--delay" ? delayedRefill(deps.settings, deps.openWallet) : refill(deps.settings, deps.openWallet)).catch(() => 0);
919
1074
  return 0;
920
1075
  case "help":
921
1076
  case "--help":
@@ -1007,10 +1162,13 @@ async function setup(deps) {
1007
1162
  const settings = deps.settings;
1008
1163
  say(deps, scrollBanner(VERSION, deps.color));
1009
1164
  say(deps);
1010
- const issuer = await loadIssuer(settings.home, settings.gatewayUrl, {
1011
- fetch: deps.fetch,
1012
- fresh: true
1013
- }).catch((error) => {
1165
+ const issuer = await inGatewayTurn(
1166
+ deps,
1167
+ () => loadIssuer(settings.home, settings.gatewayUrl, {
1168
+ fetch: deps.fetch,
1169
+ fresh: true
1170
+ })
1171
+ ).catch((error) => {
1014
1172
  throw new Error(`Cannot reach Patron at ${settings.gatewayUrl}: ${error.message}`);
1015
1173
  });
1016
1174
  const budget = await askBudget(deps, DEFAULT_MONTHLY_BUDGET_USD);
@@ -1035,7 +1193,10 @@ async function setup(deps) {
1035
1193
  const starter = config.wallet === "link" ? PACK_SIZE : STARTER_TOKENS;
1036
1194
  const first = purchasableTokens(config, await readUsage(settings.home), starter);
1037
1195
  step(deps, `Adding ${tokensUsd(first)} to your balance`);
1038
- const bought = await buyWithinBudget(settings, config, starter, deps.openWallet);
1196
+ const bought = await inGatewayTurn(
1197
+ deps,
1198
+ () => buyWithinBudget(settings, config, starter, deps.openWallet)
1199
+ );
1039
1200
  done(deps, `${tokensUsd(bought)} ready to pay sites`);
1040
1201
  deps.startRefill();
1041
1202
  await installStableCli(settings.home).catch(() => void 0);
@@ -1084,7 +1245,7 @@ async function tempoSignIn(deps, budgetUnits, issuer) {
1084
1245
  deps,
1085
1246
  deps.settings.privateKey ? "Using PATRON_PRIVATE_KEY" : "Signing in with Tempo Wallet"
1086
1247
  );
1087
- const { signIn } = await import("./wallet-6TDMXRWW.js");
1248
+ const { signIn } = await import("./wallet-OTFEEKIG.js");
1088
1249
  const account = await signIn(deps.settings, {
1089
1250
  budgetUnits,
1090
1251
  issuer,
@@ -1124,8 +1285,8 @@ async function askBudget(deps, fallback) {
1124
1285
  const preset = process.env.PATRON_BUDGET_USD;
1125
1286
  if (!deps.interactive) {
1126
1287
  const value = preset ? parseUsd(preset) : fallback;
1127
- if (value === void 0 || value <= 0)
1128
- throw new UsageError("PATRON_BUDGET_USD must be a positive amount.");
1288
+ if (value === void 0 || value < 1e-3)
1289
+ throw new UsageError("PATRON_BUDGET_USD must be at least 0.001.");
1129
1290
  return value;
1130
1291
  }
1131
1292
  const prompter = deps.prompter();
@@ -1198,7 +1359,7 @@ async function changeBudget(deps, input) {
1198
1359
  } else budget = await askBudget(deps, config.monthlyBudgetUsd);
1199
1360
  if (config.wallet === "tempo" && budget >= config.monthlyBudgetUsd) {
1200
1361
  step(deps, "Approve the new spending limit in Tempo Wallet");
1201
- const { authorizeBudget } = await import("./wallet-6TDMXRWW.js");
1362
+ const { authorizeBudget } = await import("./wallet-OTFEEKIG.js");
1202
1363
  const issuer = await loadIssuer(settings.home, settings.gatewayUrl, {
1203
1364
  fetch: deps.fetch
1204
1365
  });
@@ -1254,42 +1415,100 @@ async function tip(deps, url, amount = "0.10") {
1254
1415
  );
1255
1416
  const amount2 = Math.max(missing, purchasableTokens(config, usage, PACK_SIZE));
1256
1417
  step(deps, `Adding ${tokensUsd(amount2)} to your balance`);
1257
- await buyWithinBudget(settings, config, amount2, deps.openWallet);
1418
+ await inGatewayTurn(
1419
+ deps,
1420
+ () => buyWithinBudget(settings, config, amount2, deps.openWallet)
1421
+ );
1258
1422
  }
1259
- const issuer = await loadIssuer(settings.home, settings.gatewayUrl, {
1260
- fetch: deps.fetch
1423
+ const issuer = (await storedIssuer(settings.home, settings.gatewayUrl))?.issuer ?? await inGatewayTurn(
1424
+ deps,
1425
+ () => loadIssuer(settings.home, settings.gatewayUrl, { fetch: deps.fetch, fresh: true })
1426
+ );
1427
+ const redeemFetch = (input, init) => deps.fetch(input, { ...init, signal: AbortSignal.timeout(TIP_REDEEM_TIMEOUT_MS) });
1428
+ let told = false;
1429
+ const claimBatch = (cancelled) => awaitRedemptions(settings.home, TIP_BATCH_LEASE_MS, {
1430
+ ...waiting(deps),
1431
+ ...cancelled ? { cancelled } : {},
1432
+ onWait: (ms) => {
1433
+ if (told) return;
1434
+ told = true;
1435
+ say(
1436
+ deps,
1437
+ styleText(
1438
+ ` Waiting ${minutes(ms)} after a recent purchase, so Patron can't link it to this tip. Ctrl-C keeps your balance.`,
1439
+ "dim",
1440
+ deps.color
1441
+ )
1442
+ );
1443
+ }
1261
1444
  });
1262
- const tokens = await store.takeMany(count);
1445
+ await claimBatch();
1446
+ let stopped = false;
1447
+ const stop = () => {
1448
+ stopped = true;
1449
+ process.off("SIGINT", stop);
1450
+ process.off("SIGTERM", stop);
1451
+ };
1452
+ process.once("SIGINT", stop);
1453
+ process.once("SIGTERM", stop);
1263
1454
  let paid = 0;
1455
+ let sent = 0;
1264
1456
  let rejection;
1265
- const unsent = [];
1266
- for (let index = 0; index < tokens.length; index += 8) {
1267
- const batch = tokens.slice(index, index + 8);
1268
- const results = await Promise.all(
1269
- batch.map(
1270
- (token) => redeem({
1271
- domain,
1272
- fetch: deps.fetch,
1273
- issuer,
1274
- relayUrl: settings.relayUrl,
1275
- token
1276
- }).catch(() => {
1277
- unsent.push(token);
1278
- return void 0;
1279
- })
1280
- )
1281
- );
1282
- for (const result of results) {
1283
- if (result?.ok) paid += 1;
1284
- else if (result) rejection = result.error ?? "rejected";
1457
+ try {
1458
+ while (sent < count && !stopped) {
1459
+ if (sent > 0 && !await claimBatch(() => stopped)) break;
1460
+ const batch = await store.takeMany(Math.min(TIP_BATCH, count - sent));
1461
+ if (batch.length === 0) break;
1462
+ sent += batch.length;
1463
+ const unsent = [];
1464
+ let rejected = 0;
1465
+ const results = await Promise.all(
1466
+ batch.map(
1467
+ (token) => redeem({
1468
+ domain,
1469
+ fetch: redeemFetch,
1470
+ issuer,
1471
+ relayUrl: settings.relayUrl,
1472
+ token
1473
+ }).catch(() => {
1474
+ unsent.push(token);
1475
+ return void 0;
1476
+ })
1477
+ )
1478
+ );
1479
+ const ok = results.filter((result) => result?.ok).length;
1480
+ for (const result of results)
1481
+ if (result && !result.ok) {
1482
+ rejected += 1;
1483
+ rejection = result.error ?? "rejected";
1484
+ }
1485
+ sent -= unsent.length;
1486
+ await store.add(unsent);
1487
+ if (ok > 0)
1488
+ await updateUsage(settings.home, (usage) => {
1489
+ usage.domains[domain] = (usage.domains[domain] ?? 0) + ok;
1490
+ usage.tippedTokens += ok;
1491
+ });
1492
+ paid += ok;
1493
+ if (count > TIP_BATCH && ok > 0)
1494
+ say(
1495
+ deps,
1496
+ styleText(
1497
+ ` ${tokensUsd(paid)} of ${tokensUsd(count)} sent`,
1498
+ "dim",
1499
+ deps.color
1500
+ )
1501
+ );
1502
+ if (ok === 0 && (rejected > 0 || unsent.length > 0)) break;
1285
1503
  }
1504
+ } finally {
1505
+ process.off("SIGINT", stop);
1506
+ process.off("SIGTERM", stop);
1286
1507
  }
1287
- await store.add(unsent);
1288
- if (paid > 0)
1289
- await updateUsage(settings.home, (usage) => {
1290
- usage.domains[domain] = (usage.domains[domain] ?? 0) + paid;
1291
- usage.tippedTokens += paid;
1292
- });
1508
+ if (stopped && sent < count)
1509
+ throw new Error(
1510
+ `Tip stopped: tipped ${domain} ${tokensUsd(paid)} of ${tokensUsd(count)}; the rest stays in your balance.`
1511
+ );
1293
1512
  if (paid === 0)
1294
1513
  throw new Error(
1295
1514
  rejection ? `Patron rejected the tip to ${domain}: ${rejection}.` : `Could not reach Patron to tip ${domain}; your balance was kept.`
@@ -1316,7 +1535,7 @@ async function setName(deps, name) {
1316
1535
  const issuer = await loadIssuer(settings.home, settings.gatewayUrl, {
1317
1536
  fetch: deps.fetch
1318
1537
  });
1319
- const open = deps.openWallet ?? (async (s, c, i, max) => (await import("./wallet-6TDMXRWW.js")).openWallet(s, c.wallet, i, max));
1538
+ const open = deps.openWallet ?? (async (s, c, i, max) => (await import("./wallet-OTFEEKIG.js")).openWallet(s, c.wallet, i, max));
1320
1539
  const wallet = await open(settings, config, issuer, 0n);
1321
1540
  await setProfile(settings.gatewayUrl, wallet, name, deps.fetch);
1322
1541
  await writeConfig(settings.home, { ...config, name });
@@ -1324,9 +1543,11 @@ async function setName(deps, name) {
1324
1543
  }
1325
1544
  async function uninstall(deps) {
1326
1545
  const config = await readConfig(deps.settings.home);
1327
- const results = (await removeAgents(deps.agents)).filter(
1328
- (result) => result.state !== "missing"
1329
- );
1546
+ const removed = await removeAgents(deps.agents);
1547
+ const results = removed.filter((result) => result.state !== "missing");
1548
+ const notFound = removed.filter(
1549
+ (result) => result.state === "missing" && config?.agents.includes(result.agent)
1550
+ ).map((result) => result.label);
1330
1551
  say(deps, styleText("Patron uninstall", "bold", deps.color));
1331
1552
  for (const line of formatAgentResults(results, deps.color)) say(deps, line);
1332
1553
  if (results.length === 0)
@@ -1358,6 +1579,10 @@ async function uninstall(deps) {
1358
1579
  deps.color
1359
1580
  )
1360
1581
  );
1582
+ if (notFound.length > 0)
1583
+ throw new Error(
1584
+ `Found no Patron hook to remove for ${notFound.join(", ")}. If a hook running "patron hook" is still in its settings, delete it by hand.`
1585
+ );
1361
1586
  }
1362
1587
  function readVersion() {
1363
1588
  for (const path of ["../package.json", "../../package.json"]) {