@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.
Files changed (42) hide show
  1. package/README.md +7 -3
  2. package/package.json +7 -6
  3. package/src/core/accounts/oauth-pool.ts +35 -0
  4. package/src/core/accounts/registry.ts +4 -0
  5. package/src/core/accounts/routing.ts +22 -0
  6. package/src/core/anthropic/identity.ts +54 -0
  7. package/src/core/anthropic/oauth.ts +3 -0
  8. package/src/core/anthropic/quota.ts +227 -28
  9. package/src/core/anthropic/store.ts +4 -1
  10. package/src/core/codex/quota.ts +43 -11
  11. package/src/core/codex/store.ts +21 -11
  12. package/src/core/config.ts +8 -10
  13. package/src/domains/agents/format.ts +39 -0
  14. package/src/domains/agents/index.ts +39 -50
  15. package/src/domains/remote/config-path.ts +0 -2
  16. package/src/domains/remote/index.ts +4 -41
  17. package/src/domains/subscriptions/accounts-picker.ts +44 -4
  18. package/src/domains/subscriptions/accounts.ts +31 -8
  19. package/src/domains/subscriptions/index.ts +2 -2
  20. package/src/domains/subscriptions/provider.ts +46 -5
  21. package/src/domains/subscriptions/providers/anthropic.ts +37 -5
  22. package/src/domains/subscriptions/providers/codex.ts +120 -254
  23. package/src/domains/subscriptions/providers/hosted.ts +2 -2
  24. package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
  25. package/src/domains/workflows/index.ts +1 -1
  26. package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
  27. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  28. package/src/domains/workflows/runtime/agent-limits.ts +24 -1
  29. package/src/domains/workflows/runtime/agent-retry.ts +11 -6
  30. package/src/domains/workflows/runtime/agent-runner.ts +42 -20
  31. package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
  32. package/src/domains/workflows/runtime/engine.ts +48 -12
  33. package/src/domains/workflows/runtime/progress.ts +2 -2
  34. package/src/domains/workflows/runtime/types.ts +4 -4
  35. package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
  36. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
  37. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
  38. package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
  39. package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
  40. package/src/domains/workflows/runtime/worktree.ts +35 -1
  41. package/src/ui/usage-bars.ts +1 -2
  42. package/src/core/codex/oauth.ts +0 -129
@@ -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: { injectStatus: true, workers: [] },
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 = { injectStatus: raw.injectStatus !== false, defaults: raw.defaults, workers: raw.workers };
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 = { injectStatus: raw.injectStatus !== false, defaults: raw.defaults, workers: raw.workers };
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 config = normalize(exists ? readJson<Partial<PiPlusConfig>>(path, {}) : undefined);
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 raw = readJson<any>(legacyPath, undefined);
136
- if (raw === undefined) continue;
137
- if (migration.apply(raw, config)) migrated = true;
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 snapshot: { at: number; agents: AgentInfo[] } = { at: 0, agents: [] };
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 formatCoordination = (): string[] => {
319
- const { coordinator, reports, repoThread } = client.coordination;
320
- const lines: string[] = [];
321
- if (coordinator) lines.push(`You report to coordinator '${coordinator}'. Use agent_board action 'report' to send them progress, blockers, and completed work.`);
322
- if (reports?.length) lines.push(`You are the coordinator for: ${reports.join(", ")}. Their reports arrive as agent-board messages; keep their work aligned.`);
323
- if (repoThread) lines.push(`Shared repo room: thread '${repoThread}' automatically contains every agent working in this repository. Post repo-wide notices there with agent_board send.`);
324
- return lines;
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 formatSnapshot = (agents: AgentInfo[]): string => {
327
- const coordinationLines = formatCoordination();
328
- if (!agents.length) return ["[Live agent board]\nNo other Pi agents are currently running.", ...coordinationLines].join("\n");
329
- const lines = agents.slice(0, 20).map((agent) => {
330
- const commit = agent.commit ? agent.commit.slice(0, 12) : "no-commit";
331
- const sameLine = agent.repo && gitInfo.repo === agent.repo && agent.branch === gitInfo.branch;
332
- const divergent = sameLine && agent.commit && gitInfo.commit && agent.commit !== gitInfo.commit ? " ⚠ different commit" : "";
333
- const activity = agent.state === "tool" && agent.lastTool ? agent.lastTool : agent.state;
334
- const role = agent.reports?.length ? " [coordinator]" : agent.coordinator ? ` [reports to ${agent.coordinator}]` : "";
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" || event.t === "coordination") { updateStatus(); return; }
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
- const message = event.message as BoardMessage;
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", async (event) => {
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) => { client.stop(); eventCtx.ui.setStatus("agent-board", undefined); ctx = undefined; self = undefined; });
379
-
380
- // A transient, late context message avoids growing the session and keeps the static prompt cache stable.
381
- pi.on("context", async (event) => {
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 transient Live agent board context before starting work; avoid duplicating work and compare Git commit hashes before relying on another agent's changes.",
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 { injectStatus: parsed.injectStatus !== false, workers };
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, force = false): Promise<WorkerStatus[]> {
327
+ async function probeWorkers(config: Config): Promise<WorkerStatus[]> {
335
328
  const active = config.workers.filter((worker) => worker.enabled);
336
- if (active.length === 0) return [];
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
- let statuses = await probeWorkers(config, true);
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(providers: AccountProvider[]): Promise<AccountRow[]> {
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.list();
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
- detail: account.primary ? "primary" : undefined,
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 { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { accountRows, openAccountsPicker, type AccountState } from "./accounts-picker.ts";
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.list(),
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, registerCodexProvider } from "./providers/codex.ts";
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
- registerCodexProvider(pi);
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 { applyQuotaHeaders, refreshAllQuota } from "../../core/anthropic/quota.ts";
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
- ...storage.accounts
54
- .filter((a) => a.enabled !== false && a.type === "oauth" && a.access)
55
- .map((a, index) => ({
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
- /** Refreshes stale idle-account quota when a message starts. */
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
  }