@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,208 +1,208 @@
1
- import type { Component } from "@earendil-works/pi-tui";
2
- import { hasTruecolor, levelColor } from "../../ui/format.ts";
3
-
4
- /**
5
- * The `/provider` picker.
6
- *
7
- * Built on SettingsList so toggling happens *in place*: the component persists
8
- * and only its value strings change, rather than the dialog closing and
9
- * reopening on every keypress, which caused a full-screen redraw (the flicker).
10
- *
11
- * States are colour coded rather than prefixed, so the list reads at a glance:
12
- *
13
- * Allowed green reachable without a prompt
14
- * Approved cyan metered, granted for this session
15
- * Needs Approval yellow metered, will prompt
16
- * Denied red blocked by policy outright
17
- */
18
-
19
- export const STATE_TEXT = {
20
- auto: "Allowed",
21
- approved: "Approved",
22
- blocked: "Needs Approval",
23
- denied: "Denied",
24
- } as const;
25
-
26
- export type StateKey = keyof typeof STATE_TEXT;
27
-
28
- /** The two values SettingsList cycles between for a binary toggle. */
29
- export const TOGGLE_VALUES = [STATE_TEXT.auto, STATE_TEXT.blocked];
30
-
31
- export interface ProviderRow {
32
- /** Stable id used by SettingsList and by the toggle handler. */
33
- id: string;
34
- /** Provider id this row controls. */
35
- provider: string;
36
- /** Human name, e.g. "Anthropic" or "OpenRouter". */
37
- display: string;
38
- state: StateKey;
39
- }
40
-
41
- /**
42
- * Where each state sits on the usage-bar gradient.
43
- *
44
- * `levelColor` maps 0-100 onto the same red-to-green ramp the quota bars use,
45
- * so "Allowed" is the green of a full bar and "Needs Approval" is the amber of
46
- * one running low. Reusing the ramp keeps the two surfaces consistent instead
47
- * of pairing a bespoke green here with a different green in the footer.
48
- */
49
- const STATE_LEVEL: Record<StateKey, number> = {
50
- auto: 100,
51
- approved: 100,
52
- blocked: 45,
53
- denied: 0,
54
- };
55
-
56
- /** Fallback for terminals without truecolor. */
57
- const STATE_THEME_COLOUR: Record<StateKey, string> = {
58
- auto: "success",
59
- approved: "success",
60
- blocked: "warning",
61
- denied: "error",
62
- };
63
-
64
- /**
65
- * Colour a state string. Kept separate from rendering so callers can reuse it
66
- * for notices and be consistent with the list.
67
- */
68
- export function colourState(theme: any, state: StateKey, text: string = STATE_TEXT[state]): string {
69
- // Approved shares auto's green: both mean usable, and a third hue would imply
70
- // a third risk level. The label already distinguishes them.
71
- if (hasTruecolor()) return levelColor(STATE_LEVEL[state])(text);
72
- return theme.fg(STATE_THEME_COLOUR[state], text);
73
- }
74
-
75
- /** The row label: a status dot on the usage-bar ramp, plus the name. */
76
- function labelFor(theme: any, row: ProviderRow): string {
77
- const dot = colourState(theme, row.state, "●");
78
- const name = row.state === "denied" ? theme.fg("muted", row.display) : row.display;
79
- return `${dot} ${name}`;
80
- }
81
-
82
- /**
83
- * The chrome pi uses for `/model` and its other in-chat pickers.
84
- *
85
- * Not a box: a full-width accent rule, a bold title, the body, then a closing
86
- * rule. Reproduced from pi's own `frame(theme, title, body, footer)` helper so
87
- * this reads as part of the chat flow rather than as a floating dialog.
88
- *
89
- * Input and mouse events pass straight through, so the frame is presentation
90
- * only and does not disturb the in-place updates.
91
- */
92
- function framed(theme: any, list: any, title: string): Component {
93
- const rule = (width: number) => theme.fg("accent", "─".repeat(Math.max(1, width)));
94
-
95
- return {
96
- invalidate: () => list.invalidate?.(),
97
- handleInput: (data: string) => list.handleInput(data),
98
- handleMouse: (event: any) => list.handleMouse?.(event),
99
- render(width: number): string[] {
100
- const inner = Math.max(1, width);
101
- // pi pads title and footer by one column; the list renders flush.
102
- return [
103
- rule(inner),
104
- ` ${theme.fg("accent", theme.bold(title))}`,
105
- ...list.render(inner),
106
- rule(inner),
107
- ];
108
- },
109
- } as Component;
110
- }
111
-
112
- /**
113
- * Theme for the picker. Every callback takes `selected` so the highlighted row
114
- * can be emphasised without the caller tracking cursor position.
115
- */
116
- function pickerTheme(theme: any) {
117
- return {
118
- label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
119
- value: (text: string, _selected: boolean) => text,
120
- description: (text: string) => theme.fg("dim", text),
121
- cursor: theme.fg("accent", "›"),
122
- hint: (text: string) => theme.fg("dim", text),
123
- };
124
- }
125
-
126
- export interface PickerDeps {
127
- /** Re-reads the current rows, so the picker never shows stale state. */
128
- rows: () => Promise<ProviderRow[]>;
129
- /**
130
- * Applies a toggle and returns the resulting state.
131
- *
132
- * Deliberately synchronous. SettingsList cycles its own `values` the instant
133
- * Enter is pressed, so an async toggle would let the text change one frame
134
- * before the colour caught up. Keeping it sync means the label, the dot and
135
- * the value all land in the same render.
136
- */
137
- toggle: (provider: string) => StateKey;
138
- }
139
-
140
- /**
141
- * Opens the picker and resolves when the user cancels.
142
- *
143
- * The loop only re-enters when the row set itself changes (for example a
144
- * provider appearing after re-authentication); ordinary toggles just call
145
- * `updateValue`, which is a single re-render of one component.
146
- */
147
- export async function openProviderPicker(ctx: any, deps: PickerDeps): Promise<void> {
148
- const { SettingsList } = await import("@earendil-works/pi-tui");
149
-
150
- const initial = await deps.rows();
151
- if (initial.length === 0) {
152
- ctx.ui.notify("No providers are configured. Sign in with /account or pi auth.", "info");
153
- return;
154
- }
155
-
156
- await ctx.ui.custom((_tui: any, theme: any, _keys: any, done: (v: void) => void) => {
157
- const rows = initial;
158
- let list: any;
159
-
160
- // Held by reference: SettingsList reads these objects on every render, so
161
- // mutating `label` here recolours the status dot without rebuilding the
162
- // component. `updateValue` only refreshes the value column.
163
- // `values` carries the COLOURED strings, not plain text. SettingsList shows
164
- // whichever it cycles to immediately, so pre-colouring them means the new
165
- // text arrives already in the right colour rather than flashing uncoloured.
166
- const colouredToggle = [colourState(theme, "auto"), colourState(theme, "blocked")];
167
-
168
- const items = rows.map((row) => ({
169
- id: row.id,
170
- label: labelFor(theme, row),
171
- values: [...colouredToggle],
172
- currentValue: colourState(theme, row.state),
173
- }));
174
-
175
- // Synchronous throughout: label, dot and value all change in one render.
176
- const onChange = (id: string) => {
177
- const row = rows.find((r) => r.id === id);
178
- const item = items.find((i) => i.id === id);
179
- if (!row || !item) return;
180
-
181
- if (row.state === "denied") {
182
- // Undo the value SettingsList optimistically cycled to.
183
- list?.updateValue(id, colourState(theme, row.state));
184
- ctx.ui.notify(`${row.display} is denied in policy. Edit pi-plus.json to change that.`, "warning");
185
- return;
186
- }
187
-
188
- const next = deps.toggle(row.provider);
189
- row.state = next;
190
- item.label = labelFor(theme, row);
191
- list?.updateValue(id, colourState(theme, next));
192
- list?.invalidate?.();
193
- };
194
-
195
- list = new SettingsList(
196
- items,
197
- 12,
198
- pickerTheme(theme),
199
- onChange,
200
- () => done(undefined),
201
- { enableSearch: false },
202
- );
203
-
204
- return framed(theme, list, "Providers") as Component & { dispose?(): void };
205
- });
206
- // No `overlay` option: the picker renders inline in the chat flow rather than
207
- // floating over it.
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { hasTruecolor, levelColor } from "../../ui/format.ts";
3
+
4
+ /**
5
+ * The `/provider` picker.
6
+ *
7
+ * Built on SettingsList so toggling happens *in place*: the component persists
8
+ * and only its value strings change, rather than the dialog closing and
9
+ * reopening on every keypress, which caused a full-screen redraw (the flicker).
10
+ *
11
+ * States are colour coded rather than prefixed, so the list reads at a glance:
12
+ *
13
+ * Allowed green reachable without a prompt
14
+ * Approved cyan metered, granted for this session
15
+ * Needs Approval yellow metered, will prompt
16
+ * Denied red blocked by policy outright
17
+ */
18
+
19
+ export const STATE_TEXT = {
20
+ auto: "Allowed",
21
+ approved: "Approved",
22
+ blocked: "Needs Approval",
23
+ denied: "Denied",
24
+ } as const;
25
+
26
+ export type StateKey = keyof typeof STATE_TEXT;
27
+
28
+ /** The two values SettingsList cycles between for a binary toggle. */
29
+ export const TOGGLE_VALUES = [STATE_TEXT.auto, STATE_TEXT.blocked];
30
+
31
+ export interface ProviderRow {
32
+ /** Stable id used by SettingsList and by the toggle handler. */
33
+ id: string;
34
+ /** Provider id this row controls. */
35
+ provider: string;
36
+ /** Human name, e.g. "Anthropic" or "OpenRouter". */
37
+ display: string;
38
+ state: StateKey;
39
+ }
40
+
41
+ /**
42
+ * Where each state sits on the usage-bar gradient.
43
+ *
44
+ * `levelColor` maps 0-100 onto the same red-to-green ramp the quota bars use,
45
+ * so "Allowed" is the green of a full bar and "Needs Approval" is the amber of
46
+ * one running low. Reusing the ramp keeps the two surfaces consistent instead
47
+ * of pairing a bespoke green here with a different green in the footer.
48
+ */
49
+ const STATE_LEVEL: Record<StateKey, number> = {
50
+ auto: 100,
51
+ approved: 100,
52
+ blocked: 45,
53
+ denied: 0,
54
+ };
55
+
56
+ /** Fallback for terminals without truecolor. */
57
+ const STATE_THEME_COLOUR: Record<StateKey, string> = {
58
+ auto: "success",
59
+ approved: "success",
60
+ blocked: "warning",
61
+ denied: "error",
62
+ };
63
+
64
+ /**
65
+ * Colour a state string. Kept separate from rendering so callers can reuse it
66
+ * for notices and be consistent with the list.
67
+ */
68
+ export function colourState(theme: any, state: StateKey, text: string = STATE_TEXT[state]): string {
69
+ // Approved shares auto's green: both mean usable, and a third hue would imply
70
+ // a third risk level. The label already distinguishes them.
71
+ if (hasTruecolor()) return levelColor(STATE_LEVEL[state])(text);
72
+ return theme.fg(STATE_THEME_COLOUR[state], text);
73
+ }
74
+
75
+ /** The row label: a status dot on the usage-bar ramp, plus the name. */
76
+ function labelFor(theme: any, row: ProviderRow): string {
77
+ const dot = colourState(theme, row.state, "●");
78
+ const name = row.state === "denied" ? theme.fg("muted", row.display) : row.display;
79
+ return `${dot} ${name}`;
80
+ }
81
+
82
+ /**
83
+ * The chrome pi uses for `/model` and its other in-chat pickers.
84
+ *
85
+ * Not a box: a full-width accent rule, a bold title, the body, then a closing
86
+ * rule. Reproduced from pi's own `frame(theme, title, body, footer)` helper so
87
+ * this reads as part of the chat flow rather than as a floating dialog.
88
+ *
89
+ * Input and mouse events pass straight through, so the frame is presentation
90
+ * only and does not disturb the in-place updates.
91
+ */
92
+ function framed(theme: any, list: any, title: string): Component {
93
+ const rule = (width: number) => theme.fg("accent", "─".repeat(Math.max(1, width)));
94
+
95
+ return {
96
+ invalidate: () => list.invalidate?.(),
97
+ handleInput: (data: string) => list.handleInput(data),
98
+ handleMouse: (event: any) => list.handleMouse?.(event),
99
+ render(width: number): string[] {
100
+ const inner = Math.max(1, width);
101
+ // pi pads title and footer by one column; the list renders flush.
102
+ return [
103
+ rule(inner),
104
+ ` ${theme.fg("accent", theme.bold(title))}`,
105
+ ...list.render(inner),
106
+ rule(inner),
107
+ ];
108
+ },
109
+ } as Component;
110
+ }
111
+
112
+ /**
113
+ * Theme for the picker. Every callback takes `selected` so the highlighted row
114
+ * can be emphasised without the caller tracking cursor position.
115
+ */
116
+ function pickerTheme(theme: any) {
117
+ return {
118
+ label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
119
+ value: (text: string, _selected: boolean) => text,
120
+ description: (text: string) => theme.fg("dim", text),
121
+ cursor: theme.fg("accent", "›"),
122
+ hint: (text: string) => theme.fg("dim", text),
123
+ };
124
+ }
125
+
126
+ export interface PickerDeps {
127
+ /** Re-reads the current rows, so the picker never shows stale state. */
128
+ rows: () => Promise<ProviderRow[]>;
129
+ /**
130
+ * Applies a toggle and returns the resulting state.
131
+ *
132
+ * Deliberately synchronous. SettingsList cycles its own `values` the instant
133
+ * Enter is pressed, so an async toggle would let the text change one frame
134
+ * before the colour caught up. Keeping it sync means the label, the dot and
135
+ * the value all land in the same render.
136
+ */
137
+ toggle: (provider: string) => StateKey;
138
+ }
139
+
140
+ /**
141
+ * Opens the picker and resolves when the user cancels.
142
+ *
143
+ * The loop only re-enters when the row set itself changes (for example a
144
+ * provider appearing after re-authentication); ordinary toggles just call
145
+ * `updateValue`, which is a single re-render of one component.
146
+ */
147
+ export async function openProviderPicker(ctx: any, deps: PickerDeps): Promise<void> {
148
+ const { SettingsList } = await import("@earendil-works/pi-tui");
149
+
150
+ const initial = await deps.rows();
151
+ if (initial.length === 0) {
152
+ ctx.ui.notify("No providers are configured. Sign in with /account or pi auth.", "info");
153
+ return;
154
+ }
155
+
156
+ await ctx.ui.custom((_tui: any, theme: any, _keys: any, done: (v: void) => void) => {
157
+ const rows = initial;
158
+ let list: any;
159
+
160
+ // Held by reference: SettingsList reads these objects on every render, so
161
+ // mutating `label` here recolours the status dot without rebuilding the
162
+ // component. `updateValue` only refreshes the value column.
163
+ // `values` carries the COLOURED strings, not plain text. SettingsList shows
164
+ // whichever it cycles to immediately, so pre-colouring them means the new
165
+ // text arrives already in the right colour rather than flashing uncoloured.
166
+ const colouredToggle = [colourState(theme, "auto"), colourState(theme, "blocked")];
167
+
168
+ const items = rows.map((row) => ({
169
+ id: row.id,
170
+ label: labelFor(theme, row),
171
+ values: [...colouredToggle],
172
+ currentValue: colourState(theme, row.state),
173
+ }));
174
+
175
+ // Synchronous throughout: label, dot and value all change in one render.
176
+ const onChange = (id: string) => {
177
+ const row = rows.find((r) => r.id === id);
178
+ const item = items.find((i) => i.id === id);
179
+ if (!row || !item) return;
180
+
181
+ if (row.state === "denied") {
182
+ // Undo the value SettingsList optimistically cycled to.
183
+ list?.updateValue(id, colourState(theme, row.state));
184
+ ctx.ui.notify(`${row.display} is denied in policy. Edit pi-plus.json to change that.`, "warning");
185
+ return;
186
+ }
187
+
188
+ const next = deps.toggle(row.provider);
189
+ row.state = next;
190
+ item.label = labelFor(theme, row);
191
+ list?.updateValue(id, colourState(theme, next));
192
+ list?.invalidate?.();
193
+ };
194
+
195
+ list = new SettingsList(
196
+ items,
197
+ 12,
198
+ pickerTheme(theme),
199
+ onChange,
200
+ () => done(undefined),
201
+ { enableSearch: false },
202
+ );
203
+
204
+ return framed(theme, list, "Providers") as Component & { dispose?(): void };
205
+ });
206
+ // No `overlay` option: the picker renders inline in the chat flow rather than
207
+ // floating over it.
208
208
  }
@@ -1,41 +1,41 @@
1
- import { readConfig, updateConfig } from "../../core/config.ts";
2
-
3
- /**
4
- * Accessors for the `remote` section of `pi-plus.json`.
5
- *
6
- * The worker list previously lived in its own `remote-workers.json`, then
7
- * `remote.json`. Both are migrated by core/config.ts on first read.
8
- */
9
-
10
- export interface RemoteWorkerRecord {
11
- name: string;
12
- ssh: string;
13
- root?: string;
14
- nice?: number;
15
- tags?: string[];
16
- identityFile?: string;
17
- port?: number;
18
- enabled?: boolean;
19
- [key: string]: unknown;
20
- }
21
-
22
- export interface RemoteSettings {
23
- injectStatus: boolean;
24
- defaults: Record<string, unknown>;
25
- workers: RemoteWorkerRecord[];
26
- }
27
-
28
- export function readRemote(): RemoteSettings {
29
- const section = readConfig().remote;
30
- return {
31
- injectStatus: section.injectStatus !== false,
32
- defaults: (section.defaults as Record<string, unknown>) ?? {},
33
- workers: (section.workers as RemoteWorkerRecord[]) ?? [],
34
- };
35
- }
36
-
37
- export function writeRemoteWorkers(workers: RemoteWorkerRecord[]): boolean {
38
- return updateConfig((config) => {
39
- config.remote.workers = workers;
40
- });
41
- }
1
+ import { readConfig, updateConfig } from "../../core/config.ts";
2
+
3
+ /**
4
+ * Accessors for the `remote` section of `pi-plus.json`.
5
+ *
6
+ * The worker list previously lived in its own `remote-workers.json`, then
7
+ * `remote.json`. Both are migrated by core/config.ts on first read.
8
+ */
9
+
10
+ export interface RemoteWorkerRecord {
11
+ name: string;
12
+ ssh: string;
13
+ root?: string;
14
+ nice?: number;
15
+ tags?: string[];
16
+ identityFile?: string;
17
+ port?: number;
18
+ enabled?: boolean;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export interface RemoteSettings {
23
+ injectStatus: boolean;
24
+ defaults: Record<string, unknown>;
25
+ workers: RemoteWorkerRecord[];
26
+ }
27
+
28
+ export function readRemote(): RemoteSettings {
29
+ const section = readConfig().remote;
30
+ return {
31
+ injectStatus: section.injectStatus !== false,
32
+ defaults: (section.defaults as Record<string, unknown>) ?? {},
33
+ workers: (section.workers as RemoteWorkerRecord[]) ?? [],
34
+ };
35
+ }
36
+
37
+ export function writeRemoteWorkers(workers: RemoteWorkerRecord[]): boolean {
38
+ return updateConfig((config) => {
39
+ config.remote.workers = workers;
40
+ });
41
+ }