takomi 2.1.43 → 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 (44) hide show
  1. package/.pi/README.md +4 -3
  2. package/.pi/extensions/oauth-router/README.md +3 -3
  3. package/.pi/extensions/oauth-router/commands.ts +66 -39
  4. package/.pi/extensions/oauth-router/config.ts +34 -34
  5. package/.pi/extensions/oauth-router/index.ts +51 -10
  6. package/.pi/extensions/oauth-router/report-ui.ts +205 -0
  7. package/.pi/extensions/oauth-router/scripts/vibe-verify.py +5 -5
  8. package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +41 -3
  9. package/.pi/extensions/takomi-context-manager/diagnostics.ts +26 -0
  10. package/.pi/extensions/takomi-context-manager/index.ts +20 -9
  11. package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +36 -15
  12. package/.pi/extensions/takomi-context-manager/policy-tools.ts +93 -42
  13. package/.pi/extensions/takomi-context-manager/skill-categories.d.ts +11 -0
  14. package/.pi/extensions/takomi-context-manager/skill-categories.js +212 -0
  15. package/.pi/extensions/takomi-context-manager/skill-registry.ts +179 -8
  16. package/.pi/extensions/takomi-context-manager/skill-tools.ts +208 -103
  17. package/.pi/extensions/takomi-context-manager/tool-renderers.ts +112 -0
  18. package/.pi/extensions/takomi-context-manager/types.ts +6 -0
  19. package/.pi/extensions/takomi-runtime/commands.ts +45 -12
  20. package/.pi/extensions/takomi-runtime/gate-provenance.ts +24 -0
  21. package/.pi/extensions/takomi-runtime/index.ts +133 -56
  22. package/.pi/extensions/takomi-runtime/routing-policy.ts +14 -2
  23. package/.pi/extensions/takomi-runtime/takomi-stats.js +46 -26
  24. package/.pi/extensions/takomi-runtime/tool-renderers.ts +234 -0
  25. package/.pi/extensions/takomi-runtime/workflow-catalog.ts +54 -0
  26. package/.pi/extensions/takomi-subagents/agents.ts +4 -0
  27. package/.pi/extensions/takomi-subagents/async-lifecycle.ts +401 -0
  28. package/.pi/extensions/takomi-subagents/detached-results.ts +940 -0
  29. package/.pi/extensions/takomi-subagents/index.ts +46 -1
  30. package/.pi/extensions/takomi-subagents/native-render.ts +250 -188
  31. package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +55 -46
  32. package/.pi/extensions/takomi-subagents/pi-subagents-internal.ts +11 -5
  33. package/.pi/extensions/takomi-subagents/result-heartbeat.ts +55 -0
  34. package/.pi/extensions/takomi-subagents/subagent-ux.ts +162 -0
  35. package/.pi/extensions/takomi-subagents/tool-runner.ts +158 -28
  36. package/.pi/takomi/model-routing.md +282 -3
  37. package/assets/.agent/skills/shared-resend-portfolio/SKILL.md +124 -0
  38. package/package.json +4 -3
  39. package/src/pi-harness.js +41 -1
  40. package/src/pi-takomi-core/types.ts +10 -0
  41. package/src/pi-takomi-core/workflows.ts +86 -45
  42. package/src/skills-catalog.js +2 -202
  43. package/src/skills-installer.js +4 -1
  44. package/src/takomi-stats.js +5 -5
@@ -13,7 +13,6 @@ import {
13
13
  getSessionPaths,
14
14
  getNextTaskId,
15
15
  getWorkflowDefinition,
16
- listWorkflowDefinitions,
17
16
  markStageExpanded,
18
17
  normalizeSessionState,
19
18
  renderMasterPlan,
@@ -34,7 +33,6 @@ import {
34
33
  type VibeLifecycleStage,
35
34
  } from "../../../src/pi-takomi-core";
36
35
  import {
37
- renderRuntimeStatus,
38
36
  renderRuntimeWidget,
39
37
  renderTakomiHeader,
40
38
  TakomiFooterComponent,
@@ -51,12 +49,27 @@ import {
51
49
  } from "./shared";
52
50
  import { TakomiContextPanel, wireContextPanel } from "./context-panel";
53
51
  import { registerTakomiCommands } from "./commands";
52
+ import { USER_GATE_AUTO_PROVENANCE_ENTRY } from "./gate-provenance";
54
53
  import {
55
54
  DEFAULT_TAKOMI_PROFILE,
56
55
  getProfileDefaults,
57
56
  loadTakomiProfile,
58
57
  } from "./profile";
59
58
  import { installTakomiRoutingPolicy, previewTakomiRoutingPolicy, renderRoutingPolicyPreview, resolveTakomiRoutingPolicy } from "./routing-policy";
59
+ import {
60
+ discoverWorkflowPlaybooks,
61
+ showWorkflowCatalogForBoard,
62
+ } from "./workflow-catalog";
63
+ import {
64
+ renderTakomiBoardCall,
65
+ renderTakomiBoardResult,
66
+ renderTakomiModeCall,
67
+ renderTakomiModeResult,
68
+ renderTakomiRoutingCall,
69
+ renderTakomiRoutingResult,
70
+ renderTakomiWorkflowCall,
71
+ renderTakomiWorkflowResult,
72
+ } from "./tool-renderers";
60
73
 
61
74
  type TakomiModeSource = "idle" | "manual" | "model" | "board";
62
75
 
@@ -400,6 +413,24 @@ function getIncompleteChecklistItems(checklist?: OrchestratorTask["checklist"]):
400
413
  .map((item) => item.text);
401
414
  }
402
415
 
416
+ type BoardErrorSeverity = "warning" | "error";
417
+
418
+ function createBoardErrorResult(
419
+ text: string,
420
+ code: string,
421
+ severity: BoardErrorSeverity,
422
+ details: Record<string, unknown> = {},
423
+ ) {
424
+ return {
425
+ content: [{ type: "text" as const, text }],
426
+ // Keep this semantic error independent of Pi's transport-level isError.
427
+ // Renderers use it to retain warning/error meaning even when Pi does not
428
+ // pass top-level result flags back into renderResult.
429
+ details: { ...details, error: { code, message: text, severity } },
430
+ isError: true,
431
+ };
432
+ }
433
+
403
434
  function getCompletionGateError(task: Pick<OrchestratorTask, "id" | "title" | "checklist">): string | undefined {
404
435
  if (!task.checklist?.length) {
405
436
  return `Task ${task.id} cannot be marked completed until it has a checklist.`;
@@ -689,10 +720,15 @@ function installTakomiFooter(ctx: ExtensionContext, stateRef: { current: TakomiS
689
720
  ctx.ui.setFooter((tui, theme, footerData) => new TakomiFooterComponent(tui, theme, footerData, ctx, () => stateRef.current));
690
721
  }
691
722
 
692
- // Mutable state ref so the footer closure always reads the latest state
693
- const footerStateRef: { current: TakomiState; installed: boolean } = { current: cloneState(DEFAULT_STATE), installed: false };
723
+ function hasVisibleRuntimeWidget(state: TakomiState): boolean {
724
+ return state.enabled && (state.modeSource ?? "idle") !== "idle";
725
+ }
694
726
 
695
- async function refreshUi(ctx: ExtensionContext, state: TakomiState) {
727
+ async function refreshUi(
728
+ ctx: ExtensionContext,
729
+ state: TakomiState,
730
+ footerStateRef: { current: TakomiState; context?: ExtensionContext },
731
+ ) {
696
732
  if (!ctx.hasUI) return;
697
733
  ctx.ui.setTitle("Takomi");
698
734
  ctx.ui.setHeader((_tui, theme) => ({
@@ -702,17 +738,25 @@ async function refreshUi(ctx: ExtensionContext, state: TakomiState) {
702
738
  },
703
739
  }));
704
740
  footerStateRef.current = state;
705
- ctx.ui.setStatus("takomi-runtime", renderRuntimeStatus(ctx.ui.theme, state));
741
+
742
+ // The mode indicator belongs in the widget above the editor. Keeping a
743
+ // second setStatus copy makes Pi's default footer duplicate it whenever a
744
+ // custom footer is replaced or a session is rebound.
745
+ ctx.ui.setStatus("takomi-runtime", undefined);
706
746
  const widget = renderRuntimeWidget(ctx.ui.theme, state);
707
747
  ctx.ui.setWidget("takomi-runtime", widget.length > 0 ? widget : undefined);
708
- if (!footerStateRef.installed) {
748
+
749
+ // A replacement session receives a fresh UI context even when extension
750
+ // modules remain cached. Install once per context, not once per module.
751
+ if (footerStateRef.context !== ctx) {
709
752
  installTakomiFooter(ctx, footerStateRef);
710
- footerStateRef.installed = true;
753
+ footerStateRef.context = ctx;
711
754
  }
712
755
  }
713
756
 
714
757
  export default function takomiRuntime(pi: ExtensionAPI) {
715
758
  let state = cloneState(DEFAULT_STATE);
759
+ const footerStateRef: { current: TakomiState; context?: ExtensionContext } = { current: state };
716
760
  const subagentController = getTakomiSubagentController();
717
761
  const contextPanel = new TakomiContextPanel();
718
762
  let runtimeCtx: ExtensionContext | undefined;
@@ -734,6 +778,12 @@ export default function takomiRuntime(pi: ExtensionAPI) {
734
778
  pi.appendEntry(STATE_ENTRY, state);
735
779
  }
736
780
 
781
+ // This is intentionally separate from generic runtime-state persistence.
782
+ // Only user slash-command handlers receive the recorder below.
783
+ function recordUserGateAutoProvenance(authorized: boolean): void {
784
+ pi.appendEntry(USER_GATE_AUTO_PROVENANCE_ENTRY, { authorized });
785
+ }
786
+
737
787
  function syncContextPanelState() {
738
788
  contextPanel.setRuntimeState({
739
789
  role: state.role,
@@ -798,7 +848,7 @@ export default function takomiRuntime(pi: ExtensionAPI) {
798
848
  mutator();
799
849
  persistState();
800
850
  syncContextPanelState();
801
- await refreshUi(ctx, state);
851
+ await refreshUi(ctx, state, footerStateRef);
802
852
  const resolvedMessage = typeof message === "function" ? message() : message;
803
853
  if (resolvedMessage) ctx.ui.notify(resolvedMessage, "info");
804
854
  }
@@ -821,6 +871,7 @@ export default function takomiRuntime(pi: ExtensionAPI) {
821
871
  registerTakomiCommands(pi, {
822
872
  getState: () => state,
823
873
  updateState,
874
+ recordUserGateAutoProvenance,
824
875
  setStageAndWorkflow: (stage, options) => setStageAndWorkflow(state, stage, options),
825
876
  hasGenesisArtifacts,
826
877
  subagentController,
@@ -849,12 +900,13 @@ export default function takomiRuntime(pi: ExtensionAPI) {
849
900
  },
850
901
  resetRuntime: async (ctx) => {
851
902
  await updateState(ctx, () => {
903
+ recordUserGateAutoProvenance(false);
852
904
  state = cloneState(DEFAULT_STATE);
853
905
  activeSubagentLabel = undefined;
854
906
  activeSubagentAgent = undefined;
855
907
  activeSubagentTask = undefined;
856
908
  activeSubagentStatus = undefined;
857
- }, "Takomi runtime state reset");
909
+ }, () => hasVisibleRuntimeWidget(state) ? "" : "Takomi runtime state reset");
858
910
  subagentController.reset(ctx);
859
911
  contextPanel.resetSession();
860
912
  contextPanel.show(ctx);
@@ -910,9 +962,11 @@ export default function takomiRuntime(pi: ExtensionAPI) {
910
962
 
911
963
  persistState();
912
964
  syncContextPanelState();
913
- await refreshUi(ctx, state);
965
+ await refreshUi(ctx, state, footerStateRef);
914
966
  const label = state.modeSource === "idle" ? "idle" : `${state.modeSource}:${state.stage ?? state.role}`;
915
- return `Takomi mode set to ${label}${state.modeReason ? ` (${state.modeReason})` : ""}.`;
967
+ const text = `Takomi mode set to ${label}${state.modeReason ? ` (${state.modeReason})` : ""}.`;
968
+ if (!hasVisibleRuntimeWidget(state)) ctx.ui.notify(text, "info");
969
+ return text;
916
970
  }
917
971
 
918
972
  pi.registerTool({
@@ -931,12 +985,13 @@ export default function takomiRuntime(pi: ExtensionAPI) {
931
985
  }),
932
986
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
933
987
  const text = await applyTakomiMode(ctx, params.mode, "model", params.reason);
934
- ctx.ui.notify(text, "info");
935
988
  return {
936
989
  content: [{ type: "text", text }],
937
990
  details: { mode: params.mode, source: state.modeSource, reason: state.modeReason, role: state.role, stage: state.stage, workflow: state.workflow },
938
991
  };
939
992
  },
993
+ renderCall: renderTakomiModeCall,
994
+ renderResult: (result, options, theme) => renderTakomiModeResult(result, options, theme),
940
995
  });
941
996
 
942
997
  pi.registerTool({
@@ -955,7 +1010,15 @@ export default function takomiRuntime(pi: ExtensionAPI) {
955
1010
  }),
956
1011
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
957
1012
  const scope = params.scope ?? "global";
958
- const preview = previewTakomiRoutingPolicy(ctx.cwd, params.policyText, { scope });
1013
+ const availableModels = (() => {
1014
+ try {
1015
+ const available = (ctx as typeof ctx & { modelRegistry?: { getAvailable?: () => Array<{ provider?: string; id?: string; name?: string }> } }).modelRegistry?.getAvailable?.() ?? [];
1016
+ return available.map((model) => `${model.provider ? `${model.provider}/` : ""}${model.id ?? model.name ?? ""}`).filter(Boolean);
1017
+ } catch {
1018
+ return [];
1019
+ }
1020
+ })();
1021
+ const preview = previewTakomiRoutingPolicy(ctx.cwd, params.policyText, { scope, availableModels });
959
1022
  const result = await installTakomiRoutingPolicy(ctx.cwd, params.policyText, { scope });
960
1023
  const scopeNote = scope === "global"
961
1024
  ? "This global policy applies unless a project-local override exists."
@@ -978,6 +1041,8 @@ export default function takomiRuntime(pi: ExtensionAPI) {
978
1041
  details: { result, preview, reviewNotes: params.reviewNotes },
979
1042
  };
980
1043
  },
1044
+ renderCall: renderTakomiRoutingCall,
1045
+ renderResult: (result, options, theme) => renderTakomiRoutingResult(result, options, theme),
981
1046
  });
982
1047
 
983
1048
  pi.registerTool({
@@ -989,20 +1054,10 @@ export default function takomiRuntime(pi: ExtensionAPI) {
989
1054
  workflow: Type.Optional(StringEnum(["vibe-genesis", "vibe-design", "vibe-build"] as const)),
990
1055
  }),
991
1056
  async execute(_toolCallId, params) {
992
- if (params.workflow) {
993
- const workflow = getWorkflowDefinition(params.workflow);
994
- return {
995
- content: [{ type: "text", text: `${workflow.title}\n\n${workflow.playbook}` }],
996
- details: workflow,
997
- };
998
- }
999
-
1000
- const workflows = listWorkflowDefinitions();
1001
- return {
1002
- content: [{ type: "text", text: workflows.map((workflow) => `${workflow.id}: ${workflow.purpose}`).join("\n") }],
1003
- details: undefined,
1004
- };
1057
+ return discoverWorkflowPlaybooks(params.workflow);
1005
1058
  },
1059
+ renderCall: renderTakomiWorkflowCall,
1060
+ renderResult: (result, options, theme) => renderTakomiWorkflowResult(result, options, theme),
1006
1061
  });
1007
1062
 
1008
1063
  pi.registerTool({
@@ -1072,16 +1127,12 @@ export default function takomiRuntime(pi: ExtensionAPI) {
1072
1127
  }),
1073
1128
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1074
1129
  if (params.action === "show_workflows") {
1075
- const workflows = listWorkflowDefinitions();
1076
- return {
1077
- content: [{ type: "text", text: workflows.map((workflow) => `${workflow.id}: ${workflow.playbook}`).join("\n\n") }],
1078
- details: { workflows },
1079
- };
1130
+ return showWorkflowCatalogForBoard();
1080
1131
  }
1081
1132
 
1082
1133
  if (params.action === "show_session") {
1083
1134
  if (!params.sessionId) {
1084
- return { content: [{ type: "text", text: "sessionId is required for show_session" }], details: {}, isError: true };
1135
+ return createBoardErrorResult("sessionId is required for show_session", "missing-session-id", "warning");
1085
1136
  }
1086
1137
  assertSafeSessionId(params.sessionId);
1087
1138
  const paths = getSessionPaths(ctx.cwd, params.sessionId);
@@ -1100,13 +1151,18 @@ ${stateJson}`
1100
1151
 
1101
1152
  if (params.action === "update_task") {
1102
1153
  if (!params.sessionId || !params.taskId) {
1103
- return { content: [{ type: "text", text: "sessionId and taskId are required for update_task" }], details: {}, isError: true };
1154
+ return createBoardErrorResult("sessionId and taskId are required for update_task", "missing-task-context", "warning");
1104
1155
  }
1105
1156
  assertSafeTaskId(params.taskId);
1106
1157
  const { state: sessionState } = await loadSessionState(ctx.cwd, params.sessionId);
1107
1158
  const idx = sessionState.tasks.findIndex((task) => task.id === params.taskId);
1108
1159
  if (idx === -1) {
1109
- return { content: [{ type: "text", text: `Task ${params.taskId} not found in session ${params.sessionId}` }], details: {}, isError: true };
1160
+ return createBoardErrorResult(
1161
+ `Task ${params.taskId} not found in session ${params.sessionId}`,
1162
+ "task-not-found",
1163
+ "error",
1164
+ { sessionId: params.sessionId, taskId: params.taskId },
1165
+ );
1110
1166
  }
1111
1167
  const current = sessionState.tasks[idx];
1112
1168
  const checklist = resolveChecklistState(current.checklist, params.checklist, params.checklistUpdates);
@@ -1119,16 +1175,12 @@ ${stateJson}`
1119
1175
  if (params.status === "completed") {
1120
1176
  const completionGateError = getCompletionGateError(nextTask);
1121
1177
  if (completionGateError) {
1122
- return {
1123
- content: [{ type: "text", text: completionGateError }],
1124
- details: {
1125
- sessionId: params.sessionId,
1126
- taskId: current.id,
1127
- incompleteChecklistItems: getIncompleteChecklistItems(nextTask.checklist),
1128
- checklist: nextTask.checklist,
1129
- },
1130
- isError: true,
1131
- };
1178
+ return createBoardErrorResult(completionGateError, "completion-gate", "warning", {
1179
+ sessionId: params.sessionId,
1180
+ taskId: current.id,
1181
+ incompleteChecklistItems: getIncompleteChecklistItems(nextTask.checklist),
1182
+ checklist: nextTask.checklist,
1183
+ });
1132
1184
  }
1133
1185
  }
1134
1186
  sessionState.tasks[idx] = nextTask;
@@ -1137,7 +1189,7 @@ ${stateJson}`
1137
1189
  state.modeReason = state.modeReason ?? "board task update";
1138
1190
  persistState();
1139
1191
  syncContextPanelState();
1140
- await refreshUi(ctx, state);
1192
+ await refreshUi(ctx, state, footerStateRef);
1141
1193
  const nextState = buildSessionState(
1142
1194
  sessionState.sessionId,
1143
1195
  sessionState.title,
@@ -1166,7 +1218,11 @@ ${stateJson}`
1166
1218
 
1167
1219
  if (params.action === "expand_stage") {
1168
1220
  if (!params.sessionId || !params.stage || !params.tasks?.length) {
1169
- return { content: [{ type: "text", text: "sessionId, stage, and at least one task are required for expand_stage" }], details: {}, isError: true };
1221
+ return createBoardErrorResult(
1222
+ "sessionId, stage, and at least one task are required for expand_stage",
1223
+ "invalid-expansion",
1224
+ "warning",
1225
+ );
1170
1226
  }
1171
1227
 
1172
1228
  const { state: sessionState } = await loadSessionState(ctx.cwd, params.sessionId);
@@ -1197,7 +1253,7 @@ ${stateJson}`
1197
1253
  state.modeReason = `expanded ${params.stage} stage`;
1198
1254
  persistState();
1199
1255
  syncContextPanelState();
1200
- await refreshUi(ctx, state);
1256
+ await refreshUi(ctx, state, footerStateRef);
1201
1257
 
1202
1258
  return {
1203
1259
  content: [{ type: "text", text: `Expanded ${params.stage} stage in session ${nextState.sessionId}.\n\nDocs: ${paths.root}\nState: ${paths.stateFile}\n\n${buildTaskRows(nextState.tasks)}` }],
@@ -1242,13 +1298,15 @@ ${stateJson}`
1242
1298
  state.modeReason = "orchestrator session";
1243
1299
  persistState();
1244
1300
  syncContextPanelState();
1245
- await refreshUi(ctx, state);
1301
+ await refreshUi(ctx, state, footerStateRef);
1246
1302
 
1247
1303
  return {
1248
1304
  content: [{ type: "text", text: `Created Takomi orchestrator session ${nextState.sessionId} in hybrid mode\n\nDocs: ${paths.root}\nState: ${paths.stateFile}\n\n${buildTaskRows(nextState.tasks) || "No tasks provided."}` }],
1249
1305
  details: { sessionId: nextState.sessionId, paths, tasks: nextState.tasks, lifecycle: nextState.lifecycle, mode: nextState.mode },
1250
1306
  };
1251
1307
  },
1308
+ renderCall: renderTakomiBoardCall,
1309
+ renderResult: (result, options, theme, context) => renderTakomiBoardResult(result, options, theme, context?.args),
1252
1310
  });
1253
1311
 
1254
1312
  pi.on("input", async (event) => {
@@ -1263,20 +1321,39 @@ ${stateJson}`
1263
1321
  state.enabled = true;
1264
1322
  try {
1265
1323
  const cwd = runtimeCtx?.cwd ?? process.cwd();
1266
- const preview = previewTakomiRoutingPolicy(cwd, text, { scope: "global" });
1324
+ const availableModels = (() => {
1325
+ try {
1326
+ const available = (runtimeCtx as typeof runtimeCtx & { modelRegistry?: { getAvailable?: () => Array<{ provider?: string; id?: string; name?: string }> } })?.modelRegistry?.getAvailable?.() ?? [];
1327
+ return available.map((model) => `${model.provider ? `${model.provider}/` : ""}${model.id ?? model.name ?? ""}`).filter(Boolean);
1328
+ } catch {
1329
+ return [];
1330
+ }
1331
+ })();
1332
+ const preview = previewTakomiRoutingPolicy(cwd, text, { scope: "global", availableModels });
1333
+ const activePolicy = await resolveTakomiRoutingPolicy(cwd);
1334
+ const replacementWarning = activePolicy.text && activePolicy.text.trim().length > preview.policy.trim().length * 2
1335
+ ? `WARNING: This input is much shorter than the active policy (${preview.policy.length} vs ${activePolicy.text.length} characters). It will replace the file, not merge into it. Inspect any referenced full source before applying.`
1336
+ : "The supplied text replaces the policy file exactly; it is not merged with the current policy.";
1267
1337
  return { action: "transform", text: [
1268
1338
  "Review this Takomi routing policy extraction before it is saved.",
1269
1339
  "",
1270
1340
  "Rules:",
1271
1341
  "- Do not invent providers or model IDs not grounded in the policy.",
1272
- "- Providerless names like GPT-5.5 are routing intent unless a preferred provider/router is declared.",
1273
- "- Valid Takomi roles are: general, orchestrator, architect, designer, coder, reviewer.",
1274
- "- If the extraction is correct and safe, call takomi_apply_routing_policy with scope=global and the exact original policy text.",
1275
- "- If it is ambiguous or wrong, explain what the user should clarify and do not call the tool.",
1342
+ "- Providerless names are valid routing intent. Require a provider only for executable model overrides.",
1343
+ "- Check the available Pi model registry below before asking whether a named model exists or what its exact ID is.",
1344
+ "- Conditional task routes need not be forced into role-wide agentOverrides.",
1345
+ "- Valid role-wide Takomi overrides are: general, orchestrator, architect, designer, coder, reviewer. Other headings may be policy concepts or execution routes.",
1346
+ "- Preserve the user's complete authored policy. If this is a summary/excerpt and a referenced full source exists, inspect and apply that source rather than overwriting it with the excerpt.",
1347
+ "- If correct and safe, call takomi_apply_routing_policy with scope=global and the complete intended policy text.",
1348
+ "- Ask only for unresolved provider/account choices or genuine ambiguity; do not ask for facts available in registry or files.",
1276
1349
  "",
1277
1350
  "Deterministic extraction:",
1278
1351
  renderRoutingPolicyPreview(preview),
1279
1352
  "",
1353
+ replacementWarning,
1354
+ "",
1355
+ availableModels.length ? `Available Pi models:\n${availableModels.map((model) => `- ${model}`).join("\n")}` : "Available Pi models: registry unavailable; inspect it before asking the user if possible.",
1356
+ "",
1280
1357
  "Original policy text:",
1281
1358
  "```",
1282
1359
  preview.policy,
@@ -1356,7 +1433,7 @@ ${stateJson}`
1356
1433
  }
1357
1434
  persistState();
1358
1435
  syncContextPanelState();
1359
- await refreshUi(ctx, state);
1436
+ await refreshUi(ctx, state, footerStateRef);
1360
1437
 
1361
1438
  const routingPolicy = await resolveTakomiRoutingPolicy(runtimeCwd);
1362
1439
  const optionalFeatureContext = (() => {
@@ -1458,7 +1535,7 @@ ${stateJson}`
1458
1535
 
1459
1536
  syncContextPanelState();
1460
1537
  contextPanel.rebuildFromSession(ctx);
1461
- await refreshUi(ctx, state);
1538
+ await refreshUi(ctx, state, footerStateRef);
1462
1539
  contextPanel.show(ctx);
1463
1540
  flushPendingSubagentEvents();
1464
1541
  });
@@ -205,7 +205,7 @@ export async function resolveTakomiRoutingPolicy(cwd: string): Promise<ResolvedR
205
205
  return { source: "missing" };
206
206
  }
207
207
 
208
- export function previewTakomiRoutingPolicy(cwd: string, input: string, options: { scope?: RoutingPolicyInstallScope } = {}): RoutingPolicyPreviewResult {
208
+ export function previewTakomiRoutingPolicy(cwd: string, input: string, options: { scope?: RoutingPolicyInstallScope; availableModels?: string[] } = {}): RoutingPolicyPreviewResult {
209
209
  const policy = extractQuotedPolicy(input);
210
210
  if (!policy) throw new Error("No routing policy text found. Paste the policy after /takomi routing or inside triple quotes.");
211
211
 
@@ -217,7 +217,19 @@ export function previewTakomiRoutingPolicy(cwd: string, input: string, options:
217
217
  ? path.join(cwd, PROJECT_PI_SETTINGS_RELATIVE)
218
218
  : GLOBAL_PI_SETTINGS_PATH;
219
219
  const { overrides, detected } = deriveSubagentDefaults(policy);
220
- return { scope, policy, policyPath, settingsPath, detectedDefaults: detected, overrides };
220
+
221
+ // Resolve named model families from Pi's registry for review visibility. These
222
+ // are conditional routing intents, not role-wide defaults, so do not invent
223
+ // agentOverrides merely to make the extraction non-empty.
224
+ const availableModels = [...new Set(options.availableModels ?? [])];
225
+ for (const alias of ["luna", "sol", "terra"]) {
226
+ if (!new RegExp(`\\b${alias}\\b`, "i").test(policy)) continue;
227
+ const matches = availableModels.filter((model) => new RegExp(`(?:^|/)gpt[-_.]?5\\.6[-_.]?${alias}$`, "i").test(model));
228
+ if (matches.length === 1) detected.push(`${alias[0].toUpperCase()}${alias.slice(1)} intent resolves to ${matches[0]} (conditional route)`);
229
+ else if (matches.length > 1) detected.push(`${alias[0].toUpperCase()}${alias.slice(1)} intent matches available models: ${matches.join(", ")}`);
230
+ else detected.push(`${alias[0].toUpperCase()}${alias.slice(1)} remains a providerless conditional routing intent`);
231
+ }
232
+ return { scope, policy, policyPath, settingsPath, detectedDefaults: [...new Set(detected)], overrides };
221
233
  }
222
234
 
223
235
  export async function installTakomiRoutingPolicy(cwd: string, input: string, options: { scope?: RoutingPolicyInstallScope } = {}): Promise<RoutingPolicyInstallResult> {
@@ -15,29 +15,47 @@ const pc = {
15
15
  magenta: ansi(35, 39),
16
16
  };
17
17
 
18
+ // USD per million tokens: input, cache read, output, cache write.
19
+ // OAuth-router's configured catalog is merged over these fallbacks at runtime,
20
+ // keeping Stats aligned with custom/new models without another hard-coded edit.
18
21
  const PRICES = {
19
- 'gpt-5.5': [5.00, 0.50, 30.00],
20
- 'gpt-5.4': [2.50, 0.25, 15.00],
21
- 'gpt-5.4-mini': [0.75, 0.075, 4.50],
22
- 'gpt-5.4-nano': [0.20, 0.02, 1.25],
23
- 'gpt-5.3-codex': [2.50, 0.25, 15.00],
24
- 'gpt-5.2-codex': [1.75, 0.175, 14.00],
25
- 'gpt-5-codex': [1.25, 0.125, 10.00],
26
- 'gpt-5.2': [1.75, 0.175, 14.00],
27
- 'gpt-5.1': [1.25, 0.125, 10.00],
28
- 'gpt-5': [1.25, 0.125, 10.00],
29
- 'gpt-5-mini': [0.25, 0.025, 2.00],
30
- 'gpt-4.1': [2.00, 0.50, 8.00],
31
- 'gpt-4o': [2.50, 1.25, 10.00],
32
- 'o4-mini': [1.10, 0.275, 4.40],
33
- 'claude-sonnet-4-6': [3.00, 0.30, 15.00],
22
+ 'gpt-5.6-luna': [1.00, 0.10, 6.00, 1.25],
23
+ 'gpt-5.6-sol': [5.00, 0.50, 30.00, 6.25],
24
+ 'gpt-5.6-terra': [2.50, 0.25, 15.00, 3.125],
25
+ 'gpt-5.5': [5.00, 0.50, 30.00, 6.25],
26
+ 'gpt-5.4': [2.50, 0.25, 15.00, 3.125],
27
+ 'gpt-5.4-mini': [0.75, 0.075, 4.50, 0.9375],
28
+ 'gpt-5.4-nano': [0.20, 0.02, 1.25, 0.25],
29
+ 'gpt-5.3-codex': [2.50, 0.25, 15.00, 3.125],
30
+ 'gpt-5.2-codex': [1.75, 0.175, 14.00, 2.1875],
31
+ 'gpt-5-codex': [1.25, 0.125, 10.00, 1.5625],
32
+ 'gpt-5.2': [1.75, 0.175, 14.00, 2.1875],
33
+ 'gpt-5.1': [1.25, 0.125, 10.00, 1.5625],
34
+ 'gpt-5': [1.25, 0.125, 10.00, 1.5625],
35
+ 'gpt-5-mini': [0.25, 0.025, 2.00, 0.3125],
36
+ 'gpt-4.1': [2.00, 0.50, 8.00, 2.00],
37
+ 'gpt-4o': [2.50, 1.25, 10.00, 2.50],
38
+ 'o4-mini': [1.10, 0.275, 4.40, 1.10],
39
+ 'claude-sonnet-4-6': [3.00, 0.30, 15.00, 3.75],
34
40
  };
35
41
 
36
42
  async function exists(target) { try { await fs.access(target); return true; } catch { return false; } }
37
43
  function safeJson(line) { try { return JSON.parse(line); } catch { return null; } }
38
44
  function dayOf(ts) { return typeof ts === 'string' && ts.length >= 10 ? ts.slice(0, 10) : 'unknown'; }
39
- function add(map, key, patch) { const row = map.get(key) || { key, input: 0, cache: 0, output: 0, total: 0, cost: 0, events: 0 }; for (const [k,v] of Object.entries(patch)) row[k] = (row[k] || 0) + (Number(v) || 0); if (!Object.prototype.hasOwnProperty.call(patch, 'events')) row.events += 1; map.set(key, row); }
40
- function cost(model, input, cache, output, additiveCache = true) { const p = PRICES[model]; if (!p) return 0; const nonCached = additiveCache ? input : Math.max(input - cache, 0); return (nonCached*p[0] + cache*p[1] + output*p[2]) / 1_000_000; }
45
+ function add(map, key, patch) { const row = map.get(key) || { key, input: 0, cache: 0, cacheWrite: 0, output: 0, total: 0, cost: 0, events: 0 }; for (const [k,v] of Object.entries(patch)) row[k] = (row[k] || 0) + (Number(v) || 0); if (!Object.prototype.hasOwnProperty.call(patch, 'events')) row.events += 1; map.set(key, row); }
46
+ function cost(model, input, cache, output, cacheWrite = 0, prices = PRICES) { const p = prices[model]; if (!p) return 0; return (input*p[0] + cache*p[1] + output*p[2] + cacheWrite*(p[3] ?? p[0])) / 1_000_000; }
47
+ async function loadPrices(home) {
48
+ const prices = { ...PRICES };
49
+ const configPath = path.join(home, '.pi', 'agent', 'oauth-router', 'config.json');
50
+ try {
51
+ const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
52
+ for (const model of config.models || []) {
53
+ if (!model?.id || !model?.cost) continue;
54
+ prices[model.id] = [model.cost.input || 0, model.cost.cacheRead || 0, model.cost.output || 0, model.cost.cacheWrite ?? model.cost.input ?? 0];
55
+ }
56
+ } catch {}
57
+ return prices;
58
+ }
41
59
  function fmtTokens(n) { if (n >= 1e9) return `${(n/1e9).toFixed(2)}B`; if (n >= 1e6) return `${(n/1e6).toFixed(1)}M`; if (n >= 1e3) return `${(n/1e3).toFixed(1)}K`; return String(Math.round(n || 0)); }
42
60
  function fmtMoney(n) { return `$${(n || 0).toFixed(n > 100 ? 0 : 2)}`; }
43
61
  function ms(n) { if (!n) return '-'; const s = Math.round(n/1000); if (s < 60) return `${s}s`; const m = Math.floor(s/60); if (m < 60) return `${m}m ${s%60}s`; const h = Math.floor(m/60); return `${h}h ${m%60}m`; }
@@ -117,7 +135,7 @@ function pushTask(taskRows, task) {
117
135
  taskRows.push(task);
118
136
  }
119
137
 
120
- async function scanPiSessions(root, source, events, sessionRows = [], taskRows = []) {
138
+ async function scanPiSessions(root, source, events, sessionRows = [], taskRows = [], prices = PRICES) {
121
139
  for (const file of await files(root)) {
122
140
  let provider = 'unknown', model = 'unknown', session = path.basename(file, '.jsonl'), cwd = '', currentTask = null;
123
141
  const row = { key: session, session, source, file, project: projectKey(file), cwd, start: '', end: '', turns: 0, messages: 0, toolCalls: 0, subagentCalls: 0, roles: new Map(), stages: new Map(), workflows: new Map(), activeMs: 0, activityTimestamps: [] };
@@ -170,7 +188,7 @@ async function scanPiSessions(root, source, events, sessionRows = [], taskRows =
170
188
  }
171
189
  }
172
190
  const u = msg && msg.usage;
173
- if (u) events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'usage', input: +u.input||0, cache: +u.cacheRead||0, output: +u.output||0, total: +u.totalTokens||0, cost: cost(model, +u.input||0, +u.cacheRead||0, +u.output||0, true) });
191
+ if (u) events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'usage', input: +u.input||0, cache: +u.cacheRead||0, cacheWrite: +u.cacheWrite||0, output: +u.output||0, total: +u.totalTokens||0, cost: cost(model, +u.input||0, +u.cacheRead||0, +u.output||0, +u.cacheWrite||0, prices) });
174
192
  }
175
193
  } catch {
176
194
  continue;
@@ -193,18 +211,19 @@ export async function collectTakomiStats(opts = {}) {
193
211
  const home = opts.home || os.homedir();
194
212
  const cwd = opts.cwd || process.cwd();
195
213
  const rawEvents = [], rawSessions = [], rawTasks = [];
214
+ const prices = await loadPrices(home);
196
215
  const globalSessions = path.resolve(path.join(home, '.pi', 'agent', 'sessions'));
197
216
  const projectSessions = path.resolve(path.join(cwd, '.pi', 'agent', 'sessions'));
198
- await scanPiSessions(globalSessions, 'pi-global', rawEvents, rawSessions, rawTasks);
199
- if (projectSessions !== globalSessions) await scanPiSessions(projectSessions, 'pi-project', rawEvents, rawSessions, rawTasks);
200
- await scanPiSessions(path.join(cwd, '.pi', 'takomi'), 'takomi-project', rawEvents, rawSessions, rawTasks);
217
+ await scanPiSessions(globalSessions, 'pi-global', rawEvents, rawSessions, rawTasks, prices);
218
+ if (projectSessions !== globalSessions) await scanPiSessions(projectSessions, 'pi-project', rawEvents, rawSessions, rawTasks, prices);
219
+ await scanPiSessions(path.join(cwd, '.pi', 'takomi'), 'takomi-project', rawEvents, rawSessions, rawTasks, prices);
201
220
  const sinceDay = parseSince(opts.since);
202
221
  const events = rawEvents.filter(e => !sinceDay || e.day >= sinceDay);
203
222
  const sessionRows = rawSessions.filter(s => !sinceDay || dayOf(s.end || s.start) >= sinceDay);
204
223
  const taskRows = rawTasks.filter(t => !sinceDay || dayOf(t.end || t.start) >= sinceDay);
205
224
  const runs = await scanRunHistory(path.join(home, '.pi', 'agent', 'run-history.jsonl'));
206
225
  const byDay = new Map(), byModel = new Map(), bySource = new Map(), byProject = new Map(), byTool = new Map(), byRole = new Map(), byStage = new Map(), byWorkflow = new Map();
207
- let totals = { input: 0, cache: 0, output: 0, total: 0, cost: 0, events: events.filter(e => e.kind === 'usage').length, toolCalls: 0, turns: 0 };
226
+ let totals = { input: 0, cache: 0, cacheWrite: 0, output: 0, total: 0, cost: 0, events: events.filter(e => e.kind === 'usage').length, toolCalls: 0, turns: 0 };
208
227
  for (const s of sessionRows) { totals.toolCalls += s.toolCalls; totals.turns += s.turns; }
209
228
  for (const e of events) {
210
229
  if (e.kind === 'tool') { add(byTool, e.tool || 'unknown', { total: 0, events: 1 }); continue; }
@@ -214,7 +233,7 @@ export async function collectTakomiStats(opts = {}) {
214
233
  add(byWorkflow, e.workflow || 'unknown', { total: 0, events: 1 });
215
234
  continue;
216
235
  }
217
- totals.input += e.input; totals.cache += e.cache; totals.output += e.output; totals.total += e.total; totals.cost += e.cost;
236
+ totals.input += e.input; totals.cache += e.cache; totals.cacheWrite += e.cacheWrite || 0; totals.output += e.output; totals.total += e.total; totals.cost += e.cost;
218
237
  add(byDay, e.day, e); add(byModel, e.model, e); add(bySource, e.source, e); add(byProject, e.project, e);
219
238
  }
220
239
  const byAgent = new Map(); let longestRun = null;
@@ -509,7 +528,8 @@ export function renderTakomiStats(stats, opts = {}) {
509
528
  // ── Stat Cards Row 1 ─────────────────────────────────────────────────
510
529
  const cards1 = [
511
530
  statCard(fmtTokens(stats.totals.total), 'Lifetime Tokens'),
512
- statCard(fmtTokens(stats.totals.cache), 'Cache Tokens'),
531
+ statCard(fmtTokens(stats.totals.cache), 'Cache Reads'),
532
+ statCard(fmtTokens(stats.totals.cacheWrite), 'Cache Writes'),
513
533
  statCard(fmtMoney(stats.totals.cost), 'Est. Cost'),
514
534
  statCard(String(stats.sessions), 'Sessions'),
515
535
  statCard(String(stats.totals.turns), 'Main Turns'),
@@ -677,7 +697,7 @@ export function renderTakomiStats(stats, opts = {}) {
677
697
  lines.push('');
678
698
  lines.push(' ' + pc.dim(hrule(W - 4)));
679
699
  lines.push(' ' + pc.dim('Privacy: metadata only · no raw prompts or transcripts'));
680
- lines.push(' ' + pc.dim('Costs are estimates when provider prices are unknown.'));
700
+ lines.push(' ' + pc.dim('Costs use the oauth-router catalog when available; cache reads and writes are priced separately.'));
681
701
  lines.push('');
682
702
 
683
703
  return lines.join('\n');