@code-yeongyu/senpi 2026.10.16-3 → 2026.10.16-4

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 (99) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/bundle/chunks/{chunk-I5ZJEXTM.js → chunk-2NO5ZOYE.js} +6 -6
  3. package/dist/bundle/chunks/{chunk-4R4PRTF5.js → chunk-CJRE5LLR.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-X5VCHHXR.js → chunk-GY5O7NIQ.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-AVALXFDW.js → chunk-JD4CV3GH.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-SSEGIXRL.js → chunk-KUQ3K33L.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-FMYLLJBQ.js → chunk-KWC3MCLP.js} +1 -1
  8. package/dist/bundle/chunks/chunk-MLL7AGVM.js +3 -0
  9. package/dist/bundle/chunks/chunk-PAJKEG2L.js +8 -0
  10. package/dist/bundle/chunks/{chunk-DPRV3FR7.js → chunk-PWHAOWNH.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-HJPX7PBL.js → chunk-XCRGEMY2.js} +125 -118
  12. package/dist/bundle/chunks/{cli-main-JVPFJGP5.js → cli-main-I6JF2GPS.js} +1 -1
  13. package/dist/bundle/chunks/{help-fast-path-SVCMHCYG.js → help-fast-path-APSHUDY6.js} +1 -1
  14. package/dist/bundle/chunks/{host-session-command-46PTZGIB.js → host-session-command-BUJUJHDA.js} +1 -1
  15. package/dist/bundle/chunks/{interactive-mode-BROZRY34.js → interactive-mode-4Q3TIXOZ.js} +1 -1
  16. package/dist/bundle/chunks/{multi-session-host-SMQR5SCJ.js → multi-session-host-XEZB2XVR.js} +5 -4
  17. package/dist/bundle/chunks/{package-manager-cli-2QFJY2AS.js → package-manager-cli-YWZHOZOY.js} +1 -1
  18. package/dist/bundle/chunks/{rotation-stream-QX7YSKDU.js → rotation-stream-HY3JPQCX.js} +1 -1
  19. package/dist/bundle/chunks/{rpc-mode-Z45T3NZO.js → rpc-mode-SLWXVXXE.js} +1 -1
  20. package/dist/bundle/chunks/{schedule-command-2RHSWBQO.js → schedule-command-JRWZGEDU.js} +1 -1
  21. package/dist/bundle/chunks/{session-control-endpoint-H7FFWF2A.js → session-control-endpoint-E2ZSEWXW.js} +1 -1
  22. package/dist/bundle/chunks/session-picker-5IZY7E3G.js +2 -0
  23. package/dist/bundle/chunks/session-worker.js +243 -235
  24. package/dist/bundle/cli.js +2 -2
  25. package/dist/bundle/index.js +1 -1
  26. package/dist/bundle/rpc-entry.js +1 -1
  27. package/dist/bundle/runtime-manifest.json +1 -1
  28. package/dist/core/agent-session-runtime.d.ts +15 -0
  29. package/dist/core/agent-session-runtime.js +53 -2
  30. package/dist/core/agent-session.d.ts +12 -0
  31. package/dist/core/agent-session.js +42 -1
  32. package/dist/core/context-files.d.ts +21 -0
  33. package/dist/core/context-files.js +175 -0
  34. package/dist/core/diagnostics.d.ts +1 -0
  35. package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +26 -3
  36. package/dist/core/extensions/builtin/anthropic-subscription/account-management.d.ts +5 -0
  37. package/dist/core/extensions/builtin/anthropic-subscription/account-management.js +40 -0
  38. package/dist/core/extensions/builtin/anthropic-subscription/accounts.d.ts +3 -0
  39. package/dist/core/extensions/builtin/anthropic-subscription/accounts.js +1 -1
  40. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +1 -1
  41. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +1 -0
  42. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +10 -5
  43. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +158 -17
  44. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.d.ts +13 -0
  45. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.js +89 -0
  46. package/dist/core/extensions/builtin/permission-system/always-scope.d.ts +34 -0
  47. package/dist/core/extensions/builtin/permission-system/always-scope.js +77 -0
  48. package/dist/core/extensions/builtin/permission-system/index.js +3 -1
  49. package/dist/core/extensions/builtin/permission-system/parsers.d.ts +1 -1
  50. package/dist/core/extensions/builtin/permission-system/parsers.js +24 -3
  51. package/dist/core/extensions/builtin/permission-system/prompt.js +5 -1
  52. package/dist/core/extensions/builtin/permission-system/service.js +1 -1
  53. package/dist/core/extensions/builtin/permission-system/types.d.ts +7 -0
  54. package/dist/core/package-manager.js +4 -32
  55. package/dist/core/project-trust.d.ts +8 -0
  56. package/dist/core/project-trust.js +12 -0
  57. package/dist/core/resource-loader.d.ts +5 -7
  58. package/dist/core/resource-loader.js +11 -84
  59. package/dist/core/retry-fallback/circuit-probes.d.ts +1 -0
  60. package/dist/core/retry-fallback/circuit-probes.js +3 -0
  61. package/dist/core/retry-fallback/circuit.d.ts +5 -0
  62. package/dist/core/retry-fallback/circuit.js +27 -4
  63. package/dist/core/retry-fallback/controller.d.ts +9 -4
  64. package/dist/core/retry-fallback/controller.js +21 -4
  65. package/dist/core/settings-manager.d.ts +15 -0
  66. package/dist/core/settings-manager.js +21 -1
  67. package/dist/core/skills-prompt.d.ts +18 -0
  68. package/dist/core/skills-prompt.js +131 -0
  69. package/dist/core/skills.d.ts +1 -9
  70. package/dist/core/skills.js +1 -59
  71. package/dist/core/trust-manager.d.ts +13 -4
  72. package/dist/core/trust-manager.js +52 -21
  73. package/dist/main.js +23 -4
  74. package/dist/modes/interactive/interactive-mode.js +10 -3
  75. package/dist/modes/rpc/abort-grace.d.ts +9 -0
  76. package/dist/modes/rpc/abort-grace.js +11 -0
  77. package/dist/modes/rpc/effect-commands.d.ts +0 -2
  78. package/dist/modes/rpc/effect-commands.js +2 -3
  79. package/dist/modes/rpc/host-handover-parked.d.ts +6 -0
  80. package/dist/modes/rpc/host-handover-parked.js +1 -0
  81. package/dist/modes/rpc/host-handover-reopen.js +11 -2
  82. package/dist/modes/rpc/rpc-client.d.ts +28 -2
  83. package/dist/modes/rpc/rpc-client.js +24 -11
  84. package/dist/modes/rpc/rpc-request-deadline.d.ts +12 -0
  85. package/dist/modes/rpc/rpc-request-deadline.js +15 -0
  86. package/dist/modes/rpc/session-command-router.js +7 -4
  87. package/dist/modes/rpc/session-registry-attach.js +6 -1
  88. package/dist/modes/rpc/session-registry-claims.d.ts +22 -2
  89. package/dist/modes/rpc/session-registry-claims.js +68 -0
  90. package/dist/modes/rpc/session-registry-opening.d.ts +23 -0
  91. package/dist/modes/rpc/session-registry-opening.js +57 -0
  92. package/dist/modes/rpc/session-registry.d.ts +1 -0
  93. package/dist/modes/rpc/session-registry.js +48 -60
  94. package/docs/rpc.md +6 -2
  95. package/docs/settings.md +13 -0
  96. package/package.json +6 -6
  97. package/dist/bundle/chunks/chunk-6MEETY7N.js +0 -8
  98. package/dist/bundle/chunks/chunk-TEGEPQIA.js +0 -3
  99. package/dist/bundle/chunks/session-picker-2O2BNBVE.js +0 -2
@@ -0,0 +1,175 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, sep } from "node:path";
3
+ import chalk from "chalk";
4
+ import { canonicalizePath, resolvePath } from "../utils/paths.js";
5
+ import { stripBom } from "../utils/text.js";
6
+ import { findGitPaths } from "./footer-data-provider.js";
7
+ export const DEFAULT_CONTEXT_FILE_MAX_BYTES = 64 * 1024;
8
+ export const DEFAULT_CONTEXT_FILES_MAX_TOTAL_BYTES = 128 * 1024;
9
+ const MIN_USEFUL_BYTES = 1024;
10
+ function loadContextFileFromDir(dir) {
11
+ const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
12
+ for (const filename of candidates) {
13
+ const filePath = join(dir, filename);
14
+ if (existsSync(filePath)) {
15
+ try {
16
+ if (!statSync(filePath).isFile()) {
17
+ continue;
18
+ }
19
+ return {
20
+ path: filePath,
21
+ content: stripBom(readFileSync(filePath, "utf-8")),
22
+ };
23
+ }
24
+ catch (error) {
25
+ console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
26
+ }
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+ /**
32
+ * The main repo's context file that a nested linked worktree's own copy shadows: both
33
+ * occupy the same logical repository scope, so loading both applies that context twice. Returns
34
+ * undefined when nothing is shadowed, leaving normal ancestor inheritance alone.
35
+ *
36
+ * Returned canonicalized (realpath), because `git worktree add` writes the `.git`
37
+ * file's `gitdir:` target in realpath form while cwd may still be symlinked
38
+ * (macOS `/tmp` -> `/private/tmp`).
39
+ */
40
+ function findShadowedContextFile(cwd) {
41
+ const gitPaths = findGitPaths(cwd);
42
+ if (!gitPaths)
43
+ return undefined;
44
+ const commonGitDir = canonicalizePath(gitPaths.commonGitDir);
45
+ const worktreeRoot = canonicalizePath(gitPaths.repoDir);
46
+ const mainRepoRoot = dirname(commonGitDir);
47
+ // False for an ordinary repo, where the two are the same dir, and for a sibling
48
+ // worktree (`git worktree add ../feat`), whose main repo is not an ancestor.
49
+ if (!worktreeRoot.startsWith(`${mainRepoRoot}${sep}`))
50
+ return undefined;
51
+ // dirname of the common git dir is the main worktree root only when that dir is
52
+ // itself checked out from the same repo. In a bare layout (`proj/.bare` +
53
+ // `proj/main`) it is just the directory holding `.bare`, which tracks nothing; a
54
+ // submodule's gitdir has no `commondir`, so it lands under `.git/modules`.
55
+ if (canonicalizePath(join(mainRepoRoot, ".git")) !== commonGitDir)
56
+ return undefined;
57
+ const worktreeContextFile = loadContextFileFromDir(worktreeRoot);
58
+ return worktreeContextFile ? join(mainRepoRoot, basename(worktreeContextFile.path)) : undefined;
59
+ }
60
+ function kib(bytes) {
61
+ const value = bytes / 1024;
62
+ return Number.isInteger(value) ? `${value} KiB` : `${value.toFixed(1)} KiB`;
63
+ }
64
+ function utf8Prefix(content, maxBytes) {
65
+ if (maxBytes <= 0)
66
+ return "";
67
+ const bytes = Buffer.from(content, "utf8");
68
+ let end = Math.min(maxBytes, bytes.length);
69
+ while (end > 0 && end < bytes.length && ((bytes[end] ?? 0) & 0xc0) === 0x80)
70
+ end--;
71
+ const newline = bytes.lastIndexOf(10, end - 1);
72
+ if (newline >= maxBytes * 0.75)
73
+ end = newline + 1;
74
+ return bytes.subarray(0, end).toString("utf8");
75
+ }
76
+ function positiveLimit(value, fallback) {
77
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
78
+ }
79
+ /**
80
+ * Bound what the context files add to every request. Files are given budget nearest-first
81
+ * (the cwd's file before its ancestors, the global file last), so the most specific
82
+ * instructions survive; every cut or skipped file is named in a warning.
83
+ */
84
+ function applyContextFileLimits(files, limits) {
85
+ const perFile = positiveLimit(limits.maxBytesPerFile, DEFAULT_CONTEXT_FILE_MAX_BYTES);
86
+ const total = positiveLimit(limits.maxTotalBytes, DEFAULT_CONTEXT_FILES_MAX_TOTAL_BYTES);
87
+ const limitText = `limit ${kib(perFile)} per file, ${kib(total)} total; raise contextFiles.maxBytesPerFile / contextFiles.maxTotalBytes in settings to load more`;
88
+ const kept = [];
89
+ const diagnostics = [];
90
+ let remaining = total;
91
+ for (const file of [...files].reverse()) {
92
+ const size = Buffer.byteLength(file.content, "utf8");
93
+ const allowed = Math.min(perFile, remaining);
94
+ if (size <= allowed) {
95
+ kept.unshift(file);
96
+ remaining -= size;
97
+ continue;
98
+ }
99
+ if (allowed < MIN_USEFUL_BYTES) {
100
+ const reason = perFile < MIN_USEFUL_BYTES ? "the per-file limit is too small" : "nearer context files used the budget";
101
+ diagnostics.push({
102
+ type: "warning",
103
+ category: "size-limit",
104
+ path: file.path,
105
+ message: `Context file (${kib(size)}) was not loaded: ${reason} (${limitText}).`,
106
+ });
107
+ continue;
108
+ }
109
+ const note = `\n\n[This file was truncated from ${kib(size)}.]`;
110
+ const contentBudget = allowed - Buffer.byteLength(note);
111
+ let prefix = utf8Prefix(file.content, contentBudget);
112
+ let content;
113
+ while (true) {
114
+ let openFence;
115
+ for (const match of prefix.matchAll(/^[ \t]*(`{3,}|~{3,})([^\n]*)$/gm)) {
116
+ const marker = match[1];
117
+ if (!marker)
118
+ continue;
119
+ if (openFence === undefined)
120
+ openFence = marker;
121
+ else if (marker[0] === openFence[0] && marker.length >= openFence.length && match[2]?.trim() === "") {
122
+ openFence = undefined;
123
+ }
124
+ }
125
+ const closingFence = openFence ? `${prefix.endsWith("\n") ? "" : "\n"}${openFence}\n` : "";
126
+ content = `${prefix}${closingFence}${note}`;
127
+ if (Buffer.byteLength(content) <= allowed)
128
+ break;
129
+ // A longer closing marker can expose another opener when the prefix shrinks.
130
+ prefix = utf8Prefix(prefix, Math.max(0, contentBudget - Buffer.byteLength(closingFence)));
131
+ }
132
+ const loaded = Buffer.byteLength(prefix, "utf8");
133
+ diagnostics.push({
134
+ type: "warning",
135
+ category: "size-limit",
136
+ path: file.path,
137
+ message: `Context file is ${kib(size)}; only the first ${kib(loaded)} was loaded (${limitText}).`,
138
+ });
139
+ kept.unshift({
140
+ path: file.path,
141
+ content,
142
+ });
143
+ remaining -= Buffer.byteLength(content);
144
+ }
145
+ return Object.assign(kept, { diagnostics });
146
+ }
147
+ export function loadProjectContextFiles(options) {
148
+ const resolvedCwd = resolvePath(options.cwd);
149
+ const resolvedAgentDir = resolvePath(options.agentDir);
150
+ const contextFiles = [];
151
+ const seenPaths = new Set();
152
+ const globalContext = loadContextFileFromDir(resolvedAgentDir);
153
+ if (globalContext) {
154
+ contextFiles.push(globalContext);
155
+ seenPaths.add(globalContext.path);
156
+ }
157
+ const ancestorContextFiles = [];
158
+ const shadowedContextFile = findShadowedContextFile(resolvedCwd);
159
+ let currentDir = resolvedCwd;
160
+ while (true) {
161
+ const contextFile = loadContextFileFromDir(currentDir);
162
+ const isShadowed = shadowedContextFile !== undefined && canonicalizePath(contextFile?.path ?? "") === shadowedContextFile;
163
+ if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {
164
+ ancestorContextFiles.unshift(contextFile);
165
+ seenPaths.add(contextFile.path);
166
+ }
167
+ const parentDir = dirname(currentDir);
168
+ if (parentDir === currentDir)
169
+ break;
170
+ currentDir = parentDir;
171
+ }
172
+ contextFiles.push(...ancestorContextFiles);
173
+ return applyContextFileLimits(contextFiles, options);
174
+ }
175
+ //# sourceMappingURL=context-files.js.map
@@ -8,6 +8,7 @@ export interface ResourceCollision {
8
8
  }
9
9
  export interface ResourceDiagnostic {
10
10
  type: "warning" | "error" | "collision";
11
+ category?: "size-limit";
11
12
  message: string;
12
13
  path?: string;
13
14
  collision?: ResourceCollision;
@@ -3,7 +3,7 @@ import { describeModelBlocks } from "../../../credential-pool/model-scope.js";
3
3
  import { accountDisplayNameCommand, promptAccountDisplayName } from "../account-display-name.js";
4
4
  import { createExtensionLoginInteraction, LOGIN_CANCELLED_MESSAGE } from "../oauth-login-interaction.js";
5
5
  import { emitProviderAccountsChanged } from "./account-events.js";
6
- import { ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, pinProviderAccount, removeProviderAccount } from "./account-management.js";
6
+ import { ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, pinProviderAccount, removeProviderAccount, unblockProviderAccount, } from "./account-management.js";
7
7
  import { emptyCredential, listAccounts } from "./accounts.js";
8
8
  import { selectAccount } from "./affinity.js";
9
9
  import { loadAnthropicSubscriptionProviderSettingsFromDisk, } from "./settings.js";
@@ -65,7 +65,7 @@ export function registerClaudeAccountCommand(pi, deps = {}) {
65
65
  });
66
66
  pi.registerCommand("claude-account", {
67
67
  description: "List and manage Anthropic Subscription accounts.",
68
- argumentHint: "[add | remove <id> | pin <id> | unpin | rename <id> <display name...> | clear-name <id>]",
68
+ argumentHint: "[add | remove <id> | pin <id> | unpin | unblock <id> | rename <id> <display name...> | clear-name <id>]",
69
69
  requiresArguments: false,
70
70
  handler: async (rawArgs, ctx) => {
71
71
  if (await accountDisplayNameCommand(ctx, ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, rawArgs))
@@ -92,7 +92,11 @@ export function registerClaudeAccountCommand(pi, deps = {}) {
92
92
  await unpinAccount(ctx);
93
93
  return;
94
94
  }
95
- ctx.ui.notify("Usage: /claude-account [add | remove <id> | pin <id> | unpin | rename <id> <display name...> | clear-name <id>]", "error");
95
+ if (action === "unblock") {
96
+ await unblockNamedAccount(ctx, args[1], environment);
97
+ return;
98
+ }
99
+ ctx.ui.notify("Usage: /claude-account [add | remove <id> | pin <id> | unpin | unblock <id> | rename <id> <display name...> | clear-name <id>]", "error");
96
100
  },
97
101
  });
98
102
  }
@@ -194,6 +198,25 @@ async function pinNamedAccount(ctx, name, environment) {
194
198
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
195
199
  }
196
200
  }
201
+ async function unblockNamedAccount(ctx, name, environment) {
202
+ if (!name) {
203
+ ctx.ui.notify("Usage: /claude-account unblock <name>", "error");
204
+ return;
205
+ }
206
+ if (!readAccounts(ctx, environment).accounts.some((account) => account.name === name)) {
207
+ return accountError(ctx, name);
208
+ }
209
+ try {
210
+ const env = new Proxy({}, { get: (_target, key) => environment(String(key)) });
211
+ const lifted = await unblockProviderAccount(ctx.modelRegistry.authStorage, ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, name, env);
212
+ ctx.ui.notify(lifted
213
+ ? `Unblocked Anthropic Subscription account: ${name}. The next turn may use it.`
214
+ : `Anthropic Subscription account '${name}' has no usage block to lift.`, "info");
215
+ }
216
+ catch (error) {
217
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
218
+ }
219
+ }
197
220
  async function unpinAccount(ctx) {
198
221
  const credential = asCredential(ctx.modelRegistry.authStorage.get(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID));
199
222
  if (!credential?.pinned) {
@@ -11,4 +11,9 @@ export type ProviderAccountSummary = {
11
11
  export declare function getProviderAccounts(storage: AuthStorage, provider: string, env?: NodeJS.ProcessEnv): ProviderAccountSummary[];
12
12
  export declare function pinProviderAccount(storage: AuthStorage, provider: string, name: string | null, env?: NodeJS.ProcessEnv): Promise<void>;
13
13
  export declare function removeProviderAccount(storage: AuthStorage, provider: string, name: string): Promise<void>;
14
+ /**
15
+ * Lifts a usage or rate-limit block the user knows is wrong (extra usage bought, a misread reset).
16
+ * Auth blocks stay: only a login or the re-probe can lift those. Returns whether a block was lifted.
17
+ */
18
+ export declare function unblockProviderAccount(storage: AuthStorage, provider: string, name: string, env?: NodeJS.ProcessEnv): Promise<boolean>;
14
19
  //# sourceMappingURL=account-management.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { accountDisplayName } from "@earendil-works/pi-ai/auth/pool/slots";
2
2
  import { emitProviderAccountsChanged } from "./account-events.js";
3
3
  import { emptyCredential, listAccounts, pinAccount, removeAccount, } from "./accounts.js";
4
+ import { forgetSessionBlocksFor } from "./failover.js";
4
5
  export const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
5
6
  export function getProviderAccounts(storage, provider, env = process.env) {
6
7
  assertManagedProvider(provider);
@@ -50,6 +51,45 @@ export async function removeProviderAccount(storage, provider, name) {
50
51
  });
51
52
  emitProviderAccountsChanged(provider);
52
53
  }
54
+ /**
55
+ * Lifts a usage or rate-limit block the user knows is wrong (extra usage bought, a misread reset).
56
+ * Auth blocks stay: only a login or the re-probe can lift those. Returns whether a block was lifted.
57
+ */
58
+ export async function unblockProviderAccount(storage, provider, name, env = process.env) {
59
+ assertManagedProvider(provider);
60
+ let lifted = false;
61
+ await storage.modify(provider, async (current) => {
62
+ const credential = credentialFrom(current);
63
+ const account = listAccounts(credential, (key) => env[key]).find((candidate) => candidate.name === name);
64
+ if (!account)
65
+ throw new Error(`Provider account not found: ${name}`);
66
+ if (account.blockedUntil === undefined || account.blockReason === "auth_error")
67
+ return current;
68
+ lifted = true;
69
+ const state = credential.slotState?.[name];
70
+ return {
71
+ ...credential,
72
+ ...(state === undefined
73
+ ? {}
74
+ : {
75
+ slotState: {
76
+ ...credential.slotState,
77
+ [name]: state.modelBlocks ? { modelBlocks: state.modelBlocks } : {},
78
+ },
79
+ }),
80
+ accounts: (credential.accounts ?? []).map((candidate) => {
81
+ if (candidate.name !== name)
82
+ return candidate;
83
+ const { blockedUntil: _blockedUntil, blockReason: _blockReason, blockedAt: _blockedAt, ...rest } = candidate;
84
+ return rest;
85
+ }),
86
+ };
87
+ });
88
+ const sessionScoped = forgetSessionBlocksFor(name);
89
+ if (lifted || sessionScoped)
90
+ emitProviderAccountsChanged(provider);
91
+ return lifted || sessionScoped;
92
+ }
53
93
  function assertManagedProvider(provider) {
54
94
  if (provider !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
55
95
  throw new Error(`Provider account management is unavailable for: ${provider}`);
@@ -10,6 +10,8 @@ export type AccountSlot = {
10
10
  source: "login" | "import" | "env";
11
11
  blockedUntil?: number;
12
12
  blockReason?: string;
13
+ /** When the current block was stored, so a turn that started earlier never clears it (senpi#3462). */
14
+ blockedAt?: number;
13
15
  /** Rate limits that bind one model family on this account, not the account (senpi#2555). */
14
16
  modelBlocks?: ModelBlocks;
15
17
  /**
@@ -23,6 +25,7 @@ export type AccountSlot = {
23
25
  export type SlotState = Record<string, {
24
26
  blockedUntil?: number;
25
27
  blockReason?: string;
28
+ blockedAt?: number;
26
29
  modelBlocks?: ModelBlocks;
27
30
  }>;
28
31
  export type AnthropicSubscriptionCredential = OAuthCredential & {
@@ -57,7 +57,7 @@ export function upsertAccount(credential, slot) {
57
57
  const existing = storedSlots(credential).find((candidate) => candidate.name === slot.name);
58
58
  if (!existing)
59
59
  return { ...credential, accounts: [...storedSlots(credential), slot] };
60
- const { blockedUntil: _blockedUntil, blockReason: _blockReason, modelBlocks: _modelBlocks, authRecoveryGrant: _authRecoveryGrant, ...identity } = existing;
60
+ const { blockedUntil: _blockedUntil, blockReason: _blockReason, blockedAt: _blockedAt, modelBlocks: _modelBlocks, authRecoveryGrant: _authRecoveryGrant, ...identity } = existing;
61
61
  const refreshed = {
62
62
  ...identity,
63
63
  access: slot.access,
@@ -47,7 +47,7 @@ export function clearExpiredBlocks(accounts, now = Date.now()) {
47
47
  return accounts.map((account) => {
48
48
  let available = account;
49
49
  if (account.blockReason !== undefined && (account.blockedUntil === undefined || account.blockedUntil <= now)) {
50
- const { blockedUntil: _blockedUntil, blockReason: _blockReason, ...rest } = account;
50
+ const { blockedUntil: _blockedUntil, blockReason: _blockReason, blockedAt: _blockedAt, ...rest } = account;
51
51
  available = rest;
52
52
  }
53
53
  if (available.modelBlocks === undefined)
@@ -228,6 +228,7 @@ export async function* queryWithAuthLane(input) {
228
228
  });
229
229
  },
230
230
  classify: classifySdkError,
231
+ ...(input.sessionId === undefined ? {} : { sessionKey: input.sessionId }),
231
232
  onLongContextRefused: () => markLongContextRefused(),
232
233
  store: pool.store,
233
234
  providerId: ANTHROPIC_SUBSCRIPTION_PROVIDER_ID,
@@ -3,7 +3,7 @@ import type { AccountSlot } from "./accounts.ts";
3
3
  import type { SdkErrorClassification } from "./errors.ts";
4
4
  export declare const MAX_RATE_LIMIT_BLOCK_MS: number;
5
5
  export declare const DEFAULT_RATE_LIMIT_BLOCK_MS = 60000;
6
- /** Same-account retries per turn for a transient failure (overload, network, server error) before rotating. */
6
+ /** Same-account retries per turn for a transient failure (overload, network, server error); it never rotates. */
7
7
  export declare const TRANSIENT_RETRIES_PER_TURN = 2;
8
8
  export declare const TRANSIENT_RETRY_DELAY_MS = 1000;
9
9
  export declare const TURN_RETRY_SUPPRESSION_PREFIX = "senpi:no-turn-retry:";
@@ -33,7 +33,11 @@ export type FailoverOptions<TEvent> = {
33
33
  signal?: AbortSignal;
34
34
  /** Records a refused 1M window so the retry, built by `runAttempt`, requests the standard one. */
35
35
  onLongContextRefused?: () => void;
36
+ /** The session this turn belongs to: blocks that bind only this session are remembered under it. */
37
+ sessionKey?: string;
36
38
  };
39
+ /** `/claude-account unblock` lifts the account's session-scoped blocks in every session too. Returns whether one was lifted. */
40
+ export declare function forgetSessionBlocksFor(name: string): boolean;
37
41
  export declare class ClassifiedSdkError extends Error {
38
42
  readonly classification: SdkErrorClassification;
39
43
  readonly original: unknown;
@@ -41,10 +45,11 @@ export declare class ClassifiedSdkError extends Error {
41
45
  constructor(classification: SdkErrorClassification, original: unknown, suppressTurnRetry: boolean);
42
46
  }
43
47
  /**
44
- * Runs one attempt per account, plus at most TRANSIENT_RETRIES_PER_TURN same-account retries
45
- * for a transient failure (and one retry on newer stored credentials). A retry is transparent only before a
46
- * text, thinking, or tool-call event reaches the caller; post-delta failures
47
- * are marked so AgentSession never replays the partial turn.
48
+ * Runs one attempt per account for account-level failures (a limit, billing, auth), plus at most
49
+ * TRANSIENT_RETRIES_PER_TURN same-account retries for a transient failure, after which the turn fails on
50
+ * that account instead of rotating (and one retry on newer stored credentials). A retry is transparent only
51
+ * before a text, thinking, or tool-call event reaches the caller; post-delta failures are marked so
52
+ * AgentSession never replays the partial turn.
48
53
  */
49
54
  export declare function runFailover<TEvent>(options: FailoverOptions<TEvent>): AsyncGenerator<TEvent>;
50
55
  //# sourceMappingURL=failover.d.ts.map
@@ -1,13 +1,95 @@
1
1
  import { AUTH_BLOCK_REPROBE_MS } from "../../../credential-pool/auth-reprobe.js";
2
- import { activeModelBlockUntil, mergeModelBlocks, modelBlockKey, withModelBlock, } from "../../../credential-pool/model-scope.js";
2
+ import { activeModelBlockUntil, mergeModelBlocks, modelBlockKey, pruneModelBlocks, withModelBlock, } from "../../../credential-pool/model-scope.js";
3
3
  import { usageLimitResetMs } from "../../../credential-pool/reset-time.js";
4
4
  import { clearExpiredBlocks } from "./affinity.js";
5
+ import { isConfirmedAccountUsageLimit, usageLimitBlockMs } from "./usage-limit-block.js";
5
6
  export const MAX_RATE_LIMIT_BLOCK_MS = 48 * 60 * 60 * 1_000;
6
7
  export const DEFAULT_RATE_LIMIT_BLOCK_MS = 60_000;
7
- /** Same-account retries per turn for a transient failure (overload, network, server error) before rotating. */
8
+ /** Same-account retries per turn for a transient failure (overload, network, server error); it never rotates. */
8
9
  export const TRANSIENT_RETRIES_PER_TURN = 2;
9
10
  export const TRANSIENT_RETRY_DELAY_MS = 1_000;
10
11
  export const TURN_RETRY_SUPPRESSION_PREFIX = "senpi:no-turn-retry:";
12
+ /**
13
+ * Blocks that bind only the session that hit them, in memory for the process: a plain rate limit and a
14
+ * one-model limit. Another session on the same account keeps using it and finds out on its own request.
15
+ */
16
+ const sessionBlocks = new Map();
17
+ /**
18
+ * Only a verdict that the account itself cannot serve anyone is shared through the credential store: a
19
+ * confirmed account-level usage limit, a billing or organization refusal, or a rejected token. Moving
20
+ * every session off the account is right then, since leaving them would retry a dead account. A plain
21
+ * rate limit, an overload or a one-model limit is not that verdict, so it never moves other sessions
22
+ * (senpi#3466).
23
+ */
24
+ function isSharedVerdict(classification, error) {
25
+ if (classification.kind === "auth_error" ||
26
+ classification.kind === "billing" ||
27
+ classification.kind === "org_not_allowed")
28
+ return true;
29
+ return (classification.kind === "rate_limit" &&
30
+ classification.modelFamily === undefined &&
31
+ isConfirmedAccountUsageLimit(errorText(error)));
32
+ }
33
+ function rememberSessionBlock(sessionKey, account) {
34
+ if (sessionKey === undefined)
35
+ return;
36
+ const blocks = sessionBlocks.get(sessionKey) ?? new Map();
37
+ blocks.set(account.name, {
38
+ blockedUntil: account.blockedUntil,
39
+ blockReason: account.blockReason,
40
+ blockedAt: account.blockedAt,
41
+ modelBlocks: account.modelBlocks,
42
+ });
43
+ sessionBlocks.set(sessionKey, blocks);
44
+ }
45
+ /** Drops elapsed session blocks for every session, so sessions that ended leave nothing behind. */
46
+ function pruneSessionBlocks(now) {
47
+ for (const [sessionKey, blocks] of sessionBlocks) {
48
+ for (const [name, block] of blocks) {
49
+ if ((block.blockedUntil ?? 0) <= now && pruneModelBlocks(block.modelBlocks, now) === undefined)
50
+ blocks.delete(name);
51
+ }
52
+ if (blocks.size === 0)
53
+ sessionBlocks.delete(sessionKey);
54
+ }
55
+ }
56
+ function withSessionBlocks(accounts, sessionKey, now) {
57
+ pruneSessionBlocks(now);
58
+ const blocks = sessionKey === undefined ? undefined : sessionBlocks.get(sessionKey);
59
+ if (blocks === undefined)
60
+ return [...accounts];
61
+ return accounts.map((account) => {
62
+ const own = blocks.get(account.name);
63
+ if (own === undefined)
64
+ return account;
65
+ const later = (own.blockedUntil ?? 0) > (account.blockedUntil ?? 0);
66
+ return {
67
+ ...account,
68
+ ...(later ? { blockedUntil: own.blockedUntil, blockReason: own.blockReason, blockedAt: own.blockedAt } : {}),
69
+ modelBlocks: mergeModelBlocks(account.modelBlocks, own.modelBlocks, 0),
70
+ };
71
+ });
72
+ }
73
+ function forgetSessionBlock(sessionKey, name) {
74
+ if (sessionKey === undefined)
75
+ return;
76
+ const blocks = sessionBlocks.get(sessionKey);
77
+ if (!blocks?.delete(name))
78
+ return;
79
+ if (blocks.size === 0)
80
+ sessionBlocks.delete(sessionKey);
81
+ }
82
+ /** `/claude-account unblock` lifts the account's session-scoped blocks in every session too. Returns whether one was lifted. */
83
+ export function forgetSessionBlocksFor(name) {
84
+ let lifted = false;
85
+ for (const [sessionKey, blocks] of [...sessionBlocks]) {
86
+ if (!blocks.has(name))
87
+ continue;
88
+ lifted = true;
89
+ forgetSessionBlock(sessionKey, name);
90
+ }
91
+ return lifted;
92
+ }
11
93
  export class ClassifiedSdkError extends Error {
12
94
  constructor(classification, original, suppressTurnRetry) {
13
95
  const detail = original instanceof Error ? original.message : String(original);
@@ -53,7 +135,7 @@ function retryAfterMs(error) {
53
135
  }
54
136
  function blockedAccount(account, classification, now, attempt, baseBlockMs, error, model) {
55
137
  if (classification.kind === "auth_error") {
56
- return { ...account, blockedUntil: now + AUTH_BLOCK_REPROBE_MS, blockReason: "auth_error" };
138
+ return { ...account, blockedUntil: now + AUTH_BLOCK_REPROBE_MS, blockReason: "auth_error", blockedAt: now };
57
139
  }
58
140
  const fallback = Math.min(MAX_RATE_LIMIT_BLOCK_MS, baseBlockMs * 2 ** attempt);
59
141
  if (classification.modelFamily !== undefined && model !== undefined) {
@@ -64,8 +146,45 @@ function blockedAccount(account, classification, now, attempt, baseBlockMs, erro
64
146
  const key = modelBlockKey(classification.modelFamily, model);
65
147
  return { ...account, modelBlocks: withModelBlock(account.modelBlocks, key, now + duration, now) };
66
148
  }
67
- const duration = Math.min(MAX_RATE_LIMIT_BLOCK_MS, retryAfterMs(error) ?? fallback);
68
- return { ...account, blockedUntil: now + duration, blockReason: classification.kind };
149
+ // A spent account is out until its limit resets, capped at the limit's own window; a fixed
150
+ // short block sent a pinned account back to the same limit every minute (senpi#3462).
151
+ const hinted = retryAfterMs(error) ?? usageLimitBlockMs(errorText(error), account.name, now);
152
+ const duration = Math.min(MAX_RATE_LIMIT_BLOCK_MS, hinted ?? fallback);
153
+ return { ...account, blockedUntil: now + duration, blockReason: classification.kind, blockedAt: now };
154
+ }
155
+ /**
156
+ * An account that just served a turn is not spent: a limit block stored before the turn started (a
157
+ * misread reset, a stale failure) is cleared so it cannot hold a working account out. A block stored
158
+ * after the turn started is newer evidence than this success and stays. Auth blocks keep their re-probe.
159
+ */
160
+ async function clearBlockAfterSuccess(store, providerId, name, startedAt) {
161
+ const clearable = (slot) => slot?.blockedUntil !== undefined && slot.blockReason !== "auth_error" && (slot.blockedAt ?? 0) <= startedAt;
162
+ const stored = await store.read(providerId);
163
+ if (stored?.type !== "oauth")
164
+ return;
165
+ const read = stored;
166
+ const slot = read.accounts?.find((account) => account.name === name) ?? read.slotState?.[name];
167
+ if (!clearable(slot))
168
+ return;
169
+ await store.modify(providerId, async (current) => {
170
+ if (current?.type !== "oauth")
171
+ return current;
172
+ const credential = current;
173
+ const state = credential.slotState?.[name];
174
+ const slotState = state === undefined || !clearable(state)
175
+ ? credential.slotState
176
+ : { ...credential.slotState, [name]: { ...(state.modelBlocks ? { modelBlocks: state.modelBlocks } : {}) } };
177
+ return {
178
+ ...credential,
179
+ ...(slotState === undefined ? {} : { slotState }),
180
+ accounts: (credential.accounts ?? []).map((account) => {
181
+ if (account.name !== name || !clearable(account))
182
+ return account;
183
+ const { blockedUntil: _blockedUntil, blockReason: _blockReason, blockedAt: _blockedAt, ...rest } = account;
184
+ return rest;
185
+ }),
186
+ };
187
+ });
69
188
  }
70
189
  function isTransient(classification) {
71
190
  return classification.kind === "overloaded" || classification.kind === "other";
@@ -111,6 +230,7 @@ async function persistBlock(store, providerId, account, now) {
111
230
  [account.name]: {
112
231
  blockedUntil: account.blockedUntil,
113
232
  blockReason: account.blockReason,
233
+ ...(account.blockedAt === undefined ? {} : { blockedAt: account.blockedAt }),
114
234
  // Union with what a concurrent request stored meanwhile, later expiry winning.
115
235
  modelBlocks: mergeModelBlocks(credential.slotState?.[account.name]?.modelBlocks, account.modelBlocks, now),
116
236
  },
@@ -129,6 +249,7 @@ async function persistBlock(store, providerId, account, now) {
129
249
  ...existing,
130
250
  blockedUntil: account.blockedUntil,
131
251
  blockReason: account.blockReason,
252
+ ...(account.blockedAt === undefined ? {} : { blockedAt: account.blockedAt }),
132
253
  modelBlocks: mergeModelBlocks(existing.modelBlocks, account.modelBlocks, now),
133
254
  }
134
255
  : existing);
@@ -142,15 +263,16 @@ function usable(account, now, model) {
142
263
  activeModelBlockUntil(account.modelBlocks, model, now) === undefined);
143
264
  }
144
265
  /**
145
- * Runs one attempt per account, plus at most TRANSIENT_RETRIES_PER_TURN same-account retries
146
- * for a transient failure (and one retry on newer stored credentials). A retry is transparent only before a
147
- * text, thinking, or tool-call event reaches the caller; post-delta failures
148
- * are marked so AgentSession never replays the partial turn.
266
+ * Runs one attempt per account for account-level failures (a limit, billing, auth), plus at most
267
+ * TRANSIENT_RETRIES_PER_TURN same-account retries for a transient failure, after which the turn fails on
268
+ * that account instead of rotating (and one retry on newer stored credentials). A retry is transparent only
269
+ * before a text, thinking, or tool-call event reaches the caller; post-delta failures are marked so
270
+ * AgentSession never replays the partial turn.
149
271
  */
150
272
  export async function* runFailover(options) {
151
273
  const now = options.now ?? Date.now;
152
274
  const baseBlockMs = options.baseBlockMs ?? DEFAULT_RATE_LIMIT_BLOCK_MS;
153
- let accounts = clearExpiredBlocks(options.accounts, now());
275
+ let accounts = clearExpiredBlocks(withSessionBlocks(options.accounts, options.sessionKey, now()), now());
154
276
  let lastError;
155
277
  const retriedOnStoredMaterial = new Set();
156
278
  let transientRetriesUsed = 0;
@@ -163,6 +285,8 @@ export async function* runFailover(options) {
163
285
  retryAccountName = undefined;
164
286
  const account = { ...(retrySlot ?? options.selectFn(accounts)) };
165
287
  let visibleDeltaEmitted = false;
288
+ let completed = false;
289
+ const startedAt = now();
166
290
  try {
167
291
  const attemptStream = await options.runAttempt(account);
168
292
  for await (const event of attemptStream) {
@@ -172,7 +296,7 @@ export async function* runFailover(options) {
172
296
  visibleDeltaEmitted ||= (options.isVisibleDelta ?? defaultIsVisibleDelta)(event);
173
297
  yield event;
174
298
  }
175
- return;
299
+ completed = true;
176
300
  }
177
301
  catch (error) {
178
302
  const classification = options.classify(error);
@@ -191,11 +315,14 @@ export async function* runFailover(options) {
191
315
  attempt--;
192
316
  continue;
193
317
  }
194
- // A transient failure says nothing about the account, and on the config-dir lane moving
195
- // to another account re-sends the whole conversation (senpi#2891): retry it in place first.
196
- // The budget is per turn, not per account: an outage that fails every account must not
197
- // multiply into retries on each of them.
198
- if (!visibleDeltaEmitted && isTransient(classification) && transientRetriesUsed < TRANSIENT_RETRIES_PER_TURN) {
318
+ // A transient failure says nothing about the account: it is retried in place a bounded
319
+ // number of times, then the turn fails on this account and the session's own retry and
320
+ // model fallback take over. It never blocks or rotates the account: moving to another
321
+ // account writes the whole conversation to a cold prompt cache, and a persisted block would
322
+ // move every other session off a healthy account too (senpi#2891, senpi#3466).
323
+ if (isTransient(classification)) {
324
+ if (visibleDeltaEmitted || transientRetriesUsed >= TRANSIENT_RETRIES_PER_TURN)
325
+ throw classified;
199
326
  const delayMs = TRANSIENT_RETRY_DELAY_MS * 2 ** transientRetriesUsed;
200
327
  transientRetriesUsed += 1;
201
328
  try {
@@ -209,7 +336,12 @@ export async function* runFailover(options) {
209
336
  continue;
210
337
  }
211
338
  const blocked = blockedAccount(account, classification, now(), attempt, baseBlockMs, error, options.model);
212
- const superseded = await persistBlock(options.store, options.providerId, blocked, now());
339
+ // With no session to hold it, a block is shared as before: dropping it would re-send every
340
+ // sessionless request to the spent account until its window resets.
341
+ const shared = isSharedVerdict(classification, error) || options.sessionKey === undefined;
342
+ if (!shared)
343
+ rememberSessionBlock(options.sessionKey, blocked);
344
+ const superseded = shared ? await persistBlock(options.store, options.providerId, blocked, now()) : undefined;
213
345
  if (superseded &&
214
346
  !visibleDeltaEmitted &&
215
347
  usable(superseded, now(), options.model) &&
@@ -239,6 +371,15 @@ export async function* runFailover(options) {
239
371
  if (visibleDeltaEmitted)
240
372
  throw classified;
241
373
  }
374
+ if (completed) {
375
+ forgetSessionBlock(options.sessionKey, account.name);
376
+ // Outside the attempt's try: clearing is best-effort hygiene after a finished turn, so a store
377
+ // failure is logged and the block stays for the next success; it never fails or replays the turn.
378
+ await clearBlockAfterSuccess(options.store, options.providerId, account.name, startedAt).catch((error) => {
379
+ process.stderr.write(`senpi anthropic-subscription: could not clear a stored block on ${account.name}: ${error instanceof Error ? error.message : String(error)}\n`);
380
+ });
381
+ return;
382
+ }
242
383
  }
243
384
  throw lastError ?? new Error("Anthropic Subscription failover exhausted without an attempt");
244
385
  }