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.
- package/.pi/README.md +4 -3
- package/.pi/extensions/oauth-router/commands.ts +66 -35
- package/.pi/extensions/oauth-router/index.ts +51 -7
- package/.pi/extensions/oauth-router/report-ui.ts +205 -0
- package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +41 -3
- package/.pi/extensions/takomi-context-manager/diagnostics.ts +26 -0
- package/.pi/extensions/takomi-context-manager/index.ts +3 -2
- package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +36 -15
- package/.pi/extensions/takomi-context-manager/policy-tools.ts +93 -42
- package/.pi/extensions/takomi-context-manager/skill-categories.d.ts +11 -0
- package/.pi/extensions/takomi-context-manager/skill-categories.js +212 -0
- package/.pi/extensions/takomi-context-manager/skill-registry.ts +179 -8
- package/.pi/extensions/takomi-context-manager/skill-tools.ts +208 -103
- package/.pi/extensions/takomi-context-manager/tool-renderers.ts +112 -0
- package/.pi/extensions/takomi-context-manager/types.ts +6 -0
- package/.pi/extensions/takomi-runtime/commands.ts +45 -12
- package/.pi/extensions/takomi-runtime/gate-provenance.ts +24 -0
- package/.pi/extensions/takomi-runtime/index.ts +111 -42
- package/.pi/extensions/takomi-runtime/routing-policy.ts +14 -2
- package/.pi/extensions/takomi-runtime/takomi-stats.js +46 -26
- package/.pi/extensions/takomi-runtime/tool-renderers.ts +234 -0
- package/.pi/extensions/takomi-runtime/workflow-catalog.ts +54 -0
- package/.pi/extensions/takomi-subagents/agents.ts +4 -0
- package/.pi/extensions/takomi-subagents/async-lifecycle.ts +401 -0
- package/.pi/extensions/takomi-subagents/detached-results.ts +940 -0
- package/.pi/extensions/takomi-subagents/index.ts +46 -1
- package/.pi/extensions/takomi-subagents/native-render.ts +250 -188
- package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +55 -46
- package/.pi/extensions/takomi-subagents/pi-subagents-internal.ts +11 -5
- package/.pi/extensions/takomi-subagents/result-heartbeat.ts +55 -0
- package/.pi/extensions/takomi-subagents/subagent-ux.ts +162 -0
- package/.pi/extensions/takomi-subagents/tool-runner.ts +158 -28
- package/.pi/takomi/model-routing.md +282 -3
- package/assets/.agent/skills/shared-resend-portfolio/SKILL.md +124 -0
- package/package.json +4 -3
- package/src/pi-takomi-core/types.ts +10 -0
- package/src/pi-takomi-core/workflows.ts +86 -45
- package/src/skills-catalog.js +2 -202
- 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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
502
|
+
showReport(ctx, `Refreshed account ${refreshed.id} (${refreshed.label}).${usageMessage}`);
|
|
484
503
|
return;
|
|
485
504
|
}
|
|
486
505
|
case "help":
|
|
487
506
|
default: {
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
+
}
|