@selesai/code 0.13.1 → 0.13.3

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.
@@ -12,7 +12,7 @@ Rewind metadata lives in the session itself as hidden entries, so rewind history
12
12
 
13
13
  ## Requirements
14
14
 
15
- - Pi agent v0.65.0+
15
+ - Pi agent v0.74.0+
16
16
  - Node.js (for installation)
17
17
  - Git repository
18
18
 
@@ -181,6 +181,8 @@ Rewind restores the exact file state for the snapshot domain it owns:
181
181
 
182
182
  Before restoring target contents, it deletes paths present in the current snapshot but absent from the target snapshot, then restores the target snapshot into the worktree only.
183
183
 
184
+ Submodules are supported only when their paths stay unchanged, their worktrees are clean and initialized, and each target commit is already available locally. Rewind checks out those target commits without fetching. Exact restore is refused with a clear error for dirty or unavailable submodule state, added or removed submodule paths, and nested submodules; uncommitted files inside submodules are not part of snapshots.
185
+
184
186
  Out of scope:
185
187
 
186
188
  - ignored files
@@ -240,6 +242,7 @@ grep '"customType":"rewind-' ~/.pi/agent/sessions/**/*.jsonl
240
242
  ## Limitations
241
243
 
242
244
  - Only works in git repositories
245
+ - Submodule snapshots contain gitlink commits, not uncommitted nested files; unsupported submodule states are refused instead of reported as exact restores
243
246
  - Session metadata grows append-only; retention only trims git reachability
244
247
  - Discovery for retention is best-effort across discovered Pi session roots and explicit `parentSession` ancestors
245
248
  - Ignored files and empty directories are outside the snapshot model
@@ -17,6 +17,8 @@ const RETENTION_SWEEP_THRESHOLD = 50;
17
17
  const RETENTION_VERSION = 2;
18
18
  const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
19
19
 
20
+ class UnsupportedSubmoduleStateError extends Error {}
21
+
20
22
  type ExecFn = (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number }>;
21
23
 
22
24
  type GitExecResult = Awaited<ReturnType<ExecFn>>;
@@ -70,6 +72,11 @@ interface ExactState {
70
72
  treeSha: string;
71
73
  }
72
74
 
75
+ interface GitlinkEntry {
76
+ path: string;
77
+ commitSha: string;
78
+ }
79
+
73
80
  interface ActiveBranchState {
74
81
  currentCommitSha?: string;
75
82
  currentTreeSha?: string;
@@ -387,7 +394,7 @@ export default function rewindExtension(pi: ExtensionAPI) {
387
394
  let pendingTreeState: PendingResultingState | null = null;
388
395
  let activePromptText: string | null = null;
389
396
  let newSnapshotsSinceSweep = 0;
390
- let sweepRunning = false;
397
+ let retentionSweep: Promise<void> | undefined;
391
398
  let sweepCompletedThisSession = false;
392
399
  let forceConversationOnlyOnNextFork = false;
393
400
  let forceConversationOnlySource: string | null = null;
@@ -461,6 +468,87 @@ export default function rewindExtension(pi: ExtensionAPI) {
461
468
  return repoRoot;
462
469
  }
463
470
 
471
+ async function getGitlinkEntries(treeSha: string, gitPrefix: string[] = []): Promise<GitlinkEntry[]> {
472
+ const result = await execGitChecked([...gitPrefix, "ls-tree", "-r", "-z", treeSha, "--"]);
473
+ return parseGitlinkEntries(result.stdout);
474
+ }
475
+
476
+ function parseGitlinkEntries(output: string): GitlinkEntry[] {
477
+ return output.split("\0").flatMap((record) => {
478
+ const separator = record.indexOf("\t");
479
+ if (separator < 0) return [];
480
+
481
+ const [mode, type, commitSha] = record.slice(0, separator).split(/\s+/);
482
+ if (mode !== "160000" || type !== "commit" || !commitSha) return [];
483
+ return [{ path: record.slice(separator + 1), commitSha }];
484
+ });
485
+ }
486
+
487
+ async function getIndexedGitlinkEntries(): Promise<GitlinkEntry[]> {
488
+ const result = await execGitChecked(["ls-files", "--stage", "-z"]);
489
+ return parseGitlinkEntries(result.stdout);
490
+ }
491
+
492
+ async function assertSubmoduleWorktreesClean(root: string, entries: GitlinkEntry[], action: string) {
493
+ for (const entry of entries) {
494
+ const submodulePath = resolve(root, entry.path);
495
+ if (!isInsidePath(submodulePath, root)) {
496
+ throw new UnsupportedSubmoduleStateError(`refusing ${action} for submodule path outside repo root: ${entry.path}`);
497
+ }
498
+
499
+ const status = await pi.exec("git", ["-C", submodulePath, "status", "--porcelain", "--untracked-files=all"]);
500
+ if (status.code !== 0) {
501
+ throw new UnsupportedSubmoduleStateError(`cannot ${action} with uninitialized submodule: ${entry.path}`);
502
+ }
503
+ if (status.stdout.trim()) {
504
+ throw new UnsupportedSubmoduleStateError(`cannot ${action} with dirty submodule: ${entry.path}`);
505
+ }
506
+ }
507
+ }
508
+
509
+ async function prepareSubmoduleRestore(root: string, currentEntries: GitlinkEntry[], targetEntries: GitlinkEntry[]) {
510
+ const currentPaths = new Set(currentEntries.map((entry) => entry.path));
511
+ const targetPaths = new Set(targetEntries.map((entry) => entry.path));
512
+ const changedPaths = [...new Set([...currentPaths, ...targetPaths])]
513
+ .filter((entryPath) => !currentPaths.has(entryPath) || !targetPaths.has(entryPath));
514
+ if (changedPaths.length > 0) {
515
+ throw new UnsupportedSubmoduleStateError(
516
+ `exact rewind does not support adding or removing submodules: ${changedPaths.join(", ")}`,
517
+ );
518
+ }
519
+
520
+ await assertSubmoduleWorktreesClean(root, currentEntries, "restore exact files");
521
+
522
+ const commitsToInspect = new Map<string, GitlinkEntry>();
523
+ for (const entry of [...currentEntries, ...targetEntries]) {
524
+ commitsToInspect.set(`${entry.path}\0${entry.commitSha}`, entry);
525
+ }
526
+
527
+ for (const entry of commitsToInspect.values()) {
528
+ const entryPath = entry.path;
529
+ const commitSha = entry.commitSha;
530
+ const submodulePath = resolve(root, entryPath);
531
+ const commit = await pi.exec("git", ["-C", submodulePath, "cat-file", "-e", `${commitSha}^{commit}`]);
532
+ if (commit.code !== 0) {
533
+ throw new UnsupportedSubmoduleStateError(
534
+ `cannot inspect submodule ${entryPath}: commit ${commitSha.slice(0, 12)} is unavailable locally`,
535
+ );
536
+ }
537
+
538
+ const targetTreeSha = (await execGitChecked(["-C", submodulePath, "show", "-s", "--format=%T", commitSha])).stdout.trim();
539
+ const nestedEntries = await getGitlinkEntries(targetTreeSha, ["-C", submodulePath]);
540
+ if (nestedEntries.length > 0) {
541
+ throw new UnsupportedSubmoduleStateError(`cannot restore nested submodules: ${entryPath}`);
542
+ }
543
+ }
544
+ }
545
+
546
+ async function restoreSubmodules(root: string, entries: GitlinkEntry[]) {
547
+ for (const entry of entries) {
548
+ await execGitChecked(["-C", resolve(root, entry.path), "checkout", "--detach", entry.commitSha]);
549
+ }
550
+ }
551
+
464
552
  async function captureWorktreeTree(): Promise<{ treeSha: string }> {
465
553
  const root = await getRepoRoot(pi.exec);
466
554
  const tempDir = await mkdtemp(join(tmpdir(), "pi-rewind-"));
@@ -470,7 +558,12 @@ export default function rewindExtension(pi: ExtensionAPI) {
470
558
  const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
471
559
  await execAsync("git add -A", { cwd: root, env });
472
560
  const { stdout } = await execAsync("git write-tree", { cwd: root, env });
473
- return { treeSha: stdout.trim() };
561
+ const treeSha = stdout.trim();
562
+ const entriesByPath = new Map<string, GitlinkEntry>();
563
+ for (const entry of await getIndexedGitlinkEntries()) entriesByPath.set(entry.path, entry);
564
+ for (const entry of await getGitlinkEntries(treeSha)) entriesByPath.set(entry.path, entry);
565
+ await assertSubmoduleWorktreesClean(root, [...entriesByPath.values()], "capture exact files");
566
+ return { treeSha };
474
567
  } finally {
475
568
  await rm(tempDir, { recursive: true, force: true }).catch(() => {
476
569
  // Best effort cleanup for temporary index directory.
@@ -542,19 +635,38 @@ export default function rewindExtension(pi: ExtensionAPI) {
542
635
  return "preserved-empty";
543
636
  }
544
637
 
545
- let head: string | undefined;
546
- for (const commitSha of uniqueLiveCommits) {
547
- head = await createStoreKeepaliveCommit(commitSha, head);
548
- }
638
+ let attempts = 0;
639
+ let lastError: unknown;
640
+ let preserveConcurrentHead = false;
641
+
642
+ while (attempts < 5) {
643
+ attempts += 1;
644
+ const oldHead = await getStoreHead();
645
+ // A failed compare-and-swap means another session added snapshots while
646
+ // this sweep was running. Preserve that new reachability and let a later
647
+ // uncontended sweep prune anything no longer retained.
648
+ let head = preserveConcurrentHead ? oldHead : undefined;
649
+ for (const commitSha of uniqueLiveCommits) {
650
+ head = await createStoreKeepaliveCommit(commitSha, head);
651
+ }
549
652
 
550
- const oldHead = await getStoreHead();
551
- if (oldHead) {
552
- await execGitChecked(["update-ref", STORE_REF, head!, oldHead]);
553
- return "rewritten";
653
+ try {
654
+ if (oldHead) {
655
+ await execGitChecked(["update-ref", STORE_REF, head!, oldHead]);
656
+ } else {
657
+ await execGitChecked(["update-ref", STORE_REF, head!, LEGACY_ZERO_SHA]);
658
+ }
659
+ return "rewritten";
660
+ } catch (error) {
661
+ // Retry if another process updated the store ref concurrently.
662
+ // Keep the most recent error for actionable failure context.
663
+ lastError = error;
664
+ preserveConcurrentHead = true;
665
+ }
554
666
  }
555
667
 
556
- await execGitChecked(["update-ref", STORE_REF, head!, LEGACY_ZERO_SHA]);
557
- return "rewritten";
668
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
669
+ throw new Error(`failed to rewrite rewind store ref: ${detail}`);
558
670
  }
559
671
 
560
672
  async function ensureSnapshotForTree(treeSha: string): Promise<string> {
@@ -606,6 +718,12 @@ export default function rewindExtension(pi: ExtensionAPI) {
606
718
  const { treeSha: currentTreeSha } = await captureWorktreeTree();
607
719
  const targetTreeSha = await getCommitTreeSha(targetCommitSha);
608
720
 
721
+ const currentSubmodules = await getGitlinkEntries(currentTreeSha);
722
+ const targetSubmodules = await getGitlinkEntries(targetTreeSha);
723
+ if (currentSubmodules.length > 0 || targetSubmodules.length > 0) {
724
+ await prepareSubmoduleRestore(repoRoot!, currentSubmodules, targetSubmodules);
725
+ }
726
+
609
727
  if (currentTreeSha === targetTreeSha) {
610
728
  lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha };
611
729
  return { changed: false, targetTreeSha };
@@ -615,6 +733,13 @@ export default function rewindExtension(pi: ExtensionAPI) {
615
733
  const pathsToDelete = await getDeletedPaths(currentTreeSha, targetTreeSha);
616
734
  await deletePathsFromWorkingTree(pathsToDelete);
617
735
  await execGitChecked(["restore", `--source=${targetCommitSha}`, "--worktree", "--", "."]);
736
+ if (targetSubmodules.length > 0) {
737
+ await restoreSubmodules(repoRoot!, targetSubmodules);
738
+ const { treeSha: restoredTreeSha } = await captureWorktreeTree();
739
+ if (restoredTreeSha !== targetTreeSha) {
740
+ throw new Error(`submodule restore did not produce target tree ${targetTreeSha}`);
741
+ }
742
+ }
618
743
  lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha };
619
744
  return { changed: true, undoCommitSha, targetTreeSha };
620
745
  }
@@ -964,13 +1089,13 @@ export default function rewindExtension(pi: ExtensionAPI) {
964
1089
  if (reason === "new-snapshots" && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return;
965
1090
  if (reason === "shutdown" && sweepCompletedThisSession && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return;
966
1091
  if (!repoRoot) return;
967
- if (sweepRunning) return;
968
- sweepRunning = true;
969
- try {
970
- await runRetentionSweep(ctx, reason);
971
- } finally {
972
- sweepRunning = false;
973
- }
1092
+ // Session replacement invalidates ctx after session_shutdown resolves, so reuse
1093
+ // the in-flight startup sweep and let shutdown await it before returning.
1094
+ if (retentionSweep) return retentionSweep;
1095
+ retentionSweep = runRetentionSweep(ctx, reason).finally(() => {
1096
+ retentionSweep = undefined;
1097
+ });
1098
+ return retentionSweep;
974
1099
  }
975
1100
 
976
1101
  async function runRetentionSweep(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") {
@@ -1127,7 +1252,12 @@ export default function rewindExtension(pi: ExtensionAPI) {
1127
1252
  }
1128
1253
 
1129
1254
  await getRepoRoot(pi.exec);
1130
- await reconstructState(ctx);
1255
+ try {
1256
+ await reconstructState(ctx);
1257
+ } catch (error) {
1258
+ if (!(error instanceof UnsupportedSubmoduleStateError)) throw error;
1259
+ notify(ctx, `Rewind unavailable: ${error.message}`, "warning");
1260
+ }
1131
1261
  updateStatus(ctx);
1132
1262
  maybeSweepRetention(ctx, "startup").catch((error) => {
1133
1263
  notify(ctx, `Rewind retention startup sweep failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -1151,7 +1281,7 @@ export default function rewindExtension(pi: ExtensionAPI) {
1151
1281
  const ctx = activeContext;
1152
1282
  if (!ctx) return;
1153
1283
 
1154
- checkpointEntry(ctx, data.entryId).catch((error) => {
1284
+ return checkpointEntry(ctx, data.entryId).catch((error) => {
1155
1285
  notify(ctx, `Rewind: failed to checkpoint ${data.entryId} (${error instanceof Error ? error.message : String(error)})`, "warning");
1156
1286
  });
1157
1287
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-rewind-hook",
3
- "version": "1.8.4",
3
+ "version": "1.8.6",
4
4
  "description": "Rewind extension for Pi agent - automatic git checkpoints with file/conversation restore",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,12 +18,14 @@
18
18
  "author": "nicobailon",
19
19
  "license": "MIT",
20
20
  "pi": {
21
- "extensions": ["./index.ts"]
21
+ "extensions": [
22
+ "./index.ts"
23
+ ]
22
24
  },
23
25
  "scripts": {
24
26
  "test": "npx --yes tsx --tsconfig tsconfig.test.json --test index.test.ts"
25
27
  },
26
- "devDependencies": {
28
+ "peerDependencies": {
27
29
  "@selesai/code": "*"
28
30
  }
29
31
  }
@@ -205,8 +205,6 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
205
205
  enqueue([
206
206
  "pane", "report-metadata", paneId,
207
207
  "--source", DEFAULT_SOURCE,
208
- "--agent", "pi",
209
- "--applies-to-source", "herdr:pi",
210
208
  "--clear-state-labels",
211
209
  "--clear-token", "summary",
212
210
  "--clear-token", "title-suffix",
@@ -220,8 +218,6 @@ export function registerHerdrStatusBridge(options: HerdrStatusBridgeOptions): He
220
218
  enqueue([
221
219
  "pane", "report-metadata", paneId,
222
220
  "--source", DEFAULT_SOURCE,
223
- "--agent", "pi",
224
- "--applies-to-source", "herdr:pi",
225
221
  "--state-label", `idle=${text}`,
226
222
  "--state-label", `done=${text}`,
227
223
  "--state-label", `working=${text}`,
@@ -159,17 +159,15 @@ describe("Herdr status bridge", () => {
159
159
  label: "⏳ 1 subagent (worker)",
160
160
  }]);
161
161
  assert.equal(commands.length, 1);
162
- assert.deepEqual(commands[0]?.slice(0, 8), [
162
+ assert.deepEqual(commands[0]?.slice(0, 4), [
163
163
  "pane",
164
164
  "report-metadata",
165
165
  "w1:p1",
166
166
  "--source",
167
- "pi-subagents:herdr",
168
- "--agent",
169
- "pi",
170
- "--applies-to-source",
171
167
  ]);
172
- assert.ok(commands[0]?.includes("herdr:pi"));
168
+ assert.ok(commands[0]?.includes("pi-subagents:herdr"));
169
+ assert.ok(!commands[0]?.includes("--agent"));
170
+ assert.ok(!commands[0]?.includes("--applies-to-source"));
173
171
  assert.ok(commands[0]?.includes("--state-label"));
174
172
  assert.ok(commands[0]?.includes("working=⏳ 1 subagent (worker)"));
175
173
  assert.ok(commands[0]?.includes("idle=⏳ 1 subagent (worker)"));
@@ -13,6 +13,16 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
13
13
  import { dirname, join } from "node:path";
14
14
  import { getAgentDir, getModelsPath } from "@selesai/code";
15
15
  import type { AuthStorage, ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionStartEvent } from "@selesai/code";
16
+ import {
17
+ createAssistantMessageEventStream,
18
+ lazyStream,
19
+ type AssistantMessageEvent,
20
+ type AssistantMessageEventStream,
21
+ type Context,
22
+ type Model,
23
+ type SimpleStreamOptions,
24
+ } from "@earendil-works/pi-ai";
25
+ import { getApiProvider } from "@earendil-works/pi-ai/compat";
16
26
 
17
27
  export const TOKEN_IN_PROVIDER = "tokenin";
18
28
  export const TOKEN_IN_DASHBOARD_URL = "https://token.selesai.in/dashboard/tokens";
@@ -216,6 +226,291 @@ function isOnboardingComplete(agentDir: string = getAgentDir()): boolean {
216
226
  return existsSync(getOnboardingMarkerPath(agentDir));
217
227
  }
218
228
 
229
+ // ---------------------------------------------------------------------------
230
+ // Multi-key load balancing / auto-failover
231
+ // ---------------------------------------------------------------------------
232
+
233
+ export const DEFAULT_TOKENIN_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
234
+
235
+ /** In-memory cooldown tracker mapping token/accountId -> cooldown expires timestamp (ms). */
236
+ const tokenCooldowns = new Map<string, number>();
237
+
238
+ /** Clear all in-memory cooldowns. Exported for tests. */
239
+ export function clearTokenInCooldowns(): void {
240
+ tokenCooldowns.clear();
241
+ }
242
+
243
+ /** Check if a token/account is currently on cooldown. */
244
+ export function isTokenInOnCooldown(tokenId: string, now: number = Date.now()): boolean {
245
+ const expires = tokenCooldowns.get(tokenId);
246
+ if (expires === undefined) return false;
247
+ if (now >= expires) {
248
+ tokenCooldowns.delete(tokenId);
249
+ return false;
250
+ }
251
+ return true;
252
+ }
253
+
254
+ /** Put a token/account on cooldown for a given duration. */
255
+ export function setTokenInCooldown(tokenId: string, durationMs: number = DEFAULT_TOKENIN_COOLDOWN_MS, now: number = Date.now()): void {
256
+ tokenCooldowns.set(tokenId, now + durationMs);
257
+ }
258
+
259
+ /** Detect if an error message or AssistantMessage stop indicates an auth/quota failure eligible for failover. */
260
+ export function isRotateableTokenInError(error: unknown): boolean {
261
+ if (!error) return false;
262
+ let text = "";
263
+ if (typeof error === "string") {
264
+ text = error;
265
+ } else if (error instanceof Error) {
266
+ text = `${error.name}: ${error.message}`;
267
+ } else if (typeof error === "object" && error !== null) {
268
+ const obj = error as Record<string, unknown>;
269
+ if (typeof obj.errorMessage === "string") {
270
+ text = obj.errorMessage;
271
+ } else if (typeof obj.message === "string") {
272
+ text = obj.message;
273
+ } else if (Array.isArray(obj.content)) {
274
+ // AssistantMessage with content blocks containing error text or errorMessage
275
+ for (const block of obj.content) {
276
+ if (typeof block === "object" && block !== null) {
277
+ const b = block as Record<string, unknown>;
278
+ if (typeof b.errorMessage === "string") text += ` ${b.errorMessage}`;
279
+ if (typeof b.text === "string") text += ` ${b.text}`;
280
+ }
281
+ }
282
+ }
283
+ if (!text) text = JSON.stringify(error);
284
+ } else {
285
+ text = JSON.stringify(error);
286
+ }
287
+
288
+ const lower = text.toLowerCase();
289
+ // HTTP 401 / Invalid Key
290
+ if (lower.includes("401") || lower.includes("unauthorized") || lower.includes("invalid_api_key") || lower.includes("invalid api key") || lower.includes("authentication_error")) {
291
+ return true;
292
+ }
293
+ // HTTP 429 / Rate limit
294
+ if (lower.includes("429") || lower.includes("rate_limit") || lower.includes("rate limit") || lower.includes("too many requests")) {
295
+ return true;
296
+ }
297
+ // Budget / Quota / Credits
298
+ if (
299
+ lower.includes("budget has been exceeded") ||
300
+ lower.includes("budget_exceeded") ||
301
+ lower.includes("max_budget") ||
302
+ lower.includes("insufficient_quota") ||
303
+ lower.includes("quota exceeded") ||
304
+ lower.includes("exceeds max_budget") ||
305
+ lower.includes("credit limit") ||
306
+ lower.includes("out of credits")
307
+ ) {
308
+ return true;
309
+ }
310
+ return false;
311
+ }
312
+
313
+ /** Helper to persist active credential to auth.json and tokenin-auth.json without needing AuthStorage instance. */
314
+ function persistActiveTokenInAccount(account: TokenInAccount, authPath: string = getTokenInAuthPath()): void {
315
+ try {
316
+ // Update tokenin-auth.json
317
+ const auth = readTokenInAuth(authPath);
318
+ auth.activeId = account.id;
319
+ writeTokenInAuth(auth, authPath);
320
+
321
+ // Update auth.json
322
+ const agentDir = dirname(authPath);
323
+ const globalAuthPath = join(agentDir, "auth.json");
324
+ const current = existsSync(globalAuthPath) ? JSON.parse(readFileSync(globalAuthPath, "utf-8")) as Record<string, unknown> : {};
325
+ current[TOKEN_IN_PROVIDER] = { type: "api_key", key: account.apiKey };
326
+ mkdirSync(dirname(globalAuthPath), { recursive: true, mode: 0o700 });
327
+ writeFileSync(globalAuthPath, `${JSON.stringify(current, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
328
+ try {
329
+ chmodSync(globalAuthPath, 0o600);
330
+ } catch {
331
+ // best-effort
332
+ }
333
+ } catch {
334
+ // best-effort sync
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Custom streamSimple implementation for tokenin provider that wraps OpenAI completions
340
+ * and automatically rotates to alternative saved keys on 401/429/budget exceeded errors.
341
+ */
342
+ export function createTokenInStreamSimple(options?: {
343
+ authPath?: string;
344
+ getAuthStorage?: () => AuthStorage | undefined;
345
+ onRotate?: (failedAccount: TokenInAccount, nextAccount: TokenInAccount, reason: string) => void;
346
+ streamSimple?: (model: Model<any>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
347
+ }) {
348
+ const authPath = options?.authPath ?? getTokenInAuthPath();
349
+ const baseApi = options?.streamSimple ? undefined : getApiProvider("openai-completions");
350
+ const streamSimple = options?.streamSimple ?? baseApi!.streamSimple.bind(baseApi!);
351
+
352
+ const activate = (account: TokenInAccount): void => {
353
+ const authStorage = options?.getAuthStorage?.();
354
+ if (authStorage) {
355
+ applyTokenInAccountToAuth(account, authStorage, authPath);
356
+ } else {
357
+ persistActiveTokenInAccount(account, authPath);
358
+ }
359
+ };
360
+
361
+ const withAccountAuthorization = (streamOptions: SimpleStreamOptions | undefined, account: TokenInAccount): SimpleStreamOptions => {
362
+ const headers = Object.fromEntries(
363
+ Object.entries(streamOptions?.headers ?? {}).filter(([name]) => name.toLowerCase() !== "authorization"),
364
+ );
365
+ return {
366
+ ...streamOptions,
367
+ apiKey: account.apiKey,
368
+ headers: { ...headers, Authorization: `Bearer ${account.apiKey}` },
369
+ };
370
+ };
371
+
372
+ return function tokenInStreamSimple(
373
+ model: Model<any>,
374
+ context: Context,
375
+ streamOptions?: SimpleStreamOptions,
376
+ ): AssistantMessageEventStream {
377
+ return lazyStream(model, async () => {
378
+ const auth = readTokenInAuth(authPath);
379
+ const accounts = auth.accounts;
380
+
381
+ // If no accounts saved, fall back directly to base implementation
382
+ if (accounts.length === 0) {
383
+ return streamSimple(model, context, streamOptions);
384
+ }
385
+
386
+ // Order accounts: active account first, followed by remaining accounts
387
+ const activeIndex = accounts.findIndex((a) => a.id === auth.activeId || a.apiKey === streamOptions?.apiKey);
388
+ const orderedAccounts: TokenInAccount[] = [];
389
+ if (activeIndex >= 0) {
390
+ orderedAccounts.push(accounts[activeIndex]!);
391
+ for (let i = 0; i < accounts.length; i++) {
392
+ if (i !== activeIndex) orderedAccounts.push(accounts[i]!);
393
+ }
394
+ } else {
395
+ orderedAccounts.push(...accounts);
396
+ }
397
+
398
+ // Filter/order candidates: prioritize those not currently on cooldown
399
+ const availableAccounts = orderedAccounts.filter((a) => !isTokenInOnCooldown(a.id));
400
+ const candidateAccounts = (availableAccounts.length > 0 ? availableAccounts : orderedAccounts).map((account) => ({ ...account }));
401
+
402
+ let lastErrorEvent: AssistantMessageEvent | undefined;
403
+ let lastThrownError: unknown;
404
+
405
+ for (let i = 0; i < candidateAccounts.length; i++) {
406
+ const currentAccount = candidateAccounts[i]!;
407
+
408
+ const requestOptions = withAccountAuthorization(streamOptions, currentAccount);
409
+
410
+ let underlyingStream: AssistantMessageEventStream;
411
+ try {
412
+ underlyingStream = streamSimple(model, context, requestOptions);
413
+ } catch (err) {
414
+ lastThrownError = err;
415
+ if (isRotateableTokenInError(err) && i + 1 < candidateAccounts.length) {
416
+ setTokenInCooldown(currentAccount.id);
417
+ const nextAccount = candidateAccounts[i + 1]!;
418
+ activate(nextAccount);
419
+ options?.onRotate?.(currentAccount, nextAccount, String(err));
420
+ continue;
421
+ }
422
+ throw err;
423
+ }
424
+
425
+ // Hold a leading start event until the request proves it produced output. This
426
+ // permits an immediate upstream error to be retried without exposing two starts.
427
+ const iterator = underlyingStream[Symbol.asyncIterator]();
428
+ let firstResult: IteratorResult<AssistantMessageEvent>;
429
+ try {
430
+ firstResult = await iterator.next();
431
+ } catch (err) {
432
+ lastThrownError = err;
433
+ if (isRotateableTokenInError(err) && i + 1 < candidateAccounts.length) {
434
+ setTokenInCooldown(currentAccount.id);
435
+ const nextAccount = candidateAccounts[i + 1]!;
436
+ activate(nextAccount);
437
+ options?.onRotate?.(currentAccount, nextAccount, String(err));
438
+ continue;
439
+ }
440
+ throw err;
441
+ }
442
+
443
+ if (firstResult.done) {
444
+ const passthrough = createAssistantMessageEventStream();
445
+ passthrough.end();
446
+ return passthrough;
447
+ }
448
+
449
+ const bufferedEvents = [firstResult.value];
450
+ if (bufferedEvents[0].type === "start") {
451
+ try {
452
+ const next = await iterator.next();
453
+ if (!next.done) bufferedEvents.push(next.value);
454
+ } catch (err) {
455
+ lastThrownError = err;
456
+ if (isRotateableTokenInError(err) && i + 1 < candidateAccounts.length) {
457
+ setTokenInCooldown(currentAccount.id);
458
+ const nextAccount = candidateAccounts[i + 1]!;
459
+ activate(nextAccount);
460
+ options?.onRotate?.(currentAccount, nextAccount, String(err));
461
+ continue;
462
+ }
463
+ throw err;
464
+ }
465
+ }
466
+
467
+ const immediateError = bufferedEvents.find((event) => event.type === "error");
468
+ if (immediateError?.type === "error" && isRotateableTokenInError(immediateError.error)) {
469
+ lastErrorEvent = immediateError;
470
+ setTokenInCooldown(currentAccount.id);
471
+ if (i + 1 < candidateAccounts.length) {
472
+ const nextAccount = candidateAccounts[i + 1]!;
473
+ activate(nextAccount);
474
+ options?.onRotate?.(currentAccount, nextAccount, immediateError.error.errorMessage ?? "Error");
475
+ continue;
476
+ }
477
+ }
478
+
479
+ const outputStream = createAssistantMessageEventStream();
480
+ (async () => {
481
+ try {
482
+ for (const event of bufferedEvents) outputStream.push(event);
483
+ while (true) {
484
+ const next = await iterator.next();
485
+ if (next.done) break;
486
+ outputStream.push(next.value);
487
+ }
488
+ outputStream.end();
489
+ } catch {
490
+ outputStream.end();
491
+ }
492
+ })();
493
+
494
+ return outputStream;
495
+ }
496
+
497
+ // If loop exhausted with a rotateable error event, return a stream with that error
498
+ if (lastErrorEvent) {
499
+ const errorStream = createAssistantMessageEventStream();
500
+ errorStream.push(lastErrorEvent);
501
+ errorStream.end();
502
+ return errorStream;
503
+ }
504
+
505
+ if (lastThrownError) {
506
+ throw lastThrownError;
507
+ }
508
+
509
+ return streamSimple(model, context, streamOptions);
510
+ });
511
+ };
512
+ }
513
+
219
514
  async function openDashboard(pi: ExtensionAPI): Promise<void> {
220
515
  const url = TOKEN_IN_DASHBOARD_URL;
221
516
  let command: string;
@@ -537,7 +832,14 @@ async function runOnboarding(pi: ExtensionAPI, ctx: ExtensionContext): Promise<v
537
832
  }
538
833
 
539
834
  export default function tokenInOnboardingExtension(pi: ExtensionAPI): void {
835
+ let authStorage: AuthStorage | undefined;
836
+ pi.registerProvider("tokenin", {
837
+ api: "openai-completions",
838
+ streamSimple: createTokenInStreamSimple({ getAuthStorage: () => authStorage }),
839
+ });
840
+
540
841
  pi.on("session_start", async (event: SessionStartEvent, ctx: ExtensionContext) => {
842
+ authStorage = ctx.modelRegistry.authStorage;
541
843
  if (event.reason !== "startup") return;
542
844
  if (isOnboardingComplete()) return;
543
845
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Maintained, extension-first Pi coding agent with built-in workflows, subagents, web research, questions, skills, and an enhanced terminal UI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,9 +0,0 @@
1
- ____ ____ __ ____ ____ ______ ______
2
- /\ _`\ /\ _`\ /\ \ /\ _`\ /\ _`\ /\ _ \/\__ _\
3
- \ \,\L\_\ \ \L\_\ \ \ \ \ \L\_\ \,\L\_\ \ \ \L\ \/_/\ \/
4
- \/_\__ \\ \ _\L\ \ \ __\ \ _\L\/_\__ \ \ \ __ \ \ \ \
5
- /\ \L\ \ \ \L\ \ \ \L\ \\ \ \L\ \/\ \L\ \ \ \ \/\ \ \_\ \__
6
- \ `\____\ \____/\ \____/ \ \____/\ `\____\ \ \_\ \_\/\_____\
7
- \/_____/\/___/ \/___/ \/___/ \/_____/ \/_/\/_/\/_____/
8
-
9
-