takomi 2.1.2 → 2.1.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 (52) hide show
  1. package/.pi/README.md +124 -124
  2. package/.pi/agents/architect.md +15 -15
  3. package/.pi/agents/coder.md +14 -14
  4. package/.pi/agents/designer.md +17 -17
  5. package/.pi/agents/orchestrator.md +22 -22
  6. package/.pi/agents/reviewer.md +16 -16
  7. package/.pi/extensions/oauth-router/README.md +125 -125
  8. package/.pi/extensions/oauth-router/commands.ts +380 -380
  9. package/.pi/extensions/oauth-router/config.ts +200 -200
  10. package/.pi/extensions/oauth-router/index.ts +41 -41
  11. package/.pi/extensions/oauth-router/oauth-flow.ts +154 -154
  12. package/.pi/extensions/oauth-router/oauth-store.ts +121 -121
  13. package/.pi/extensions/oauth-router/package.json +14 -14
  14. package/.pi/extensions/oauth-router/policies.ts +27 -27
  15. package/.pi/extensions/oauth-router/provider.ts +492 -492
  16. package/.pi/extensions/oauth-router/scripts/vibe-verify.py +98 -98
  17. package/.pi/extensions/oauth-router/state.ts +174 -174
  18. package/.pi/extensions/oauth-router/types.ts +153 -153
  19. package/.pi/extensions/takomi-runtime/command-text.ts +130 -130
  20. package/.pi/extensions/takomi-runtime/commands.ts +179 -179
  21. package/.pi/extensions/takomi-runtime/context-panel.ts +282 -282
  22. package/.pi/extensions/takomi-runtime/index.ts +1288 -1288
  23. package/.pi/extensions/takomi-runtime/profile.ts +114 -114
  24. package/.pi/extensions/takomi-runtime/routing-policy.ts +105 -105
  25. package/.pi/extensions/takomi-runtime/shared.ts +511 -492
  26. package/.pi/extensions/takomi-runtime/subagent-controller.ts +364 -364
  27. package/.pi/extensions/takomi-runtime/subagent-render.ts +501 -501
  28. package/.pi/extensions/takomi-runtime/subagent-types.ts +90 -83
  29. package/.pi/extensions/takomi-runtime/ui.ts +133 -133
  30. package/.pi/extensions/takomi-subagents/agent-aliases.ts +18 -18
  31. package/.pi/extensions/takomi-subagents/agents.ts +113 -113
  32. package/.pi/extensions/takomi-subagents/delegation-plan.ts +95 -95
  33. package/.pi/extensions/takomi-subagents/dispatch-helpers.ts +26 -26
  34. package/.pi/extensions/takomi-subagents/dispatch.ts +306 -215
  35. package/.pi/extensions/takomi-subagents/index.ts +76 -75
  36. package/.pi/extensions/takomi-subagents/live-updates.ts +136 -83
  37. package/.pi/extensions/takomi-subagents/native-render.ts +5 -142
  38. package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +228 -0
  39. package/.pi/extensions/takomi-subagents/tool-runner.ts +209 -209
  40. package/.pi/themes/takomi-noir.json +81 -81
  41. package/package.json +59 -59
  42. package/src/cli.js +14 -0
  43. package/src/doctor.js +87 -84
  44. package/src/pi-harness.js +355 -351
  45. package/src/pi-installer.js +193 -171
  46. package/src/pi-takomi-core/index.ts +4 -4
  47. package/src/pi-takomi-core/orchestration.ts +402 -402
  48. package/src/pi-takomi-core/routing.ts +93 -93
  49. package/src/pi-takomi-core/types.ts +173 -173
  50. package/src/pi-takomi-core/workflows.ts +299 -299
  51. package/src/skills-installer.js +101 -101
  52. package/src/update-check.js +140 -0
@@ -1,98 +1,98 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import os
6
- import shutil
7
- import subprocess
8
- import sys
9
- from pathlib import Path
10
-
11
- ROOT = Path(__file__).resolve().parent.parent
12
- REQUIRED = [
13
- ROOT / "index.ts",
14
- ROOT / "provider.ts",
15
- ROOT / "commands.ts",
16
- ROOT / "oauth-flow.ts",
17
- ROOT / "oauth-store.ts",
18
- ROOT / "state.ts",
19
- ROOT / "policies.ts",
20
- ROOT / "config.ts",
21
- ROOT / "types.ts",
22
- ROOT / "README.md",
23
- ROOT / "docs" / "Project_Requirements.md",
24
- ROOT / "docs" / "Coding_Guidelines.md",
25
- ROOT / "docs" / "Builder_Prompt.md",
26
- ROOT / "docs" / "issues" / "FR-001.md",
27
- ]
28
- EXPECTED_MODELS = ["oauth-router", "gpt-4o", "gpt-4.1", "o4-mini", "gpt-5.4"]
29
- PI_CANDIDATES = [
30
- os.environ.get("PI_BIN"),
31
- shutil.which("pi"),
32
- shutil.which("pi.cmd"),
33
- str(Path.home() / "AppData" / "Roaming" / "npm" / "pi"),
34
- str(Path.home() / "AppData" / "Roaming" / "npm" / "pi.cmd"),
35
- ]
36
-
37
-
38
- def resolve_pi() -> str | None:
39
- for candidate in PI_CANDIDATES:
40
- if not candidate:
41
- continue
42
- path = Path(candidate)
43
- if path.exists():
44
- return str(path)
45
- return None
46
-
47
-
48
- def run(cmd: list[str], timeout: int = 90) -> tuple[int, str, str]:
49
- proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
50
- return proc.returncode, proc.stdout, proc.stderr
51
-
52
-
53
- def main() -> int:
54
- parser = argparse.ArgumentParser()
55
- parser.add_argument("--quick", action="store_true")
56
- args = parser.parse_args()
57
-
58
- failed = False
59
-
60
- for path in REQUIRED:
61
- if not path.exists():
62
- print(f"[FAIL] missing: {path}")
63
- failed = True
64
- else:
65
- print(f"[ OK ] found: {path.relative_to(ROOT)}")
66
-
67
- if args.quick:
68
- return 1 if failed else 0
69
-
70
- pi_bin = resolve_pi()
71
- if not pi_bin:
72
- print("[FAIL] unable to locate `pi` binary")
73
- return 1
74
-
75
- try:
76
- code, stdout, stderr = run([pi_bin, "--list-models"])
77
- output = f"{stdout}\n{stderr}"
78
- if code != 0:
79
- print("[FAIL] `pi --list-models` failed")
80
- print(output.strip())
81
- failed = True
82
- else:
83
- print("[ OK ] `pi --list-models` executed")
84
- for token in EXPECTED_MODELS:
85
- if token not in output:
86
- print(f"[FAIL] model token not found in list output: {token}")
87
- failed = True
88
- else:
89
- print(f"[ OK ] model token present: {token}")
90
- except Exception as exc:
91
- print(f"[FAIL] unable to execute `pi --list-models`: {exc}")
92
- failed = True
93
-
94
- return 1 if failed else 0
95
-
96
-
97
- if __name__ == "__main__":
98
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ ROOT = Path(__file__).resolve().parent.parent
12
+ REQUIRED = [
13
+ ROOT / "index.ts",
14
+ ROOT / "provider.ts",
15
+ ROOT / "commands.ts",
16
+ ROOT / "oauth-flow.ts",
17
+ ROOT / "oauth-store.ts",
18
+ ROOT / "state.ts",
19
+ ROOT / "policies.ts",
20
+ ROOT / "config.ts",
21
+ ROOT / "types.ts",
22
+ ROOT / "README.md",
23
+ ROOT / "docs" / "Project_Requirements.md",
24
+ ROOT / "docs" / "Coding_Guidelines.md",
25
+ ROOT / "docs" / "Builder_Prompt.md",
26
+ ROOT / "docs" / "issues" / "FR-001.md",
27
+ ]
28
+ EXPECTED_MODELS = ["oauth-router", "gpt-4o", "gpt-4.1", "o4-mini", "gpt-5.4"]
29
+ PI_CANDIDATES = [
30
+ os.environ.get("PI_BIN"),
31
+ shutil.which("pi"),
32
+ shutil.which("pi.cmd"),
33
+ str(Path.home() / "AppData" / "Roaming" / "npm" / "pi"),
34
+ str(Path.home() / "AppData" / "Roaming" / "npm" / "pi.cmd"),
35
+ ]
36
+
37
+
38
+ def resolve_pi() -> str | None:
39
+ for candidate in PI_CANDIDATES:
40
+ if not candidate:
41
+ continue
42
+ path = Path(candidate)
43
+ if path.exists():
44
+ return str(path)
45
+ return None
46
+
47
+
48
+ def run(cmd: list[str], timeout: int = 90) -> tuple[int, str, str]:
49
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
50
+ return proc.returncode, proc.stdout, proc.stderr
51
+
52
+
53
+ def main() -> int:
54
+ parser = argparse.ArgumentParser()
55
+ parser.add_argument("--quick", action="store_true")
56
+ args = parser.parse_args()
57
+
58
+ failed = False
59
+
60
+ for path in REQUIRED:
61
+ if not path.exists():
62
+ print(f"[FAIL] missing: {path}")
63
+ failed = True
64
+ else:
65
+ print(f"[ OK ] found: {path.relative_to(ROOT)}")
66
+
67
+ if args.quick:
68
+ return 1 if failed else 0
69
+
70
+ pi_bin = resolve_pi()
71
+ if not pi_bin:
72
+ print("[FAIL] unable to locate `pi` binary")
73
+ return 1
74
+
75
+ try:
76
+ code, stdout, stderr = run([pi_bin, "--list-models"])
77
+ output = f"{stdout}\n{stderr}"
78
+ if code != 0:
79
+ print("[FAIL] `pi --list-models` failed")
80
+ print(output.strip())
81
+ failed = True
82
+ else:
83
+ print("[ OK ] `pi --list-models` executed")
84
+ for token in EXPECTED_MODELS:
85
+ if token not in output:
86
+ print(f"[FAIL] model token not found in list output: {token}")
87
+ failed = True
88
+ else:
89
+ print(f"[ OK ] model token present: {token}")
90
+ except Exception as exc:
91
+ print(f"[FAIL] unable to execute `pi --list-models`: {exc}")
92
+ failed = True
93
+
94
+ return 1 if failed else 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ sys.exit(main())
@@ -1,174 +1,174 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { STATE_PATH, writeJsonFile } from "./config.ts";
3
- import type { RouterAccountState, RouterRuntimeState, RoutingPolicyName } from "./types.ts";
4
-
5
- const DEFAULT_STATE: RouterRuntimeState = {
6
- version: 1,
7
- policy: "round-robin",
8
- rrCursor: 0,
9
- weightedCursor: 0,
10
- accounts: {},
11
- };
12
-
13
- function normalizeAccountState(accountId: string, input?: Partial<RouterAccountState>): RouterAccountState {
14
- return {
15
- accountId,
16
- authHealth: input?.authHealth === "invalid" ? "invalid" : "ok",
17
- cooldownUntil: Number.isFinite(input?.cooldownUntil) ? input?.cooldownUntil : undefined,
18
- penaltyUntil: Number.isFinite(input?.penaltyUntil) ? input?.penaltyUntil : undefined,
19
- lastUsedAt: Number.isFinite(input?.lastUsedAt) ? input?.lastUsedAt : undefined,
20
- lastTriedAt: Number.isFinite(input?.lastTriedAt) ? input?.lastTriedAt : undefined,
21
- lastModel: typeof input?.lastModel === "string" ? input.lastModel : undefined,
22
- lastStatus: Number.isFinite(input?.lastStatus) ? input?.lastStatus : undefined,
23
- lastError: typeof input?.lastError === "string" ? input.lastError : undefined,
24
- failures: Number.isFinite(input?.failures) ? input!.failures! : 0,
25
- rateLimitCount: Number.isFinite(input?.rateLimitCount) ? input!.rateLimitCount! : 0,
26
- authFailureCount: Number.isFinite(input?.authFailureCount) ? input!.authFailureCount! : 0,
27
- successCount: Number.isFinite(input?.successCount) ? input!.successCount! : 0,
28
- };
29
- }
30
-
31
- export class RouterStateStore {
32
- private data: RouterRuntimeState;
33
-
34
- constructor(initialPolicy: RoutingPolicyName) {
35
- this.data = this.load(initialPolicy);
36
- }
37
-
38
- private load(initialPolicy: RoutingPolicyName): RouterRuntimeState {
39
- if (!existsSync(STATE_PATH)) {
40
- const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
41
- writeJsonFile(STATE_PATH, initial, true);
42
- return initial;
43
- }
44
-
45
- try {
46
- const parsed = JSON.parse(readFileSync(STATE_PATH, "utf8")) as Partial<RouterRuntimeState>;
47
- const accounts = Object.fromEntries(
48
- Object.entries(parsed.accounts ?? {}).map(([accountId, state]) => [accountId, normalizeAccountState(accountId, state)]),
49
- );
50
- return {
51
- version: 1,
52
- policy:
53
- parsed.policy === "weighted-round-robin" || parsed.policy === "round-robin" ? parsed.policy : initialPolicy,
54
- rrCursor: Number.isFinite(parsed.rrCursor) ? parsed.rrCursor : 0,
55
- weightedCursor: Number.isFinite(parsed.weightedCursor) ? parsed.weightedCursor : 0,
56
- accounts,
57
- };
58
- } catch {
59
- const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
60
- writeJsonFile(STATE_PATH, initial, true);
61
- return initial;
62
- }
63
- }
64
-
65
- private save() {
66
- writeJsonFile(STATE_PATH, this.data, true);
67
- }
68
-
69
- snapshot(): RouterRuntimeState {
70
- return JSON.parse(JSON.stringify(this.data)) as RouterRuntimeState;
71
- }
72
-
73
- getPolicy(defaultPolicy: RoutingPolicyName): RoutingPolicyName {
74
- if (this.data.policy !== "round-robin" && this.data.policy !== "weighted-round-robin") {
75
- this.data.policy = defaultPolicy;
76
- this.save();
77
- }
78
- return this.data.policy;
79
- }
80
-
81
- setPolicy(policy: RoutingPolicyName) {
82
- this.data.policy = policy;
83
- this.save();
84
- }
85
-
86
- getCursor(policy: RoutingPolicyName): number {
87
- return policy === "weighted-round-robin" ? this.data.weightedCursor : this.data.rrCursor;
88
- }
89
-
90
- advanceCursor(policy: RoutingPolicyName, next: number) {
91
- if (policy === "weighted-round-robin") {
92
- this.data.weightedCursor = next;
93
- } else {
94
- this.data.rrCursor = next;
95
- }
96
- this.save();
97
- }
98
-
99
- ensureAccount(accountId: string): RouterAccountState {
100
- if (!this.data.accounts[accountId]) {
101
- this.data.accounts[accountId] = normalizeAccountState(accountId);
102
- this.save();
103
- }
104
- return this.data.accounts[accountId]!;
105
- }
106
-
107
- pruneAccountIds(validIds: string[]) {
108
- const valid = new Set(validIds);
109
- for (const id of Object.keys(this.data.accounts)) {
110
- if (!valid.has(id)) delete this.data.accounts[id];
111
- }
112
- this.save();
113
- }
114
-
115
- markAttempt(accountId: string, modelId: string) {
116
- const account = this.ensureAccount(accountId);
117
- account.lastTriedAt = Date.now();
118
- account.lastModel = modelId;
119
- this.save();
120
- }
121
-
122
- markSuccess(accountId: string, status?: number) {
123
- const account = this.ensureAccount(accountId);
124
- account.authHealth = "ok";
125
- account.lastUsedAt = Date.now();
126
- account.lastStatus = status;
127
- account.lastError = undefined;
128
- account.penaltyUntil = undefined;
129
- account.failures = 0;
130
- account.successCount += 1;
131
- this.save();
132
- }
133
-
134
- markRateLimit(accountId: string, retryAfterMs: number, status = 429, message = "Rate limited") {
135
- const account = this.ensureAccount(accountId);
136
- const now = Date.now();
137
- account.lastStatus = status;
138
- account.lastError = message;
139
- account.cooldownUntil = now + Math.max(1_000, retryAfterMs);
140
- account.failures += 1;
141
- account.rateLimitCount += 1;
142
- this.save();
143
- }
144
-
145
- markAuthFailure(accountId: string, status = 401, message = "Authentication failed") {
146
- const account = this.ensureAccount(accountId);
147
- account.authHealth = "invalid";
148
- account.lastStatus = status;
149
- account.lastError = message;
150
- account.failures += 1;
151
- account.authFailureCount += 1;
152
- account.cooldownUntil = undefined;
153
- account.penaltyUntil = undefined;
154
- this.save();
155
- }
156
-
157
- markTransientFailure(accountId: string, penaltyMs: number, status = 500, message = "Transient upstream failure") {
158
- const account = this.ensureAccount(accountId);
159
- account.lastStatus = status;
160
- account.lastError = message;
161
- account.penaltyUntil = Date.now() + Math.max(1_000, penaltyMs);
162
- account.failures += 1;
163
- this.save();
164
- }
165
-
166
- clearHealth(accountId: string) {
167
- const account = this.ensureAccount(accountId);
168
- account.authHealth = "ok";
169
- account.cooldownUntil = undefined;
170
- account.penaltyUntil = undefined;
171
- account.lastError = undefined;
172
- this.save();
173
- }
174
- }
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { STATE_PATH, writeJsonFile } from "./config.ts";
3
+ import type { RouterAccountState, RouterRuntimeState, RoutingPolicyName } from "./types.ts";
4
+
5
+ const DEFAULT_STATE: RouterRuntimeState = {
6
+ version: 1,
7
+ policy: "round-robin",
8
+ rrCursor: 0,
9
+ weightedCursor: 0,
10
+ accounts: {},
11
+ };
12
+
13
+ function normalizeAccountState(accountId: string, input?: Partial<RouterAccountState>): RouterAccountState {
14
+ return {
15
+ accountId,
16
+ authHealth: input?.authHealth === "invalid" ? "invalid" : "ok",
17
+ cooldownUntil: Number.isFinite(input?.cooldownUntil) ? input?.cooldownUntil : undefined,
18
+ penaltyUntil: Number.isFinite(input?.penaltyUntil) ? input?.penaltyUntil : undefined,
19
+ lastUsedAt: Number.isFinite(input?.lastUsedAt) ? input?.lastUsedAt : undefined,
20
+ lastTriedAt: Number.isFinite(input?.lastTriedAt) ? input?.lastTriedAt : undefined,
21
+ lastModel: typeof input?.lastModel === "string" ? input.lastModel : undefined,
22
+ lastStatus: Number.isFinite(input?.lastStatus) ? input?.lastStatus : undefined,
23
+ lastError: typeof input?.lastError === "string" ? input.lastError : undefined,
24
+ failures: Number.isFinite(input?.failures) ? input!.failures! : 0,
25
+ rateLimitCount: Number.isFinite(input?.rateLimitCount) ? input!.rateLimitCount! : 0,
26
+ authFailureCount: Number.isFinite(input?.authFailureCount) ? input!.authFailureCount! : 0,
27
+ successCount: Number.isFinite(input?.successCount) ? input!.successCount! : 0,
28
+ };
29
+ }
30
+
31
+ export class RouterStateStore {
32
+ private data: RouterRuntimeState;
33
+
34
+ constructor(initialPolicy: RoutingPolicyName) {
35
+ this.data = this.load(initialPolicy);
36
+ }
37
+
38
+ private load(initialPolicy: RoutingPolicyName): RouterRuntimeState {
39
+ if (!existsSync(STATE_PATH)) {
40
+ const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
41
+ writeJsonFile(STATE_PATH, initial, true);
42
+ return initial;
43
+ }
44
+
45
+ try {
46
+ const parsed = JSON.parse(readFileSync(STATE_PATH, "utf8")) as Partial<RouterRuntimeState>;
47
+ const accounts = Object.fromEntries(
48
+ Object.entries(parsed.accounts ?? {}).map(([accountId, state]) => [accountId, normalizeAccountState(accountId, state)]),
49
+ );
50
+ return {
51
+ version: 1,
52
+ policy:
53
+ parsed.policy === "weighted-round-robin" || parsed.policy === "round-robin" ? parsed.policy : initialPolicy,
54
+ rrCursor: Number.isFinite(parsed.rrCursor) ? parsed.rrCursor : 0,
55
+ weightedCursor: Number.isFinite(parsed.weightedCursor) ? parsed.weightedCursor : 0,
56
+ accounts,
57
+ };
58
+ } catch {
59
+ const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
60
+ writeJsonFile(STATE_PATH, initial, true);
61
+ return initial;
62
+ }
63
+ }
64
+
65
+ private save() {
66
+ writeJsonFile(STATE_PATH, this.data, true);
67
+ }
68
+
69
+ snapshot(): RouterRuntimeState {
70
+ return JSON.parse(JSON.stringify(this.data)) as RouterRuntimeState;
71
+ }
72
+
73
+ getPolicy(defaultPolicy: RoutingPolicyName): RoutingPolicyName {
74
+ if (this.data.policy !== "round-robin" && this.data.policy !== "weighted-round-robin") {
75
+ this.data.policy = defaultPolicy;
76
+ this.save();
77
+ }
78
+ return this.data.policy;
79
+ }
80
+
81
+ setPolicy(policy: RoutingPolicyName) {
82
+ this.data.policy = policy;
83
+ this.save();
84
+ }
85
+
86
+ getCursor(policy: RoutingPolicyName): number {
87
+ return policy === "weighted-round-robin" ? this.data.weightedCursor : this.data.rrCursor;
88
+ }
89
+
90
+ advanceCursor(policy: RoutingPolicyName, next: number) {
91
+ if (policy === "weighted-round-robin") {
92
+ this.data.weightedCursor = next;
93
+ } else {
94
+ this.data.rrCursor = next;
95
+ }
96
+ this.save();
97
+ }
98
+
99
+ ensureAccount(accountId: string): RouterAccountState {
100
+ if (!this.data.accounts[accountId]) {
101
+ this.data.accounts[accountId] = normalizeAccountState(accountId);
102
+ this.save();
103
+ }
104
+ return this.data.accounts[accountId]!;
105
+ }
106
+
107
+ pruneAccountIds(validIds: string[]) {
108
+ const valid = new Set(validIds);
109
+ for (const id of Object.keys(this.data.accounts)) {
110
+ if (!valid.has(id)) delete this.data.accounts[id];
111
+ }
112
+ this.save();
113
+ }
114
+
115
+ markAttempt(accountId: string, modelId: string) {
116
+ const account = this.ensureAccount(accountId);
117
+ account.lastTriedAt = Date.now();
118
+ account.lastModel = modelId;
119
+ this.save();
120
+ }
121
+
122
+ markSuccess(accountId: string, status?: number) {
123
+ const account = this.ensureAccount(accountId);
124
+ account.authHealth = "ok";
125
+ account.lastUsedAt = Date.now();
126
+ account.lastStatus = status;
127
+ account.lastError = undefined;
128
+ account.penaltyUntil = undefined;
129
+ account.failures = 0;
130
+ account.successCount += 1;
131
+ this.save();
132
+ }
133
+
134
+ markRateLimit(accountId: string, retryAfterMs: number, status = 429, message = "Rate limited") {
135
+ const account = this.ensureAccount(accountId);
136
+ const now = Date.now();
137
+ account.lastStatus = status;
138
+ account.lastError = message;
139
+ account.cooldownUntil = now + Math.max(1_000, retryAfterMs);
140
+ account.failures += 1;
141
+ account.rateLimitCount += 1;
142
+ this.save();
143
+ }
144
+
145
+ markAuthFailure(accountId: string, status = 401, message = "Authentication failed") {
146
+ const account = this.ensureAccount(accountId);
147
+ account.authHealth = "invalid";
148
+ account.lastStatus = status;
149
+ account.lastError = message;
150
+ account.failures += 1;
151
+ account.authFailureCount += 1;
152
+ account.cooldownUntil = undefined;
153
+ account.penaltyUntil = undefined;
154
+ this.save();
155
+ }
156
+
157
+ markTransientFailure(accountId: string, penaltyMs: number, status = 500, message = "Transient upstream failure") {
158
+ const account = this.ensureAccount(accountId);
159
+ account.lastStatus = status;
160
+ account.lastError = message;
161
+ account.penaltyUntil = Date.now() + Math.max(1_000, penaltyMs);
162
+ account.failures += 1;
163
+ this.save();
164
+ }
165
+
166
+ clearHealth(accountId: string) {
167
+ const account = this.ensureAccount(accountId);
168
+ account.authHealth = "ok";
169
+ account.cooldownUntil = undefined;
170
+ account.penaltyUntil = undefined;
171
+ account.lastError = undefined;
172
+ this.save();
173
+ }
174
+ }