@jameslovespancakes/pi-plus 1.0.0 → 1.0.1

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 (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +190 -190
  3. package/config/pi-plus.example.json +60 -60
  4. package/config/skills/model-routing/SKILL.md +86 -86
  5. package/images/pi-plus.svg +10 -10
  6. package/package.json +67 -67
  7. package/server/board-server.mjs +641 -641
  8. package/server/package.json +17 -17
  9. package/src/core/accounts/registry.ts +93 -93
  10. package/src/core/anthropic/client-identity.ts +241 -241
  11. package/src/core/catalog/quality.ts +314 -314
  12. package/src/core/config.ts +169 -169
  13. package/src/core/env.ts +58 -58
  14. package/src/core/exec/process.ts +146 -146
  15. package/src/core/exec/ssh-config.ts +157 -157
  16. package/src/core/policy/policy.ts +183 -183
  17. package/src/core/quota/pool.ts +64 -64
  18. package/src/core/quota/usage-source.ts +289 -289
  19. package/src/core/store.ts +43 -43
  20. package/src/domains/agents/board-setup.ts +409 -409
  21. package/src/domains/agents/index.ts +462 -462
  22. package/src/domains/models/catalog-tool.ts +361 -361
  23. package/src/domains/models/index.ts +14 -14
  24. package/src/domains/models/policy-gate.ts +169 -169
  25. package/src/domains/models/provider-picker.ts +207 -207
  26. package/src/domains/remote/config-path.ts +41 -41
  27. package/src/domains/remote/index.ts +866 -866
  28. package/src/domains/remote/setup.ts +425 -425
  29. package/src/domains/setup/index.ts +220 -220
  30. package/src/domains/subscriptions/accounts.ts +242 -242
  31. package/src/domains/subscriptions/footer.ts +182 -182
  32. package/src/domains/subscriptions/index.ts +42 -42
  33. package/src/domains/subscriptions/provider.ts +219 -219
  34. package/src/domains/subscriptions/providers/anthropic.ts +149 -149
  35. package/src/domains/subscriptions/providers/codex.ts +148 -148
  36. package/src/domains/subscriptions/routing.ts +72 -72
  37. package/src/services/usage-service.ts +186 -186
  38. package/src/ui/format.ts +73 -73
  39. package/src/ui/usage-bars.ts +154 -154
  40. package/src/vendor/anthropic.ts +109 -109
@@ -1,183 +1,183 @@
1
- import { readConfig, updateConfig } from "../config.ts";
2
-
3
- /**
4
- * Approval policy for model selection.
5
- *
6
- * Subscription providers are free to use. Metered providers (OpenRouter and
7
- * anything else that bills per token) require an explicit approval before a
8
- * request is allowed to leave the machine.
9
- */
10
-
11
- export interface PolicyFile {
12
- autoApprove: string[];
13
- requireApproval: string[];
14
- deny: string[];
15
- }
16
-
17
- const DEFAULT_POLICY: PolicyFile = {
18
- autoApprove: ["anthropic/*", "openai-codex/*"],
19
- requireApproval: ["openrouter/*", "google/*", "openai/*", "xai/*"],
20
- deny: [],
21
- };
22
-
23
- export type Decision =
24
- | { allowed: true; reason: "auto" | "approved" }
25
- | { allowed: false; reason: "denied" | "needs-approval"; message: string };
26
-
27
- const approvals = new Map<string, number>();
28
-
29
- export function loadPolicy(): PolicyFile {
30
- return readConfig().policy;
31
- }
32
-
33
- export function savePolicy(next: PolicyFile): void {
34
- updateConfig((config) => {
35
- config.policy = {
36
- autoApprove: next.autoApprove ?? DEFAULT_POLICY.autoApprove,
37
- requireApproval: next.requireApproval ?? DEFAULT_POLICY.requireApproval,
38
- deny: next.deny ?? DEFAULT_POLICY.deny,
39
- };
40
- });
41
- }
42
-
43
- function matches(pattern: string, value: string): boolean {
44
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
45
- return new RegExp(`^${escaped}$`).test(value);
46
- }
47
-
48
- function matchesAny(patterns: string[], value: string): boolean {
49
- return patterns.some((pattern) => matches(pattern, value));
50
- }
51
-
52
- /**
53
- * Provider ids that require approval, derived from the policy patterns so the
54
- * toggle list always reflects the configured file rather than a hardcoded set.
55
- */
56
- export function gatedProviders(): string[] {
57
- const names = loadPolicy().requireApproval
58
- .map((pattern) => pattern.split("/")[0])
59
- .filter((name) => name && !name.includes("*"));
60
- return [...new Set(names)].sort();
61
- }
62
-
63
- export type ProviderState = "auto" | "approved" | "blocked" | "denied";
64
-
65
- /** How the policy currently treats a provider, for display and toggling. */
66
- export function providerState(provider: string): ProviderState {
67
- const current = loadPolicy();
68
- if (matchesAny(current.deny, `${provider}/*`) || matchesAny(current.deny, provider)) return "denied";
69
- if (matchesAny(current.requireApproval, `${provider}/*`)) {
70
- return isApproved(provider) ? "approved" : "blocked";
71
- }
72
- return "auto";
73
- }
74
-
75
- /**
76
- * Flips whether a provider may be used.
77
- *
78
- * Auto-approved providers are moved into `requireApproval` so the switch is
79
- * reversible; gated ones just gain or lose their session grant. Denied
80
- * providers are left alone; `deny` is an explicit, deliberate block.
81
- */
82
- export function toggleProvider(provider: string): ProviderState {
83
- const state = providerState(provider);
84
- if (state === "denied") return state;
85
-
86
- if (state === "auto") {
87
- const current = loadPolicy();
88
- savePolicy({
89
- ...current,
90
- autoApprove: current.autoApprove.filter((pattern) => !matches(pattern, `${provider}/*`) && pattern !== `${provider}/*`),
91
- requireApproval: [...new Set([...current.requireApproval, `${provider}/*`])],
92
- });
93
- revoke(provider);
94
- return "blocked";
95
- }
96
-
97
- if (state === "approved") {
98
- revoke(provider);
99
- return "blocked";
100
- }
101
-
102
- approve(provider);
103
- return "approved";
104
- }
105
-
106
- /** Grant approval for a provider until `untilMs` (omitted means this session). */
107
- export function approve(provider: string, durationMs?: number): void {
108
- approvals.set(provider, durationMs ? Date.now() + durationMs : Number.MAX_SAFE_INTEGER);
109
- }
110
-
111
- export function revoke(provider: string): void {
112
- approvals.delete(provider);
113
- }
114
-
115
- export function isApproved(provider: string): boolean {
116
- const until = approvals.get(provider);
117
- return until !== undefined && Date.now() < until;
118
- }
119
-
120
- /** Flips a provider's approval and reports the resulting state. */
121
- export function toggleApproval(provider: string): boolean {
122
- if (isApproved(provider)) {
123
- revoke(provider);
124
- return false;
125
- }
126
- approve(provider);
127
- return true;
128
- }
129
-
130
- export interface ProviderApproval {
131
- provider: string;
132
- approved: boolean;
133
- until?: number;
134
- }
135
-
136
- /** Current approval state for every gated provider, for display. */
137
- export function approvalStates(): ProviderApproval[] {
138
- return gatedProviders().map((provider) => {
139
- const until = approvals.get(provider);
140
- const approved = until !== undefined && Date.now() < until;
141
- return { provider, approved, until: approved && until !== Number.MAX_SAFE_INTEGER ? until : undefined };
142
- });
143
- }
144
-
145
- export function checkModel(provider: string, modelId: string): Decision {
146
- const current = loadPolicy();
147
- const ref = `${provider}/${modelId}`;
148
-
149
- if (matchesAny(current.deny, ref) || matchesAny(current.deny, `${provider}/*`)) {
150
- return { allowed: false, reason: "denied", message: `${ref} is denied by model policy.` };
151
- }
152
- if (matchesAny(current.autoApprove, ref)) return { allowed: true, reason: "auto" };
153
-
154
- const gated = matchesAny(current.requireApproval, ref);
155
- if (!gated) return { allowed: true, reason: "auto" };
156
-
157
- if (!isApproved(provider)) {
158
- return {
159
- allowed: false,
160
- reason: "needs-approval",
161
- message:
162
- `${ref} is a metered (pay-per-token) model and is not approved in this session. `
163
- + `Use a subscription model such as anthropic/* or openai-codex/*, or ask the user to run `
164
- + `/provider approve ${provider}.`,
165
- };
166
- }
167
-
168
- return { allowed: true, reason: "approved" };
169
- }
170
-
171
- export function policySummary(): string {
172
- const current = loadPolicy();
173
- const active = approvalStates()
174
- .filter((entry) => entry.approved)
175
- .map((entry) => `${entry.provider}${entry.until ? ` (until ${new Date(entry.until).toLocaleTimeString()})` : " (session)"}`);
176
- return [
177
- "Model approval policy",
178
- ` auto-approved: ${current.autoApprove.join(", ") || "none"}`,
179
- ` needs approval: ${current.requireApproval.join(", ") || "none"}`,
180
- ` denied: ${current.deny.join(", ") || "none"}`,
181
- ` approved now: ${active.join(", ") || "none"}`,
182
- ].join("\n");
183
- }
1
+ import { readConfig, updateConfig } from "../config.ts";
2
+
3
+ /**
4
+ * Approval policy for model selection.
5
+ *
6
+ * Subscription providers are free to use. Metered providers (OpenRouter and
7
+ * anything else that bills per token) require an explicit approval before a
8
+ * request is allowed to leave the machine.
9
+ */
10
+
11
+ export interface PolicyFile {
12
+ autoApprove: string[];
13
+ requireApproval: string[];
14
+ deny: string[];
15
+ }
16
+
17
+ const DEFAULT_POLICY: PolicyFile = {
18
+ autoApprove: ["anthropic/*", "openai-codex/*"],
19
+ requireApproval: ["openrouter/*", "google/*", "openai/*", "xai/*"],
20
+ deny: [],
21
+ };
22
+
23
+ export type Decision =
24
+ | { allowed: true; reason: "auto" | "approved" }
25
+ | { allowed: false; reason: "denied" | "needs-approval"; message: string };
26
+
27
+ const approvals = new Map<string, number>();
28
+
29
+ export function loadPolicy(): PolicyFile {
30
+ return readConfig().policy;
31
+ }
32
+
33
+ export function savePolicy(next: PolicyFile): void {
34
+ updateConfig((config) => {
35
+ config.policy = {
36
+ autoApprove: next.autoApprove ?? DEFAULT_POLICY.autoApprove,
37
+ requireApproval: next.requireApproval ?? DEFAULT_POLICY.requireApproval,
38
+ deny: next.deny ?? DEFAULT_POLICY.deny,
39
+ };
40
+ });
41
+ }
42
+
43
+ function matches(pattern: string, value: string): boolean {
44
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
45
+ return new RegExp(`^${escaped}$`).test(value);
46
+ }
47
+
48
+ function matchesAny(patterns: string[], value: string): boolean {
49
+ return patterns.some((pattern) => matches(pattern, value));
50
+ }
51
+
52
+ /**
53
+ * Provider ids that require approval, derived from the policy patterns so the
54
+ * toggle list always reflects the configured file rather than a hardcoded set.
55
+ */
56
+ export function gatedProviders(): string[] {
57
+ const names = loadPolicy().requireApproval
58
+ .map((pattern) => pattern.split("/")[0])
59
+ .filter((name) => name && !name.includes("*"));
60
+ return [...new Set(names)].sort();
61
+ }
62
+
63
+ export type ProviderState = "auto" | "approved" | "blocked" | "denied";
64
+
65
+ /** How the policy currently treats a provider, for display and toggling. */
66
+ export function providerState(provider: string): ProviderState {
67
+ const current = loadPolicy();
68
+ if (matchesAny(current.deny, `${provider}/*`) || matchesAny(current.deny, provider)) return "denied";
69
+ if (matchesAny(current.requireApproval, `${provider}/*`)) {
70
+ return isApproved(provider) ? "approved" : "blocked";
71
+ }
72
+ return "auto";
73
+ }
74
+
75
+ /**
76
+ * Flips whether a provider may be used.
77
+ *
78
+ * Auto-approved providers are moved into `requireApproval` so the switch is
79
+ * reversible; gated ones just gain or lose their session grant. Denied
80
+ * providers are left alone; `deny` is an explicit, deliberate block.
81
+ */
82
+ export function toggleProvider(provider: string): ProviderState {
83
+ const state = providerState(provider);
84
+ if (state === "denied") return state;
85
+
86
+ if (state === "auto") {
87
+ const current = loadPolicy();
88
+ savePolicy({
89
+ ...current,
90
+ autoApprove: current.autoApprove.filter((pattern) => !matches(pattern, `${provider}/*`) && pattern !== `${provider}/*`),
91
+ requireApproval: [...new Set([...current.requireApproval, `${provider}/*`])],
92
+ });
93
+ revoke(provider);
94
+ return "blocked";
95
+ }
96
+
97
+ if (state === "approved") {
98
+ revoke(provider);
99
+ return "blocked";
100
+ }
101
+
102
+ approve(provider);
103
+ return "approved";
104
+ }
105
+
106
+ /** Grant approval for a provider until `untilMs` (omitted means this session). */
107
+ export function approve(provider: string, durationMs?: number): void {
108
+ approvals.set(provider, durationMs ? Date.now() + durationMs : Number.MAX_SAFE_INTEGER);
109
+ }
110
+
111
+ export function revoke(provider: string): void {
112
+ approvals.delete(provider);
113
+ }
114
+
115
+ export function isApproved(provider: string): boolean {
116
+ const until = approvals.get(provider);
117
+ return until !== undefined && Date.now() < until;
118
+ }
119
+
120
+ /** Flips a provider's approval and reports the resulting state. */
121
+ export function toggleApproval(provider: string): boolean {
122
+ if (isApproved(provider)) {
123
+ revoke(provider);
124
+ return false;
125
+ }
126
+ approve(provider);
127
+ return true;
128
+ }
129
+
130
+ export interface ProviderApproval {
131
+ provider: string;
132
+ approved: boolean;
133
+ until?: number;
134
+ }
135
+
136
+ /** Current approval state for every gated provider, for display. */
137
+ export function approvalStates(): ProviderApproval[] {
138
+ return gatedProviders().map((provider) => {
139
+ const until = approvals.get(provider);
140
+ const approved = until !== undefined && Date.now() < until;
141
+ return { provider, approved, until: approved && until !== Number.MAX_SAFE_INTEGER ? until : undefined };
142
+ });
143
+ }
144
+
145
+ export function checkModel(provider: string, modelId: string): Decision {
146
+ const current = loadPolicy();
147
+ const ref = `${provider}/${modelId}`;
148
+
149
+ if (matchesAny(current.deny, ref) || matchesAny(current.deny, `${provider}/*`)) {
150
+ return { allowed: false, reason: "denied", message: `${ref} is denied by model policy.` };
151
+ }
152
+ if (matchesAny(current.autoApprove, ref)) return { allowed: true, reason: "auto" };
153
+
154
+ const gated = matchesAny(current.requireApproval, ref);
155
+ if (!gated) return { allowed: true, reason: "auto" };
156
+
157
+ if (!isApproved(provider)) {
158
+ return {
159
+ allowed: false,
160
+ reason: "needs-approval",
161
+ message:
162
+ `${ref} is a metered (pay-per-token) model and is not approved in this session. `
163
+ + `Use a subscription model such as anthropic/* or openai-codex/*, or ask the user to run `
164
+ + `/provider approve ${provider}.`,
165
+ };
166
+ }
167
+
168
+ return { allowed: true, reason: "approved" };
169
+ }
170
+
171
+ export function policySummary(): string {
172
+ const current = loadPolicy();
173
+ const active = approvalStates()
174
+ .filter((entry) => entry.approved)
175
+ .map((entry) => `${entry.provider}${entry.until ? ` (until ${new Date(entry.until).toLocaleTimeString()})` : " (session)"}`);
176
+ return [
177
+ "Model approval policy",
178
+ ` auto-approved: ${current.autoApprove.join(", ") || "none"}`,
179
+ ` needs approval: ${current.requireApproval.join(", ") || "none"}`,
180
+ ` denied: ${current.deny.join(", ") || "none"}`,
181
+ ` approved now: ${active.join(", ") || "none"}`,
182
+ ].join("\n");
183
+ }
@@ -1,64 +1,64 @@
1
- export type UsageRow = {
2
- group: string;
3
- label: string;
4
- remaining: number;
5
- resetAt?: number;
6
- checkedAt?: number;
7
- stale?: boolean;
8
- capacity?: number;
9
- };
10
-
11
- /*
12
- * How old a usage sample may be and still be poolable.
13
- *
14
- * This was 6 minutes when every refresh fetched the usage endpoint live.
15
- * Quota now comes from response headers, backed by a poll at most once per
16
- * 10 minutes, so a 6 minute bound marked idle accounts stale almost all the
17
- * time. It must stay comfortably above that poll interval; the underlying
18
- * windows are 5 hours and 7 days, so a sample minutes old is still accurate.
19
- */
20
- export const CLAUDE_FRESH_MS = 12 * 60_000;
21
- export const isClaudeAccount = (row: UsageRow) => row.group.startsWith("Claude ") && !row.group.startsWith("Claude pool ×");
22
- export const isFresh = (row: UsageRow, now = Date.now()) => !row.stale && !!row.checkedAt
23
- && now - row.checkedAt < CLAUDE_FRESH_MS && (!row.resetAt || row.resetAt > now);
24
-
25
- /** Percent of combined capacity, not a claim that quota transfers between accounts.
26
- * Without published capacities this is explicitly an equal-account estimate.
27
- */
28
- export function combinedWindow(rows: UsageRow[], label: string, expected: number, now = Date.now(), allowPartial = false) {
29
- const matching = rows.filter((r) => isClaudeAccount(r) && r.label === label && isFresh(r, now));
30
- const partial = matching.length !== expected;
31
- if (!expected || !matching.length || (partial && !allowPartial)) return undefined;
32
- const weighted = matching.every((r) => typeof r.capacity === "number" && r.capacity > 0);
33
- const total = matching.reduce((sum, r) => sum + (weighted ? r.capacity! : 1), 0);
34
- const remaining = matching.reduce((sum, r) => sum + r.remaining * (weighted ? r.capacity! : 1), 0) / total;
35
- const resets = matching.map((r) => r.resetAt).filter((t): t is number => !!t && t > now);
36
- return { label, remaining, resetAt: resets.length ? Math.min(...resets) : undefined, estimated: !weighted, partial };
37
- }
38
-
39
- export function scopedLabels(rows: UsageRow[], modelId?: string): string[] {
40
- const labels = [...new Set(rows.filter(isClaudeAccount).map((r) => r.label).filter((l) => l.startsWith("7d ")))];
41
- const model = modelId?.toLowerCase() ?? "";
42
- return labels.sort((a, b) => Number(matchesScope(b, model)) - Number(matchesScope(a, model)) || a.localeCompare(b));
43
- }
44
-
45
- function matchesScope(label: string, model: string): boolean {
46
- const family = label.slice(3).toLowerCase();
47
- return model.includes(family) || (family === "fable" && model.includes("mythos"));
48
- }
49
-
50
- /** An account must pass ALL applicable windows; independent averages cannot answer this. */
51
- export function poolAvailability(rows: UsageRow[], expected: number, modelId?: string, now = Date.now()) {
52
- const groups = [...new Set(rows.filter(isClaudeAccount).map((r) => r.group))];
53
- let ready = 0;
54
- let unknown = Math.max(0, expected - groups.length);
55
- for (const group of groups) {
56
- const account = rows.filter((r) => r.group === group);
57
- const required = ["5h", "7d", ...scopedLabels(account).filter((l) => matchesScope(l, modelId?.toLowerCase() ?? ""))];
58
- const windows = required.map((label) => account.find((r) => r.label === label));
59
- if (windows.some((r) => r && isFresh(r, now) && r.remaining <= 0)) continue;
60
- if (windows.some((r) => !r || !isFresh(r, now))) { unknown++; continue; }
61
- ready++;
62
- }
63
- return { ready, unknown, total: expected };
64
- }
1
+ export type UsageRow = {
2
+ group: string;
3
+ label: string;
4
+ remaining: number;
5
+ resetAt?: number;
6
+ checkedAt?: number;
7
+ stale?: boolean;
8
+ capacity?: number;
9
+ };
10
+
11
+ /*
12
+ * How old a usage sample may be and still be poolable.
13
+ *
14
+ * This was 6 minutes when every refresh fetched the usage endpoint live.
15
+ * Quota now comes from response headers, backed by a poll at most once per
16
+ * 10 minutes, so a 6 minute bound marked idle accounts stale almost all the
17
+ * time. It must stay comfortably above that poll interval; the underlying
18
+ * windows are 5 hours and 7 days, so a sample minutes old is still accurate.
19
+ */
20
+ export const CLAUDE_FRESH_MS = 12 * 60_000;
21
+ export const isClaudeAccount = (row: UsageRow) => row.group.startsWith("Claude ") && !row.group.startsWith("Claude pool ×");
22
+ export const isFresh = (row: UsageRow, now = Date.now()) => !row.stale && !!row.checkedAt
23
+ && now - row.checkedAt < CLAUDE_FRESH_MS && (!row.resetAt || row.resetAt > now);
24
+
25
+ /** Percent of combined capacity, not a claim that quota transfers between accounts.
26
+ * Without published capacities this is explicitly an equal-account estimate.
27
+ */
28
+ export function combinedWindow(rows: UsageRow[], label: string, expected: number, now = Date.now(), allowPartial = false) {
29
+ const matching = rows.filter((r) => isClaudeAccount(r) && r.label === label && isFresh(r, now));
30
+ const partial = matching.length !== expected;
31
+ if (!expected || !matching.length || (partial && !allowPartial)) return undefined;
32
+ const weighted = matching.every((r) => typeof r.capacity === "number" && r.capacity > 0);
33
+ const total = matching.reduce((sum, r) => sum + (weighted ? r.capacity! : 1), 0);
34
+ const remaining = matching.reduce((sum, r) => sum + r.remaining * (weighted ? r.capacity! : 1), 0) / total;
35
+ const resets = matching.map((r) => r.resetAt).filter((t): t is number => !!t && t > now);
36
+ return { label, remaining, resetAt: resets.length ? Math.min(...resets) : undefined, estimated: !weighted, partial };
37
+ }
38
+
39
+ export function scopedLabels(rows: UsageRow[], modelId?: string): string[] {
40
+ const labels = [...new Set(rows.filter(isClaudeAccount).map((r) => r.label).filter((l) => l.startsWith("7d ")))];
41
+ const model = modelId?.toLowerCase() ?? "";
42
+ return labels.sort((a, b) => Number(matchesScope(b, model)) - Number(matchesScope(a, model)) || a.localeCompare(b));
43
+ }
44
+
45
+ function matchesScope(label: string, model: string): boolean {
46
+ const family = label.slice(3).toLowerCase();
47
+ return model.includes(family) || (family === "fable" && model.includes("mythos"));
48
+ }
49
+
50
+ /** An account must pass ALL applicable windows; independent averages cannot answer this. */
51
+ export function poolAvailability(rows: UsageRow[], expected: number, modelId?: string, now = Date.now()) {
52
+ const groups = [...new Set(rows.filter(isClaudeAccount).map((r) => r.group))];
53
+ let ready = 0;
54
+ let unknown = Math.max(0, expected - groups.length);
55
+ for (const group of groups) {
56
+ const account = rows.filter((r) => r.group === group);
57
+ const required = ["5h", "7d", ...scopedLabels(account).filter((l) => matchesScope(l, modelId?.toLowerCase() ?? ""))];
58
+ const windows = required.map((label) => account.find((r) => r.label === label));
59
+ if (windows.some((r) => r && isFresh(r, now) && r.remaining <= 0)) continue;
60
+ if (windows.some((r) => !r || !isFresh(r, now))) { unknown++; continue; }
61
+ ready++;
62
+ }
63
+ return { ready, unknown, total: expected };
64
+ }