takomi 2.1.33 → 2.1.35

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.
@@ -35,9 +35,6 @@ const SUBCOMMAND_COMPLETIONS: Record<string, TakomiCompletion[]> = {
35
35
  { value: "status", label: "status", description: "Show active subagents" },
36
36
  { value: "on", label: "on", description: "Allow subagent delegation" },
37
37
  { value: "off", label: "off", description: "Disable subagent delegation" },
38
- { value: "expand", label: "expand", description: "Expand native tool results" },
39
- { value: "collapse", label: "collapse", description: "Collapse native tool results" },
40
- { value: "toggle", label: "toggle", description: "Toggle native tool result expansion" },
41
38
  ],
42
39
  stats: [
43
40
  { value: "overview", label: "overview", description: "Show the full profile-card dashboard" },
@@ -98,7 +95,7 @@ export function commandHelp(): string {
98
95
  "/takomi plan [title]",
99
96
  "/takomi mode <direct|orchestrate|review>",
100
97
  "/takomi gate <auto|review>",
101
- "/takomi subagents <on|off|status|expand|collapse|toggle>",
98
+ "/takomi subagents <on|off|status>",
102
99
  "/takomi stats [overview|daily|models|projects|projects-full|sessions|sessions-full|tasks|tasks-full|tools|subagents|sources] [since 7d]",
103
100
  "/takomi routing [show|where]",
104
101
  "/takomi routing <policy text> # updates global policy",
@@ -134,6 +131,7 @@ export function statusText(state: TakomiRuntimeCommandState, subagentController:
134
131
  return [
135
132
  `Takomi: ${state.enabled ? "on" : "off"}`,
136
133
  `Mode: ${state.role}`,
134
+ `Source: ${state.modeSource ?? "idle"}${state.modeReason ? ` (${state.modeReason})` : ""}`,
137
135
  `Stage: ${state.stage ?? "-"}`,
138
136
  `Workflow: ${state.workflow ?? "-"}`,
139
137
  `Plan bias: ${state.planMode ? "on" : "off"}`,
@@ -20,6 +20,8 @@ export type TakomiRuntimeCommandState = {
20
20
  workflow?: TakomiWorkflowId;
21
21
  activeSessionId?: string;
22
22
  subagentsEnabled: boolean;
23
+ modeSource?: "idle" | "manual" | "model" | "board";
24
+ modeReason?: string;
23
25
  };
24
26
 
25
27
  type RegisterTakomiCommandOptions = {
@@ -36,6 +38,8 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
36
38
  async function handleStage(ctx: ExtensionCommandContext, stage: VibeLifecycleStage, prompt?: string): Promise<void> {
37
39
  await options.updateState(ctx, () => {
38
40
  options.getState().enabled = true;
41
+ options.getState().modeSource = "manual";
42
+ options.getState().modeReason = `/${stage}`;
39
43
  options.setStageAndWorkflow(stage, { preserveRole: stage === "genesis" && options.getState().role === "orchestrator" });
40
44
  options.getState().planMode = stage !== "build";
41
45
  }, workflowPrompt(stage, prompt));
@@ -54,17 +58,27 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
54
58
  state.autoOrch = false;
55
59
  state.planMode = false;
56
60
  state.role = "general";
61
+ state.stage = undefined;
62
+ state.workflow = undefined;
63
+ state.modeSource = "idle";
64
+ state.modeReason = undefined;
57
65
  } else if (mode === "orchestrate") {
58
66
  state.autoOrch = true;
59
67
  state.planMode = true;
60
68
  state.role = "orchestrator";
61
69
  state.stage = hasGenesis ? "build" : "genesis";
62
70
  state.workflow = hasGenesis ? "vibe-build" : "vibe-genesis";
71
+ state.modeSource = "manual";
72
+ state.modeReason = "/takomi mode orchestrate";
63
73
  } else {
64
74
  state.autoOrch = false;
65
75
  state.planMode = true;
66
76
  state.launchMode = "manual";
67
77
  state.role = "review";
78
+ state.stage = undefined;
79
+ state.workflow = undefined;
80
+ state.modeSource = "manual";
81
+ state.modeReason = "/takomi mode review";
68
82
  }
69
83
  }, () => `Takomi mode set to ${mode}`);
70
84
  }
@@ -198,27 +212,7 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
198
212
  ctx.ui.notify(statusText(options.getState(), controller), controller.hasRuns() ? "info" : "warning");
199
213
  return;
200
214
  }
201
- if (action === "expand" || action === "fullscreen") {
202
- ctx.ui.setToolsExpanded(true);
203
- ctx.ui.notify("Expanded native tool results for Takomi subagent output.", "info");
204
- return;
205
- }
206
- if (action === "collapse") {
207
- ctx.ui.setToolsExpanded(false);
208
- ctx.ui.notify("Collapsed native tool results.", "info");
209
- return;
210
- }
211
- if (action === "toggle") {
212
- const expanded = !ctx.ui.getToolsExpanded();
213
- ctx.ui.setToolsExpanded(expanded);
214
- ctx.ui.notify(`${expanded ? "Expanded" : "Collapsed"} native tool results.`, "info");
215
- return;
216
- }
217
- if (action === "next" || action === "prev") {
218
- ctx.ui.notify("Takomi now uses Pi's native inline result UI. Subagent navigation is handled by the transcript/tool results.", "info");
219
- return;
220
- }
221
- ctx.ui.notify("Usage: /takomi subagents <on|off|status|expand|collapse|toggle>", "warning");
215
+ ctx.ui.notify("Usage: /takomi subagents <on|off|status>", "warning");
222
216
  }
223
217
 
224
218
  pi.registerCommand("takomi", {
@@ -206,6 +206,18 @@ function calculateActiveMs(intervals: ActivityInterval[]): number {
206
206
  return mergeIntervals(intervals).reduce((total, interval) => total + (interval.end - interval.start), 0);
207
207
  }
208
208
 
209
+ function clampActiveMs(activeMs: number, sessionStart: number, now = Date.now()): number {
210
+ if (!Number.isFinite(activeMs) || activeMs <= 0) return 0;
211
+ const ageMs = Math.max(0, now - sessionStart);
212
+ return Math.min(activeMs, ageMs);
213
+ }
214
+
215
+ function boundedPendingStart(starts: number[], sessionStart: number, now = Date.now()): number | undefined {
216
+ const valid = starts.filter((start) => Number.isFinite(start) && start <= now);
217
+ if (valid.length === 0) return undefined;
218
+ return Math.max(sessionStart, Math.min(...valid));
219
+ }
220
+
209
221
  function formatDisplayPath(filePath: string, cwd?: string): string {
210
222
  const normalized = filePath.replace(/^@/, "");
211
223
  const absolute = path.isAbsolute(normalized) ? normalized : undefined;
@@ -312,11 +324,13 @@ class ContextPanelComponent implements Component {
312
324
  lines.push("");
313
325
 
314
326
  if (ctx) {
315
- const age = formatDuration(Date.now() - state.sessionStart);
327
+ const now = Date.now();
328
+ const ageMs = Math.max(0, now - state.sessionStart);
329
+ const age = formatDuration(ageMs);
316
330
  const pendingStarts = Object.values(state.pendingToolStarts ?? {});
317
- const pendingStart = pendingStarts.length > 0 ? Math.min(...pendingStarts) : undefined;
318
- const activeMs = pendingStart !== undefined ? state.activeMs + Math.max(0, Date.now() - pendingStart) : state.activeMs;
319
- const active = formatDuration(activeMs);
331
+ const pendingStart = boundedPendingStart(pendingStarts, state.sessionStart, now);
332
+ const rawActiveMs = pendingStart !== undefined ? state.activeMs + Math.max(0, now - pendingStart) : state.activeMs;
333
+ const active = formatDuration(clampActiveMs(rawActiveMs, state.sessionStart, now));
320
334
  lines.push(`${pad}${theme.fg("dim", "Age:")} ${theme.fg("muted", age)}`);
321
335
  lines.push(`${pad}${theme.fg("dim", "Active:")} ${theme.fg("muted", active)}`);
322
336
 
@@ -417,15 +431,18 @@ export class TakomiContextPanel {
417
431
  this.requestRender?.();
418
432
  }
419
433
 
420
- private recomputeActiveMs(): void {
421
- this.state.activeMs = calculateActiveMs(this.state.activityIntervals);
434
+ private recomputeActiveMs(now = Date.now()): void {
435
+ this.state.activeMs = clampActiveMs(calculateActiveMs(this.state.activityIntervals), this.state.sessionStart, now);
422
436
  }
423
437
 
424
438
  private addActivityInterval(start: number, end: number): void {
425
- if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return;
426
- this.state.activityIntervals.push({ start, end });
427
- this.recomputeActiveMs();
428
- this.state.lastActivityAt = Math.max(this.state.lastActivityAt, end);
439
+ const now = Date.now();
440
+ const boundedStart = Math.max(this.state.sessionStart, start);
441
+ const boundedEnd = Math.min(now, end);
442
+ if (!Number.isFinite(boundedStart) || !Number.isFinite(boundedEnd) || boundedEnd <= boundedStart) return;
443
+ this.state.activityIntervals.push({ start: boundedStart, end: boundedEnd });
444
+ this.recomputeActiveMs(now);
445
+ this.state.lastActivityAt = Math.max(this.state.lastActivityAt, boundedEnd);
429
446
  }
430
447
 
431
448
  private noteActivity(timestamp = Date.now(), allowLongGap = false): void {
@@ -564,10 +581,13 @@ export class TakomiContextPanel {
564
581
  lastActivityPoint = ts;
565
582
  }
566
583
 
584
+ const now = Date.now();
567
585
  next.sessionStart = firstSessionTs ?? next.sessionStart;
568
- next.activityIntervals = intervals;
569
- next.activeMs = calculateActiveMs(intervals);
570
- next.lastActivityAt = lastActivityPoint ?? next.sessionStart;
586
+ next.activityIntervals = intervals
587
+ .map((interval) => ({ start: Math.max(next.sessionStart, interval.start), end: Math.min(now, interval.end) }))
588
+ .filter((interval) => Number.isFinite(interval.start) && Number.isFinite(interval.end) && interval.end > interval.start);
589
+ next.activeMs = clampActiveMs(calculateActiveMs(next.activityIntervals), next.sessionStart, now);
590
+ next.lastActivityAt = Math.min(now, Math.max(next.sessionStart, lastActivityPoint ?? next.sessionStart));
571
591
 
572
592
  this.state = next;
573
593
  this.requestRender?.();
@@ -671,6 +691,10 @@ export function wireContextPanel(pi: ExtensionAPI, panel: TakomiContextPanel): v
671
691
  panel.rebuildFromSession(ctx);
672
692
  });
673
693
 
694
+ pi.on("session_compact", (_event, ctx) => {
695
+ panel.rebuildFromSession(ctx);
696
+ });
697
+
674
698
  pi.registerShortcut("alt+c", {
675
699
  description: "Toggle Takomi context panel",
676
700
  handler: async (ctx) => {
@@ -58,6 +58,8 @@ import {
58
58
  } from "./profile";
59
59
  import { installTakomiRoutingPolicy, previewTakomiRoutingPolicy, renderRoutingPolicyPreview, resolveTakomiRoutingPolicy } from "./routing-policy";
60
60
 
61
+ type TakomiModeSource = "idle" | "manual" | "model" | "board";
62
+
61
63
  type TakomiState = {
62
64
  enabled: boolean;
63
65
  autoOrch: boolean;
@@ -69,15 +71,18 @@ type TakomiState = {
69
71
  activeSessionId?: string;
70
72
  subagentsEnabled: boolean;
71
73
  lastFullPromptKey?: string;
74
+ modeSource?: TakomiModeSource;
75
+ modeReason?: string;
72
76
  };
73
77
 
74
78
  const DEFAULT_STATE: TakomiState = {
75
79
  enabled: true,
76
- autoOrch: true,
80
+ autoOrch: false,
77
81
  launchMode: "auto",
78
82
  planMode: false,
79
83
  role: "general",
80
84
  subagentsEnabled: true,
85
+ modeSource: "idle",
81
86
  };
82
87
 
83
88
  const STATE_ENTRY = "takomi-runtime-state";
@@ -97,6 +102,8 @@ const ThinkingSchema = Type.Union([
97
102
  Type.Literal("xhigh"),
98
103
  ]);
99
104
 
105
+ const TakomiModeSchema = StringEnum(["idle", "code", "orchestrate", "review", "genesis", "design", "build"] as const);
106
+
100
107
  function cloneState(state: TakomiState): TakomiState {
101
108
  return { ...state };
102
109
  }
@@ -111,6 +118,8 @@ function formatState(state: TakomiState): string {
111
118
  `launch=${state.launchMode}`,
112
119
  `plan=${state.planMode ? "on" : "off"}`,
113
120
  `subagents=${state.subagentsEnabled ? "on" : "off"}`,
121
+ `source=${state.modeSource ?? "idle"}`,
122
+ state.modeReason ? `reason=${state.modeReason}` : "",
114
123
  state.activeSessionId ? `session=${state.activeSessionId}` : "",
115
124
  ].filter(Boolean).join(" | ");
116
125
  }
@@ -766,6 +775,8 @@ export default function takomiRuntime(pi: ExtensionAPI) {
766
775
  state.stage = "genesis";
767
776
  state.workflow = "vibe-genesis";
768
777
  state.role = "orchestrator";
778
+ state.modeSource = "manual";
779
+ state.modeReason = "/takomi plan";
769
780
  });
770
781
  return `Takomi plan created session ${session.sessionId}\nMaster plan: ${paths.masterPlan}`;
771
782
  },
@@ -783,34 +794,82 @@ export default function takomiRuntime(pi: ExtensionAPI) {
783
794
  },
784
795
  });
785
796
 
786
- pi.registerShortcut("alt+t", {
787
- description: "Toggle native tool result expansion",
788
- handler: async (ctx) => {
789
- const expanded = !ctx.ui.getToolsExpanded();
790
- ctx.ui.setToolsExpanded(expanded);
791
- ctx.ui.notify(`${expanded ? "Expanded" : "Collapsed"} native tool results.`, "info");
792
- },
793
- });
794
797
 
795
- pi.registerShortcut("alt+shift+t", {
796
- description: "Expand native tool results",
797
- handler: async (ctx) => {
798
- ctx.ui.setToolsExpanded(true);
799
- ctx.ui.notify("Expanded native tool results for Takomi subagent output.", "info");
800
- },
801
- });
798
+ async function applyTakomiMode(ctx: ExtensionContext, mode: string, source: TakomiModeSource, reason?: string): Promise<string> {
799
+ const hasGenesis = await hasGenesisArtifacts(ctx.cwd);
800
+ state.enabled = true;
801
+ state.modeSource = source;
802
+ state.modeReason = reason?.trim() || undefined;
803
+
804
+ switch (mode) {
805
+ case "idle":
806
+ state.modeSource = "idle";
807
+ state.modeReason = undefined;
808
+ state.autoOrch = false;
809
+ state.planMode = false;
810
+ state.role = "general";
811
+ state.stage = undefined;
812
+ state.workflow = undefined;
813
+ break;
814
+ case "code":
815
+ state.autoOrch = false;
816
+ state.planMode = false;
817
+ state.role = "code";
818
+ state.stage = undefined;
819
+ state.workflow = undefined;
820
+ break;
821
+ case "review":
822
+ state.autoOrch = false;
823
+ state.planMode = true;
824
+ state.launchMode = "manual";
825
+ state.role = "review";
826
+ state.stage = undefined;
827
+ state.workflow = undefined;
828
+ break;
829
+ case "orchestrate":
830
+ state.autoOrch = true;
831
+ state.planMode = true;
832
+ state.role = "orchestrator";
833
+ state.stage = hasGenesis ? "build" : "genesis";
834
+ state.workflow = hasGenesis ? "vibe-build" : "vibe-genesis";
835
+ break;
836
+ case "genesis":
837
+ case "design":
838
+ case "build":
839
+ setStageAndWorkflow(state, mode);
840
+ state.autoOrch = mode === "build";
841
+ state.planMode = mode !== "build";
842
+ break;
843
+ }
802
844
 
803
- pi.registerShortcut("alt+n", {
804
- description: "Show native subagent navigation hint",
805
- handler: async (ctx) => {
806
- ctx.ui.notify("Native subagent results are shown inline by Pi; use the transcript/tool expansion instead of Takomi focus cycling.", "info");
807
- },
808
- });
845
+ persistState();
846
+ syncContextPanelState();
847
+ await refreshUi(ctx, state);
848
+ const label = state.modeSource === "idle" ? "idle" : `${state.modeSource}:${state.stage ?? state.role}`;
849
+ return `Takomi mode set to ${label}${state.modeReason ? ` (${state.modeReason})` : ""}.`;
850
+ }
809
851
 
810
- pi.registerShortcut("alt+p", {
811
- description: "Show native subagent navigation hint",
812
- handler: async (ctx) => {
813
- ctx.ui.notify("Native subagent results are shown inline by Pi; use the transcript/tool expansion instead of Takomi focus cycling.", "info");
852
+ pi.registerTool({
853
+ name: "takomi_mode",
854
+ label: "Takomi Mode",
855
+ description: "Set or clear the visible Takomi runtime mode after the active model decides a task benefits from code, review, lifecycle, or orchestration handling.",
856
+ promptSnippet: "Optional: call takomi_mode when you decide the current request should visibly enter Takomi code/review/orchestration/lifecycle mode. Do not call it for normal creative/general conversation. Use mode=idle to clear Takomi mode.",
857
+ promptGuidelines: [
858
+ "Let the user's request drive the choice; do not switch modes just because a vague word like code/review/build appears alone.",
859
+ "Prefer mode=code for direct coding in the current chat, mode=orchestrate only for broad/multi-step durable work, and mode=review for critique/audit/QA.",
860
+ "Use mode=idle when the user asks normal non-coding/non-Takomi questions and Takomi should get out of the way.",
861
+ ],
862
+ parameters: Type.Object({
863
+ mode: TakomiModeSchema,
864
+ reason: Type.Optional(Type.String({ description: "Short human-readable reason for the switch" })),
865
+ }),
866
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
867
+ const text = await applyTakomiMode(ctx, params.mode, "model", params.reason);
868
+ ctx.ui.notify(text, "info");
869
+ return {
870
+ content: [{ type: "text", text }],
871
+ details: { mode: params.mode, source: state.modeSource, reason: state.modeReason, role: state.role, stage: state.stage, workflow: state.workflow },
872
+ };
814
873
  },
815
874
  });
816
875
 
@@ -1007,6 +1066,12 @@ ${stateJson}`
1007
1066
  }
1008
1067
  }
1009
1068
  sessionState.tasks[idx] = nextTask;
1069
+ state.activeSessionId = params.sessionId;
1070
+ state.modeSource = state.modeSource === "idle" ? "board" : state.modeSource;
1071
+ state.modeReason = state.modeReason ?? "board task update";
1072
+ persistState();
1073
+ syncContextPanelState();
1074
+ await refreshUi(ctx, state);
1010
1075
  const nextState = buildSessionState(
1011
1076
  sessionState.sessionId,
1012
1077
  sessionState.title,
@@ -1062,8 +1127,11 @@ ${stateJson}`
1062
1127
  }
1063
1128
  }
1064
1129
  state.activeSessionId = nextState.sessionId;
1130
+ state.modeSource = "board";
1131
+ state.modeReason = `expanded ${params.stage} stage`;
1065
1132
  persistState();
1066
1133
  syncContextPanelState();
1134
+ await refreshUi(ctx, state);
1067
1135
 
1068
1136
  return {
1069
1137
  content: [{ type: "text", text: `Expanded ${params.stage} stage in session ${nextState.sessionId}.\n\nDocs: ${paths.root}\nState: ${paths.stateFile}\n\n${buildTaskRows(nextState.tasks)}` }],
@@ -1104,8 +1172,11 @@ ${stateJson}`
1104
1172
  state.role = "orchestrator";
1105
1173
  state.stage = nextState.lifecycle.genesis.status === "completed" ? "build" : "genesis";
1106
1174
  state.workflow = state.stage === "genesis" ? "vibe-genesis" : "vibe-build";
1175
+ state.modeSource = "board";
1176
+ state.modeReason = "orchestrator session";
1107
1177
  persistState();
1108
1178
  syncContextPanelState();
1179
+ await refreshUi(ctx, state);
1109
1180
 
1110
1181
  return {
1111
1182
  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."}` }],
@@ -1152,11 +1223,15 @@ ${stateJson}`
1152
1223
 
1153
1224
  if (lowered === "use takomi") {
1154
1225
  state.enabled = true;
1226
+ state.modeSource = "manual";
1227
+ state.modeReason = "explicit user request";
1155
1228
  return { action: "transform", text: "Use the Takomi runtime, identify the correct lifecycle stage, and proceed accordingly." };
1156
1229
  }
1157
1230
 
1158
1231
  if (lowered.startsWith("use takomi ")) {
1159
1232
  state.enabled = true;
1233
+ state.modeSource = "manual";
1234
+ state.modeReason = "explicit user request";
1160
1235
  const route = decideRoute(text.slice("use takomi ".length));
1161
1236
  if (route.stage) setStageAndWorkflow(state, route.stage, { preserveRole: state.role === "orchestrator" && route.stage === "genesis" });
1162
1237
  else if (route.role !== "general") state.role = route.role;
@@ -1164,14 +1239,20 @@ ${stateJson}`
1164
1239
  }
1165
1240
 
1166
1241
  if (/\bvibe genesis\b/i.test(text)) {
1242
+ state.modeSource = "manual";
1243
+ state.modeReason = "vibe genesis";
1167
1244
  setStageAndWorkflow(state, "genesis", { preserveRole: state.role === "orchestrator" });
1168
1245
  return { action: "transform", text };
1169
1246
  }
1170
1247
  if (/\bvibe design\b/i.test(text)) {
1248
+ state.modeSource = "manual";
1249
+ state.modeReason = "vibe design";
1171
1250
  setStageAndWorkflow(state, "design");
1172
1251
  return { action: "transform", text };
1173
1252
  }
1174
1253
  if (/\bvibe build\b/i.test(text)) {
1254
+ state.modeSource = "manual";
1255
+ state.modeReason = "vibe build";
1175
1256
  setStageAndWorkflow(state, "build");
1176
1257
  return { action: "transform", text };
1177
1258
  }
@@ -1180,43 +1261,36 @@ ${stateJson}`
1180
1261
  });
1181
1262
 
1182
1263
  pi.on("before_agent_start", async (event, ctx) => {
1183
- if (!state.enabled) return;
1264
+ if (!state.enabled || (state.modeSource ?? "idle") === "idle") return;
1184
1265
 
1185
1266
  let effectiveState = cloneState(state);
1186
1267
  const runtimeCwd = ctx.cwd;
1187
1268
  const genesisExists = await hasGenesisArtifacts(runtimeCwd);
1188
1269
  const route = decideRoute(event.prompt);
1189
- if (state.autoOrch && shouldAutoRoute(event.prompt)) {
1190
- effectiveState.role = "orchestrator";
1191
- effectiveState.stage = genesisExists ? "build" : "genesis";
1192
- effectiveState.workflow = genesisExists ? "vibe-build" : "vibe-genesis";
1193
- }
1194
-
1195
- const shouldHonorRoute = route.stage || route.role !== "general" || route.sessionRecommendation !== "none";
1196
- if (shouldHonorRoute && route.stage) {
1197
- effectiveState.stage = route.stage;
1198
- effectiveState.workflow = route.workflow;
1199
- effectiveState.role = effectiveState.role === "orchestrator" && route.stage === "genesis" ? "orchestrator" : route.role;
1200
- } else if (shouldHonorRoute && route.role !== "general") {
1201
- effectiveState.role = route.role;
1202
- }
1203
-
1204
- let routingNote = route.reason;
1270
+ let routingNote = state.modeReason
1271
+ ? `Takomi mode selected by ${state.modeSource}: ${state.modeReason}.`
1272
+ : `Takomi mode selected by ${state.modeSource ?? "runtime"}.`;
1205
1273
  const explicitLifecycleWaiver = /skip genesis|waive genesis|genesis complete|already have (a )?(prd|requirements)|design complete|jump straight to build/i.test(event.prompt);
1206
- const orchestrationActive = effectiveState.role === "orchestrator" || route.executionMode === "orchestrate";
1274
+ const orchestrationActive = effectiveState.role === "orchestrator";
1207
1275
  if (!genesisExists && orchestrationActive && !explicitLifecycleWaiver) {
1208
1276
  effectiveState.stage = "genesis";
1209
1277
  effectiveState.workflow = "vibe-genesis";
1210
1278
  routingNote = "Blank project detected; orchestrator remains in control and must honor Genesis → Design → Build.";
1211
1279
  }
1280
+ if (effectiveState.stage !== state.stage || effectiveState.workflow !== state.workflow || effectiveState.role !== state.role) {
1281
+ state.role = effectiveState.role;
1282
+ state.stage = effectiveState.stage;
1283
+ state.workflow = effectiveState.workflow;
1284
+ }
1212
1285
 
1213
1286
  const promptKey = `${effectiveState.role}:${effectiveState.workflow ?? "none"}`;
1214
1287
  const includeFullWorkflow = Boolean(effectiveState.workflow && effectiveState.lastFullPromptKey !== promptKey);
1215
1288
  if (includeFullWorkflow) {
1216
1289
  state.lastFullPromptKey = promptKey;
1217
- persistState();
1218
- syncContextPanelState();
1219
1290
  }
1291
+ persistState();
1292
+ syncContextPanelState();
1293
+ await refreshUi(ctx, state);
1220
1294
 
1221
1295
  const routingPolicy = await resolveTakomiRoutingPolicy(runtimeCwd);
1222
1296
  const optionalFeatureContext = (() => {
@@ -1263,14 +1337,14 @@ ${stateJson}`
1263
1337
  `Execution mode: ${route.executionMode}. Session recommendation: ${route.sessionRecommendation}.`,
1264
1338
  `Takomi execution gate: ${effectiveState.launchMode === "manual" ? "review" : "auto"}. In review gate mode, show the delegation plan before launching and return to the user after each task with results, verification guidance, and the recommended next step.`,
1265
1339
  !effectiveState.subagentsEnabled ? "Takomi subagents are disabled for this session. Do not call takomi_subagent or subagent until the user enables subagents." : "",
1266
- !genesisExists ? "Project foundation is missing or incomplete. Do not skip Genesis unless the user explicitly waives it." : "",
1267
- "Takomi is the default orchestration mindset here. Do not wait for the literal phrase 'use Takomi' before applying lifecycle judgment.",
1268
- "Task fan-out is flexible. Do not force exactly three tasks; decompose Genesis, Design, and Build work to fit the actual scope.",
1269
- "A new orchestration session should usually begin with one Genesis foundation task that creates or updates the required markdown artifacts, then expand later stages only when the scope justifies it.",
1270
- "If a follow-up request is small, one-shot it. If it is multi-part or large, create or expand an orchestration session instead of pretending it is a single task.",
1340
+ orchestrationActive && !genesisExists ? "Project foundation is missing or incomplete. Do not skip Genesis unless the user explicitly waives it." : "",
1341
+ "Do not escalate to orchestration or review just because this is coding-related; stay in the selected Takomi mode unless the user asks or a durable board/session is genuinely needed.",
1342
+ orchestrationActive ? "Task fan-out is flexible. Do not force exactly three tasks; decompose Genesis, Design, and Build work to fit the actual scope." : "",
1343
+ orchestrationActive ? "A new orchestration session should usually begin with one Genesis foundation task that creates or updates the required markdown artifacts, then expand later stages only when the scope justifies it." : "",
1344
+ orchestrationActive ? "If a follow-up request is small, one-shot it. If it is multi-part or large, create or expand an orchestration session instead of pretending it is a single task." : "",
1271
1345
  "Before any Takomi subagent dispatch or model override, use the injected Pi model-registry context and project routing policy. Prefer provider-qualified model IDs. Do not run `pi --list-models` unless the registry context is missing or the user asks for a visible diagnostic.",
1272
- "When useful, state the current Takomi stage and the recommended next stage.",
1273
- effectiveState.stage === "build"
1346
+ "When useful, state the current Takomi mode/stage and the recommended next step.",
1347
+ orchestrationActive && effectiveState.stage === "build"
1274
1348
  ? "For build orchestration, use takomi_subagent to dispatch work to specialist subagents, then record the result on takomi_board; reuse the same conversation id when sending fixes back to the agent."
1275
1349
  : "",
1276
1350
  ].filter(Boolean);
@@ -11,6 +11,8 @@ import {
11
11
  } from "./routing-policy";
12
12
 
13
13
  export const TAKOMI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
14
+ const PROJECT_TAKOMI_POLICY_ROOT_RELATIVE = path.join(".pi", "takomi");
15
+ const MAX_POLICY_BYTES = 128 * 1024;
14
16
 
15
17
  type Settings = {
16
18
  takomi?: { modelRoutingPolicyFile?: string };
@@ -140,6 +142,8 @@ function collectModelsFromPolicy(text: string): string[] {
140
142
 
141
143
  function readPolicyTextSync(filePath: string): string | undefined {
142
144
  try {
145
+ const info = fs.statSync(filePath);
146
+ if (!info.isFile() || info.size > MAX_POLICY_BYTES) return undefined;
143
147
  const text = fs.readFileSync(filePath, "utf8").trim();
144
148
  return text || undefined;
145
149
  } catch {
@@ -147,18 +151,44 @@ function readPolicyTextSync(filePath: string): string | undefined {
147
151
  }
148
152
  }
149
153
 
154
+ function isPathInside(root: string, candidate: string): boolean {
155
+ const relative = path.relative(root, candidate);
156
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
157
+ }
158
+
159
+ function resolveSafeProjectPolicyPathSync(cwd: string, configured: string): string | undefined {
160
+ if (!configured) return undefined;
161
+
162
+ const projectPolicyRoot = path.resolve(cwd, PROJECT_TAKOMI_POLICY_ROOT_RELATIVE);
163
+ const resolvedPath = path.isAbsolute(configured) ? path.resolve(configured) : path.resolve(cwd, configured);
164
+ if (!isPathInside(projectPolicyRoot, resolvedPath)) return undefined;
165
+
166
+ try {
167
+ const realCwd = fs.realpathSync(cwd);
168
+ const realRoot = fs.realpathSync(projectPolicyRoot);
169
+ const realFile = fs.realpathSync(resolvedPath);
170
+ if (!isPathInside(realCwd, realRoot)) return undefined;
171
+ if (!isPathInside(realRoot, realFile)) return undefined;
172
+ return realFile;
173
+ } catch {
174
+ return resolvedPath;
175
+ }
176
+ }
177
+
150
178
  function resolvePolicySync(cwd: string): { policyPath?: string; text?: string } {
151
179
  const projectSettings = readSettingsFileSync(path.join(cwd, PROJECT_PI_SETTINGS_RELATIVE));
152
180
  const projectTakomi = asRecord(projectSettings.takomi);
153
181
  const configuredProject = typeof projectTakomi.modelRoutingPolicyFile === "string"
154
182
  ? projectTakomi.modelRoutingPolicyFile
155
183
  : TAKOMI_ROUTING_POLICY_RELATIVE;
156
- const configuredProjectPath = path.isAbsolute(configuredProject) ? configuredProject : path.join(cwd, configuredProject);
157
- const configuredProjectText = readPolicyTextSync(configuredProjectPath);
158
- if (configuredProjectText) return { policyPath: configuredProjectPath, text: configuredProjectText };
184
+ const configuredProjectPath = resolveSafeProjectPolicyPathSync(cwd, configuredProject);
185
+ if (configuredProjectPath) {
186
+ const configuredProjectText = readPolicyTextSync(configuredProjectPath);
187
+ if (configuredProjectText) return { policyPath: configuredProjectPath, text: configuredProjectText };
188
+ }
159
189
 
160
- const defaultProjectPath = path.join(cwd, TAKOMI_ROUTING_POLICY_RELATIVE);
161
- if (path.resolve(defaultProjectPath) !== path.resolve(configuredProjectPath)) {
190
+ const defaultProjectPath = resolveSafeProjectPolicyPathSync(cwd, TAKOMI_ROUTING_POLICY_RELATIVE);
191
+ if (defaultProjectPath && path.resolve(defaultProjectPath) !== path.resolve(configuredProjectPath ?? "")) {
162
192
  const defaultProjectText = readPolicyTextSync(defaultProjectPath);
163
193
  if (defaultProjectText) return { policyPath: defaultProjectPath, text: defaultProjectText };
164
194
  }
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -14,6 +14,8 @@ export const BUNDLED_TAKOMI_ROUTING_POLICY_PATH = path.resolve(
14
14
  "takomi",
15
15
  "model-routing.md",
16
16
  );
17
+ const PROJECT_TAKOMI_POLICY_ROOT_RELATIVE = path.join(".pi", "takomi");
18
+ const MAX_POLICY_BYTES = 128 * 1024;
17
19
 
18
20
  export type RoutingPolicyInstallResult = {
19
21
  policyPath: string;
@@ -56,6 +58,8 @@ async function readJsonObject(filePath: string): Promise<JsonObject> {
56
58
 
57
59
  async function readPolicyText(filePath: string): Promise<string | undefined> {
58
60
  try {
61
+ const info = await stat(filePath);
62
+ if (!info.isFile() || info.size > MAX_POLICY_BYTES) return undefined;
59
63
  const text = (await readFile(filePath, "utf8")).trim();
60
64
  return text || undefined;
61
65
  } catch {
@@ -63,6 +67,28 @@ async function readPolicyText(filePath: string): Promise<string | undefined> {
63
67
  }
64
68
  }
65
69
 
70
+ function isPathInside(root: string, candidate: string): boolean {
71
+ const relative = path.relative(root, candidate);
72
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
73
+ }
74
+
75
+ async function resolveSafeProjectPolicyPath(cwd: string, configured: string): Promise<string | undefined> {
76
+ if (!configured) return undefined;
77
+
78
+ const projectPolicyRoot = path.resolve(cwd, PROJECT_TAKOMI_POLICY_ROOT_RELATIVE);
79
+ const resolvedPath = path.isAbsolute(configured) ? path.resolve(configured) : path.resolve(cwd, configured);
80
+ if (!isPathInside(projectPolicyRoot, resolvedPath)) return undefined;
81
+
82
+ try {
83
+ const [realCwd, realRoot, realFile] = await Promise.all([realpath(cwd), realpath(projectPolicyRoot), realpath(resolvedPath)]);
84
+ if (!isPathInside(realCwd, realRoot)) return undefined;
85
+ if (!isPathInside(realRoot, realFile)) return undefined;
86
+ return realFile;
87
+ } catch {
88
+ return resolvedPath;
89
+ }
90
+ }
91
+
66
92
  function extractQuotedPolicy(text: string): string {
67
93
  const triple = text.match(/"""([\s\S]*?)"""|```(?:\w+)?\s*([\s\S]*?)```/);
68
94
  const raw = (triple?.[1] ?? triple?.[2] ?? text).trim();
@@ -133,14 +159,16 @@ export async function resolveTakomiRoutingPolicy(cwd: string): Promise<ResolvedR
133
159
  const configuredProject = typeof projectTakomi.modelRoutingPolicyFile === "string"
134
160
  ? projectTakomi.modelRoutingPolicyFile
135
161
  : TAKOMI_ROUTING_POLICY_RELATIVE;
136
- const configuredProjectPath = path.isAbsolute(configuredProject) ? configuredProject : path.join(cwd, configuredProject);
137
- const configuredProjectText = await readPolicyText(configuredProjectPath);
138
- if (configuredProjectText) {
139
- return { source: "project", policyPath: configuredProjectPath, text: configuredProjectText };
162
+ const configuredProjectPath = await resolveSafeProjectPolicyPath(cwd, configuredProject);
163
+ if (configuredProjectPath) {
164
+ const configuredProjectText = await readPolicyText(configuredProjectPath);
165
+ if (configuredProjectText) {
166
+ return { source: "project", policyPath: configuredProjectPath, text: configuredProjectText };
167
+ }
140
168
  }
141
169
 
142
- const defaultProjectPath = path.join(cwd, TAKOMI_ROUTING_POLICY_RELATIVE);
143
- if (path.resolve(defaultProjectPath) !== path.resolve(configuredProjectPath)) {
170
+ const defaultProjectPath = await resolveSafeProjectPolicyPath(cwd, TAKOMI_ROUTING_POLICY_RELATIVE);
171
+ if (defaultProjectPath && path.resolve(defaultProjectPath) !== path.resolve(configuredProjectPath ?? "")) {
144
172
  const defaultProjectText = await readPolicyText(defaultProjectPath);
145
173
  if (defaultProjectText) {
146
174
  return { source: "project", policyPath: defaultProjectPath, text: defaultProjectText };