@maxiaochao/pi-toolkit 0.11.6 → 0.11.8

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 (52) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +2 -1
  3. package/package.json +5 -3
  4. package/packages/herdr-subagents/ARCHITECTURE.md +82 -378
  5. package/packages/herdr-subagents/CHANGELOG.md +4 -0
  6. package/packages/herdr-subagents/README.md +52 -321
  7. package/packages/herdr-subagents/TESTING.md +36 -53
  8. package/packages/herdr-subagents/extensions/herdr-subagents.ts +24 -158
  9. package/packages/herdr-subagents/extensions/subagent-report.ts +21 -7
  10. package/packages/herdr-subagents/package.json +4 -3
  11. package/packages/herdr-subagents/src/batch-runner.ts +5 -3
  12. package/packages/herdr-subagents/src/herdr.ts +11 -0
  13. package/packages/herdr-subagents/src/live-agent.ts +49 -114
  14. package/packages/herdr-subagents/src/parameters.ts +2 -6
  15. package/packages/herdr-subagents/src/protocol.ts +4 -5
  16. package/packages/herdr-subagents/src/records.ts +2 -0
  17. package/packages/herdr-subagents/src/tool.ts +9 -51
  18. package/packages/herdr-subagents/src/validation.ts +0 -4
  19. package/packages/herdr-subagents/tests/core-state-machine.mjs +178 -0
  20. package/packages/herdr-subagents/tests/extension-sources-load.mjs +7 -3
  21. package/packages/openai-account-pool/CHANGELOG.md +11 -0
  22. package/packages/openai-account-pool/README.md +67 -0
  23. package/packages/openai-account-pool/TESTING.md +28 -0
  24. package/packages/openai-account-pool/docs/demo.gif +0 -0
  25. package/packages/openai-account-pool/docs/screenshot.png +0 -0
  26. package/packages/openai-account-pool/extensions/openai-account-pool.ts +368 -0
  27. package/packages/openai-account-pool/package.json +45 -0
  28. package/packages/openai-account-pool/src/auth.ts +418 -0
  29. package/packages/openai-account-pool/src/availability.ts +230 -0
  30. package/packages/openai-account-pool/src/command-completions.ts +18 -0
  31. package/packages/openai-account-pool/src/enrollment.ts +339 -0
  32. package/packages/openai-account-pool/src/lease.ts +128 -0
  33. package/packages/openai-account-pool/src/lock.ts +171 -0
  34. package/packages/openai-account-pool/src/login-slot.ts +158 -0
  35. package/packages/openai-account-pool/src/policy.ts +176 -0
  36. package/packages/openai-account-pool/src/provider.ts +511 -0
  37. package/packages/openai-account-pool/src/status-widget.ts +62 -0
  38. package/packages/openai-account-pool/src/store.ts +259 -0
  39. package/packages/openai-account-pool/src/usage.ts +291 -0
  40. package/packages/openai-account-pool/tests/command-completions.mjs +10 -0
  41. package/packages/openai-account-pool/tests/enrollment.mjs +322 -0
  42. package/packages/openai-account-pool/tests/failover-stream.mjs +554 -0
  43. package/packages/openai-account-pool/tests/helpers/fixtures.mjs +40 -0
  44. package/packages/openai-account-pool/tests/helpers/native-credential-store.mjs +58 -0
  45. package/packages/openai-account-pool/tests/helpers/pi-runtime.mjs +24 -0
  46. package/packages/openai-account-pool/tests/helpers/refresh-child.mjs +29 -0
  47. package/packages/openai-account-pool/tests/integration-login-ownership.mjs +299 -0
  48. package/packages/openai-account-pool/tests/login-slot.mjs +131 -0
  49. package/packages/openai-account-pool/tests/multiprocess.mjs +73 -0
  50. package/packages/openai-account-pool/tests/selection.mjs +286 -0
  51. package/packages/openai-account-pool/tests/store-ownership.mjs +369 -0
  52. package/packages/openai-account-pool/tests/usage.mjs +428 -0
@@ -0,0 +1,158 @@
1
+ import type { AuthEvent, AuthInfoLink, AuthPrompt, OAuthDeviceCodeInfo, ProviderAuthInteraction } from "@earendil-works/pi-ai";
2
+
3
+ /**
4
+ * One OAuth login inside pi's single editor slot.
5
+ *
6
+ * pi hands extensions one slot below the chat: `ui.select` puts the editor back when it closes,
7
+ * so a selector opened while the login dialog is mounted takes the dialog down with it, and every
8
+ * later auth step renders into a component nobody can see. The Codex flow hits exactly that, as it
9
+ * asks for the login method first and shows the sign-in URL and the manual-code input afterwards.
10
+ *
11
+ * The slot therefore mounts the dialog lazily and steps out of it around every selector, and keeps
12
+ * auth events in the order the provider emitted them.
13
+ */
14
+
15
+ /** The part of pi's login dialog this module drives. */
16
+ export interface LoginDialog {
17
+ /** Aborts when the user cancels inside the dialog. */
18
+ readonly signal: AbortSignal;
19
+ showAuth(url: string, instructions?: string): void;
20
+ showDeviceCode(info: OAuthDeviceCodeInfo): void;
21
+ showInfo(message: string, links?: readonly AuthInfoLink[]): void;
22
+ showProgress(message: string): void;
23
+ showWaiting(message: string): void;
24
+ showManualInput(message: string): Promise<string>;
25
+ showPrompt(message: string, placeholder?: string): Promise<string>;
26
+ }
27
+
28
+ export interface LoginSlotUi {
29
+ /**
30
+ * Mounts a login dialog in the slot and reports it together with the callback that takes it
31
+ * down again. Resolves once the slot has been handed back.
32
+ */
33
+ mount(register: (dialog: LoginDialog, close: () => void) => void): Promise<unknown>;
34
+ /** Shows a selector in the same slot. Resolves to undefined when the user cancels. */
35
+ select(message: string, options: readonly string[], signal?: AbortSignal): Promise<string | undefined>;
36
+ }
37
+
38
+ const CANCELLED = "Login cancelled";
39
+
40
+ /** A prompt can lose its race, e.g. a manual code while the callback server wins. */
41
+ function withCancellation<T>(answer: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
42
+ if (!signal) return answer;
43
+ if (signal.aborted) return Promise.reject(new Error(CANCELLED));
44
+ let onAbort: (() => void) | undefined;
45
+ const aborted = new Promise<never>((_resolve, reject) => {
46
+ onAbort = () => reject(new Error(CANCELLED));
47
+ signal.addEventListener("abort", onAbort, { once: true });
48
+ });
49
+ return Promise.race([answer, aborted]).finally(() => {
50
+ if (onAbort) signal.removeEventListener("abort", onAbort);
51
+ });
52
+ }
53
+
54
+ export class LoginSlot {
55
+ private readonly aborter = new AbortController();
56
+ private dialog: LoginDialog | undefined;
57
+ private opening: Promise<LoginDialog> | undefined;
58
+ private hide: (() => void) | undefined;
59
+ private dismissed: Promise<unknown> = Promise.resolve();
60
+ /** Serializes everything that needs the slot to itself. Never rejects. */
61
+ private queue: Promise<void> = Promise.resolve();
62
+ private readonly ui: LoginSlotUi;
63
+
64
+ constructor(ui: LoginSlotUi) {
65
+ this.ui = ui;
66
+ }
67
+
68
+ /** What `oauth.login()` is called with. */
69
+ get interaction(): ProviderAuthInteraction {
70
+ return {
71
+ signal: this.aborter.signal,
72
+ prompt: (request) => this.prompt(request),
73
+ notify: (event) => this.notify(event),
74
+ };
75
+ }
76
+
77
+ /** Gives the slot back once the login is over. */
78
+ async close(): Promise<void> {
79
+ await this.queue;
80
+ await this.unmount();
81
+ }
82
+
83
+ private enqueue<T>(task: () => Promise<T>): Promise<T> {
84
+ const running = this.queue.then(task);
85
+ this.queue = running.then(
86
+ () => undefined,
87
+ () => undefined,
88
+ );
89
+ return running;
90
+ }
91
+
92
+ private open(): Promise<LoginDialog> {
93
+ if (this.opening) return this.opening;
94
+ let ready: (dialog: LoginDialog) => void = () => {};
95
+ let failed: (error: unknown) => void = () => {};
96
+ const shown = new Promise<LoginDialog>((resolve, reject) => {
97
+ ready = resolve;
98
+ failed = reject;
99
+ });
100
+ this.dismissed = this.ui
101
+ .mount((dialog, close) => {
102
+ // Cancelling inside the dialog cancels the login, the same key `/login` honours.
103
+ dialog.signal.addEventListener("abort", () => this.aborter.abort(), { once: true });
104
+ this.dialog = dialog;
105
+ this.hide = close;
106
+ ready(dialog);
107
+ })
108
+ .catch((error: unknown) => {
109
+ failed(error);
110
+ });
111
+ this.opening = shown;
112
+ return shown;
113
+ }
114
+
115
+ private async unmount(): Promise<void> {
116
+ const hide = this.hide;
117
+ const dismissed = this.dismissed;
118
+ this.dialog = undefined;
119
+ this.opening = undefined;
120
+ this.hide = undefined;
121
+ this.dismissed = Promise.resolve();
122
+ hide?.();
123
+ await dismissed;
124
+ }
125
+
126
+ private async prompt(request: AuthPrompt): Promise<string> {
127
+ if (request.type === "select") return withCancellation(this.select(request), request.signal);
128
+ // Wait for the events that arrived before this prompt, so the dialog reads in order.
129
+ await this.queue;
130
+ const dialog = await this.open();
131
+ const answer =
132
+ request.type === "manual_code" ? dialog.showManualInput(request.message) : dialog.showPrompt(request.message, request.placeholder);
133
+ return withCancellation(answer, request.signal);
134
+ }
135
+
136
+ private select(request: AuthPrompt & { type: "select" }): Promise<string> {
137
+ return this.enqueue(async () => {
138
+ await this.unmount();
139
+ const labels = request.options.map((option) => option.label);
140
+ const chosen = await this.ui.select(request.message, labels, request.signal);
141
+ const index = chosen === undefined ? -1 : labels.indexOf(chosen);
142
+ if (index < 0) throw new Error(CANCELLED);
143
+ return request.options[index].id;
144
+ });
145
+ }
146
+
147
+ private notify(event: AuthEvent): void {
148
+ void this.enqueue(async () => {
149
+ const dialog = await this.open();
150
+ if (event.type === "auth_url") dialog.showAuth(event.url, event.instructions);
151
+ else if (event.type === "device_code") {
152
+ dialog.showDeviceCode(event);
153
+ dialog.showWaiting("等待浏览器完成授权…");
154
+ } else if (event.type === "info") dialog.showInfo(event.message, event.links);
155
+ else dialog.showProgress(event.message);
156
+ }).catch(() => undefined);
157
+ }
158
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Pure decisions: what a failure proves, which account to use next, and whether this request may
3
+ * still switch accounts. Nothing here reads files or the network, so every rule is testable.
4
+ *
5
+ * Classification reads structured evidence only. pi's Codex adapter formats a plain HTTP 429 with
6
+ * the same "You have hit your ChatGPT usage limit" text as a real subscription limit, so matching
7
+ * the user-facing message would switch accounts on ordinary rate limiting.
8
+ */
9
+
10
+ export type FailureKind =
11
+ /** The subscription's own limit is reached: the only automatic quota switch. */
12
+ | "quota"
13
+ /** The stored authorization is proven invalid; the account needs re-authorizing. */
14
+ | "auth_invalid"
15
+ /** Authentication was rejected once; the same account may be repaired first. */
16
+ | "auth_retry"
17
+ /** Rate limiting, network trouble or a server-side blip: retry the same account. */
18
+ | "transient"
19
+ | "cancelled"
20
+ /** A definite error that another account would not fix. */
21
+ | "fatal"
22
+ | "unknown";
23
+
24
+ export interface ProviderErrorBody {
25
+ code?: unknown;
26
+ type?: unknown;
27
+ message?: unknown;
28
+ plan_type?: unknown;
29
+ resets_at?: unknown;
30
+ resets_in_seconds?: unknown;
31
+ }
32
+
33
+ export interface FailureEvidence {
34
+ status?: number;
35
+ body?: ProviderErrorBody;
36
+ cancelled?: boolean;
37
+ /** Set when the failure came from the OAuth token exchange rather than the model request. */
38
+ oauthRefresh?: { invalidGrant: boolean };
39
+ /** Transport-level failure such as DNS, reset connection or timeout. */
40
+ networkFailure?: boolean;
41
+ /** Model the request was for; quota without plan-level evidence is scoped to it. */
42
+ modelId?: string;
43
+ }
44
+
45
+ export interface Classification {
46
+ kind: FailureKind;
47
+ /** Short, non-secret reason for status output and state records. */
48
+ reason: string;
49
+ /** Cooldown scope: the whole account, or `model:<id>` when the evidence is not plan-level. */
50
+ scope: string;
51
+ /** Server-stated reset time in epoch ms, or null when the server stated none. */
52
+ serverResetAtMs: number | null;
53
+ }
54
+
55
+ export const ACCOUNT_SCOPE = "account";
56
+ const MAX_RESET_HORIZON_MS = 30 * 24 * 60 * 60 * 1_000;
57
+
58
+ function errorCode(body: ProviderErrorBody | undefined): string {
59
+ const code = typeof body?.code === "string" ? body.code : "";
60
+ if (code) return code.toLowerCase();
61
+ return typeof body?.type === "string" ? body.type.toLowerCase() : "";
62
+ }
63
+
64
+ /** Only a field that is finite and lands in a plausible window counts as a reset time. */
65
+ export function serverResetAtMs(body: ProviderErrorBody | undefined, now: number): number | null {
66
+ const inWindow = (value: number) => value > now - 24 * 60 * 60 * 1_000 && value < now + MAX_RESET_HORIZON_MS;
67
+ if (typeof body?.resets_at === "number" && Number.isFinite(body.resets_at)) {
68
+ // The Codex error reports epoch seconds.
69
+ const candidate = body.resets_at * 1_000;
70
+ if (inWindow(candidate)) return candidate;
71
+ }
72
+ if (typeof body?.resets_in_seconds === "number" && Number.isFinite(body.resets_in_seconds) && body.resets_in_seconds >= 0) {
73
+ const candidate = now + body.resets_in_seconds * 1_000;
74
+ if (inWindow(candidate)) return candidate;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ export function classifyFailure(evidence: FailureEvidence, now: number): Classification {
80
+ const scopeForModel = evidence.modelId ? `model:${evidence.modelId}` : ACCOUNT_SCOPE;
81
+ const plain = (kind: FailureKind, reason: string): Classification => ({ kind, reason, scope: scopeForModel, serverResetAtMs: null });
82
+
83
+ if (evidence.cancelled) return plain("cancelled", "cancelled");
84
+
85
+ if (evidence.oauthRefresh) {
86
+ // A network problem during the token exchange says nothing about the authorization.
87
+ return evidence.oauthRefresh.invalidGrant ? plain("auth_invalid", "invalid_grant") : plain("transient", "oauth_refresh_unavailable");
88
+ }
89
+ if (evidence.networkFailure) return plain("transient", "network_failure");
90
+
91
+ const code = errorCode(evidence.body);
92
+ const status = evidence.status;
93
+
94
+ if (code === "usage_limit_reached") {
95
+ // A plan-level limit is shared by every model; without that evidence stay model-scoped.
96
+ const planLevel = typeof evidence.body?.plan_type === "string" && evidence.body.plan_type.length > 0;
97
+ return {
98
+ kind: "quota",
99
+ reason: "usage_limit_reached",
100
+ scope: planLevel ? ACCOUNT_SCOPE : scopeForModel,
101
+ serverResetAtMs: serverResetAtMs(evidence.body, now),
102
+ };
103
+ }
104
+ if (code === "invalid_grant" || code === "invalid_token" || code === "token_revoked") return plain("auth_invalid", code);
105
+ if (code === "rate_limit_exceeded") return plain("transient", "rate_limit_exceeded");
106
+ // usage_not_included means the plan does not cover this model at all; another account is a guess.
107
+ if (code === "usage_not_included") return plain("fatal", "usage_not_included");
108
+ if (code === "context_length_exceeded") return plain("fatal", "context_length_exceeded");
109
+
110
+ if (status === 401) return plain("auth_retry", "unauthorized");
111
+ if (status === 429) return plain("transient", "http_429");
112
+ if (status !== undefined && status >= 500) return plain("transient", `http_${status}`);
113
+ if (status === 403) return plain("fatal", "http_403");
114
+ if (status !== undefined && status >= 400) return plain("fatal", `http_${status}`);
115
+
116
+ return plain("unknown", "unknown_error");
117
+ }
118
+
119
+ export interface AccountAvailability {
120
+ id: string;
121
+ ordinal: number;
122
+ /** Why the account cannot be used now; undefined means usable. */
123
+ unavailable?: { reason: string; untilMs: number | null; serverResetAtMs: number | null };
124
+ /** The account is usable but should be probed under a lease, after a cooldown ended. */
125
+ needsProbe: boolean;
126
+ authInvalid: boolean;
127
+ }
128
+
129
+ export interface SelectionInput {
130
+ accounts: AccountAvailability[];
131
+ /** Sticky preference of this process: keep using the account that currently works. */
132
+ preferredId?: string;
133
+ /** Accounts this logical request already tried, so no account is tried twice. */
134
+ attemptedIds: readonly string[];
135
+ }
136
+
137
+ /**
138
+ * Sticky selection: stay on the preferred account while it works, otherwise take the next usable
139
+ * account in the order they were added. Recovered accounts become candidates again.
140
+ */
141
+ export function selectAccount(input: SelectionInput): AccountAvailability | undefined {
142
+ const attempted = new Set(input.attemptedIds);
143
+ const usable = input.accounts.filter((account) => !attempted.has(account.id) && !account.unavailable && !account.authInvalid);
144
+ const preferred = usable.find((account) => account.id === input.preferredId);
145
+ if (preferred) return preferred;
146
+ return [...usable].sort((left, right) => left.ordinal - right.ordinal)[0];
147
+ }
148
+
149
+ export type FailoverAction =
150
+ /** Try the next account for this same request. */
151
+ | "failover"
152
+ /** Re-read and refresh the current account once, then retry it. */
153
+ | "repair"
154
+ /** Finish the request with the provider's error. */
155
+ | "stop";
156
+
157
+ export interface FailoverInput {
158
+ classification: Classification;
159
+ /** Text, thinking or a tool call already reached the caller, so nothing may be replayed. */
160
+ emittedOutput: boolean;
161
+ /** Another untried, usable account exists for this request. */
162
+ hasRemainingCandidate: boolean;
163
+ /** The current account already used its one authentication repair. */
164
+ repairUsed: boolean;
165
+ }
166
+
167
+ export function decideFailover(input: FailoverInput): { action: FailoverAction; reason: string } {
168
+ const { kind } = input.classification;
169
+ if (kind === "cancelled") return { action: "stop", reason: "cancelled" };
170
+ if (kind === "transient" || kind === "fatal" || kind === "unknown") return { action: "stop", reason: kind };
171
+ if (kind === "auth_retry" && !input.repairUsed) return { action: "repair", reason: "authentication_repair" };
172
+ // Everything below is quota or a proven authorization problem.
173
+ if (input.emittedOutput) return { action: "stop", reason: "output_already_delivered" };
174
+ if (!input.hasRemainingCandidate) return { action: "stop", reason: "pool_exhausted" };
175
+ return { action: "failover", reason: input.classification.reason };
176
+ }