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.
- package/README.md +124 -5
- package/SKILL.md +341 -0
- package/dist/daemon/index.js +7995 -14602
- package/dist/index.js +155916 -14225
- package/package.json +9 -4
- package/src/cli.ts +279 -110
- package/src/daemon/args.ts +4 -5
- package/src/daemon/http/index.ts +37 -2
- package/src/daemon/index.ts +94 -33
- package/src/daemon/models.ts +13 -10
- package/src/daemon/wallet/auto-refill.ts +1 -1
- package/src/daemon/wallet/coco-client.ts +205 -3
- package/src/daemon/wallet/cocod-client.ts +19 -3
- package/src/daemon/wallet/index.ts +1 -1
- package/src/index.ts +9 -1
- package/src/runtime.ts +55 -0
- package/src/start-daemon.ts +4 -8
- package/src/tui/usage/app.ts +16 -2
- package/src/tui/usage/data.ts +110 -0
- package/src/tui/usage/render.ts +45 -20
- package/src/utils/clients.ts +74 -1
- package/src/utils/config.ts +14 -0
- package/src/utils/daemon-stop.ts +125 -0
- package/src/utils/standalone-update.ts +214 -0
- package/src/utils/update-checker.ts +81 -18
- package/src/version.ts +3 -0
- package/src/cli.test.ts +0 -192
- package/src/daemon/fatal-error.test.ts +0 -104
- package/src/daemon/http/usage-summary.test.ts +0 -272
- package/src/daemon/models.test.ts +0 -81
- package/src/daemon/wallet/cleanup.test.ts +0 -168
- package/src/daemon/wallet/coco-client.npc.test.ts +0 -252
- package/src/daemon/wallet/coco-client.test.ts +0 -669
- package/src/daemon/wallet/diagnostics.test.ts +0 -378
- package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
- package/src/daemon/wallet/migration.test.ts +0 -171
- package/src/daemon/wallet/receive-dedup.test.ts +0 -238
package/src/start-daemon.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
160
|
+
const proc = Bun.spawn(daemonSpawnCommand(args), {
|
|
165
161
|
stdout: debugLogFd,
|
|
166
162
|
stderr: debugLogFd,
|
|
167
163
|
stdin: "ignore",
|
package/src/tui/usage/app.ts
CHANGED
|
@@ -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,
|
|
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;
|
package/src/tui/usage/data.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tui/usage/render.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { Tab } from "./types.ts";
|
|
|
3
3
|
import {
|
|
4
4
|
formatNumber,
|
|
5
5
|
formatTime,
|
|
6
|
-
|
|
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,
|
|
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; //
|
|
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 = "
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
592
|
+
const timeCol = 10;
|
|
593
|
+
const modelCol = 18;
|
|
584
594
|
const tokensCol = 18;
|
|
585
595
|
const costCol = 18;
|
|
586
|
-
|
|
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(
|
|
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 (
|
|
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,
|
|
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
|
|
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}${
|
|
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,
|
|
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,
|
|
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
|
}
|
package/src/utils/clients.ts
CHANGED
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
ensureDaemonRunning,
|
|
7
7
|
} from "./daemon-client";
|
|
8
8
|
import { logger } from "./logger";
|
|
9
|
-
import {
|
|
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
|
|
package/src/utils/config.ts
CHANGED
|
@@ -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
|
+
}
|