takomi 2.1.44 → 2.1.45

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 (39) hide show
  1. package/.pi/README.md +4 -3
  2. package/.pi/extensions/oauth-router/commands.ts +66 -35
  3. package/.pi/extensions/oauth-router/index.ts +51 -7
  4. package/.pi/extensions/oauth-router/report-ui.ts +205 -0
  5. package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +41 -3
  6. package/.pi/extensions/takomi-context-manager/diagnostics.ts +26 -0
  7. package/.pi/extensions/takomi-context-manager/index.ts +3 -2
  8. package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +36 -15
  9. package/.pi/extensions/takomi-context-manager/policy-tools.ts +93 -42
  10. package/.pi/extensions/takomi-context-manager/skill-categories.d.ts +11 -0
  11. package/.pi/extensions/takomi-context-manager/skill-categories.js +212 -0
  12. package/.pi/extensions/takomi-context-manager/skill-registry.ts +179 -8
  13. package/.pi/extensions/takomi-context-manager/skill-tools.ts +208 -103
  14. package/.pi/extensions/takomi-context-manager/tool-renderers.ts +112 -0
  15. package/.pi/extensions/takomi-context-manager/types.ts +6 -0
  16. package/.pi/extensions/takomi-runtime/commands.ts +45 -12
  17. package/.pi/extensions/takomi-runtime/gate-provenance.ts +24 -0
  18. package/.pi/extensions/takomi-runtime/index.ts +111 -42
  19. package/.pi/extensions/takomi-runtime/routing-policy.ts +14 -2
  20. package/.pi/extensions/takomi-runtime/takomi-stats.js +46 -26
  21. package/.pi/extensions/takomi-runtime/tool-renderers.ts +234 -0
  22. package/.pi/extensions/takomi-runtime/workflow-catalog.ts +54 -0
  23. package/.pi/extensions/takomi-subagents/agents.ts +4 -0
  24. package/.pi/extensions/takomi-subagents/async-lifecycle.ts +401 -0
  25. package/.pi/extensions/takomi-subagents/detached-results.ts +940 -0
  26. package/.pi/extensions/takomi-subagents/index.ts +46 -1
  27. package/.pi/extensions/takomi-subagents/native-render.ts +250 -188
  28. package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +55 -46
  29. package/.pi/extensions/takomi-subagents/pi-subagents-internal.ts +11 -5
  30. package/.pi/extensions/takomi-subagents/result-heartbeat.ts +55 -0
  31. package/.pi/extensions/takomi-subagents/subagent-ux.ts +162 -0
  32. package/.pi/extensions/takomi-subagents/tool-runner.ts +158 -28
  33. package/.pi/takomi/model-routing.md +282 -3
  34. package/assets/.agent/skills/shared-resend-portfolio/SKILL.md +124 -0
  35. package/package.json +4 -3
  36. package/src/pi-takomi-core/types.ts +10 -0
  37. package/src/pi-takomi-core/workflows.ts +86 -45
  38. package/src/skills-catalog.js +2 -202
  39. package/src/skills-installer.js +4 -1
package/.pi/README.md CHANGED
@@ -34,8 +34,8 @@ Inside Pi, use:
34
34
  - `/takomi build [prompt]` to implement while cross-checking against approved UI/design artifacts
35
35
  - `/takomi plan [title]` to create a Genesis-first orchestration session that can expand through Design and Build
36
36
  - `/takomi mode direct`, `/takomi mode orchestrate`, or `/takomi mode review` to choose the session operating mode
37
- - `/takomi gate auto` to continue approved plans automatically
38
- - `/takomi gate review` to return to the user after each task with results and verification guidance
37
+ - `/takomi gate auto` to continue approved plans automatically and explicitly authorize project-local agents for this session
38
+ - `/takomi gate review` (or `/takomi gate manual`) to return to the user after each task with results and verification guidance and revoke that authorization
39
39
  - `/takomi subagents on` or `/takomi subagents off` to allow or disable delegated subagents
40
40
  - `/takomi subagents list` to list available Takomi agents without exposing the raw `subagent` tool
41
41
  - `/takomi subagents status` to inspect active subagent state
@@ -109,7 +109,8 @@ So when working on packaging, agents should distinguish between:
109
109
  - Direct `takomi_subagent` calls build a `TakomiDelegationPlan` before launch. In auto mode the plan launches immediately; in manual mode the plan is returned for review until `confirmLaunch=true` is supplied.
110
110
  - The subagent tool supports `conversationId`, so reviewed work can be sent back to the same agent for continuation instead of restarting from scratch.
111
111
  - The subagent tool supports Pi-style single, parallel `tasks`, sequential `chain`, explicit `context=fork|fresh`, `async=true`, and native read/control actions such as `action=list`, `action=status`, `action=doctor`, `action=interrupt`, and `action=resume`.
112
- - The subagent tool supports `agentScope` values of `user`, `project`, and `both`; project-local agents require confirmation by default.
112
+ - The subagent tool supports `agentScope` values of `user`, `project`, and `both`; project-local agents require confirmation by default. The only session authorization bypasses are `TAKOMI_TRUST_PROJECT_AGENTS` and the explicit user `/takomi gate auto` provenance marker; model mode changes plus profile, default, or generic runtime `auto` state do not authorize project agents.
113
+ - The explicit user gate-auto marker is stored in session history so it survives resuming that same session. `/takomi gate review`, `/takomi gate manual`, review mode, and `/takomi-reset` append a revocation; it is not a cross-session or profile setting.
113
114
  - The subagent tool also supports per-run `workflow`, `skills`, `model`, `fallbackModels`, `thinking`, `checklist`, `concurrency`, and `worktree` overrides.
114
115
  - `takomi_board` records session/state/markdown artifacts only; it does not run subagents.
115
116
  - Pi's default `subagent` tool remains owned by the user-level/default subagent extension to avoid tool-name conflicts; Takomi uses `takomi_subagent` as the preferred lifecycle-aware execution interface and renders it with the native Pi-style result surface.
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { createAccountFromUpstream } from "./oauth-flow.ts";
3
+ import { createRouterReportLines, createRouterReportWidget, ROUTER_REPORT_DISMISS_HINT, ROUTER_REPORT_WIDGET_KEY } from "./report-ui.ts";
3
4
  import type { RouterStatusRow, RouterUsageSummary, RouterUsageWindowSummary, RoutingPolicyName, StoredRouterAccount } from "./types.ts";
4
5
  import { RouterRuntime } from "./provider.ts";
5
6
 
@@ -273,12 +274,32 @@ export function formatUsageRawReport(runtime: RouterRuntime, accountId?: string)
273
274
  return ["# oauth-router usage raw", "", ...rows].join("\n");
274
275
  }
275
276
 
276
- function emitReport(ctx: ExtensionCommandContext, text: string) {
277
- const lines = text.split(/\r?\n/);
278
- ctx.ui.setWidget("oauth-router-report", lines, { placement: "belowEditor" });
279
- ctx.ui.notify(lines[0] || "oauth-router report updated", "info");
277
+ export function emitRouterReport(ctx: ExtensionCommandContext, text: string) {
278
+ // A single key gives every report predictable replacement semantics. Add the
279
+ // dismiss affordance at this presentation boundary, not to formatter output,
280
+ // so model/source report text remains unchanged. Omitting options uses Pi's
281
+ // default above-editor placement for both TUI and RPC widgets.
282
+ const reportText = `${ROUTER_REPORT_DISMISS_HINT}\n\n${text}`;
283
+ if (ctx.mode === "tui") {
284
+ ctx.ui.setWidget(ROUTER_REPORT_WIDGET_KEY, createRouterReportWidget(reportText));
285
+ return;
286
+ }
287
+ // RPC accepts string-array widgets only; ctx.hasUI is true there, so ctx.mode
288
+ // is the capability signal that prevents its visible report from being ignored.
289
+ ctx.ui.setWidget(ROUTER_REPORT_WIDGET_KEY, createRouterReportLines(reportText));
290
+ }
291
+
292
+ export function clearRouterReport(ctx: ExtensionCommandContext) {
293
+ ctx.ui.setWidget(ROUTER_REPORT_WIDGET_KEY, undefined);
280
294
  }
281
295
 
296
+ export type RouterReportEmitter = (ctx: ExtensionCommandContext, text: string) => void;
297
+
298
+ export type RouterReportControls = {
299
+ show: RouterReportEmitter;
300
+ clear: (ctx: ExtensionCommandContext) => void;
301
+ };
302
+
282
303
  async function pickUpstream(runtime: RouterRuntime, ctx: ExtensionCommandContext, requestedId?: string) {
283
304
  const upstreams = runtime.listUpstreams().filter((upstream) => upstream.enabled);
284
305
  if (upstreams.length === 0) throw new Error("No enabled upstreams are configured");
@@ -385,12 +406,11 @@ function setFooterStatus(ctx: ExtensionCommandContext, runtime: RouterRuntime) {
385
406
  ctx.ui.setStatus("oauth-router", `oauth-router ${healthy}/${rows.length || 0} healthy | ${runtime.getPolicy()}`);
386
407
  }
387
408
 
388
- function showCommandHint(ctx: ExtensionCommandContext, title: string, lines: string[]) {
389
- emitReport(ctx, [`# ${title}`, "", ...lines].join("\n"));
390
- ctx.ui.notify(title, "info");
409
+ function showCommandHint(ctx: ExtensionCommandContext, title: string, lines: string[], showReport: RouterReportEmitter = emitRouterReport) {
410
+ showReport(ctx, [`# ${title}`, "", ...lines].join("\n"));
391
411
  }
392
412
 
393
- async function handleRouterLogin(runtime: RouterRuntime, args: string, ctx: ExtensionCommandContext) {
413
+ async function handleRouterLogin(runtime: RouterRuntime, args: string, ctx: ExtensionCommandContext, showReport: RouterReportEmitter = emitRouterReport) {
394
414
  const [command = "help", first, ...rest] = parseArgs(args);
395
415
 
396
416
  switch (command) {
@@ -415,18 +435,17 @@ async function handleRouterLogin(runtime: RouterRuntime, args: string, ctx: Exte
415
435
  runtime.clearAccountHealth(duplicate.id);
416
436
  const usageMessage = await refreshUsageAfterCredentialChange(runtime, duplicate.id);
417
437
  setFooterStatus(ctx, runtime);
418
- emitReport(ctx, `Updated existing account ${duplicate.id} (${duplicate.label}) with fresh credentials instead of adding a duplicate.${usageMessage}`);
438
+ showReport(ctx, `Updated existing account ${duplicate.id} (${duplicate.label}) with fresh credentials instead of adding a duplicate.${usageMessage}`);
419
439
  return;
420
440
  }
421
441
  runtime.addAccount(account);
422
442
  const usageMessage = await refreshUsageAfterCredentialChange(runtime, account.id);
423
443
  setFooterStatus(ctx, runtime);
424
- emitReport(ctx, `Added account ${account.id} (${account.label}) for upstream ${upstream.id}.${usageMessage}`);
425
- ctx.ui.notify(`Added ${account.id}`, "info");
444
+ showReport(ctx, `Added account ${account.id} (${account.label}) for upstream ${upstream.id}.${usageMessage}`);
426
445
  return;
427
446
  }
428
447
  case "list": {
429
- emitReport(ctx, formatAccountsReport(runtime));
448
+ showReport(ctx, formatAccountsReport(runtime));
430
449
  setFooterStatus(ctx, runtime);
431
450
  return;
432
451
  }
@@ -439,7 +458,7 @@ async function handleRouterLogin(runtime: RouterRuntime, args: string, ctx: Exte
439
458
  }
440
459
  runtime.removeAccount(account.id);
441
460
  setFooterStatus(ctx, runtime);
442
- emitReport(ctx, `Removed account ${account.id}.`);
461
+ showReport(ctx, `Removed account ${account.id}.`);
443
462
  return;
444
463
  }
445
464
  case "rename": {
@@ -447,7 +466,7 @@ async function handleRouterLogin(runtime: RouterRuntime, args: string, ctx: Exte
447
466
  const label = await getRequiredLabel(ctx, account.label, rest.join(" "));
448
467
  runtime.renameAccount(account.id, label);
449
468
  setFooterStatus(ctx, runtime);
450
- emitReport(ctx, `Renamed account ${account.id} to ${label}.`);
469
+ showReport(ctx, `Renamed account ${account.id} to ${label}.`);
451
470
  return;
452
471
  }
453
472
  case "relogin": {
@@ -472,7 +491,7 @@ async function handleRouterLogin(runtime: RouterRuntime, args: string, ctx: Exte
472
491
  runtime.clearAccountHealth(existing.id);
473
492
  const usageMessage = await refreshUsageAfterCredentialChange(runtime, existing.id);
474
493
  setFooterStatus(ctx, runtime);
475
- emitReport(ctx, `Re-logged account ${existing.id} (${existing.label}) and cleared auth state.${usageMessage}`);
494
+ showReport(ctx, `Re-logged account ${existing.id} (${existing.label}) and cleared auth state.${usageMessage}`);
476
495
  return;
477
496
  }
478
497
  case "refresh": {
@@ -480,12 +499,12 @@ async function handleRouterLogin(runtime: RouterRuntime, args: string, ctx: Exte
480
499
  const refreshed = await runtime.refreshAccount(account.id);
481
500
  const usageMessage = await refreshUsageAfterCredentialChange(runtime, refreshed.id);
482
501
  setFooterStatus(ctx, runtime);
483
- emitReport(ctx, `Refreshed account ${refreshed.id} (${refreshed.label}).${usageMessage}`);
502
+ showReport(ctx, `Refreshed account ${refreshed.id} (${refreshed.label}).${usageMessage}`);
484
503
  return;
485
504
  }
486
505
  case "help":
487
506
  default: {
488
- emitReport(
507
+ showReport(
489
508
  ctx,
490
509
  [
491
510
  "# oauth-router commands",
@@ -506,6 +525,7 @@ async function handleRouterLogin(runtime: RouterRuntime, args: string, ctx: Exte
506
525
  "- /router-disable <id>",
507
526
  "- /router-policy <round-robin|weighted-round-robin>",
508
527
  "- /router-weight <id> <n>",
528
+ "- /router-clear (dismiss the current report)",
509
529
  ].join("\n"),
510
530
  );
511
531
  return;
@@ -513,16 +533,27 @@ async function handleRouterLogin(runtime: RouterRuntime, args: string, ctx: Exte
513
533
  }
514
534
  }
515
535
 
516
- export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime) {
536
+ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime, reportControls?: RouterReportControls) {
537
+ const showReport = reportControls?.show ?? emitRouterReport;
538
+ const clearReport = reportControls?.clear ?? clearRouterReport;
539
+
517
540
  pi.registerCommand("router-login", {
518
541
  description: "Manage oauth-router accounts",
519
- handler: async (args, ctx) => handleRouterLogin(runtime, args || "", ctx),
542
+ handler: async (args, ctx) => handleRouterLogin(runtime, args || "", ctx, showReport),
543
+ });
544
+
545
+ pi.registerCommand("router-clear", {
546
+ description: "Dismiss the current oauth-router report widget",
547
+ handler: async (_args, ctx) => {
548
+ clearReport(ctx);
549
+ setFooterStatus(ctx, runtime);
550
+ },
520
551
  });
521
552
 
522
553
  pi.registerCommand("router-status", {
523
554
  description: "Show oauth-router health and routing state",
524
555
  handler: async (_args, ctx) => {
525
- emitReport(ctx, formatStatusReport(runtime));
556
+ showReport(ctx, formatStatusReport(runtime));
526
557
  setFooterStatus(ctx, runtime);
527
558
  },
528
559
  });
@@ -530,7 +561,7 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
530
561
  pi.registerCommand("router-accounts", {
531
562
  description: "Show compact oauth-router account list",
532
563
  handler: async (_args, ctx) => {
533
- emitReport(ctx, formatAccountsReport(runtime));
564
+ showReport(ctx, formatAccountsReport(runtime));
534
565
  setFooterStatus(ctx, runtime);
535
566
  },
536
567
  });
@@ -539,7 +570,7 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
539
570
  description: "Show oauth-router visual provider quota and local usage",
540
571
  handler: async (args, ctx) => {
541
572
  const [id] = parseArgs(args || "");
542
- emitReport(ctx, formatUsageReport(runtime, id));
573
+ showReport(ctx, formatUsageReport(runtime, id));
543
574
  setFooterStatus(ctx, runtime);
544
575
  },
545
576
  });
@@ -548,7 +579,7 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
548
579
  description: "Show raw oauth-router usage/provider quota details",
549
580
  handler: async (args, ctx) => {
550
581
  const [id] = parseArgs(args || "");
551
- emitReport(ctx, formatUsageRawReport(runtime, id));
582
+ showReport(ctx, formatUsageRawReport(runtime, id));
552
583
  setFooterStatus(ctx, runtime);
553
584
  },
554
585
  });
@@ -557,7 +588,7 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
557
588
  description: "Alias for visual oauth-router usage/quota",
558
589
  handler: async (args, ctx) => {
559
590
  const [id] = parseArgs(args || "");
560
- emitReport(ctx, formatUsageReport(runtime, id));
591
+ showReport(ctx, formatUsageReport(runtime, id));
561
592
  setFooterStatus(ctx, runtime);
562
593
  },
563
594
  });
@@ -580,7 +611,7 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
580
611
  }
581
612
 
582
613
  const report = formatUsageReport(runtime, target === "all" ? undefined : target);
583
- emitReport(ctx, failures.length ? [report, "", "## Refresh failures", ...failures].join("\n") : report);
614
+ showReport(ctx, failures.length ? [report, "", "## Refresh failures", ...failures].join("\n") : report);
584
615
  if (failures.length) ctx.ui.notify(`oauth-router usage refreshed with ${failures.length} failure(s)`, "error");
585
616
  setFooterStatus(ctx, runtime);
586
617
  },
@@ -594,7 +625,7 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
594
625
  const label = await getRequiredLabel(ctx, account.label, labelParts.join(" "));
595
626
  runtime.renameAccount(account.id, label);
596
627
  setFooterStatus(ctx, runtime);
597
- emitReport(ctx, `Renamed account ${account.id} to ${label}.`);
628
+ showReport(ctx, `Renamed account ${account.id} to ${label}.`);
598
629
  },
599
630
  });
600
631
 
@@ -609,13 +640,13 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
609
640
  }
610
641
  runtime.removeAccount(account.id);
611
642
  setFooterStatus(ctx, runtime);
612
- emitReport(ctx, `Deleted account ${account.id}.`);
643
+ showReport(ctx, `Deleted account ${account.id}.`);
613
644
  },
614
645
  });
615
646
 
616
647
  pi.registerCommand("router-relogin", {
617
648
  description: "Re-login and recover an oauth-router account",
618
- handler: async (args, ctx) => handleRouterLogin(runtime, `relogin ${args || ""}`, ctx),
649
+ handler: async (args, ctx) => handleRouterLogin(runtime, `relogin ${args || ""}`, ctx, showReport),
619
650
  });
620
651
 
621
652
  pi.registerCommand("router-enable", {
@@ -625,7 +656,7 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
625
656
  const account = await pickAccount(runtime, ctx, id, "Choose account to enable");
626
657
  runtime.setEnabled(account.id, true);
627
658
  setFooterStatus(ctx, runtime);
628
- emitReport(ctx, `Enabled account ${account.id}.`);
659
+ showReport(ctx, `Enabled account ${account.id}.`);
629
660
  },
630
661
  });
631
662
 
@@ -636,7 +667,7 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
636
667
  const account = await pickAccount(runtime, ctx, id, "Choose account to disable");
637
668
  runtime.setEnabled(account.id, false);
638
669
  setFooterStatus(ctx, runtime);
639
- emitReport(ctx, `Disabled account ${account.id}.`);
670
+ showReport(ctx, `Disabled account ${account.id}.`);
640
671
  },
641
672
  });
642
673
 
@@ -651,7 +682,7 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
651
682
  "",
652
683
  "Usage: /router-policy <round-robin|weighted-round-robin>",
653
684
  "Aliases: rr, wrr, weighted",
654
- ]);
685
+ ], showReport);
655
686
  return;
656
687
  }
657
688
  if (!policy) {
@@ -660,12 +691,12 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
660
691
  "",
661
692
  `Current policy: ${runtime.getPolicy()}`,
662
693
  "Valid values: round-robin, weighted-round-robin",
663
- ]);
694
+ ], showReport);
664
695
  return;
665
696
  }
666
697
  runtime.setPolicy(policy);
667
698
  setFooterStatus(ctx, runtime);
668
- emitReport(ctx, `Routing policy set to ${policy}.`);
699
+ showReport(ctx, `Routing policy set to ${policy}.`);
669
700
  },
670
701
  });
671
702
 
@@ -680,12 +711,12 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
680
711
  weight = Number(response);
681
712
  }
682
713
  if (!Number.isFinite(weight)) {
683
- showCommandHint(ctx, "router-weight", ["Usage: /router-weight <id> <n>", "Example: /router-weight acct_ab12cd34 3"]);
714
+ showCommandHint(ctx, "router-weight", ["Usage: /router-weight <id> <n>", "Example: /router-weight acct_ab12cd34 3"], showReport);
684
715
  return;
685
716
  }
686
717
  runtime.setWeight(account.id, weight);
687
718
  setFooterStatus(ctx, runtime);
688
- emitReport(ctx, `Updated weight for ${account.id} to ${Math.max(1, Math.floor(weight))}.`);
719
+ showReport(ctx, `Updated weight for ${account.id} to ${Math.max(1, Math.floor(weight))}.`);
689
720
  },
690
721
  });
691
722
  }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { registerRouterCommands, formatStatusReport } from "./commands.ts";
2
+ import { emitRouterReport, formatStatusReport, registerRouterCommands, type RouterReportControls } from "./commands.ts";
3
+ import { ROUTER_REPORT_WIDGET_KEY } from "./report-ui.ts";
3
4
  import { registerRouterProvider, RouterRuntime } from "./provider.ts";
4
5
  import type { RouterUiEvent } from "./types.ts";
5
6
 
@@ -64,8 +65,33 @@ function notifyActivity(ctx: ExtensionContext, event: RouterUiEvent) {
64
65
  ctx.ui.notify(message, level);
65
66
  }
66
67
 
67
- function installRouterUiBridge(pi: ExtensionAPI, runtime: RouterRuntime, notifyOnLoad = false) {
68
+ function installRouterUiBridge(pi: ExtensionAPI, runtime: RouterRuntime, notifyOnLoad = false): RouterReportControls {
68
69
  let activeCtx: ExtensionContext | undefined;
70
+ let reportGeneration = 0;
71
+ let activeReport: { generation: number; ctx: ExtensionContext } | undefined;
72
+
73
+ const clearReport = (fallbackCtx?: ExtensionContext, clearFallback = false) => {
74
+ const report = activeReport;
75
+ const ctx = report?.ctx ?? (clearFallback ? fallbackCtx : undefined);
76
+ if (!ctx) return;
77
+
78
+ try {
79
+ ctx.ui.setWidget(ROUTER_REPORT_WIDGET_KEY, undefined);
80
+ } catch {
81
+ // Widget lifecycle is best-effort and must not interrupt agent activity.
82
+ } finally {
83
+ // A re-entrant command can replace the report while the old widget clears.
84
+ // Its newer generation remains active instead of being erased by this clear.
85
+ if (report && activeReport?.generation === report.generation) activeReport = undefined;
86
+ }
87
+ };
88
+
89
+ const showReport: RouterReportControls["show"] = (ctx, text) => {
90
+ const report = { generation: ++reportGeneration, ctx };
91
+ activeCtx = ctx;
92
+ activeReport = report;
93
+ emitRouterReport(ctx, text);
94
+ };
69
95
 
70
96
  const setBaseStatus = (ctx: ExtensionContext) => {
71
97
  activeCtx = ctx;
@@ -84,14 +110,29 @@ function installRouterUiBridge(pi: ExtensionAPI, runtime: RouterRuntime, notifyO
84
110
  });
85
111
 
86
112
  pi.on("session_start", async (_event, ctx) => {
113
+ // A session replacement owns a fresh transport, so clear the old transport
114
+ // before accepting reports for the new one.
115
+ clearReport(activeCtx, true);
87
116
  setBaseStatus(ctx);
88
117
  if (notifyOnLoad) ctx.ui.notify("oauth-router loaded", "info");
89
118
  });
90
119
 
120
+ pi.on("input", async (event, ctx) => {
121
+ // Registered slash commands run before Pi emits input and return without an
122
+ // input event. Ordinary text/image input reaches this handler before its
123
+ // agent starts, which is the intended transient-dismissal boundary.
124
+ if (event.text.trim() || event.images?.length) clearReport(ctx);
125
+ });
126
+
91
127
  pi.on("agent_start", async (_event, ctx) => {
128
+ clearReport(ctx);
92
129
  setBaseStatus(ctx);
93
130
  });
94
131
 
132
+ pi.on("tool_execution_start", async (_event, ctx) => {
133
+ clearReport(ctx);
134
+ });
135
+
95
136
  pi.on("turn_start", async (_event, ctx) => {
96
137
  setBaseStatus(ctx);
97
138
  });
@@ -105,23 +146,26 @@ function installRouterUiBridge(pi: ExtensionAPI, runtime: RouterRuntime, notifyO
105
146
  });
106
147
 
107
148
  pi.on("session_shutdown", async () => {
149
+ // The old context owns the RPC transport. Clear its report before releasing
150
+ // it so clients do not retain a widget across a session replacement.
151
+ clearReport(activeCtx, true);
108
152
  activeCtx = undefined;
109
153
  });
154
+
155
+ return { show: showReport, clear: (ctx) => clearReport(ctx, true) };
110
156
  }
111
157
 
112
158
  export default function (pi: ExtensionAPI) {
113
159
  const runtime = new RouterRuntime();
114
160
 
115
161
  registerRouterProvider(pi, runtime);
116
- registerRouterCommands(pi, runtime);
117
- installRouterUiBridge(pi, runtime, false);
162
+ const reportControls = installRouterUiBridge(pi, runtime, false);
163
+ registerRouterCommands(pi, runtime, reportControls);
118
164
 
119
165
  pi.registerCommand("router-debug-report", {
120
166
  description: "Show a detailed oauth-router report in the UI only",
121
167
  handler: async (_args, ctx) => {
122
- const report = formatStatusReport(runtime);
123
- ctx.ui.setWidget("oauth-router-report", report.split(/\r?\n/), { placement: "belowEditor" });
124
- ctx.ui.notify("oauth-router debug report updated", "info");
168
+ reportControls.show(ctx, formatStatusReport(runtime));
125
169
  },
126
170
  });
127
171
  }
@@ -0,0 +1,205 @@
1
+ import { truncateToWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
2
+
3
+ export const ROUTER_REPORT_WIDGET_KEY = "oauth-router-report";
4
+ export const ROUTER_REPORT_DISMISS_HINT = "Dismiss: /router-clear";
5
+
6
+ type RouterTheme = {
7
+ fg(color: "accent" | "success" | "warning" | "error" | "muted" | "dim", text: string): string;
8
+ bold(text: string): string;
9
+ };
10
+
11
+ const SENSITIVE_VALUE_KEYS = new Set([
12
+ "access_token", "access-token", "accesstoken",
13
+ "refresh_token", "refresh-token", "refreshtoken",
14
+ "id_token", "id-token", "idtoken",
15
+ "auth_token", "auth-token", "authtoken",
16
+ "api_key", "api-key", "apikey",
17
+ "authorization",
18
+ "client_secret", "client-secret", "clientsecret",
19
+ "password", "secret", "code",
20
+ ]);
21
+ const SENSITIVE_QUERY_KEYS = [...SENSITIVE_VALUE_KEYS, "token", "key"];
22
+ const SENSITIVE_QUERY_VALUE = new RegExp(`([?&#](?:${SENSITIVE_QUERY_KEYS.join("|")})=)[^&#\\s"']*`, "gi");
23
+
24
+ function isKeyCharacter(value: string | undefined): boolean {
25
+ return Boolean(value && /[A-Za-z0-9_-]/.test(value));
26
+ }
27
+
28
+ function skipWhitespace(text: string, start: number): number {
29
+ let index = start;
30
+ while (/\s/.test(text[index] ?? "")) index += 1;
31
+ return index;
32
+ }
33
+
34
+ function quotedEnd(text: string, start: number): number | undefined {
35
+ const quote = text[start];
36
+ for (let index = start + 1; index < text.length; index += 1) {
37
+ if (text[index] === "\\") {
38
+ index += 1;
39
+ continue;
40
+ }
41
+ if (text[index] === quote) return index + 1;
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ type SensitiveAssignment = { key: string; valueStart: number };
47
+
48
+ /**
49
+ * Locate a complete assignment key rather than using a word-boundary regex.
50
+ * Underscores and hyphens are key characters, so `zipcode`, `account_code`,
51
+ * and `authorization_status` cannot be mistaken for sensitive keys.
52
+ */
53
+ function sensitiveAssignmentAt(text: string, start: number): SensitiveAssignment | undefined {
54
+ if (isKeyCharacter(text[start - 1])) return undefined;
55
+
56
+ let key: string;
57
+ let afterKey: number;
58
+ if (text[start] === '"' || text[start] === "'") {
59
+ const end = quotedEnd(text, start);
60
+ if (!end) return undefined;
61
+ key = text.slice(start + 1, end - 1);
62
+ afterKey = end;
63
+ } else {
64
+ if (!isKeyCharacter(text[start])) return undefined;
65
+ afterKey = start;
66
+ while (isKeyCharacter(text[afterKey])) afterKey += 1;
67
+ key = text.slice(start, afterKey);
68
+ }
69
+
70
+ if (!SENSITIVE_VALUE_KEYS.has(key.toLowerCase())) return undefined;
71
+ const delimiter = skipWhitespace(text, afterKey);
72
+ if (text[delimiter] !== ":" && text[delimiter] !== "=") return undefined;
73
+ return { key: key.toLowerCase(), valueStart: skipWhitespace(text, delimiter + 1) };
74
+ }
75
+
76
+ function redactSensitiveAssignments(text: string): string {
77
+ let output = "";
78
+ let cursor = 0;
79
+
80
+ for (let start = 0; start < text.length; start += 1) {
81
+ const assignment = sensitiveAssignmentAt(text, start);
82
+ if (!assignment || assignment.valueStart >= text.length) continue;
83
+
84
+ const valueStart = assignment.valueStart;
85
+ const quote = text[valueStart];
86
+ if (quote === '"' || quote === "'") {
87
+ const valueEnd = quotedEnd(text, valueStart);
88
+ if (!valueEnd) continue;
89
+ output += `${text.slice(cursor, valueStart + 1)}[redacted]${quote}`;
90
+ cursor = valueEnd;
91
+ start = valueEnd - 1;
92
+ continue;
93
+ }
94
+
95
+ let valueEnd = valueStart;
96
+ if (assignment.key === "authorization") {
97
+ while (valueEnd < text.length && text[valueEnd] !== "|" && text[valueEnd] !== "\r" && text[valueEnd] !== "\n") valueEnd += 1;
98
+ while (valueEnd > valueStart && /\s/.test(text[valueEnd - 1])) valueEnd -= 1;
99
+ } else {
100
+ while (valueEnd < text.length && !/[\s|,;"'&#]/.test(text[valueEnd])) valueEnd += 1;
101
+ }
102
+ if (valueEnd === valueStart) continue;
103
+
104
+ output += `${text.slice(cursor, valueStart)}[redacted]`;
105
+ cursor = valueEnd;
106
+ start = valueEnd - 1;
107
+ }
108
+
109
+ return output + text.slice(cursor);
110
+ }
111
+
112
+ /**
113
+ * Remove terminal controls and credentials from presentation only. Router data
114
+ * remains untouched; this boundary exists because labels and upstream errors
115
+ * can originate outside the extension.
116
+ */
117
+ export function sanitizeReportText(value: unknown): string {
118
+ let text = String(value ?? "")
119
+ .replace(/\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[PX^_][^\x1b]*(?:\x1b\\))/g, "")
120
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, " ")
121
+ .replace(/\s+/g, " ")
122
+ .trim();
123
+
124
+ // Bearer credentials can occur outside a key/value field. Assignment
125
+ // parsing below handles every quoted/bare sensitive-key combination and
126
+ // keeps Authorization's multi-word field value intact while redacting it.
127
+ text = text
128
+ .replace(/\b(Bearer\s+)(?:"[^"]*"|'[^']*'|[^\s,;|"']+)/gi, "$1[redacted]")
129
+ .replace(SENSITIVE_QUERY_VALUE, "$1[redacted]");
130
+ text = redactSensitiveAssignments(text)
131
+ .replace(/\b(sk-[A-Za-z0-9_-]{8,}|(?:eyJ[A-Za-z0-9_-]+\.){2}[A-Za-z0-9_-]+)\b/gi, "[redacted]");
132
+
133
+ return text;
134
+ }
135
+
136
+ /** Return the RPC-compatible report representation without terminal controls. */
137
+ export function createRouterReportLines(text: string): string[] {
138
+ return text.split(/\r?\n/).map(sanitizeReportText);
139
+ }
140
+
141
+ function fitQuotaBar(line: string, width: number): string {
142
+ const match = /\[([█░]+)\]/.exec(line);
143
+ if (!match) return line;
144
+ const source = match[1];
145
+ const targetWidth = width <= 45 ? 8 : width <= 70 ? 12 : 18;
146
+ if (source.length <= targetWidth) return line;
147
+ const filled = [...source].filter((cell) => cell === "█").length;
148
+ const targetFilled = Math.round((filled / source.length) * targetWidth);
149
+ return `${line.slice(0, match.index)}[${"█".repeat(targetFilled)}${"░".repeat(targetWidth - targetFilled)}]${line.slice(match.index + match[0].length)}`;
150
+ }
151
+
152
+ function styleLine(theme: RouterTheme, line: string): string {
153
+ if (line === ROUTER_REPORT_DISMISS_HINT) return theme.fg("accent", theme.bold(line));
154
+ if (line.startsWith("# ")) return theme.fg("accent", theme.bold(line.slice(2)));
155
+ if (line.startsWith("## ")) return theme.fg("accent", theme.bold(line.slice(3)));
156
+
157
+ // State words must win over incidental success counters or a prior healthy
158
+ // status. In particular, an explicitly disabled account is never success.
159
+ if (/\b(auth=invalid|auth invalid|invalid)\b/i.test(line)) return theme.fg("error", line);
160
+ if (/\b(degraded|cooldown|penalty)\b/i.test(line)) return theme.fg("warning", line);
161
+ if (/\b(enabled\s*=\s*false|disabled|inactive)\b/i.test(line)) return theme.fg("muted", line);
162
+ if (/^Provider:\s*/.test(line)) return theme.fg("accent", line);
163
+ if (/^Local:\s*/.test(line)) return theme.fg("muted", line);
164
+
165
+ const quota = /(\d+)% left/.exec(line);
166
+ if (quota) {
167
+ const remaining = Number(quota[1]);
168
+ return theme.fg(remaining <= 20 ? "error" : remaining <= 50 ? "warning" : "success", line);
169
+ }
170
+ if (/\b(healthy|enabled|ok)\b/i.test(line) && !/\b(unhealthy|not healthy)\b/i.test(line)) return theme.fg("success", line);
171
+ if (/\b(failures?|429s?|error)\b/i.test(line)) return theme.fg("warning", line);
172
+ if (/^(Raw:|Usage:|Aliases:|Valid values:|Compact account list|Provider bars show)/.test(line)) return theme.fg("muted", line);
173
+ if (/^(note:|headers:|claimKeys:|endpoint:|tokenExpires=)/.test(line)) return theme.fg("dim", line);
174
+ return line;
175
+ }
176
+
177
+ class RouterReportComponent implements Component {
178
+ constructor(private readonly lines: string[], private readonly theme: RouterTheme) {}
179
+
180
+ invalidate() {
181
+ // Rendering is derived solely from the report snapshot and current width.
182
+ }
183
+
184
+ render(width: number): string[] {
185
+ const usableWidth = Math.max(1, width);
186
+ const rendered: string[] = [];
187
+ for (const line of this.lines) {
188
+ if (!line) {
189
+ rendered.push("");
190
+ continue;
191
+ }
192
+ const styled = styleLine(this.theme, fitQuotaBar(line, usableWidth));
193
+ const wrapped = wrapTextWithAnsi(styled, usableWidth);
194
+ rendered.push(...(wrapped.length ? wrapped.map((part) => truncateToWidth(part, usableWidth)) : [""]));
195
+ }
196
+ return rendered;
197
+ }
198
+ }
199
+
200
+ export function createRouterReportWidget(text: string) {
201
+ // Snapshot and sanitize now as well as at render time so replacement has no
202
+ // mutable dependency and no stale report can reappear on a later lifecycle event.
203
+ const safeLines = createRouterReportLines(text);
204
+ return (_tui: unknown, theme: RouterTheme): Component => new RouterReportComponent(safeLines, theme);
205
+ }