@jameslovespancakes/pi-plus 1.0.12 → 1.0.14
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 +7 -3
- package/package.json +7 -6
- package/src/core/accounts/oauth-pool.ts +35 -0
- package/src/core/accounts/registry.ts +4 -0
- package/src/core/accounts/routing.ts +22 -0
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +227 -28
- package/src/core/anthropic/store.ts +4 -1
- package/src/core/codex/quota.ts +43 -11
- package/src/core/codex/store.ts +21 -11
- package/src/core/config.ts +8 -10
- package/src/domains/agents/format.ts +39 -0
- package/src/domains/agents/index.ts +39 -50
- package/src/domains/remote/config-path.ts +0 -2
- package/src/domains/remote/index.ts +4 -41
- package/src/domains/subscriptions/accounts-picker.ts +44 -4
- package/src/domains/subscriptions/accounts.ts +31 -8
- package/src/domains/subscriptions/index.ts +2 -2
- package/src/domains/subscriptions/provider.ts +46 -5
- package/src/domains/subscriptions/providers/anthropic.ts +37 -5
- package/src/domains/subscriptions/providers/codex.ts +120 -254
- package/src/domains/subscriptions/providers/hosted.ts +2 -2
- package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
- package/src/domains/workflows/index.ts +1 -1
- package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +24 -1
- package/src/domains/workflows/runtime/agent-retry.ts +11 -6
- package/src/domains/workflows/runtime/agent-runner.ts +42 -20
- package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
- package/src/domains/workflows/runtime/engine.ts +48 -12
- package/src/domains/workflows/runtime/progress.ts +2 -2
- package/src/domains/workflows/runtime/types.ts +4 -4
- package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
- package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
- package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
- package/src/domains/workflows/runtime/worktree.ts +35 -1
- package/src/ui/usage-bars.ts +1 -2
- package/src/core/codex/oauth.ts +0 -129
package/src/core/config.ts
CHANGED
|
@@ -15,7 +15,6 @@ export interface PolicySection {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface RemoteSection {
|
|
18
|
-
injectStatus?: boolean;
|
|
19
18
|
defaults?: Record<string, unknown>;
|
|
20
19
|
workers: Record<string, unknown>[];
|
|
21
20
|
}
|
|
@@ -33,7 +32,7 @@ const DEFAULTS: PiPlusConfig = {
|
|
|
33
32
|
requireApproval: ["openrouter/*", "google/*", "openai/*"],
|
|
34
33
|
deny: [],
|
|
35
34
|
},
|
|
36
|
-
remote: {
|
|
35
|
+
remote: { workers: [] },
|
|
37
36
|
};
|
|
38
37
|
|
|
39
38
|
/** Legacy file -> section, applied only when that section is still absent. */
|
|
@@ -82,7 +81,7 @@ const MIGRATIONS: { file: string; apply: (raw: any, into: PiPlusConfig) => boole
|
|
|
82
81
|
file: "remote.json",
|
|
83
82
|
apply: (raw, into) => {
|
|
84
83
|
if (into.remote.workers.length > 0 || !Array.isArray(raw?.workers)) return false;
|
|
85
|
-
into.remote = {
|
|
84
|
+
into.remote = { defaults: raw.defaults, workers: raw.workers };
|
|
86
85
|
return true;
|
|
87
86
|
},
|
|
88
87
|
},
|
|
@@ -90,7 +89,7 @@ const MIGRATIONS: { file: string; apply: (raw: any, into: PiPlusConfig) => boole
|
|
|
90
89
|
file: "remote-workers.json",
|
|
91
90
|
apply: (raw, into) => {
|
|
92
91
|
if (into.remote.workers.length > 0 || !Array.isArray(raw?.workers)) return false;
|
|
93
|
-
into.remote = {
|
|
92
|
+
into.remote = { defaults: raw.defaults, workers: raw.workers };
|
|
94
93
|
return true;
|
|
95
94
|
},
|
|
96
95
|
},
|
|
@@ -113,7 +112,6 @@ function normalize(raw: Partial<PiPlusConfig> | undefined): PiPlusConfig {
|
|
|
113
112
|
deny: Array.isArray(raw?.policy?.deny) ? raw.policy.deny : [],
|
|
114
113
|
},
|
|
115
114
|
remote: {
|
|
116
|
-
injectStatus: raw?.remote?.injectStatus !== false,
|
|
117
115
|
defaults: raw?.remote?.defaults,
|
|
118
116
|
workers: Array.isArray(raw?.remote?.workers) ? raw.remote.workers : [],
|
|
119
117
|
},
|
|
@@ -125,16 +123,16 @@ export function readConfig(): PiPlusConfig {
|
|
|
125
123
|
|
|
126
124
|
const path = configPath();
|
|
127
125
|
const exists = existsSync(path);
|
|
128
|
-
const
|
|
126
|
+
const raw = exists ? readJson<Partial<PiPlusConfig>>(path, {}) : undefined;
|
|
127
|
+
const config = normalize(raw);
|
|
129
128
|
|
|
130
|
-
// Only consider legacy sources when the unified file is absent or partial.
|
|
131
129
|
let migrated = false;
|
|
132
130
|
for (const migration of MIGRATIONS) {
|
|
133
131
|
const legacyPath = agentPath(migration.file);
|
|
134
132
|
if (!existsSync(legacyPath)) continue;
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
if (migration.apply(
|
|
133
|
+
const legacy = readJson<any>(legacyPath, undefined);
|
|
134
|
+
if (legacy === undefined) continue;
|
|
135
|
+
if (migration.apply(legacy, config)) migrated = true;
|
|
138
136
|
}
|
|
139
137
|
|
|
140
138
|
if (!exists || migrated) writeJson(path, config, true);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const DELIVERY_TEXT_MAX = 2_000;
|
|
2
|
+
|
|
3
|
+
export interface DeliveryMessage {
|
|
4
|
+
senderId: string;
|
|
5
|
+
senderAlias?: string;
|
|
6
|
+
text: string;
|
|
7
|
+
priority: "normal" | "urgent";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DeliveryThread {
|
|
11
|
+
id: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BoardDelivery {
|
|
16
|
+
message: DeliveryMessage;
|
|
17
|
+
thread: DeliveryThread;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cleanLine(value: unknown, max: number): string {
|
|
21
|
+
const text = String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
22
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function compactDeliveryText(text: string): string {
|
|
26
|
+
const clean = text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ").trim();
|
|
27
|
+
if (clean.length <= DELIVERY_TEXT_MAX) return clean;
|
|
28
|
+
return `${clean.slice(0, DELIVERY_TEXT_MAX - 1)}…\n[truncated; use agent_board read]`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatBoardDeliveries(deliveries: readonly BoardDelivery[]): string {
|
|
32
|
+
const lines = [`[board${deliveries.some(({ message }) => message.priority === "urgent") ? " urgent" : ""} x${deliveries.length}]`];
|
|
33
|
+
for (const { message, thread } of deliveries) {
|
|
34
|
+
const sender = message.senderAlias || message.senderId.slice(0, 8);
|
|
35
|
+
lines.push(`${message.priority === "urgent" ? "! " : ""}from=${sender} thread=${thread.id}${thread.title ? ` (${cleanLine(thread.title, 60)})` : ""}`);
|
|
36
|
+
lines.push(compactDeliveryText(message.text));
|
|
37
|
+
}
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
@@ -11,11 +11,13 @@ import { Type } from "typebox";
|
|
|
11
11
|
import WebSocket from "ws";
|
|
12
12
|
import { env } from "../../core/env.ts";
|
|
13
13
|
import { handleBoardAdmin, registerBoardLifecycle } from "./board-setup.ts";
|
|
14
|
+
import { formatBoardDeliveries, type BoardDelivery } from "./format.ts";
|
|
14
15
|
|
|
15
16
|
const HEARTBEAT_MS = 8_000;
|
|
16
17
|
const REQUEST_TIMEOUT_MS = 2_000;
|
|
17
|
-
const SNAPSHOT_CACHE_MS = 1_500;
|
|
18
18
|
const MESSAGE_MAX = 8_000;
|
|
19
|
+
const DELIVERY_DEBOUNCE_MS = 250;
|
|
20
|
+
const DELIVERY_BATCH_MAX = 8;
|
|
19
21
|
|
|
20
22
|
type State = "idle" | "thinking" | "tool";
|
|
21
23
|
type Priority = "normal" | "urgent";
|
|
@@ -301,7 +303,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
301
303
|
let ctx: ExtensionContext | undefined;
|
|
302
304
|
let self: AgentInfo | undefined;
|
|
303
305
|
let gitInfo: GitInfo = {};
|
|
304
|
-
let
|
|
306
|
+
let pendingDeliveries: BoardDelivery[] = [];
|
|
307
|
+
let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
305
308
|
|
|
306
309
|
const updateStatus = () => {
|
|
307
310
|
if (!ctx?.hasUI) return;
|
|
@@ -315,43 +318,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
315
318
|
self = { ...self, ...gitInfo };
|
|
316
319
|
client.presence(gitInfo);
|
|
317
320
|
};
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (
|
|
324
|
-
|
|
321
|
+
const flushDeliveries = () => {
|
|
322
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
323
|
+
deliveryTimer = undefined;
|
|
324
|
+
const deliveries = pendingDeliveries;
|
|
325
|
+
pendingDeliveries = [];
|
|
326
|
+
if (!ctx || !deliveries.length) return;
|
|
327
|
+
const urgent = deliveries.some(({ message }) => message.priority === "urgent");
|
|
328
|
+
if (urgent && !ctx.isIdle()) ctx.abort();
|
|
329
|
+
const details = deliveries.length === 1 ? deliveries[0] : { deliveries };
|
|
330
|
+
pi.sendMessage(
|
|
331
|
+
{ customType: "agent-board", content: formatBoardDeliveries(deliveries), display: true, details },
|
|
332
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
333
|
+
);
|
|
325
334
|
};
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
return `- ${agent.alias || agent.sessionId.slice(0, 8)}@${agent.host}${role}: ${activity}; ${agent.branch || "-"}@${commit}${divergent}${agent.lastPrompt ? `; ${agent.lastPrompt}` : ""}`;
|
|
336
|
-
});
|
|
337
|
-
return [
|
|
338
|
-
`[Live agent board, consult before working]`,
|
|
339
|
-
`Your commit: ${gitInfo.branch || "-"}@${gitInfo.commit?.slice(0, 12) || "no-commit"}`,
|
|
340
|
-
`Running collaborators:`,
|
|
341
|
-
lines.join("\n"),
|
|
342
|
-
...coordinationLines,
|
|
343
|
-
`Avoid duplicate or outdated work. Use agent_board to coordinate when useful.`,
|
|
344
|
-
].join("\n");
|
|
335
|
+
const queueDelivery = (delivery: BoardDelivery) => {
|
|
336
|
+
pendingDeliveries.push(delivery);
|
|
337
|
+
if (delivery.message.priority === "urgent" || pendingDeliveries.length >= DELIVERY_BATCH_MAX) {
|
|
338
|
+
flushDeliveries();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (deliveryTimer) return;
|
|
342
|
+
deliveryTimer = setTimeout(flushDeliveries, DELIVERY_DEBOUNCE_MS);
|
|
343
|
+
deliveryTimer.unref?.();
|
|
345
344
|
};
|
|
346
345
|
|
|
347
346
|
client.on((event) => {
|
|
348
|
-
if (event.t === "connection"
|
|
347
|
+
if (event.t === "connection") {
|
|
348
|
+
updateStatus();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (event.t === "coordination") { updateStatus(); return; }
|
|
349
352
|
if (event.t !== "message" || event.adminView || !ctx) return;
|
|
350
|
-
|
|
351
|
-
const thread = event.thread as ThreadInfo;
|
|
352
|
-
const content = `[Live agent-board message${message.priority === "urgent" ? " URGENT" : ""}]\nFrom: ${message.senderAlias || message.senderId}\nThread: ${thread.title || thread.id}\nCommit context: you are on ${gitInfo.branch || "-"}@${gitInfo.commit?.slice(0, 12) || "no-commit"}\n\n${message.text}\n\nEvaluate this at the next decision point. If relevant, adjust your work and reply through agent_board with thread '${thread.id}'. If irrelevant, continue the current task.`;
|
|
353
|
-
if (message.priority === "urgent" && !ctx.isIdle()) ctx.abort();
|
|
354
|
-
pi.sendMessage({ customType: "agent-board", content, display: true, details: { message, thread } }, { deliverAs: "steer", triggerTurn: true });
|
|
353
|
+
queueDelivery({ message: event.message as BoardMessage, thread: event.thread as ThreadInfo });
|
|
355
354
|
});
|
|
356
355
|
|
|
357
356
|
pi.on("session_start", async (_event, eventCtx) => {
|
|
@@ -365,7 +364,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
365
364
|
});
|
|
366
365
|
pi.on("session_info_changed", async (event) => { if (self) { self.alias = event.name; client.presence({ alias: event.name }); } });
|
|
367
366
|
pi.on("model_select", async (event) => client.presence({ model: `${event.model.provider}/${event.model.id}` }));
|
|
368
|
-
pi.on("before_agent_start",
|
|
367
|
+
pi.on("before_agent_start", (event) => {
|
|
369
368
|
client.presence({ state: "thinking", lastPrompt: cleanLine(event.prompt) });
|
|
370
369
|
client.activity("prompt", event.prompt);
|
|
371
370
|
});
|
|
@@ -375,20 +374,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
375
374
|
});
|
|
376
375
|
pi.on("tool_execution_end", async () => client.presence({ state: "thinking" }));
|
|
377
376
|
pi.on("agent_settled", async () => { client.presence({ state: "idle", lastTool: undefined }); refreshGit(); });
|
|
378
|
-
pi.on("session_shutdown", async (_event, eventCtx) => {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (!client.connected) return;
|
|
383
|
-
try {
|
|
384
|
-
if (Date.now() - snapshot.at > SNAPSHOT_CACHE_MS) snapshot = { at: Date.now(), agents: await client.request("agents", {}, 500) };
|
|
385
|
-
return {
|
|
386
|
-
messages: [...event.messages, {
|
|
387
|
-
role: "custom" as const, customType: "agent-board-snapshot", content: formatSnapshot(snapshot.agents),
|
|
388
|
-
display: false, details: {}, timestamp: Date.now(),
|
|
389
|
-
}],
|
|
390
|
-
};
|
|
391
|
-
} catch { return; }
|
|
377
|
+
pi.on("session_shutdown", async (_event, eventCtx) => {
|
|
378
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
379
|
+
deliveryTimer = undefined; pendingDeliveries = [];
|
|
380
|
+
client.stop(); eventCtx.ui.setStatus("agent-board", undefined); ctx = undefined; self = undefined;
|
|
392
381
|
});
|
|
393
382
|
|
|
394
383
|
pi.registerMessageRenderer("agent-board", (message, _options, theme) => new Text(theme.fg("accent", message.content as string), 1, 0));
|
|
@@ -419,7 +408,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
419
408
|
description: "Consult and message all currently running local and remote Pi agents. Every agent record includes host, state, Git branch, and commit hash. Messages are live: idle agents wake immediately and busy agents see them at the next safe turn boundary. Stopped agents are never listed and messages are not queued for them. Agents working in the same repository share an auto-created repo room, and coordinators can be assigned so subordinate agents report their progress upward.",
|
|
420
409
|
promptSnippet: "Consult live collaborators, report to your coordinator, and exchange direct, repo-room, or group messages",
|
|
421
410
|
promptGuidelines: [
|
|
422
|
-
"Consult the
|
|
411
|
+
"Consult the latest compact Live agent board snapshot before starting work; avoid duplicating work and compare Git commit hashes before relying on another agent's changes.",
|
|
423
412
|
"Use agent_board to inform collaborators when their commit or assumptions appear outdated, and reply only when coordination is useful.",
|
|
424
413
|
"If the board snapshot names a coordinator for you, use agent_board action 'report' to send them meaningful progress, blockers, and completed work instead of staying silent.",
|
|
425
414
|
"Use agent_board action 'set_coordinator' when the user puts one agent in charge of others, and 'coordinators' to inspect the current reporting structure.",
|
|
@@ -20,7 +20,6 @@ export interface RemoteWorkerRecord {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface RemoteSettings {
|
|
23
|
-
injectStatus: boolean;
|
|
24
23
|
defaults: Record<string, unknown>;
|
|
25
24
|
workers: RemoteWorkerRecord[];
|
|
26
25
|
}
|
|
@@ -28,7 +27,6 @@ export interface RemoteSettings {
|
|
|
28
27
|
export function readRemote(): RemoteSettings {
|
|
29
28
|
const section = readConfig().remote;
|
|
30
29
|
return {
|
|
31
|
-
injectStatus: section.injectStatus !== false,
|
|
32
30
|
defaults: (section.defaults as Record<string, unknown>) ?? {},
|
|
33
31
|
workers: (section.workers as RemoteWorkerRecord[]) ?? [],
|
|
34
32
|
};
|
|
@@ -31,7 +31,6 @@ interface Limits {
|
|
|
31
31
|
gpuMemoryBlockPercent: number;
|
|
32
32
|
memoryBlockPercent: number;
|
|
33
33
|
minimumFreeDiskGB: number;
|
|
34
|
-
statusCacheSeconds: number;
|
|
35
34
|
retentionHours: number;
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -58,7 +57,6 @@ interface Worker extends Omit<WorkerInput, keyof Limits | "enabled">, Limits {
|
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
interface Config {
|
|
61
|
-
injectStatus: boolean;
|
|
62
60
|
workers: Worker[];
|
|
63
61
|
}
|
|
64
62
|
|
|
@@ -100,7 +98,6 @@ const DEFAULT_LIMITS: Limits = {
|
|
|
100
98
|
gpuMemoryBlockPercent: 90,
|
|
101
99
|
memoryBlockPercent: 90,
|
|
102
100
|
minimumFreeDiskGB: 10,
|
|
103
|
-
statusCacheSeconds: 30,
|
|
104
101
|
retentionHours: 24,
|
|
105
102
|
};
|
|
106
103
|
|
|
@@ -117,8 +114,6 @@ const HARD_WALK_EXCLUDES = new Set([
|
|
|
117
114
|
"remote_tests",
|
|
118
115
|
]);
|
|
119
116
|
|
|
120
|
-
let statusCache: { expiresAt: number; key: string; statuses: WorkerStatus[] } | undefined;
|
|
121
|
-
|
|
122
117
|
function finiteNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
123
118
|
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
|
124
119
|
}
|
|
@@ -139,7 +134,6 @@ async function loadConfig(): Promise<Config> {
|
|
|
139
134
|
),
|
|
140
135
|
memoryBlockPercent: finiteNumber(defaults.memoryBlockPercent, DEFAULT_LIMITS.memoryBlockPercent, 1, 100),
|
|
141
136
|
minimumFreeDiskGB: finiteNumber(defaults.minimumFreeDiskGB, DEFAULT_LIMITS.minimumFreeDiskGB, 0, 100000),
|
|
142
|
-
statusCacheSeconds: finiteNumber(defaults.statusCacheSeconds, DEFAULT_LIMITS.statusCacheSeconds, 1, 600),
|
|
143
137
|
retentionHours: finiteNumber(defaults.retentionHours, DEFAULT_LIMITS.retentionHours, 1, 24 * 365),
|
|
144
138
|
};
|
|
145
139
|
const names = new Set<string>();
|
|
@@ -172,11 +166,10 @@ async function loadConfig(): Promise<Config> {
|
|
|
172
166
|
),
|
|
173
167
|
memoryBlockPercent: finiteNumber(raw.memoryBlockPercent, defaultLimits.memoryBlockPercent, 1, 100),
|
|
174
168
|
minimumFreeDiskGB: finiteNumber(raw.minimumFreeDiskGB, defaultLimits.minimumFreeDiskGB, 0, 100000),
|
|
175
|
-
statusCacheSeconds: finiteNumber(raw.statusCacheSeconds, defaultLimits.statusCacheSeconds, 1, 600),
|
|
176
169
|
retentionHours: finiteNumber(raw.retentionHours, defaultLimits.retentionHours, 1, 24 * 365),
|
|
177
170
|
};
|
|
178
171
|
});
|
|
179
|
-
return {
|
|
172
|
+
return { workers };
|
|
180
173
|
}
|
|
181
174
|
|
|
182
175
|
/** `-i`/`-p` only when the worker was added without an ~/.ssh/config entry. */
|
|
@@ -331,27 +324,15 @@ async function probeWorker(worker: Worker): Promise<WorkerStatus> {
|
|
|
331
324
|
}
|
|
332
325
|
}
|
|
333
326
|
|
|
334
|
-
async function probeWorkers(config: Config
|
|
327
|
+
async function probeWorkers(config: Config): Promise<WorkerStatus[]> {
|
|
335
328
|
const active = config.workers.filter((worker) => worker.enabled);
|
|
336
|
-
|
|
337
|
-
const key = JSON.stringify(active.map((worker) => [worker.name, worker.ssh, worker.root]));
|
|
338
|
-
const ttlSeconds = Math.min(...active.map((worker) => worker.statusCacheSeconds));
|
|
339
|
-
if (!force && statusCache && statusCache.key === key && statusCache.expiresAt > Date.now()) return statusCache.statuses;
|
|
340
|
-
const statuses = await Promise.all(active.map(probeWorker));
|
|
341
|
-
statusCache = { key, statuses, expiresAt: Date.now() + ttlSeconds * 1000 };
|
|
342
|
-
return statuses;
|
|
329
|
+
return Promise.all(active.map(probeWorker));
|
|
343
330
|
}
|
|
344
331
|
|
|
345
332
|
function metric(value: number | undefined): string {
|
|
346
333
|
return value === undefined ? "?" : `${Math.round(value)}%`;
|
|
347
334
|
}
|
|
348
335
|
|
|
349
|
-
function compactStatus(status: WorkerStatus): string {
|
|
350
|
-
const gpu = status.gpuPercent === undefined ? "GPU ?" : `GPU ${metric(status.gpuPercent)}`;
|
|
351
|
-
const reason = status.reasons.length ? ` (${status.reasons.join(", ")})` : "";
|
|
352
|
-
return `${status.name} ${status.state.toUpperCase()} CPU ${metric(status.cpuPercent)} MEM ${metric(status.memoryPercent)} ${gpu} active-jobs ${status.jobs ?? "?"}${reason}`;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
336
|
function detailedStatuses(statuses: WorkerStatus[]): string {
|
|
356
337
|
return statuses
|
|
357
338
|
.map((status) => {
|
|
@@ -696,7 +677,6 @@ export default function remoteJobsExtension(pi: ExtensionAPI) {
|
|
|
696
677
|
const selected = params.host ? enabled.filter((worker) => worker.name === params.host) : enabled;
|
|
697
678
|
if (params.host && selected.length === 0) throw new Error(`Unknown remote worker: ${params.host}`);
|
|
698
679
|
const statuses = await Promise.all(selected.map(probeWorker));
|
|
699
|
-
statusCache = undefined;
|
|
700
680
|
return {
|
|
701
681
|
content: [{ type: "text", text: detailedStatuses(statuses) }],
|
|
702
682
|
details: { statuses },
|
|
@@ -745,7 +725,7 @@ export default function remoteJobsExtension(pi: ExtensionAPI) {
|
|
|
745
725
|
if (requestedHost !== "auto" && !enabledWorkers.some((worker) => worker.name === requestedHost)) {
|
|
746
726
|
throw new Error(`Unknown remote worker: ${requestedHost}. Available: ${enabledWorkers.map((w) => w.name).join(", ")}`);
|
|
747
727
|
}
|
|
748
|
-
|
|
728
|
+
const statuses = await probeWorkers(config);
|
|
749
729
|
let worker = pickWorker(config, statuses, requestedHost, params.requiresGpu ?? false);
|
|
750
730
|
if (!worker) return blockedResult(undefined, undefined, detailedStatuses(statuses));
|
|
751
731
|
let workerStatus = statuses.find((status) => status.name === worker!.name);
|
|
@@ -818,7 +798,6 @@ export default function remoteJobsExtension(pi: ExtensionAPI) {
|
|
|
818
798
|
text += `\n\n[Output truncated; full log: ${remoteJob}/test.log]`;
|
|
819
799
|
}
|
|
820
800
|
void runSsh(worker, "bash -s", { input: cleanupScript(worker, snapshot.repoName), timeoutSeconds: 15 }).catch(() => {});
|
|
821
|
-
statusCache = undefined;
|
|
822
801
|
return {
|
|
823
802
|
content: [{ type: "text", text }],
|
|
824
803
|
details: {
|
|
@@ -847,20 +826,4 @@ export default function remoteJobsExtension(pi: ExtensionAPI) {
|
|
|
847
826
|
},
|
|
848
827
|
});
|
|
849
828
|
|
|
850
|
-
pi.on("before_agent_start", async (event) => {
|
|
851
|
-
try {
|
|
852
|
-
const config = await loadConfig();
|
|
853
|
-
if (!config.injectStatus) return;
|
|
854
|
-
const statuses = await probeWorkers(config);
|
|
855
|
-
if (statuses.length === 0) return;
|
|
856
|
-
const line = statuses.map(compactStatus).join("; ");
|
|
857
|
-
return {
|
|
858
|
-
systemPrompt:
|
|
859
|
-
event.systemPrompt +
|
|
860
|
-
`\n\nRemote worker capacity (recent sample): ${line}. remote_test always performs a fresh hard admission check. Never retry a BLOCKED worker immediately; choose another READY worker or run locally.`,
|
|
861
|
-
};
|
|
862
|
-
} catch {
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
});
|
|
866
829
|
}
|
|
@@ -26,15 +26,50 @@ export interface AccountRow {
|
|
|
26
26
|
providerLabel: string;
|
|
27
27
|
label: string;
|
|
28
28
|
state: AccountState;
|
|
29
|
+
primary?: boolean;
|
|
29
30
|
detail?: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
export type PrimaryAccountResolver = (
|
|
34
|
+
provider: AccountProvider,
|
|
35
|
+
) => ManagedAccount | undefined | Promise<ManagedAccount | undefined>;
|
|
36
|
+
|
|
37
|
+
/** Adds pi's own credential to a provider's sidecar-managed accounts. */
|
|
38
|
+
export async function providerAccounts(
|
|
39
|
+
provider: AccountProvider,
|
|
40
|
+
resolvePrimary?: PrimaryAccountResolver,
|
|
41
|
+
): Promise<ManagedAccount[]> {
|
|
42
|
+
// Resolve serially so providers that discover identity through a profile
|
|
43
|
+
// endpoint do not burst the same endpoint for primary and sidecar accounts.
|
|
44
|
+
const primary = resolvePrimary
|
|
45
|
+
? await Promise.resolve().then(() => resolvePrimary(provider)).catch(() => undefined)
|
|
46
|
+
: undefined;
|
|
47
|
+
const accounts = await provider.list();
|
|
48
|
+
const combined = !primary || accounts.some((account) => account.primary || account.id === primary.id)
|
|
49
|
+
? accounts
|
|
50
|
+
: [primary, ...accounts];
|
|
51
|
+
|
|
52
|
+
const ids = new Set<string>();
|
|
53
|
+
const identities = new Set<string>();
|
|
54
|
+
return combined.filter((account) => {
|
|
55
|
+
if (ids.has(account.id)) return false;
|
|
56
|
+
ids.add(account.id);
|
|
57
|
+
if (!account.identity) return true;
|
|
58
|
+
if (identities.has(account.identity)) return false;
|
|
59
|
+
identities.add(account.identity);
|
|
60
|
+
return true;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
32
64
|
/** Flattens provider accounts while loading providers in parallel. */
|
|
33
|
-
export async function accountRows(
|
|
65
|
+
export async function accountRows(
|
|
66
|
+
providers: AccountProvider[],
|
|
67
|
+
resolvePrimary?: PrimaryAccountResolver,
|
|
68
|
+
): Promise<AccountRow[]> {
|
|
34
69
|
const groups = await Promise.all(providers.map(async (provider) => {
|
|
35
70
|
let accounts: ManagedAccount[];
|
|
36
71
|
try {
|
|
37
|
-
accounts = await provider
|
|
72
|
+
accounts = await providerAccounts(provider, resolvePrimary);
|
|
38
73
|
} catch {
|
|
39
74
|
return [];
|
|
40
75
|
}
|
|
@@ -44,7 +79,8 @@ export async function accountRows(providers: AccountProvider[]): Promise<Account
|
|
|
44
79
|
providerLabel: provider.label,
|
|
45
80
|
label: account.label,
|
|
46
81
|
state: account.enabled ? "enabled" as const : "disabled" as const,
|
|
47
|
-
|
|
82
|
+
primary: account.primary,
|
|
83
|
+
detail: account.primary ? "primary · managed by pi auth" : undefined,
|
|
48
84
|
}));
|
|
49
85
|
}));
|
|
50
86
|
return groups.flat();
|
|
@@ -96,7 +132,7 @@ export async function openAccountsPicker(ctx: any, deps: AccountPickerDeps): Pro
|
|
|
96
132
|
const items: any[] = rows.map((row) => ({
|
|
97
133
|
id: row.id,
|
|
98
134
|
label: labelFor(theme, row),
|
|
99
|
-
values: [...toggleValues],
|
|
135
|
+
values: row.primary ? [colourAccountState(theme, row.state)] : [...toggleValues],
|
|
100
136
|
currentValue: colourAccountState(theme, row.state),
|
|
101
137
|
description: row.detail,
|
|
102
138
|
}));
|
|
@@ -128,6 +164,10 @@ export async function openAccountsPicker(ctx: any, deps: AccountPickerDeps): Pro
|
|
|
128
164
|
const row = rows.find((r) => r.id === id);
|
|
129
165
|
const item = items.find((i) => i.id === id);
|
|
130
166
|
if (!row || !item) return;
|
|
167
|
+
if (row.primary) {
|
|
168
|
+
ctx.ui.notify("Primary accounts are managed by pi auth.", "info");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
131
171
|
try {
|
|
132
172
|
const next = deps.toggle(row.providerId, row.id.slice(row.providerId.length + 1));
|
|
133
173
|
row.state = next;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import { readStoredCredential, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
accountRows,
|
|
4
|
+
openAccountsPicker,
|
|
5
|
+
providerAccounts,
|
|
6
|
+
type AccountState,
|
|
7
|
+
} from "./accounts-picker.ts";
|
|
3
8
|
import {
|
|
4
9
|
accountProvider,
|
|
5
10
|
accountProviders,
|
|
@@ -46,11 +51,29 @@ function bridge(pi: ExtensionAPI, ctx: any): AccountContext {
|
|
|
46
51
|
};
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
async function primaryAccount(provider: AccountProvider): Promise<ManagedAccount | undefined> {
|
|
55
|
+
try {
|
|
56
|
+
const credential = readStoredCredential(provider.id);
|
|
57
|
+
if (!credential) return undefined;
|
|
58
|
+
return {
|
|
59
|
+
id: "main",
|
|
60
|
+
label: "Primary",
|
|
61
|
+
enabled: true,
|
|
62
|
+
expiresAt: credential.type === "oauth" ? credential.expires : undefined,
|
|
63
|
+
primary: true,
|
|
64
|
+
identity: credential.type === "oauth" ? await provider.identify?.(credential.access) : undefined,
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
// A malformed/unreadable auth file must not hide the sidecar accounts.
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
49
72
|
function describe(account: ManagedAccount): string {
|
|
50
73
|
const state = !account.enabled
|
|
51
74
|
? "disabled"
|
|
52
75
|
: account.expiresAt !== undefined && account.expiresAt < Date.now() ? "expired" : "active";
|
|
53
|
-
return `${account.label.padEnd(20)} ${state}`;
|
|
76
|
+
return `${account.label.padEnd(20)} ${state}${account.primary ? " · primary" : ""}`;
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
async function listAll(ctx: any): Promise<void> {
|
|
@@ -63,7 +86,7 @@ async function listAll(ctx: any): Promise<void> {
|
|
|
63
86
|
const blocks = await Promise.all(providers.map(async (provider): Promise<string[]> => {
|
|
64
87
|
try {
|
|
65
88
|
const [accounts, mode] = await Promise.all([
|
|
66
|
-
provider
|
|
89
|
+
providerAccounts(provider, primaryAccount),
|
|
67
90
|
provider.routing?.get(),
|
|
68
91
|
]);
|
|
69
92
|
const routing = mode ? ` · routing: ${mode}` : "";
|
|
@@ -81,7 +104,7 @@ async function listAll(ctx: any): Promise<void> {
|
|
|
81
104
|
}
|
|
82
105
|
|
|
83
106
|
async function pickAccount(ctx: any, provider: AccountProvider): Promise<string | undefined> {
|
|
84
|
-
const accounts = await provider.list();
|
|
107
|
+
const accounts = (await provider.list()).filter((account) => !account.primary);
|
|
85
108
|
if (accounts.length === 0) {
|
|
86
109
|
ctx.ui.notify(`No ${provider.label} accounts yet. Add one with /accounts add ${provider.id}.`, "info");
|
|
87
110
|
return undefined;
|
|
@@ -110,7 +133,7 @@ export function registerAccountCommands(pi: ExtensionAPI): void {
|
|
|
110
133
|
const pendingState = new Map<string, AccountState>();
|
|
111
134
|
|
|
112
135
|
const rows = async () => {
|
|
113
|
-
const list = await accountRows(accountProviders());
|
|
136
|
+
const list = await accountRows(accountProviders(), primaryAccount);
|
|
114
137
|
pendingState.clear();
|
|
115
138
|
for (const row of list) pendingState.set(row.id, row.state);
|
|
116
139
|
return list;
|
|
@@ -158,8 +181,8 @@ export function registerAccountCommands(pi: ExtensionAPI): void {
|
|
|
158
181
|
ctx.ui.notify("Renaming an account requires interactive Pi mode.", "error");
|
|
159
182
|
return;
|
|
160
183
|
}
|
|
161
|
-
const list = await accountRows(accountProviders());
|
|
162
|
-
const renameable = list.filter((row) => accountProvider(row.providerId)?.rename);
|
|
184
|
+
const list = await accountRows(accountProviders(), primaryAccount);
|
|
185
|
+
const renameable = list.filter((row) => !row.primary && accountProvider(row.providerId)?.rename);
|
|
163
186
|
if (renameable.length === 0) {
|
|
164
187
|
ctx.ui.notify("No accounts can be renamed.", "info");
|
|
165
188
|
return;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { registerAccountProvider } from "../../core/accounts/registry.ts";
|
|
3
3
|
import { anthropicAccounts } from "./providers/anthropic.ts";
|
|
4
|
-
import { codexAccounts,
|
|
4
|
+
import { codexAccounts, CODEX_SPEC } from "./providers/codex.ts";
|
|
5
5
|
import { kimiAccounts, KIMI_SPEC, xaiAccounts, XAI_SPEC } from "./providers/hosted.ts";
|
|
6
6
|
import { registerPooledOAuthProvider } from "./providers/oauth-pool.ts";
|
|
7
7
|
import { registerAnthropicProvider } from "./provider.ts";
|
|
@@ -16,7 +16,7 @@ export default function subscriptions(pi: ExtensionAPI) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
registerAnthropicProvider(pi);
|
|
19
|
-
|
|
19
|
+
registerPooledOAuthProvider(pi, CODEX_SPEC);
|
|
20
20
|
registerPooledOAuthProvider(pi, KIMI_SPEC);
|
|
21
21
|
registerPooledOAuthProvider(pi, XAI_SPEC);
|
|
22
22
|
|
|
@@ -3,8 +3,16 @@ import { authorize, exchange, refreshToken } from "../../core/anthropic/oauth.ts
|
|
|
3
3
|
import {
|
|
4
4
|
billingHeader, clientIdentityHeaders, firstUserText, prependPromptBlock, signRequestBody, splitSystemPrompt,
|
|
5
5
|
} from "../../core/anthropic/client-identity.ts";
|
|
6
|
+
import {
|
|
7
|
+
anthropicAccountIdentity,
|
|
8
|
+
cachedAnthropicAccountIdentity,
|
|
9
|
+
} from "../../core/anthropic/identity.ts";
|
|
6
10
|
import { ANTHROPIC_MODELS } from "../../core/anthropic/models.ts";
|
|
7
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
ACCESS_REFRESH_INTERVAL_MS,
|
|
13
|
+
applyQuotaHeaders,
|
|
14
|
+
refreshAllQuota,
|
|
15
|
+
} from "../../core/anthropic/quota.ts";
|
|
8
16
|
import {
|
|
9
17
|
MAIN_ACCOUNT_ID, familyForModel, selectAccount, type Candidate,
|
|
10
18
|
} from "../../core/anthropic/routing.ts";
|
|
@@ -41,6 +49,17 @@ export function routeAccessToken(primary: string, modelId?: string, _sessionId?:
|
|
|
41
49
|
|
|
42
50
|
const mode = getRoutingMode(storage);
|
|
43
51
|
const family = familyForModel(modelId);
|
|
52
|
+
const primaryIdentity = cachedAnthropicAccountIdentity(primary);
|
|
53
|
+
if (!primaryIdentity) void anthropicAccountIdentity(primary).catch(() => {});
|
|
54
|
+
const identities = new Set(primaryIdentity ? [primaryIdentity] : []);
|
|
55
|
+
const sidecars = storage.accounts.filter((account) => {
|
|
56
|
+
if (account.enabled === false || account.type !== "oauth" || !account.access
|
|
57
|
+
|| typeof account.expires !== "number" || account.expires <= Date.now()) return false;
|
|
58
|
+
if (!account.identity) return true;
|
|
59
|
+
if (identities.has(account.identity)) return false;
|
|
60
|
+
identities.add(account.identity);
|
|
61
|
+
return true;
|
|
62
|
+
});
|
|
44
63
|
|
|
45
64
|
const candidates: Candidate[] = [
|
|
46
65
|
{
|
|
@@ -50,9 +69,10 @@ export function routeAccessToken(primary: string, modelId?: string, _sessionId?:
|
|
|
50
69
|
order: 0,
|
|
51
70
|
lastUsed: accountLastUsed.get(MAIN_ACCOUNT_ID) ?? Number(storage.main?.lastUsed ?? 0),
|
|
52
71
|
},
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
72
|
+
// Never route an expired/unknown-lifetime sidecar while its asynchronous
|
|
73
|
+
// refresher catches up; falling back to pi's primary is safer than a 401.
|
|
74
|
+
// Stable identities also keep duplicate logins from inflating the pool.
|
|
75
|
+
...sidecars.map((a, index) => ({
|
|
56
76
|
id: a.id,
|
|
57
77
|
access: a.access,
|
|
58
78
|
quota: a.quota,
|
|
@@ -150,12 +170,33 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
150
170
|
}
|
|
151
171
|
});
|
|
152
172
|
|
|
153
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* Keep rotating sidecar credentials even while their quota snapshot is fresh.
|
|
175
|
+
* CortexKit refreshes fallbacks on an independent background cadence for the
|
|
176
|
+
* same reason: quota freshness is not credential freshness.
|
|
177
|
+
*/
|
|
178
|
+
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
179
|
+
const startRefreshLoop = () => {
|
|
180
|
+
if (refreshTimer) return;
|
|
181
|
+
refreshTimer = setInterval(
|
|
182
|
+
() => void refreshAllQuota().catch(() => {}),
|
|
183
|
+
ACCESS_REFRESH_INTERVAL_MS + Math.floor(Math.random() * 30_000),
|
|
184
|
+
);
|
|
185
|
+
refreshTimer.unref?.();
|
|
186
|
+
};
|
|
187
|
+
|
|
154
188
|
pi.on("input", async () => {
|
|
189
|
+
startRefreshLoop();
|
|
155
190
|
void refreshAllQuota().catch(() => {});
|
|
156
191
|
});
|
|
157
192
|
|
|
158
193
|
pi.on("session_start", async () => {
|
|
194
|
+
startRefreshLoop();
|
|
159
195
|
void refreshAllQuota().catch(() => {});
|
|
160
196
|
});
|
|
197
|
+
|
|
198
|
+
pi.on("session_shutdown", async () => {
|
|
199
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
200
|
+
refreshTimer = undefined;
|
|
201
|
+
});
|
|
161
202
|
}
|