routstrd 0.4.8 → 0.4.10

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 (37) hide show
  1. package/README.md +124 -5
  2. package/SKILL.md +341 -0
  3. package/dist/daemon/index.js +7995 -14602
  4. package/dist/index.js +155916 -14225
  5. package/package.json +9 -4
  6. package/src/cli.ts +279 -110
  7. package/src/daemon/args.ts +4 -5
  8. package/src/daemon/http/index.ts +37 -2
  9. package/src/daemon/index.ts +94 -33
  10. package/src/daemon/models.ts +13 -10
  11. package/src/daemon/wallet/auto-refill.ts +1 -1
  12. package/src/daemon/wallet/coco-client.ts +205 -3
  13. package/src/daemon/wallet/cocod-client.ts +19 -3
  14. package/src/daemon/wallet/index.ts +1 -1
  15. package/src/index.ts +9 -1
  16. package/src/runtime.ts +55 -0
  17. package/src/start-daemon.ts +4 -8
  18. package/src/tui/usage/app.ts +16 -2
  19. package/src/tui/usage/data.ts +110 -0
  20. package/src/tui/usage/render.ts +45 -20
  21. package/src/utils/clients.ts +74 -1
  22. package/src/utils/config.ts +14 -0
  23. package/src/utils/daemon-stop.ts +125 -0
  24. package/src/utils/standalone-update.ts +214 -0
  25. package/src/utils/update-checker.ts +81 -18
  26. package/src/version.ts +3 -0
  27. package/src/cli.test.ts +0 -192
  28. package/src/daemon/fatal-error.test.ts +0 -104
  29. package/src/daemon/http/usage-summary.test.ts +0 -272
  30. package/src/daemon/models.test.ts +0 -81
  31. package/src/daemon/wallet/cleanup.test.ts +0 -168
  32. package/src/daemon/wallet/coco-client.npc.test.ts +0 -252
  33. package/src/daemon/wallet/coco-client.test.ts +0 -669
  34. package/src/daemon/wallet/diagnostics.test.ts +0 -378
  35. package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
  36. package/src/daemon/wallet/migration.test.ts +0 -171
  37. package/src/daemon/wallet/receive-dedup.test.ts +0 -238
@@ -9,7 +9,7 @@ import { logger } from "./utils/logger";
9
9
  import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
10
10
  import { withCrossProcessLock } from "./utils/process-lock";
11
11
  import { urlHosts } from "./utils/daemon-client";
12
- import { fileURLToPath } from "url";
12
+ import { daemonSpawnCommand } from "./runtime";
13
13
 
14
14
  const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
15
15
  const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`;
@@ -88,7 +88,8 @@ function printStartupProgress(offset: number): {
88
88
  }
89
89
  }
90
90
 
91
- function formatElapsed(elapsedMs: number): string {
91
+ /** Format an elapsed duration as "45s" or "2m 5s". */
92
+ export function formatElapsed(elapsedMs: number): string {
92
93
  const seconds = Math.floor(elapsedMs / 1000);
93
94
  if (seconds < 60) return `${seconds}s`;
94
95
  return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
@@ -151,17 +152,12 @@ async function startDaemonUnlocked(
151
152
  args.push("--provider", options.provider);
152
153
  }
153
154
 
154
- let daemonScript = fileURLToPath(new URL("./daemon/index.js", import.meta.url));
155
- if (!existsSync(daemonScript)) {
156
- daemonScript = fileURLToPath(new URL("./daemon/index.ts", import.meta.url));
157
- }
158
-
159
155
  const debugLogOffset = existsSync(DEBUG_LOG_PATH)
160
156
  ? fileSize(DEBUG_LOG_PATH)
161
157
  : 0;
162
158
  const debugLogFd = openSync(DEBUG_LOG_PATH, "a");
163
159
 
164
- const proc = Bun.spawn(["bun", daemonScript, ...args], {
160
+ const proc = Bun.spawn(daemonSpawnCommand(args), {
165
161
  stdout: debugLogFd,
166
162
  stderr: debugLogFd,
167
163
  stdin: "ignore",
@@ -1,6 +1,6 @@
1
1
  import { getVisibleTabs } from "./constants.ts";
2
2
  import type { Tab } from "./types.ts";
3
- import { fetchBalance, fetchClients, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
3
+ import { buildClientNaming, emptyClientNaming, fetchBalance, fetchClients, fetchNpubs, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type ClientNaming, type NpubEntry, type StatusInfo } from "./data.ts";
4
4
  import {
5
5
  applyScrollToContent,
6
6
  exitSearchMode,
@@ -48,6 +48,8 @@ export async function runUsageTui(): Promise<void> {
48
48
  let balance: BalanceInfo | null = null;
49
49
  let status: StatusInfo | null = null;
50
50
  let clients: ClientInfo[] = [];
51
+ let npubs: NpubEntry[] = [];
52
+ let naming: ClientNaming = emptyClientNaming();
51
53
  let visibleTabs: Tab[] = getVisibleTabs(false);
52
54
  let refreshInterval: ReturnType<typeof setInterval> | null = null;
53
55
  let autoRefresh = true;
@@ -144,6 +146,18 @@ export async function runUsageTui(): Promise<void> {
144
146
  currentTab = "clients";
145
147
  vimState.scrollPos = 0;
146
148
  }
149
+
150
+ // Names/roles live on the auth proxy, not the usage summary. Only
151
+ // fetch them when the Npubs tab is actually reachable, and keep the
152
+ // last good list if the endpoint is temporarily unreachable.
153
+ if (npubsVisible) {
154
+ const newNpubs = await fetchNpubs();
155
+ if (newNpubs.length > 0) npubs = newNpubs;
156
+ }
157
+
158
+ // Owner info (for "Name (client-id)" labels in the Recent tab) comes
159
+ // from /clients + /npubs; both are empty in local mode.
160
+ naming = buildClientNaming(clients, npubs);
147
161
  }
148
162
  render();
149
163
  } finally {
@@ -170,7 +184,7 @@ export async function runUsageTui(): Promise<void> {
170
184
  return;
171
185
  }
172
186
 
173
- const content = renderTabContent(currentTab, stats, balance, status, width, clients);
187
+ const content = renderTabContent(currentTab, stats, balance, status, width, naming);
174
188
  const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
175
189
  const chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
176
190
  const chromeLines = chrome.split("\n").length - 1;
@@ -1,5 +1,6 @@
1
1
  import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
2
  import {
3
+ callAuth,
3
4
  callDaemon,
4
5
  getDaemonBaseUrl,
5
6
  isDaemonRunning,
@@ -145,3 +146,112 @@ export async function fetchClients(): Promise<ClientInfo[]> {
145
146
  export function hasAnyNpubs(clients: ClientInfo[]): boolean {
146
147
  return clients.some((c) => !!c.ownerNpub);
147
148
  }
149
+
150
+ /** A configured npub as returned by the auth proxy (`/npubs`). */
151
+ export interface NpubEntry {
152
+ npub: string;
153
+ name: string | null;
154
+ role: string;
155
+ }
156
+
157
+ /**
158
+ * Fetch the configured npubs (with their display names and roles) from the
159
+ * auth proxy. Returns an empty list when the endpoint is unavailable — e.g. a
160
+ * local daemon that doesn't route `/npubs` — so the TUI degrades gracefully.
161
+ */
162
+ export async function fetchNpubs(): Promise<NpubEntry[]> {
163
+ try {
164
+ const result = await callAuth("/npubs");
165
+ if (result.error) return [];
166
+
167
+ // Handle both wrapped { output: { npubs } } and direct { npubs } responses.
168
+ const direct = (result as { npubs?: NpubEntry[] }).npubs;
169
+ const wrapped = (result.output as { npubs?: NpubEntry[] } | undefined)?.npubs;
170
+ return direct ?? wrapped ?? [];
171
+ } catch {
172
+ return [];
173
+ }
174
+ }
175
+
176
+ /** Number of trailing npub characters the auth proxy folds into a client id. */
177
+ export const NPUB_SUFFIX_LENGTH = 7;
178
+
179
+ /**
180
+ * Lookup tables for turning a raw usage `client` id (e.g.
181
+ * `claude-code-a1b2c3d`) back into a human label (e.g. `Alice (claude-code)`).
182
+ *
183
+ * All maps are empty in local mode, so {@link resolveClientLabel} falls back to
184
+ * the raw id with no behaviour change there.
185
+ */
186
+ export interface ClientNaming {
187
+ /** Configured npubs (names + roles) from the auth proxy. */
188
+ npubs: NpubEntry[];
189
+ /** bare clientId -> owner npub, from `GET /clients`. */
190
+ ownersByClientId: Map<string, string>;
191
+ /** npub -> display name (null when unset). */
192
+ npubNames: Map<string, string | null>;
193
+ /** trailing npub suffix -> npub. */
194
+ npubsBySuffix: Map<string, string>;
195
+ }
196
+
197
+ export function buildClientNaming(clients: ClientInfo[], npubs: NpubEntry[]): ClientNaming {
198
+ const ownersByClientId = new Map<string, string>();
199
+ for (const c of clients) {
200
+ if (c.ownerNpub) ownersByClientId.set(c.clientId, c.ownerNpub);
201
+ }
202
+
203
+ const npubNames = new Map<string, string | null>();
204
+ const npubsBySuffix = new Map<string, string>();
205
+ const remember = (npub: string) => npubsBySuffix.set(npub.slice(-NPUB_SUFFIX_LENGTH), npub);
206
+
207
+ for (const n of npubs) {
208
+ npubNames.set(n.npub, n.name ?? null);
209
+ remember(n.npub);
210
+ }
211
+ // `GET /clients` also carries owner npubs, which covers entries whose owner
212
+ // isn't present in the (possibly filtered) `/npubs` response.
213
+ for (const owner of ownersByClientId.values()) remember(owner);
214
+
215
+ return { npubs, ownersByClientId, npubNames, npubsBySuffix };
216
+ }
217
+
218
+ /** Configured display name for an npub, or null when unset/blank. */
219
+ function npubDisplayName(naming: ClientNaming, npub: string): string | null {
220
+ const name = naming.npubNames.get(npub)?.trim();
221
+ return name && name.length > 0 ? name : null;
222
+ }
223
+
224
+ /** `Alice (claude-code)` when the owner has a name, else the bare client id. */
225
+ function formatClientLabel(naming: ClientNaming, bareId: string, ownerNpub: string): string {
226
+ const name = npubDisplayName(naming, ownerNpub);
227
+ return name ? `${name} (${bareId})` : bareId;
228
+ }
229
+
230
+ /**
231
+ * Render a usage entry's `client` value as `Name (client-id)`, where `Name` is
232
+ * the owner npub's display name and the `-<npub tail>` suffix is stripped.
233
+ * Falls back to the raw id when no owner/name can be resolved (local mode).
234
+ */
235
+ export function resolveClientLabel(clientId: string | undefined, naming: ClientNaming): string {
236
+ const raw = clientId && clientId.length > 0 ? clientId : "unknown";
237
+
238
+ // Exact match: the raw id is a known bare clientId or its suffixed form.
239
+ for (const [bareId, ownerNpub] of naming.ownersByClientId) {
240
+ const suffixed = `${bareId}-${ownerNpub.slice(-NPUB_SUFFIX_LENGTH)}`;
241
+ if (raw !== bareId && raw !== suffixed) continue;
242
+ return formatClientLabel(naming, bareId, ownerNpub);
243
+ }
244
+
245
+ // Fallback: strip a trailing `-<npub tail>` that matches a known npub.
246
+ for (const [suffix, npub] of naming.npubsBySuffix) {
247
+ const marker = `-${suffix}`;
248
+ if (raw.length <= marker.length || !raw.endsWith(marker)) continue;
249
+ return formatClientLabel(naming, raw.slice(0, -marker.length), npub);
250
+ }
251
+
252
+ return raw;
253
+ }
254
+
255
+ export function emptyClientNaming(): ClientNaming {
256
+ return buildClientNaming([], []);
257
+ }
@@ -3,7 +3,8 @@ import type { Tab } from "./types.ts";
3
3
  import {
4
4
  formatNumber,
5
5
  formatTime,
6
- type ClientInfo,
6
+ resolveClientLabel,
7
+ type ClientNaming,
7
8
  } from "./data.ts";
8
9
  import { vimState } from "./state.ts";
9
10
  import { stripAnsi } from "./terminal.ts";
@@ -503,20 +504,23 @@ export function renderClients(stats: UsageStats, width: number): string {
503
504
  return output;
504
505
  }
505
506
 
506
- export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string {
507
+ export function renderNpubs(stats: UsageStats, naming: ClientNaming, width: number): string {
507
508
  const npubStats = stats.summary.npubs;
508
509
  if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown");
509
510
 
511
+ // Index configured npubs by their npub so usage rows can show names/roles.
512
+ const configured = new Map(naming.npubs.map((entry) => [entry.npub, entry]));
513
+
510
514
  const totalCost = stats.totalSatsCost;
511
515
  const maxCost = npubStats[0]!.satsCost;
512
516
  const lines: string[] = [];
513
517
 
514
- const col1 = 24; // Npub (truncated)
518
+ const col1 = 24; // Name (or truncated npub)
515
519
  const col2 = 12; // Requests
516
520
  const col3 = 24; // Cost
517
521
  const col4 = 12; // Tokens
518
522
 
519
- const hNpub = "Npub".padEnd(col1);
523
+ const hNpub = "Name".padEnd(col1);
520
524
  const hReqs = "Requests".padEnd(col2);
521
525
  const hCost = "Cost".padEnd(col3);
522
526
  const hTok = "Tokens".padEnd(col4);
@@ -527,9 +531,11 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
527
531
  for (const npub of npubStats) {
528
532
  const pct = totalCost > 0 ? ((npub.satsCost / totalCost) * 100).toFixed(1) : "0.0";
529
533
  const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 0);
530
- const shortNpub = truncateNpub(npub.npub);
534
+ const entry = configured.get(npub.npub);
535
+ const name = entry?.name?.trim();
536
+ const label = name || truncateNpub(npub.npub);
531
537
 
532
- const dNpub = shortNpub.padEnd(col1);
538
+ const dNpub = label.slice(0, col1 - 1).padEnd(col1);
533
539
  const dReqs = formatReqs(npub.requests).padEnd(col2);
534
540
  const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
535
541
  const dTok = formatNumber(npub.totalTokens).padEnd(col4);
@@ -541,8 +547,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
541
547
  `${COLORS.green}${dCost}${COLORS.reset}` +
542
548
  `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`
543
549
  );
544
- // Full npub on its own line for copy-ability
545
- lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`);
550
+ // Full npub (and role when known) on its own line for copy-ability.
551
+ const roleSuffix = entry?.role ? ` [${entry.role}]` : "";
552
+ lines.push(` ${COLORS.dim}${npub.npub}${roleSuffix}${COLORS.reset}`);
546
553
  lines.push(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`);
547
554
  lines.push("");
548
555
  }
@@ -555,7 +562,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
555
562
  // Use pre-aggregated topModels from summary
556
563
  for (const topNpub of stats.summary.npubs.slice(0, 5)) {
557
564
  if (topNpub.topModels.length === 0) continue;
558
- npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
565
+ const name = configured.get(topNpub.npub)?.name?.trim();
566
+ const label = name || truncateNpub(topNpub.npub);
567
+ npubModelLines.push(`${COLORS.bold}${label}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
559
568
  for (const m of topNpub.topModels) {
560
569
  npubModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
561
570
  }
@@ -576,22 +585,38 @@ function truncateNpub(npub: string): string {
576
585
  return npub.slice(0, 10) + "…" + npub.slice(-6);
577
586
  }
578
587
 
579
- export function renderRecent(stats: UsageStats, width: number): string {
588
+ export function renderRecent(stats: UsageStats, width: number, naming: ClientNaming): string {
580
589
  const recentEntries = stats.entries.slice(0, 50);
581
590
  if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
582
591
 
583
- const clientCol = 14;
592
+ const timeCol = 10;
593
+ const modelCol = 18;
584
594
  const tokensCol = 18;
585
595
  const costCol = 18;
586
- const providerCol = Math.max(16, width - 4 - 10 - 18 - tokensCol - costCol - clientCol - 5);
596
+
597
+ // Remote mode: `Alice (claude-code)` — owner display name + the client id
598
+ // with the `-<npub tail>` suffix stripped. Local mode (no owner/name data)
599
+ // keeps the previous behaviour: the raw id in a fixed 14-col cell.
600
+ const hasOwnerInfo = naming.npubs.length > 0 || naming.ownersByClientId.size > 0;
601
+ const clientLabels = recentEntries.map((entry) =>
602
+ hasOwnerInfo ? resolveClientLabel(entry.client, naming) : entry.client || "unknown"
603
+ );
604
+ const maxLabelLen = clientLabels.reduce((max, label) => Math.max(max, label.length), 6);
605
+ const clientCol = hasOwnerInfo ? Math.min(32, Math.max(14, maxLabelLen)) : 14;
606
+
607
+ // Budget: time + model + tokens + cost + provider + client + 5 separators.
608
+ const innerWidth = Math.max(0, width - 4);
609
+ const providerCol = Math.max(12, innerWidth - timeCol - modelCol - tokensCol - costCol - 5 - clientCol);
610
+
587
611
  const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0;
588
612
  const lines: string[] = [];
589
- lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"I/CR/CW/O".padEnd(tokensCol)} ${"I/O/T in sats".padEnd(costCol)} ${"BASE:PROVIDER".padEnd(providerCol)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
613
+ lines.push(`${COLORS.bold}${["TIME".padEnd(timeCol), "MODEL".padEnd(modelCol), "I/CR/CW/O".padEnd(tokensCol), "I/O/T in sats".padEnd(costCol), "BASE:PROVIDER".padEnd(providerCol), "CLIENT".padEnd(clientCol)].join(" ")}${COLORS.reset}`);
590
614
  lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
591
615
 
592
- for (const entry of recentEntries) {
616
+ for (let i = 0; i < recentEntries.length; i++) {
617
+ const entry = recentEntries[i]!;
593
618
  const time = formatTime(entry.timestamp).slice(0, 8);
594
- const model = entry.modelId.slice(0, 18).padEnd(18);
619
+ const model = entry.modelId.slice(0, modelCol).padEnd(modelCol);
595
620
  const tokens = [
596
621
  entry.promptTokens,
597
622
  entry.cacheReadInputTokens || 0,
@@ -606,16 +631,16 @@ export function renderRecent(stats: UsageStats, width: number): string {
606
631
  ].join("/");
607
632
  const baseUrl = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "");
608
633
  const provider = `${baseUrl}:${entry.provider || "unknown"}`.slice(0, providerCol).padEnd(providerCol);
609
- const clientName = (entry.client || "unknown").slice(0, clientCol - 1);
634
+ const clientLabel = clientLabels[i]!.slice(0, clientCol).padEnd(clientCol);
610
635
  const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
611
636
  const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
612
- lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(tokensCol)} ${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset}`);
637
+ lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(tokensCol)} ${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientLabel}${COLORS.reset}`);
613
638
  }
614
639
 
615
640
  return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
616
641
  }
617
642
 
618
- export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, clients: ClientInfo[] = []): string {
643
+ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, naming: ClientNaming): string {
619
644
  switch (activeTab) {
620
645
  case "overview": return renderOverview(stats, balance, status, width);
621
646
  case "today": return renderToday(stats, width);
@@ -623,8 +648,8 @@ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: B
623
648
  case "providers": return renderProviders(stats, width);
624
649
  case "tokens": return renderTokens(stats, width);
625
650
  case "clients": return renderClients(stats, width);
626
- case "npubs": return renderNpubs(stats, clients, width);
627
- case "recent": return renderRecent(stats, width);
651
+ case "npubs": return renderNpubs(stats, naming, width);
652
+ case "recent": return renderRecent(stats, width, naming);
628
653
  default: return "Unknown tab";
629
654
  }
630
655
  }
@@ -6,7 +6,11 @@ import {
6
6
  ensureDaemonRunning,
7
7
  } from "./daemon-client";
8
8
  import { logger } from "./logger";
9
- import { CLIENT_INTEGRATIONS, CLIENT_CONFIGS } from "../integrations/registry";
9
+ import {
10
+ CLIENT_INTEGRATIONS,
11
+ CLIENT_CONFIGS,
12
+ runIntegrationsForClients,
13
+ } from "../integrations/registry";
10
14
 
11
15
  export interface ClientEntry {
12
16
  clientId: string;
@@ -125,6 +129,75 @@ export async function addDaemonClient(
125
129
  return { message: output.message, client: output.client, created: true };
126
130
  }
127
131
 
132
+ /**
133
+ * Refresh routstr21 models from Nostr, then re-run every registered client
134
+ * integration so local client configs pick up new models and API keys.
135
+ *
136
+ * Shared by `routstrd refresh` and `routstrd clients --manual-refresh` — the
137
+ * same work the daemon performs on its scheduled refresh.
138
+ */
139
+ export async function refreshModelsAndClientsAction(): Promise<void> {
140
+ const config = await loadConfig();
141
+
142
+ console.log("Refreshing routstr21 models...");
143
+ const result = await callDaemon("/v1/models?refresh=true");
144
+ if (result.error) {
145
+ console.log(`Model refresh failed: ${result.error}`);
146
+ process.exit(1);
147
+ }
148
+ console.log("Models refreshed.");
149
+
150
+ const clients = await getClientsList();
151
+ if (clients.length === 0) {
152
+ console.log("No clients to refresh.");
153
+ return;
154
+ }
155
+
156
+ console.log(`Refreshing ${clients.length} client integration(s)...`);
157
+ await runIntegrationsForClients(clients, config);
158
+ console.log("Client integrations refreshed.");
159
+ }
160
+
161
+ /**
162
+ * Toggle the daemon's scheduled refresh job. Routed through the daemon so it
163
+ * also works against a remote daemon, where the config lives on the host.
164
+ */
165
+ export async function setAutomaticRefreshAction(
166
+ enabled: boolean,
167
+ ): Promise<void> {
168
+ await ensureDaemonRunning();
169
+
170
+ const result = await callDaemon("/settings/auto-refresh", {
171
+ method: "POST",
172
+ body: { enabled },
173
+ });
174
+
175
+ if (result.error) {
176
+ console.log(result.error);
177
+ process.exit(1);
178
+ }
179
+
180
+ const output = result.output as { message?: string } | undefined;
181
+ console.log(
182
+ output?.message ?? `Automatic refresh ${enabled ? "enabled" : "disabled"}.`,
183
+ );
184
+
185
+ if (enabled) {
186
+ console.log(
187
+ "The daemon will keep refreshing models and client integrations on a schedule.",
188
+ );
189
+ return;
190
+ }
191
+
192
+ console.log(
193
+ "The daemon will stop the scheduled refresh of models and client integrations.",
194
+ );
195
+ console.log("Run 'routstrd clients --manual-refresh' to refresh on demand.");
196
+ console.log(
197
+ "Run 'routstrd clients --enable-automatic-refresh' to turn it back on.",
198
+ );
199
+ }
200
+
128
201
  export async function listClientsAction(): Promise<void> {
129
202
  await ensureDaemonRunning();
130
203
 
@@ -20,6 +20,18 @@ export interface NwcAutoRefillConfig {
20
20
  cooldownMs: number;
21
21
  }
22
22
 
23
+ /**
24
+ * Scheduled background refresh job (Nostr events, routstr21 models, and client
25
+ * integrations). Missing values mean "enabled" with the default interval, so
26
+ * older config files keep working unchanged.
27
+ */
28
+ export interface AutoRefreshConfig {
29
+ /** Run the recurring refresh job. Defaults to true. */
30
+ enabled?: boolean;
31
+ /** Milliseconds between refreshes. Defaults to 21 minutes. */
32
+ intervalMs?: number;
33
+ }
34
+
23
35
  /** NWC configuration section */
24
36
  export interface NwcConfig {
25
37
  /** NWC mode: "funding_source" = NWC funds the cocod Cashu wallet */
@@ -57,6 +69,8 @@ export interface RoutstrdConfig {
57
69
  relays?: string[];
58
70
  /** NWC integration configuration */
59
71
  nwc?: NwcConfig;
72
+ /** Scheduled refresh job settings (see AutoRefreshConfig). */
73
+ autoRefresh?: AutoRefreshConfig;
60
74
  /**
61
75
  * Default max_tokens (chat/completions) and max_output_tokens (responses)
62
76
  * injected into proxied requests when the client does not supply one.
@@ -0,0 +1,125 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { isDaemonRunning } from "./daemon-client";
3
+ import { defaultIsProcessRunning } from "../daemon/wallet/coco-client";
4
+ import { walletPidPath } from "../daemon/wallet/paths";
5
+ import { formatElapsed } from "../start-daemon";
6
+
7
+ /**
8
+ * Wait for a daemon that was asked to stop (POST /stop) to fully exit.
9
+ *
10
+ * The daemon shuts down gracefully: it stops accepting new connections
11
+ * right away, but keeps serving ongoing requests until they finish; only
12
+ * then does it dispose of the wallet, release the wallet PID lock, and
13
+ * exit. Spawning a replacement before the lock is released makes it abort
14
+ * with "Cannot claim the routstrd wallet lock", so restarts must wait for
15
+ * the old process to fully exit — telling the user why it is taking a
16
+ * while ("Finishing all ongoing requests...") and how to force it
17
+ * ("run 'kill -9 <PID>' to force stop") instead of racing ahead.
18
+ */
19
+ export interface WaitForDaemonToExitOptions {
20
+ /** Wallet PID lock file held by the running daemon. */
21
+ pidFilePath?: string;
22
+ /** Returns true while the daemon still answers /health. */
23
+ isHealthy?: () => Promise<boolean>;
24
+ /** Returns the live PID recorded in the wallet lock file, or null when the
25
+ * file is gone or unreadable. */
26
+ readLockPid?: (pidFilePath: string) => number | null;
27
+ /** Returns true when a PID is alive (zombies count as dead). */
28
+ isProcessRunning?: (pid: number) => boolean;
29
+ sleep?: (ms: number) => Promise<void>;
30
+ log?: (message: string) => void;
31
+ /** How long to wait for the health check to stop responding after /stop. */
32
+ healthTimeoutMs?: number;
33
+ /** Silent window before reporting that ongoing requests are finishing. */
34
+ drainGraceMs?: number;
35
+ /** How long to wait for the old daemon to exit and release the lock. */
36
+ drainTimeoutMs?: number;
37
+ /** How often to re-report that ongoing requests are still finishing. */
38
+ drainHeartbeatMs?: number;
39
+ /** Interval between health and lock polls. */
40
+ pollIntervalMs?: number;
41
+ }
42
+
43
+ /** How often to re-report that the daemon is still finishing requests. */
44
+ const DRAIN_HEARTBEAT_MS = 10_000;
45
+
46
+ function defaultReadLockPid(pidFilePath: string): number | null {
47
+ if (!existsSync(pidFilePath)) return null;
48
+ try {
49
+ const pid = Number.parseInt(readFileSync(pidFilePath, "utf8").trim(), 10);
50
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export async function waitForDaemonToExit(
57
+ options: WaitForDaemonToExitOptions = {},
58
+ ): Promise<void> {
59
+ const {
60
+ pidFilePath = walletPidPath(),
61
+ isHealthy = isDaemonRunning,
62
+ readLockPid = defaultReadLockPid,
63
+ isProcessRunning = defaultIsProcessRunning,
64
+ sleep = (ms: number) =>
65
+ new Promise<void>((resolve) => setTimeout(resolve, ms)),
66
+ log = (message: string) => console.log(message),
67
+ healthTimeoutMs = 10_000,
68
+ drainGraceMs = 1_000,
69
+ drainTimeoutMs = 10 * 60_000,
70
+ drainHeartbeatMs = DRAIN_HEARTBEAT_MS,
71
+ pollIntervalMs = 100,
72
+ } = options;
73
+
74
+ // Phase 1: the daemon stops accepting new connections promptly after
75
+ // /stop, so its health check should stop responding within seconds.
76
+ const healthDeadline = Date.now() + healthTimeoutMs;
77
+ while (await isHealthy()) {
78
+ if (Date.now() >= healthDeadline) {
79
+ throw new Error(
80
+ `routstrd did not stop within ${Math.round(healthTimeoutMs / 1000)} seconds`,
81
+ );
82
+ }
83
+ await sleep(pollIntervalMs);
84
+ }
85
+
86
+ // Phase 2: the process stays alive while it finishes ongoing requests and
87
+ // disposes of the wallet. The wallet PID lock is released only right before
88
+ // the process exits, so wait for it (or for the recorded PID to die). Both
89
+ // progress messages offer 'kill -9 <PID>': only SIGKILL interrupts a stuck
90
+ // drain — a plain SIGTERM just re-runs the same graceful shutdown.
91
+ const drainStartedAt = Date.now();
92
+ const drainDeadline = drainStartedAt + drainTimeoutMs;
93
+ let drainAnnounced = false;
94
+ let nextHeartbeatAt = 0;
95
+
96
+ for (;;) {
97
+ const lockPid = readLockPid(pidFilePath);
98
+ if (lockPid === null || !isProcessRunning(lockPid)) return;
99
+
100
+ const now = Date.now();
101
+ if (now >= drainDeadline) {
102
+ throw new Error(
103
+ `the previous daemon (PID ${lockPid}) did not finish its ongoing requests within ` +
104
+ `${formatElapsed(drainTimeoutMs)} and still holds the wallet lock at ${pidFilePath}. ` +
105
+ `Wait for it to exit and try again, or run 'kill -9 ${lockPid}' to force it.`,
106
+ );
107
+ }
108
+
109
+ if (!drainAnnounced && now - drainStartedAt >= drainGraceMs) {
110
+ drainAnnounced = true;
111
+ log(
112
+ ` Finishing all ongoing requests... (run 'kill -9 ${lockPid}' to force stop)`,
113
+ );
114
+ nextHeartbeatAt = now + drainHeartbeatMs;
115
+ } else if (drainAnnounced && now >= nextHeartbeatAt) {
116
+ log(
117
+ ` Still finishing ongoing requests (${formatElapsed(now - drainStartedAt)} elapsed)... ` +
118
+ `(run 'kill -9 ${lockPid}' to force stop)`,
119
+ );
120
+ nextHeartbeatAt += drainHeartbeatMs;
121
+ }
122
+
123
+ await sleep(pollIntervalMs);
124
+ }
125
+ }