routstrd 0.4.10 → 0.4.12
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 +55 -15
- package/SECURITY.md +0 -1
- package/SKILL.md +267 -52
- package/dist/daemon/index.js +93747 -70861
- package/dist/index.js +65958 -42561
- package/package.json +3 -2
- package/src/cli.ts +358 -23
- package/src/daemon/http/index.ts +323 -38
- package/src/daemon/http/request-body.ts +67 -0
- package/src/daemon/http/request-path.ts +39 -0
- package/src/daemon/index.ts +1 -1
- package/src/daemon/models.ts +46 -11
- package/src/daemon/wallet/auto-refill.ts +20 -8
- package/src/daemon/wallet/cleanup.ts +16 -2
- package/src/daemon/wallet/coco-client.ts +941 -115
- package/src/daemon/wallet/index.ts +168 -50
- package/src/daemon/wallet/mint-quote-recovery.ts +119 -0
- package/src/daemon/wallet/recovery-probe.ts +312 -0
- package/src/daemon/wallet/recovery-work.ts +45 -0
- package/src/daemon/wallet/testing/fake-mint.ts +347 -0
- package/src/daemon/wallet/trusted-mints.ts +87 -0
- package/src/daemon/wallet/wallet-client.ts +206 -0
- package/src/integrations/pi.ts +189 -34
- package/src/integrations/registry.ts +14 -0
- package/src/tui/usage/render.ts +161 -22
- package/src/utils/config.ts +3 -2
- package/src/utils/cooldowns.ts +134 -0
- package/src/utils/daemon-client.ts +53 -9
- package/src/utils/history.ts +9 -0
- package/src/utils/with-timeout.ts +21 -0
- package/src/TUI refactor.md +0 -113
- package/src/daemon/wallet/cocod-client.ts +0 -505
package/src/tui/usage/render.ts
CHANGED
|
@@ -585,14 +585,111 @@ function truncateNpub(npub: string): string {
|
|
|
585
585
|
return npub.slice(0, 10) + "…" + npub.slice(-6);
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
+
/**
|
|
589
|
+
* Token breakdown of a single request, split the way the TUI colours it:
|
|
590
|
+
* input read from the prompt cache (green) vs input that was not (red,
|
|
591
|
+
* i.e. cache writes plus uncached input), plus output tokens.
|
|
592
|
+
*/
|
|
593
|
+
export interface TokenSegments {
|
|
594
|
+
/** Input tokens served from the prompt cache. */
|
|
595
|
+
cacheRead: number;
|
|
596
|
+
/** Input tokens that were not served from the cache. */
|
|
597
|
+
notCached: number;
|
|
598
|
+
/** Total input tokens, cached and not. */
|
|
599
|
+
input: number;
|
|
600
|
+
output: number;
|
|
601
|
+
/** Input + output. */
|
|
602
|
+
total: number;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Split a usage entry into cache read / not-cached input and output tokens.
|
|
607
|
+
*
|
|
608
|
+
* Providers report prompt tokens inconsistently: OpenAI-style `prompt_tokens`
|
|
609
|
+
* already includes the cached subsets, while Anthropic-style `input_tokens`
|
|
610
|
+
* reports them alongside. If the prompt is at least as large as the cached
|
|
611
|
+
* counts it is treated as containing them (OpenAI); otherwise the cached
|
|
612
|
+
* counts are extra (Anthropic). `input` is the sum of what is drawn rather
|
|
613
|
+
* than a possibly under-counted `totalTokens`.
|
|
614
|
+
*/
|
|
615
|
+
export function tokenSegments(entry: {
|
|
616
|
+
promptTokens?: number;
|
|
617
|
+
completionTokens?: number;
|
|
618
|
+
cacheReadInputTokens?: number;
|
|
619
|
+
cacheCreationInputTokens?: number;
|
|
620
|
+
}): TokenSegments {
|
|
621
|
+
const cacheRead = Math.max(0, entry.cacheReadInputTokens || 0);
|
|
622
|
+
const cacheWrite = Math.max(0, entry.cacheCreationInputTokens || 0);
|
|
623
|
+
const prompt = Math.max(0, entry.promptTokens || 0);
|
|
624
|
+
const output = Math.max(0, entry.completionTokens || 0);
|
|
625
|
+
const cached = cacheRead + cacheWrite;
|
|
626
|
+
const uncachedInput = prompt >= cached ? prompt - cached : prompt;
|
|
627
|
+
const notCached = cacheWrite + uncachedInput;
|
|
628
|
+
const input = cacheRead + notCached;
|
|
629
|
+
return { cacheRead, notCached, input, output, total: input + output };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** One coloured slice of a {@link renderStackedBar}. */
|
|
633
|
+
export interface BarSegment {
|
|
634
|
+
/** Segment magnitude; negatives are treated as zero. */
|
|
635
|
+
value: number;
|
|
636
|
+
/** ANSI escape applied to this segment's cells. */
|
|
637
|
+
color: string;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Render a fixed-width stacked bar: `trackWidth` cells split between the
|
|
642
|
+
* segments in proportion to their values, so the bar conveys *composition*
|
|
643
|
+
* (magnitude is reported separately, e.g. as the token total beside it).
|
|
644
|
+
*
|
|
645
|
+
* Cell counts use largest-remainder rounding, so the segments always fill
|
|
646
|
+
* exactly `trackWidth` cells. A row with no tokens renders as an empty track.
|
|
647
|
+
*/
|
|
648
|
+
export function renderStackedBar(segments: BarSegment[], trackWidth: number): string {
|
|
649
|
+
const track = Math.max(0, Math.floor(trackWidth));
|
|
650
|
+
if (track === 0) return "";
|
|
651
|
+
|
|
652
|
+
const values = segments.map((segment) => Math.max(0, segment.value));
|
|
653
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
654
|
+
if (total <= 0) return " ".repeat(track);
|
|
655
|
+
|
|
656
|
+
const exact = values.map((value) => (value / total) * track);
|
|
657
|
+
const cells = exact.map((value) => Math.floor(value));
|
|
658
|
+
let remaining = track - cells.reduce((sum, value) => sum + value, 0);
|
|
659
|
+
|
|
660
|
+
// Largest-remainder: leftover cells go to the biggest fractional parts.
|
|
661
|
+
const remainders = exact
|
|
662
|
+
.map((value, index) => ({ index, frac: value - Math.floor(value) }))
|
|
663
|
+
.sort((a, b) => b.frac - a.frac);
|
|
664
|
+
for (let i = 0; i < remainders.length && remaining > 0; i++, remaining--) {
|
|
665
|
+
cells[remainders[i]!.index]! += 1;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const bar = segments
|
|
669
|
+
.map((segment, i) => (cells[i]! > 0 ? `${segment.color}${"█".repeat(cells[i]!)}` : ""))
|
|
670
|
+
.join("");
|
|
671
|
+
return bar.length > 0 ? bar + COLORS.reset : " ".repeat(track);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Colour coding for the Recent tab's cache-hit bars. */
|
|
675
|
+
const TOKEN_BAR_COLORS = {
|
|
676
|
+
cacheRead: COLORS.green,
|
|
677
|
+
notCached: COLORS.red,
|
|
678
|
+
};
|
|
679
|
+
|
|
588
680
|
export function renderRecent(stats: UsageStats, width: number, naming: ClientNaming): string {
|
|
589
681
|
const recentEntries = stats.entries.slice(0, 50);
|
|
590
682
|
if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
|
|
591
683
|
|
|
592
|
-
const timeCol =
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
684
|
+
const timeCol = 8;
|
|
685
|
+
const costCol = 12;
|
|
686
|
+
// Width reserved left of the token counts for the cache-hit bar. `CACHE HIT`
|
|
687
|
+
// is the header drawn over the bar, so the bar can never be narrower.
|
|
688
|
+
const minBarWidth = "CACHE HIT".length;
|
|
689
|
+
const maxBarWidth = 20;
|
|
690
|
+
const minProviderCol = 12;
|
|
691
|
+
const minModelCol = 10;
|
|
692
|
+
const minClientCol = 6;
|
|
596
693
|
|
|
597
694
|
// Remote mode: `Alice (claude-code)` — owner display name + the client id
|
|
598
695
|
// with the `-<npub tail>` suffix stripped. Local mode (no owner/name data)
|
|
@@ -602,39 +699,81 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam
|
|
|
602
699
|
hasOwnerInfo ? resolveClientLabel(entry.client, naming) : entry.client || "unknown"
|
|
603
700
|
);
|
|
604
701
|
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
702
|
|
|
607
|
-
//
|
|
703
|
+
// Token counts are drawn as `IN - OUT` with IN pinned to the left edge of the
|
|
704
|
+
// column and OUT to the right, so both halves need the widest value on show.
|
|
705
|
+
const tokenRows = recentEntries.map((entry) => tokenSegments(entry));
|
|
706
|
+
const inputTexts = tokenRows.map((row) => formatNumber(row.input));
|
|
707
|
+
const outputTexts = tokenRows.map((row) => formatNumber(row.output));
|
|
708
|
+
const inputCol = Math.max(...inputTexts.map((text) => text.length));
|
|
709
|
+
const outputCol = Math.max(...outputTexts.map((text) => text.length));
|
|
710
|
+
const inOutCol = inputCol + outputCol + " - ".length;
|
|
711
|
+
|
|
712
|
+
// Lay out the columns against the box's inner width: start from the widest
|
|
713
|
+
// layout, hand the slack to the provider column, then give space back in
|
|
714
|
+
// priority order (bar, provider, model, client) until everything fits.
|
|
608
715
|
const innerWidth = Math.max(0, width - 4);
|
|
609
|
-
|
|
716
|
+
let modelCol = 18;
|
|
717
|
+
let clientCol = hasOwnerInfo ? Math.min(32, Math.max(14, maxLabelLen)) : 14;
|
|
718
|
+
let barWidth = maxBarWidth;
|
|
719
|
+
let showProvider = false;
|
|
720
|
+
let providerCol = 0;
|
|
721
|
+
// Column widths plus one separator between each visible column.
|
|
722
|
+
const usedWidth = () =>
|
|
723
|
+
timeCol + modelCol + (barWidth + 1 + inOutCol) + costCol + clientCol +
|
|
724
|
+
(showProvider ? providerCol + 1 : 0) + 4;
|
|
725
|
+
|
|
726
|
+
if (innerWidth - usedWidth() > minProviderCol + 1) {
|
|
727
|
+
showProvider = true;
|
|
728
|
+
providerCol = innerWidth - usedWidth();
|
|
729
|
+
}
|
|
730
|
+
while (usedWidth() > innerWidth && barWidth > minBarWidth) barWidth -= 1;
|
|
731
|
+
while (usedWidth() > innerWidth && providerCol > minProviderCol) providerCol -= 1;
|
|
732
|
+
while (usedWidth() > innerWidth && modelCol > minModelCol) modelCol -= 1;
|
|
733
|
+
while (usedWidth() > innerWidth && clientCol > minClientCol) clientCol -= 1;
|
|
734
|
+
|
|
735
|
+
const tokensCol = barWidth + 1 + inOutCol;
|
|
610
736
|
|
|
611
|
-
const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0;
|
|
612
737
|
const lines: string[] = [];
|
|
613
|
-
|
|
614
|
-
|
|
738
|
+
const header = [
|
|
739
|
+
"TIME".padEnd(timeCol),
|
|
740
|
+
"MODEL".padEnd(modelCol),
|
|
741
|
+
"CACHE HIT".padEnd(tokensCol - inOutCol) + "IN".padEnd(inputCol) + " - " + "OUT".padStart(outputCol),
|
|
742
|
+
// Right-aligned like the values below it, so the column's right edge is
|
|
743
|
+
// shared by the header and every `n sats` cell.
|
|
744
|
+
"COST".padStart(costCol),
|
|
745
|
+
...(showProvider ? ["BASE:PROVIDER".padEnd(providerCol)] : []),
|
|
746
|
+
"CLIENT".padEnd(clientCol),
|
|
747
|
+
];
|
|
748
|
+
lines.push(`${COLORS.bold}${header.join(" ")}${COLORS.reset}`);
|
|
749
|
+
lines.push(COLORS.dim + "─".repeat(innerWidth) + COLORS.reset);
|
|
615
750
|
|
|
616
751
|
for (let i = 0; i < recentEntries.length; i++) {
|
|
617
752
|
const entry = recentEntries[i]!;
|
|
618
753
|
const time = formatTime(entry.timestamp).slice(0, 8);
|
|
619
754
|
const model = entry.modelId.slice(0, modelCol).padEnd(modelCol);
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
]
|
|
755
|
+
const segments = tokenRows[i]!;
|
|
756
|
+
const bar = renderStackedBar([
|
|
757
|
+
{ value: segments.cacheRead, color: TOKEN_BAR_COLORS.cacheRead },
|
|
758
|
+
{ value: segments.notCached, color: TOKEN_BAR_COLORS.notCached },
|
|
759
|
+
], barWidth);
|
|
760
|
+
const tokens = `${bar} ${inputTexts[i]!.padEnd(inputCol)} - ${outputTexts[i]!.padStart(outputCol)}`;
|
|
626
761
|
const totalSats = typeof entry.totalMsats === "number" ? entry.totalMsats / 1000 : entry.satsCost;
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
formatCost(msatsToSats(entry.outputMsats)),
|
|
630
|
-
formatCost(totalSats),
|
|
631
|
-
].join("/");
|
|
762
|
+
// Right-aligned so the `sats` unit ends at the same column on every row.
|
|
763
|
+
const cost = `${formatCost(totalSats)} sats`.padStart(costCol);
|
|
632
764
|
const baseUrl = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "");
|
|
633
765
|
const provider = `${baseUrl}:${entry.provider || "unknown"}`.slice(0, providerCol).padEnd(providerCol);
|
|
634
766
|
const clientLabel = clientLabels[i]!.slice(0, clientCol).padEnd(clientCol);
|
|
635
767
|
const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
|
|
636
768
|
const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
|
|
637
|
-
lines.push(
|
|
769
|
+
lines.push([
|
|
770
|
+
`${COLORS.dim}${time}${COLORS.reset}`,
|
|
771
|
+
`${modelColor}${model}${COLORS.reset}`,
|
|
772
|
+
tokens,
|
|
773
|
+
`${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset}`,
|
|
774
|
+
...(showProvider ? [`${COLORS.dim}${provider}${COLORS.reset}`] : []),
|
|
775
|
+
`${clientColor}${clientLabel}${COLORS.reset}`,
|
|
776
|
+
].join(" "));
|
|
638
777
|
}
|
|
639
778
|
|
|
640
779
|
return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
|
package/src/utils/config.ts
CHANGED
|
@@ -46,8 +46,9 @@ export interface RoutstrdConfig {
|
|
|
46
46
|
port: number;
|
|
47
47
|
host: string;
|
|
48
48
|
provider: string | null;
|
|
49
|
-
cocodPath: string | null;
|
|
50
49
|
mode?: "xcashu" | "apikeys";
|
|
50
|
+
/** Opt into SDK automatic model-path selection for DeepSeek V4.1 Flash. Disabled by default; requires daemon restart after changing. */
|
|
51
|
+
autoModelPath?: boolean;
|
|
51
52
|
/** Raw upstream request/response logging. Disabled by default because logs can contain sensitive prompts, outputs, and auth/payment headers. */
|
|
52
53
|
requestResponseLogging?: {
|
|
53
54
|
/** Enable raw request/response file logging. */
|
|
@@ -85,7 +86,7 @@ export const DEFAULT_CONFIG: RoutstrdConfig = {
|
|
|
85
86
|
port: 8008,
|
|
86
87
|
host: "127.0.0.1",
|
|
87
88
|
provider: null,
|
|
88
|
-
cocodPath: null,
|
|
89
89
|
mode: "apikeys",
|
|
90
|
+
autoModelPath: false,
|
|
90
91
|
maxTokens: 64000,
|
|
91
92
|
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cooldown reporting, shared by the daemon (`GET /cooldowns`) and the
|
|
3
|
+
* `routstrd cooldowns` CLI command.
|
|
4
|
+
*
|
|
5
|
+
* Cooldown state is owned by the SDK: its `ProviderManager` writes
|
|
6
|
+
* `providersOnCooldown` into the SdkStore, and every entry blocks routing for
|
|
7
|
+
* the length of the cooldown window (`getCooldownDurationMs()`, 210s today).
|
|
8
|
+
* An entry with a `modelId` cools down only that model on the provider; an
|
|
9
|
+
* entry without one cools down every model on the provider.
|
|
10
|
+
*
|
|
11
|
+
* The SDK prunes expired entries lazily (on the next routing decision), so a
|
|
12
|
+
* read of the persisted list can contain entries that already timed out.
|
|
13
|
+
* Reporting therefore filters by age and never mutates the store.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One persisted cooldown entry, as stored by the SDK. */
|
|
17
|
+
export interface StoredCooldownEntry {
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
/** Present for model-scoped entries; absent for provider-wide ones. */
|
|
20
|
+
modelId?: string;
|
|
21
|
+
/** When the cooldown started (ms since epoch). */
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CooldownSummary {
|
|
26
|
+
baseUrl: string;
|
|
27
|
+
/** Model id for model-scoped cooldowns, `null` for provider-wide ones. */
|
|
28
|
+
modelId: string | null;
|
|
29
|
+
scope: "provider" | "model";
|
|
30
|
+
startedAt: number;
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
remainingMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CooldownsOutput {
|
|
36
|
+
/** Server time the payload was built at (ms since epoch). */
|
|
37
|
+
now: number;
|
|
38
|
+
cooldownDurationMs: number;
|
|
39
|
+
/** Number of active cooldown entries (provider- and model-scoped). */
|
|
40
|
+
count: number;
|
|
41
|
+
/** Number of distinct providers with at least one active entry. */
|
|
42
|
+
providerCount: number;
|
|
43
|
+
cooldowns: CooldownSummary[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the `/cooldowns` payload: drop expired entries, tag each entry's
|
|
48
|
+
* scope, and compute when it lifts. Longest remaining cooldown comes first.
|
|
49
|
+
*/
|
|
50
|
+
export function buildCooldownsOutput(
|
|
51
|
+
entries: StoredCooldownEntry[],
|
|
52
|
+
cooldownDurationMs: number,
|
|
53
|
+
now: number = Date.now(),
|
|
54
|
+
): CooldownsOutput {
|
|
55
|
+
const cooldowns = entries
|
|
56
|
+
.filter((entry) => now - entry.timestamp < cooldownDurationMs)
|
|
57
|
+
.map((entry): CooldownSummary => {
|
|
58
|
+
const modelId = entry.modelId ?? null;
|
|
59
|
+
const expiresAt = entry.timestamp + cooldownDurationMs;
|
|
60
|
+
return {
|
|
61
|
+
baseUrl: entry.baseUrl,
|
|
62
|
+
modelId,
|
|
63
|
+
scope: modelId === null ? "provider" : "model",
|
|
64
|
+
startedAt: entry.timestamp,
|
|
65
|
+
expiresAt,
|
|
66
|
+
remainingMs: Math.max(0, expiresAt - now),
|
|
67
|
+
};
|
|
68
|
+
})
|
|
69
|
+
.sort(
|
|
70
|
+
(a, b) =>
|
|
71
|
+
b.remainingMs - a.remainingMs ||
|
|
72
|
+
a.baseUrl.localeCompare(b.baseUrl) ||
|
|
73
|
+
(a.modelId ?? "").localeCompare(b.modelId ?? ""),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
now,
|
|
78
|
+
cooldownDurationMs,
|
|
79
|
+
count: cooldowns.length,
|
|
80
|
+
providerCount: new Set(cooldowns.map((entry) => entry.baseUrl)).size,
|
|
81
|
+
cooldowns,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Format a remaining-time value for the terminal, e.g. `2m 05s` or `42s`. */
|
|
86
|
+
export function formatCooldownRemaining(ms: number): string {
|
|
87
|
+
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
|
|
88
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
89
|
+
const seconds = totalSeconds % 60;
|
|
90
|
+
return minutes > 0
|
|
91
|
+
? `${minutes}m ${String(seconds).padStart(2, "0")}s`
|
|
92
|
+
: `${seconds}s`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Render a `/cooldowns` payload for `routstrd cooldowns`. */
|
|
96
|
+
export function formatCooldowns(output: CooldownsOutput): string {
|
|
97
|
+
const windowSeconds = Math.round(output.cooldownDurationMs / 1000);
|
|
98
|
+
const heading = `Cooldowns (${windowSeconds}s window)`;
|
|
99
|
+
|
|
100
|
+
if (output.cooldowns.length === 0) {
|
|
101
|
+
return `${heading}\n\n Nothing is on cooldown right now.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const scopeCell = (entry: CooldownSummary) =>
|
|
105
|
+
entry.scope === "provider" ? "PROVIDER" : "MODEL";
|
|
106
|
+
const urlWidth = Math.max(
|
|
107
|
+
...output.cooldowns.map((entry) => entry.baseUrl.length),
|
|
108
|
+
);
|
|
109
|
+
const modelWidth = Math.max(
|
|
110
|
+
0,
|
|
111
|
+
...output.cooldowns.map((entry) => (entry.modelId ?? "").length),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const lines = [
|
|
115
|
+
heading,
|
|
116
|
+
"",
|
|
117
|
+
` ${output.count} active across ${output.providerCount} ${
|
|
118
|
+
output.providerCount === 1 ? "provider" : "providers"
|
|
119
|
+
}:`,
|
|
120
|
+
"",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
for (const entry of output.cooldowns) {
|
|
124
|
+
const cells = [scopeCell(entry).padEnd("PROVIDER".length), entry.baseUrl.padEnd(urlWidth)];
|
|
125
|
+
if (entry.scope === "model") {
|
|
126
|
+
cells.push((entry.modelId ?? "").padEnd(modelWidth));
|
|
127
|
+
}
|
|
128
|
+
lines.push(
|
|
129
|
+
` ${cells.join(" ")} expires in ${formatCooldownRemaining(entry.remainingMs)}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
@@ -56,6 +56,32 @@ class DaemonConnectionError extends Error {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Upper bound for a single daemon request, including response-body
|
|
61
|
+
* consumption. The daemon bounds its own NWC operations, so this only guards
|
|
62
|
+
* against a wedged server; without it a hung request would block the CLI
|
|
63
|
+
* forever.
|
|
64
|
+
*/
|
|
65
|
+
export const DAEMON_REQUEST_TIMEOUT_MS = 120_000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Upper bound for value-moving wallet routes. A cashu melt/swap can
|
|
69
|
+
* legitimately run longer than {@link DAEMON_REQUEST_TIMEOUT_MS} (the mint has
|
|
70
|
+
* no request timeout in routstrd), so these get a more generous bound that
|
|
71
|
+
* still prevents an indefinite CLI hang.
|
|
72
|
+
*/
|
|
73
|
+
export const DAEMON_LONG_REQUEST_TIMEOUT_MS = 600_000;
|
|
74
|
+
|
|
75
|
+
/** Routes that may legitimately outlive the default request timeout. */
|
|
76
|
+
const LONG_RUNNING_ROUTES = ["/wallet/send/", "/wallet/receive/"];
|
|
77
|
+
|
|
78
|
+
function requestTimeoutMs(path: string): number {
|
|
79
|
+
const pathname = path.split("?")[0] ?? path;
|
|
80
|
+
return LONG_RUNNING_ROUTES.some((route) => pathname.startsWith(route))
|
|
81
|
+
? DAEMON_LONG_REQUEST_TIMEOUT_MS
|
|
82
|
+
: DAEMON_REQUEST_TIMEOUT_MS;
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
export function getDaemonBaseUrl(config: RoutstrdConfig): string {
|
|
60
86
|
if (config.daemonUrl) {
|
|
61
87
|
return config.daemonUrl.replace(/\/$/, "");
|
|
@@ -70,7 +96,7 @@ export function getAuthBaseUrl(config: RoutstrdConfig): string {
|
|
|
70
96
|
return getDaemonBaseUrl(config);
|
|
71
97
|
}
|
|
72
98
|
|
|
73
|
-
async function
|
|
99
|
+
export async function callDaemonUrl(
|
|
74
100
|
baseUrl: string,
|
|
75
101
|
path: string,
|
|
76
102
|
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
|
|
@@ -99,23 +125,41 @@ async function _callUrl(
|
|
|
99
125
|
if (authorization) headers.set("Authorization", authorization);
|
|
100
126
|
if (bodyString) headers.set("Content-Type", "application/json");
|
|
101
127
|
|
|
128
|
+
const timeoutMs = requestTimeoutMs(path);
|
|
129
|
+
const timeoutError = () =>
|
|
130
|
+
new Error(
|
|
131
|
+
`Daemon request timed out after ${timeoutMs / 1000}s; ` +
|
|
132
|
+
"any payment outcome is unknown — check before retrying",
|
|
133
|
+
);
|
|
134
|
+
// The signal stays armed while the body is read, so a daemon that sends
|
|
135
|
+
// headers and then stalls the body cannot hang the CLI either. Aborting
|
|
136
|
+
// here never cancels the daemon's operation — see timeoutError's warning.
|
|
137
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
138
|
+
|
|
102
139
|
let response: Response;
|
|
103
140
|
try {
|
|
104
141
|
response = await fetch(url, {
|
|
105
142
|
method,
|
|
106
143
|
headers,
|
|
107
144
|
body: bodyString,
|
|
145
|
+
signal,
|
|
108
146
|
});
|
|
109
147
|
} catch (error) {
|
|
148
|
+
if (signal.aborted) throw timeoutError();
|
|
149
|
+
// Only connection failures qualify for alternate-host retries.
|
|
110
150
|
throw new DaemonConnectionError(error);
|
|
111
151
|
}
|
|
112
152
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
153
|
+
try {
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
const errorData = (await response.json()) as { error?: string };
|
|
156
|
+
throw new Error(errorData.error || `HTTP ${response.status}`);
|
|
157
|
+
}
|
|
158
|
+
return (await response.json()) as CommandResponse;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (signal.aborted) throw timeoutError();
|
|
161
|
+
throw error;
|
|
116
162
|
}
|
|
117
|
-
|
|
118
|
-
return response.json() as Promise<CommandResponse>;
|
|
119
163
|
}
|
|
120
164
|
|
|
121
165
|
async function callLocalDaemon(
|
|
@@ -127,7 +171,7 @@ async function callLocalDaemon(
|
|
|
127
171
|
let connectionError: DaemonConnectionError | undefined;
|
|
128
172
|
for (const baseUrl of localDaemonBaseUrls(config)) {
|
|
129
173
|
try {
|
|
130
|
-
return await
|
|
174
|
+
return await callDaemonUrl(baseUrl, path, options, config);
|
|
131
175
|
} catch (error) {
|
|
132
176
|
if (!(error instanceof DaemonConnectionError)) throw error;
|
|
133
177
|
connectionError = error;
|
|
@@ -142,7 +186,7 @@ export async function callDaemon(
|
|
|
142
186
|
): Promise<CommandResponse> {
|
|
143
187
|
const config = await loadConfig();
|
|
144
188
|
if (config.daemonUrl) {
|
|
145
|
-
return
|
|
189
|
+
return callDaemonUrl(getDaemonBaseUrl(config), path, options, config);
|
|
146
190
|
}
|
|
147
191
|
return callLocalDaemon(path, options, config);
|
|
148
192
|
}
|
|
@@ -157,7 +201,7 @@ export async function callAuth(
|
|
|
157
201
|
if (!config.authUrl && !config.daemonUrl) {
|
|
158
202
|
return callLocalDaemon(path, options, config);
|
|
159
203
|
}
|
|
160
|
-
return
|
|
204
|
+
return callDaemonUrl(getAuthBaseUrl(config), path, options, config);
|
|
161
205
|
}
|
|
162
206
|
|
|
163
207
|
export async function isDaemonRunning(): Promise<boolean> {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Transaction types understood by the wallet history type filter. */
|
|
2
|
+
export const HISTORY_ENTRY_TYPES = ["mint", "melt", "send", "receive"] as const;
|
|
3
|
+
|
|
4
|
+
export type HistoryEntryType = (typeof HISTORY_ENTRY_TYPES)[number];
|
|
5
|
+
|
|
6
|
+
/** True when `value` is a recognized history transaction type. */
|
|
7
|
+
export function isHistoryEntryType(value: string): value is HistoryEntryType {
|
|
8
|
+
return (HISTORY_ENTRY_TYPES as readonly string[]).includes(value);
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rejects when `timeoutMs` elapses before `promise` settles.
|
|
3
|
+
* This bounds the caller's wait; it does not cancel the underlying operation.
|
|
4
|
+
*
|
|
5
|
+
* Used to bound requests that may otherwise wait forever — notably NWC calls
|
|
6
|
+
* whose underlying library applies its own timeout only after a support/encryption
|
|
7
|
+
* handshake that can itself hang on a stale relay subscription.
|
|
8
|
+
*/
|
|
9
|
+
export function withTimeout<T>(
|
|
10
|
+
promise: Promise<T>,
|
|
11
|
+
timeoutMs: number,
|
|
12
|
+
message = "Operation timed out",
|
|
13
|
+
): Promise<T> {
|
|
14
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
15
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
16
|
+
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
17
|
+
});
|
|
18
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
19
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
20
|
+
});
|
|
21
|
+
}
|
package/src/TUI refactor.md
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
# TUI refactor plan
|
|
2
|
-
|
|
3
|
-
## Goals
|
|
4
|
-
- Move the usage TUI implementation out of `src/cli/usage-tui.ts` into a dedicated `src/tui/` folder.
|
|
5
|
-
- Reduce the size and responsibility of the current monolithic file.
|
|
6
|
-
- Keep the existing CLI entrypoint stable so current usage does not break.
|
|
7
|
-
- Preserve behavior while making future TUI work easier.
|
|
8
|
-
|
|
9
|
-
## Current state
|
|
10
|
-
`src/cli/usage-tui.ts` currently mixes several concerns in one file:
|
|
11
|
-
- TUI-specific types and constants
|
|
12
|
-
- ANSI/terminal helpers
|
|
13
|
-
- scroll/search/vim navigation state
|
|
14
|
-
- data fetching from the daemon
|
|
15
|
-
- usage aggregation/stat helpers
|
|
16
|
-
- rendering for all tabs
|
|
17
|
-
- app lifecycle and keyboard event handling
|
|
18
|
-
|
|
19
|
-
This makes the file hard to extend safely.
|
|
20
|
-
|
|
21
|
-
## Refactor strategy
|
|
22
|
-
Do this incrementally and keep a thin compatibility wrapper in `src/cli/usage-tui.ts`.
|
|
23
|
-
|
|
24
|
-
### Target structure
|
|
25
|
-
- `src/tui/usage/index.ts`
|
|
26
|
-
- public entrypoint: `runUsageTui()`
|
|
27
|
-
- `src/tui/usage/types.ts`
|
|
28
|
-
- `UsageStats`, tab ids, tab metadata, derived stat types
|
|
29
|
-
- `src/tui/usage/constants.ts`
|
|
30
|
-
- tabs, colors, model/client color maps
|
|
31
|
-
- `src/tui/usage/terminal.ts`
|
|
32
|
-
- ANSI helpers, width/height helpers, `stripAnsi`
|
|
33
|
-
- `src/tui/usage/state.ts`
|
|
34
|
-
- vim/search/scroll state and state mutation helpers
|
|
35
|
-
- `src/tui/usage/data.ts`
|
|
36
|
-
- `fetchUsage()` and usage aggregation helpers
|
|
37
|
-
- `src/tui/usage/render.ts`
|
|
38
|
-
- shared render helpers and tab renderers
|
|
39
|
-
- `src/tui/usage/app.ts`
|
|
40
|
-
- main loop, render orchestration, input handling, cleanup
|
|
41
|
-
- `src/cli/usage-tui.ts`
|
|
42
|
-
- compatibility wrapper that re-exports or calls `runUsageTui()` from `src/tui/usage`
|
|
43
|
-
|
|
44
|
-
## Design choices
|
|
45
|
-
### 1. Keep CLI path compatibility
|
|
46
|
-
Do not delete the CLI file outright. Turn it into a tiny wrapper:
|
|
47
|
-
- minimal import from `../tui/usage/index.ts`
|
|
48
|
-
- export `runUsageTui()`
|
|
49
|
-
|
|
50
|
-
This avoids breaking any existing imports or scripts.
|
|
51
|
-
|
|
52
|
-
### 2. Separate pure logic from side effects
|
|
53
|
-
Keep these pure where possible:
|
|
54
|
-
- aggregation helpers
|
|
55
|
-
- formatting helpers
|
|
56
|
-
- render helpers that return strings
|
|
57
|
-
- scroll clamping logic
|
|
58
|
-
|
|
59
|
-
Keep side effects isolated in the app layer:
|
|
60
|
-
- reading terminal size
|
|
61
|
-
- writing to stdout
|
|
62
|
-
- raw mode setup
|
|
63
|
-
- signal handling
|
|
64
|
-
- interval scheduling
|
|
65
|
-
|
|
66
|
-
### 3. Avoid over-engineering
|
|
67
|
-
This should be a pragmatic refactor, not a framework:
|
|
68
|
-
- no unnecessary classes
|
|
69
|
-
- keep function-based design
|
|
70
|
-
- only extract modules around clear responsibility boundaries
|
|
71
|
-
|
|
72
|
-
### 4. Preserve behavior first
|
|
73
|
-
No UX changes unless needed to support the extraction.
|
|
74
|
-
That means:
|
|
75
|
-
- same tabs
|
|
76
|
-
- same keybindings
|
|
77
|
-
- same output format
|
|
78
|
-
- same fetch cadence
|
|
79
|
-
- same search/scroll behavior
|
|
80
|
-
|
|
81
|
-
## Implementation steps
|
|
82
|
-
1. Create `src/tui/usage/`.
|
|
83
|
-
2. Extract types/constants first.
|
|
84
|
-
3. Extract terminal helpers.
|
|
85
|
-
4. Extract data fetching + aggregation helpers.
|
|
86
|
-
5. Extract state/search/scroll logic.
|
|
87
|
-
6. Extract rendering helpers + tab renderers.
|
|
88
|
-
7. Build `app.ts` using the extracted modules.
|
|
89
|
-
8. Replace `src/cli/usage-tui.ts` with a thin wrapper.
|
|
90
|
-
9. Run a TypeScript/bun check and fix imports.
|
|
91
|
-
10. Smoke-test keyboard handling and rendering behavior.
|
|
92
|
-
|
|
93
|
-
## Risks
|
|
94
|
-
- circular imports between render/state/constants
|
|
95
|
-
- broken relative import paths during extraction
|
|
96
|
-
- subtle behavior regressions in scroll/search state
|
|
97
|
-
- terminal escape handling differences if helpers are split carelessly
|
|
98
|
-
|
|
99
|
-
## Validation checklist
|
|
100
|
-
- `src/cli/usage-tui.ts` still exposes `runUsageTui()`
|
|
101
|
-
- TUI starts from the same CLI path
|
|
102
|
-
- scroll still works for long content
|
|
103
|
-
- vim keys still work
|
|
104
|
-
- arrow keys still work
|
|
105
|
-
- tab switching still resets scroll
|
|
106
|
-
- search mode still works
|
|
107
|
-
- cleanup still restores cursor and alternate screen
|
|
108
|
-
|
|
109
|
-
## Non-goals
|
|
110
|
-
- redesigning the UI
|
|
111
|
-
- changing tab contents
|
|
112
|
-
- introducing tests unless needed for safety
|
|
113
|
-
- adding new features unrelated to the refactor
|