takomi 2.1.31 → 2.1.32

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.
@@ -28,6 +28,7 @@ Pi extension that auto-loads and registers an `oauth-router` provider with multi
28
28
  - 429 cooldowns
29
29
  - transient failure penalties
30
30
  - client/network transport failure tracking without default account cooldown
31
+ - live Pi UI footer/notification updates when an upstream attempt, retry delay, failover, or final router error happens
31
32
  - 5 router-level client/network retries by default before pre-output failover, starting at 5s and doubling
32
33
  - auth failure quarantine
33
34
  - safe pre-output failover
@@ -47,7 +48,7 @@ The extension ships with two default upstream profiles:
47
48
  - api: `openai-responses`
48
49
  - default models: `gpt-4o`, `gpt-4.1`, `o4-mini`
49
50
 
50
- Edit `~/.pi/agent/oauth-router/config.json` to add more upstreams, swap endpoints, change model catalogs, or tune retry behavior. By default client-side transport failures such as `Codex SSE response headers timed out after 10000ms` retry the same account 5 times with exponential backoff (`5s`, `10s`, `20s`, `40s`, then capped at `60s`) before router failover. These failures are recorded but do not cool down an account unless `clientNetworkPenaltyMs` is set above `0`.
51
+ Edit `~/.pi/agent/oauth-router/config.json` to add more upstreams, swap endpoints, change model catalogs, or tune retry behavior. By default client-side transport failures such as `Codex SSE response headers timed out after 10000ms` retry the same account 5 times with exponential backoff (`5s`, `10s`, `20s`, `40s`, then capped at `60s`) before router failover. While this is happening, Pi's footer shows the active retry/failover/error state so the UI no longer looks frozen. These failures are recorded but do not cool down an account unless `clientNetworkPenaltyMs` is set above `0`.
51
52
 
52
53
  ## Setup
53
54
 
@@ -1,41 +1,130 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { registerRouterCommands, formatStatusReport } from "./commands.ts";
3
- import { registerRouterProvider, RouterRuntime } from "./provider.ts";
4
-
5
- function updateFooter(pi: ExtensionAPI, runtime: RouterRuntime, notify = false) {
6
- pi.on("session_start", async (_event, ctx) => {
7
- const rows = runtime.getStatusRows();
8
- const healthy = rows.filter((row) => {
9
- if (!row.enabled) return false;
10
- if (row.authHealth !== "ok") return false;
11
- if (row.cooldownUntil && row.cooldownUntil > Date.now()) return false;
12
- if (row.penaltyUntil && row.penaltyUntil > Date.now()) return false;
13
- return true;
14
- }).length;
15
-
16
- ctx.ui.setStatus("oauth-router", `oauth-router ${healthy}/${rows.length || 0} healthy | ${runtime.getPolicy()}`);
17
- if (notify) {
18
- ctx.ui.notify("oauth-router loaded", "info");
19
- }
20
- });
21
- }
22
-
23
- export default function (pi: ExtensionAPI) {
24
- const runtime = new RouterRuntime();
25
-
26
- registerRouterProvider(pi, runtime);
27
- registerRouterCommands(pi, runtime);
28
- updateFooter(pi, runtime, false);
29
-
30
- pi.registerCommand("router-debug-report", {
31
- description: "Emit a detailed oauth-router report",
32
- handler: async () => {
33
- pi.sendMessage({
34
- customType: "oauth-router",
35
- content: formatStatusReport(runtime),
36
- display: true,
37
- details: { source: "oauth-router", debug: true },
38
- });
39
- },
40
- });
41
- }
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { registerRouterCommands, formatStatusReport } from "./commands.ts";
3
+ import { registerRouterProvider, RouterRuntime } from "./provider.ts";
4
+ import type { RouterUiEvent } from "./types.ts";
5
+
6
+ function getHealthSummary(runtime: RouterRuntime) {
7
+ const rows = runtime.getStatusRows();
8
+ const healthy = rows.filter((row) => {
9
+ if (!row.enabled) return false;
10
+ if (row.authHealth !== "ok") return false;
11
+ if (row.cooldownUntil && row.cooldownUntil > Date.now()) return false;
12
+ if (row.penaltyUntil && row.penaltyUntil > Date.now()) return false;
13
+ return true;
14
+ }).length;
15
+
16
+ return `oauth-router ${healthy}/${rows.length || 0} healthy | ${runtime.getPolicy()}`;
17
+ }
18
+
19
+ function formatDuration(ms?: number): string {
20
+ if (!ms || ms <= 0) return "now";
21
+ if (ms < 1_000) return `${Math.round(ms)}ms`;
22
+ return `${Math.round(ms / 1_000)}s`;
23
+ }
24
+
25
+ function truncate(text: string | undefined, max = 110): string {
26
+ if (!text) return "";
27
+ const clean = text.replace(/\s+/g, " ").trim();
28
+ return clean.length > max ? `${clean.slice(0, Math.max(0, max - 1))}…` : clean;
29
+ }
30
+
31
+ function formatRoute(event: RouterUiEvent): string {
32
+ const parts = [event.accountLabel || event.accountId, event.upstreamId].filter(Boolean);
33
+ return parts.length ? parts.join(" @ ") : "router";
34
+ }
35
+
36
+ function formatActivityStatus(event: RouterUiEvent): string {
37
+ const route = formatRoute(event);
38
+ const model = event.modelId ? `${event.modelId} ` : "";
39
+ const reason = truncate(event.message, 70);
40
+
41
+ switch (event.phase) {
42
+ case "attempt": {
43
+ const retry = event.retryAttempt && event.maxRetries
44
+ ? ` retry ${event.retryAttempt}/${event.maxRetries}`
45
+ : "";
46
+ return `oauth-router contacting ${model}via ${route}${retry}`;
47
+ }
48
+ case "retry":
49
+ return `oauth-router retry ${event.retryAttempt}/${event.maxRetries} in ${formatDuration(event.delayMs)} | ${reason || event.failureKind || "network error"}`;
50
+ case "failover":
51
+ return `oauth-router failover from ${route} | ${reason || event.failureKind || "upstream error"}`;
52
+ case "success":
53
+ return `oauth-router ok: ${model}via ${route}`;
54
+ case "error":
55
+ return `oauth-router error | ${reason || event.failureKind || "request failed"}`;
56
+ }
57
+ }
58
+
59
+ function notifyActivity(ctx: ExtensionContext, event: RouterUiEvent) {
60
+ if (event.phase === "attempt" || event.phase === "success") return;
61
+
62
+ const message = formatActivityStatus(event);
63
+ const level = event.phase === "error" ? "error" : "info";
64
+ ctx.ui.notify(message, level);
65
+ }
66
+
67
+ function installRouterUiBridge(pi: ExtensionAPI, runtime: RouterRuntime, notifyOnLoad = false) {
68
+ let activeCtx: ExtensionContext | undefined;
69
+
70
+ const setBaseStatus = (ctx: ExtensionContext) => {
71
+ activeCtx = ctx;
72
+ if (ctx.hasUI) ctx.ui.setStatus("oauth-router", getHealthSummary(runtime));
73
+ };
74
+
75
+ runtime.setUiReporter((event) => {
76
+ const ctx = activeCtx;
77
+ if (!ctx?.hasUI) return;
78
+ try {
79
+ ctx.ui.setStatus("oauth-router", formatActivityStatus(event));
80
+ notifyActivity(ctx, event);
81
+ } catch {
82
+ // Best-effort UI only; never disturb provider streaming.
83
+ }
84
+ });
85
+
86
+ pi.on("session_start", async (_event, ctx) => {
87
+ setBaseStatus(ctx);
88
+ if (notifyOnLoad) ctx.ui.notify("oauth-router loaded", "info");
89
+ });
90
+
91
+ pi.on("agent_start", async (_event, ctx) => {
92
+ setBaseStatus(ctx);
93
+ });
94
+
95
+ pi.on("turn_start", async (_event, ctx) => {
96
+ setBaseStatus(ctx);
97
+ });
98
+
99
+ pi.on("turn_end", async (_event, ctx) => {
100
+ setBaseStatus(ctx);
101
+ });
102
+
103
+ pi.on("agent_end", async (_event, ctx) => {
104
+ setBaseStatus(ctx);
105
+ });
106
+
107
+ pi.on("session_shutdown", async () => {
108
+ activeCtx = undefined;
109
+ });
110
+ }
111
+
112
+ export default function (pi: ExtensionAPI) {
113
+ const runtime = new RouterRuntime();
114
+
115
+ registerRouterProvider(pi, runtime);
116
+ registerRouterCommands(pi, runtime);
117
+ installRouterUiBridge(pi, runtime, false);
118
+
119
+ pi.registerCommand("router-debug-report", {
120
+ description: "Emit a detailed oauth-router report",
121
+ handler: async () => {
122
+ pi.sendMessage({
123
+ customType: "oauth-router",
124
+ content: formatStatusReport(runtime),
125
+ display: true,
126
+ details: { source: "oauth-router", debug: true },
127
+ });
128
+ },
129
+ });
130
+ }
@@ -25,6 +25,8 @@ import type {
25
25
  RouterErrorMessageInput,
26
26
  RouterModelConfig,
27
27
  RouterStatusRow,
28
+ RouterUiEvent,
29
+ RouterUiReporter,
28
30
  RouterUpstreamConfig,
29
31
  RoutingPolicyName,
30
32
  StoredRouterAccount,
@@ -176,6 +178,7 @@ export class RouterRuntime {
176
178
  private config: RouterConfig;
177
179
  private accounts: RouterAccountStore;
178
180
  private state: RouterStateStore;
181
+ private uiReporter?: RouterUiReporter;
179
182
 
180
183
  constructor() {
181
184
  this.config = loadRouterConfig();
@@ -190,6 +193,18 @@ export class RouterRuntime {
190
193
  this.state.pruneAccountIds(this.accounts.list().map((account) => account.id));
191
194
  }
192
195
 
196
+ setUiReporter(reporter?: RouterUiReporter) {
197
+ this.uiReporter = reporter;
198
+ }
199
+
200
+ private emitUi(event: RouterUiEvent) {
201
+ try {
202
+ this.uiReporter?.(event);
203
+ } catch {
204
+ // UI updates must never affect model streaming or retry behavior.
205
+ }
206
+ }
207
+
193
208
  getConfig(): RouterConfig {
194
209
  this.reloadConfig();
195
210
  return this.config;
@@ -468,17 +483,40 @@ export class RouterRuntime {
468
483
  ): Promise<{ completed: boolean; emittedMeaningfulOutput: boolean; failure?: FailureClassification }> {
469
484
  for (let retryIndex = 0; ; retryIndex += 1) {
470
485
  const result = await this.trySingleAccount(selection, outerStream, context, options);
486
+ const failure = result.failure;
471
487
  const canRetry =
472
488
  !result.completed &&
473
489
  !result.emittedMeaningfulOutput &&
474
- result.failure?.kind === "client-network" &&
490
+ failure?.kind === "client-network" &&
475
491
  retryIndex < this.config.clientNetworkMaxRetries;
476
492
 
477
- if (!canRetry) return result;
493
+ if (!canRetry || !failure) return result;
478
494
 
479
495
  const delayMs = this.getClientNetworkRetryDelayMs(retryIndex);
496
+ this.emitUi({
497
+ phase: "retry",
498
+ modelId,
499
+ accountId: selection.account.id,
500
+ accountLabel: selection.account.label,
501
+ upstreamId: selection.account.upstreamId,
502
+ failureKind: failure.kind,
503
+ status: failure.status,
504
+ message: failure.message,
505
+ retryAttempt: retryIndex + 1,
506
+ maxRetries: this.config.clientNetworkMaxRetries,
507
+ delayMs,
508
+ });
480
509
  await sleep(delayMs, options?.signal);
481
510
  this.state.markAttempt(selection.account.id, modelId);
511
+ this.emitUi({
512
+ phase: "attempt",
513
+ modelId,
514
+ accountId: selection.account.id,
515
+ accountLabel: selection.account.label,
516
+ upstreamId: selection.account.upstreamId,
517
+ retryAttempt: retryIndex + 2,
518
+ maxRetries: this.config.clientNetworkMaxRetries,
519
+ });
482
520
  }
483
521
  }
484
522
 
@@ -521,7 +559,11 @@ export class RouterRuntime {
521
559
  reason: "aborted",
522
560
  error: { ...event.error, stopReason: "aborted", errorMessage: message },
523
561
  });
524
- return { completed: true, emittedMeaningfulOutput };
562
+ return {
563
+ completed: true,
564
+ emittedMeaningfulOutput,
565
+ failure: { kind: "fatal", status: responseStatus, message },
566
+ };
525
567
  }
526
568
 
527
569
  const failure = classifyFailure(responseStatus, responseHeaders, message, this.config);
@@ -596,6 +638,13 @@ export class RouterRuntime {
596
638
  const message = lastFailure
597
639
  ? `No healthy oauth-router accounts remaining after failover. Last error: ${lastFailure.message}`
598
640
  : `No healthy oauth-router accounts are configured for model ${model.id}. Add accounts with /router-login add.`;
641
+ this.emitUi({
642
+ phase: "error",
643
+ modelId: model.id,
644
+ failureKind: lastFailure?.kind,
645
+ status: lastFailure?.status,
646
+ message,
647
+ });
599
648
  outer.push({ type: "error", reason: "error", error: createErrorMessage({ model, message }) });
600
649
  outer.end();
601
650
  return;
@@ -604,6 +653,13 @@ export class RouterRuntime {
604
653
  this.state.advanceCursor(policy, picked.nextCursor);
605
654
  tried.add(picked.selected.account.id);
606
655
  this.state.markAttempt(picked.selected.account.id, model.id);
656
+ this.emitUi({
657
+ phase: "attempt",
658
+ modelId: model.id,
659
+ accountId: picked.selected.account.id,
660
+ accountLabel: picked.selected.account.label,
661
+ upstreamId: picked.selected.account.upstreamId,
662
+ });
607
663
 
608
664
  let selection: DelegateSelection;
609
665
  try {
@@ -617,11 +673,39 @@ export class RouterRuntime {
617
673
  }
618
674
  lastFailure = { kind: "auth", status: 401, message };
619
675
  this.markFailure(picked.selected.account.id, lastFailure);
676
+ this.emitUi({
677
+ phase: "failover",
678
+ modelId: model.id,
679
+ accountId: picked.selected.account.id,
680
+ accountLabel: picked.selected.account.label,
681
+ upstreamId: picked.selected.account.upstreamId,
682
+ failureKind: lastFailure.kind,
683
+ status: lastFailure.status,
684
+ message: lastFailure.message,
685
+ });
620
686
  continue;
621
687
  }
622
688
 
623
689
  const result = await this.tryAccountWithClientNetworkRetries(selection, outer, context, model.id, options);
624
690
  if (result.completed) {
691
+ this.emitUi(result.failure
692
+ ? {
693
+ phase: "error",
694
+ modelId: model.id,
695
+ accountId: selection.account.id,
696
+ accountLabel: selection.account.label,
697
+ upstreamId: selection.account.upstreamId,
698
+ failureKind: result.failure.kind,
699
+ status: result.failure.status,
700
+ message: result.failure.message,
701
+ }
702
+ : {
703
+ phase: "success",
704
+ modelId: model.id,
705
+ accountId: selection.account.id,
706
+ accountLabel: selection.account.label,
707
+ upstreamId: selection.account.upstreamId,
708
+ });
625
709
  outer.end();
626
710
  return;
627
711
  }
@@ -630,13 +714,41 @@ export class RouterRuntime {
630
714
  lastFailure = result.failure;
631
715
 
632
716
  if (emittedMeaningfulOutput) {
717
+ this.emitUi({
718
+ phase: "error",
719
+ modelId: model.id,
720
+ accountId: selection.account.id,
721
+ accountLabel: selection.account.label,
722
+ upstreamId: selection.account.upstreamId,
723
+ failureKind: lastFailure?.kind,
724
+ status: lastFailure?.status,
725
+ message: lastFailure?.message ?? "Upstream failed after partial output; failover is unsafe.",
726
+ });
633
727
  outer.end();
634
728
  return;
635
729
  }
730
+
731
+ if (lastFailure) {
732
+ this.emitUi({
733
+ phase: "failover",
734
+ modelId: model.id,
735
+ accountId: selection.account.id,
736
+ accountLabel: selection.account.label,
737
+ upstreamId: selection.account.upstreamId,
738
+ failureKind: lastFailure.kind,
739
+ status: lastFailure.status,
740
+ message: lastFailure.message,
741
+ });
742
+ }
636
743
  }
637
744
  } catch (error) {
638
745
  const message = error instanceof Error ? error.message : String(error);
639
746
  const aborted = isAbortLike(message, options?.signal);
747
+ this.emitUi({
748
+ phase: "error",
749
+ modelId: model.id,
750
+ message: aborted ? "Request was aborted" : message,
751
+ });
640
752
  outer.push({ type: "error", reason: aborted ? "aborted" : "error", error: createErrorMessage({ model, message, stopReason: aborted ? "aborted" : "error" }) });
641
753
  outer.end();
642
754
  }
@@ -231,6 +231,24 @@ export interface RouterStreamInput {
231
231
  options?: SimpleStreamOptions;
232
232
  }
233
233
 
234
+ export type RouterUiEventPhase = "attempt" | "retry" | "failover" | "success" | "error";
235
+
236
+ export interface RouterUiEvent {
237
+ phase: RouterUiEventPhase;
238
+ modelId?: string;
239
+ accountId?: string;
240
+ accountLabel?: string;
241
+ upstreamId?: string;
242
+ failureKind?: FailureClassification["kind"];
243
+ status?: number;
244
+ message?: string;
245
+ retryAttempt?: number;
246
+ maxRetries?: number;
247
+ delayMs?: number;
248
+ }
249
+
250
+ export type RouterUiReporter = (event: RouterUiEvent) => void;
251
+
234
252
  export interface RouterErrorMessageInput {
235
253
  model: Model<Api>;
236
254
  message: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "takomi",
3
- "version": "2.1.31",
3
+ "version": "2.1.32",
4
4
  "description": "🎯 Stop wrestling with AI. Start building with purpose. The artisan's toolkit for agent workflows, Codex skills, and original Takomi capabilities like 21st.dev integration.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/pi-harness.js CHANGED
@@ -209,8 +209,26 @@ export async function inspectInstalledTakomiPiHarness(home = HOME) {
209
209
  };
210
210
  }
211
211
 
212
- function runCommand(command, args) {
213
- return spawnSync(command, args, { stdio: 'pipe', encoding: 'utf8', shell: process.platform === 'win32' });
212
+ function quoteWindowsCommandArg(value) {
213
+ const text = String(value);
214
+ if (text.length === 0) return '""';
215
+ if (!/[\s"&|<>^()%!]/.test(text)) return text;
216
+ return `"${text.replace(/"/g, '\\"')}"`;
217
+ }
218
+
219
+ function resolveCommandForSpawn(command, args = []) {
220
+ if (process.platform !== 'win32') return { command, args };
221
+ if (/\.(exe|com)$/i.test(command)) return { command, args };
222
+
223
+ return {
224
+ command: 'cmd.exe',
225
+ args: ['/d', '/s', '/c', [command, ...args].map(quoteWindowsCommandArg).join(' ')],
226
+ };
227
+ }
228
+
229
+ export function runCommand(command, args) {
230
+ const resolved = resolveCommandForSpawn(command, args);
231
+ return spawnSync(resolved.command, resolved.args, { stdio: 'pipe', encoding: 'utf8', shell: false });
214
232
  }
215
233
 
216
234
  function runCommandWithTimeout(command, args, timeoutMs) {
@@ -220,9 +238,10 @@ function runCommandWithTimeout(command, args, timeoutMs) {
220
238
  let settled = false;
221
239
  let timedOut = false;
222
240
 
223
- const child = spawn(command, args, {
241
+ const resolved = resolveCommandForSpawn(command, args);
242
+ const child = spawn(resolved.command, resolved.args, {
224
243
  stdio: ['ignore', 'pipe', 'pipe'],
225
- shell: process.platform === 'win32',
244
+ shell: false,
226
245
  windowsHide: true,
227
246
  });
228
247
 
@@ -235,10 +254,17 @@ function runCommandWithTimeout(command, args, timeoutMs) {
235
254
 
236
255
  const timer = setTimeout(() => {
237
256
  timedOut = true;
238
- try { child.kill(); } catch {}
239
- setTimeout(() => {
240
- try { child.kill('SIGKILL'); } catch {}
241
- }, 1500).unref?.();
257
+ if (process.platform === 'win32' && child.pid) {
258
+ try { spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
259
+ } else {
260
+ try { child.kill(); } catch {}
261
+ setTimeout(() => {
262
+ try { child.kill('SIGKILL'); } catch {}
263
+ }, 1500).unref?.();
264
+ }
265
+ try { child.stdout?.destroy(); } catch {}
266
+ try { child.stderr?.destroy(); } catch {}
267
+ finish({ status: null });
242
268
  }, timeoutMs);
243
269
  timer.unref?.();
244
270
 
@@ -1,8 +1,7 @@
1
1
  import fs from 'fs-extra';
2
- import { spawnSync } from 'child_process';
3
2
  import prompts from 'prompts';
4
3
  import pc from 'picocolors';
5
- import { detectPiCommand, getPiGlobalTargets } from './pi-harness.js';
4
+ import { detectPiCommand, getPiGlobalTargets, runCommand } from './pi-harness.js';
6
5
 
7
6
  export const TAKOMI_PI_OPTIONAL_FEATURES = [
8
7
  {
@@ -43,10 +42,6 @@ export const TAKOMI_PI_OPTIONAL_FEATURES = [
43
42
  },
44
43
  ];
45
44
 
46
- function runCommand(command, args) {
47
- return spawnSync(command, args, { stdio: 'pipe', encoding: 'utf8', shell: process.platform === 'win32' });
48
- }
49
-
50
45
  function packageIdentity(packageSpec) {
51
46
  if (!packageSpec.startsWith('npm:')) return packageSpec;
52
47
  const withoutPrefix = packageSpec.slice(4);