@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.
- package/CHANGELOG.md +19 -0
- package/dist/defaults/models.json +68 -12
- package/dist/extensions/cost-reconcile.test.ts +181 -4
- package/dist/extensions/cost-reconcile.ts +83 -104
- package/dist/extensions/pi-rewind-hook/README.md +4 -1
- package/dist/extensions/pi-rewind-hook/index.ts +151 -21
- package/dist/extensions/pi-rewind-hook/package.json +5 -3
- package/dist/extensions/pi-subagents/src/integrations/herdr-status.ts +0 -4
- package/dist/extensions/pi-subagents/test/unit/herdr-status-bridge.test.ts +4 -6
- package/dist/extensions/tokenin-onboarding.ts +302 -0
- package/package.json +1 -1
- package/dist/extensions/ascii.txt +0 -9
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
557
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|
|
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
|
-
"
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
|