takomi 2.1.34 → 2.1.36

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.
@@ -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) => {
@@ -92,6 +92,73 @@ let activeSubagentLabel: string | undefined;
92
92
  let activeSubagentAgent: string | undefined;
93
93
  let activeSubagentTask: string | undefined;
94
94
  let activeSubagentStatus: string | undefined;
95
+ let delayedPiVersionCheckStarted = false;
96
+
97
+ const PI_LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
98
+
99
+ function parseVersionParts(version: string): number[] | undefined {
100
+ const core = version.trim().replace(/^v/, "").split("-")[0];
101
+ if (!/^\d+(?:\.\d+)*$/.test(core)) return undefined;
102
+ return core.split(".").map((part) => Number.parseInt(part, 10) || 0);
103
+ }
104
+
105
+ function isNewerVersion(candidate: string, current: string): boolean {
106
+ const left = parseVersionParts(candidate);
107
+ const right = parseVersionParts(current);
108
+ if (!left || !right) return candidate.trim() !== current.trim();
109
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
110
+ const a = left[i] ?? 0;
111
+ const b = right[i] ?? 0;
112
+ if (a > b) return true;
113
+ if (a < b) return false;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ async function readCurrentPiVersion(): Promise<string | undefined> {
119
+ const candidates = [
120
+ path.resolve(path.dirname(process.argv[1] ?? ""), "..", "package.json"),
121
+ path.resolve(path.dirname(process.argv[1] ?? ""), "package.json"),
122
+ ];
123
+ for (const candidate of candidates) {
124
+ try {
125
+ const parsed = JSON.parse(await readFile(candidate, "utf8")) as { name?: string; version?: string };
126
+ if (parsed.name === "@earendil-works/pi-coding-agent" && typeof parsed.version === "string") return parsed.version;
127
+ } catch { }
128
+ }
129
+ return undefined;
130
+ }
131
+
132
+ function scheduleDelayedPiVersionCheck(ctx: ExtensionContext): void {
133
+ if (delayedPiVersionCheckStarted) return;
134
+ if (process.env.TAKOMI_DELAYED_PI_VERSION_CHECK !== "1") return;
135
+ if (process.env.TAKOMI_SKIP_DELAYED_PI_VERSION_CHECK === "1" || process.env.PI_OFFLINE === "1") return;
136
+ delayedPiVersionCheckStarted = true;
137
+
138
+ const delayMs = Math.max(0, Number(process.env.TAKOMI_PI_VERSION_CHECK_DELAY_MS || 3000) || 3000);
139
+ const timeoutMs = Math.max(500, Number(process.env.TAKOMI_PI_VERSION_CHECK_TIMEOUT_MS || 2500) || 2500);
140
+ setTimeout(() => {
141
+ void (async () => {
142
+ try {
143
+ const currentVersion = await readCurrentPiVersion();
144
+ if (!currentVersion) return;
145
+ const response = await fetch(PI_LATEST_VERSION_URL, {
146
+ headers: { accept: "application/json", "user-agent": `takomi-delayed-pi-version-check/${currentVersion}` },
147
+ signal: AbortSignal.timeout(timeoutMs),
148
+ });
149
+ if (!response.ok) return;
150
+ const data = await response.json() as { version?: unknown; note?: unknown };
151
+ if (typeof data.version !== "string" || !isNewerVersion(data.version, currentVersion)) return;
152
+ if (ctx.hasUI) {
153
+ const note = typeof data.note === "string" && data.note.trim() ? ` ${data.note.trim()}` : "";
154
+ ctx.ui.notify(`Pi ${data.version} is available (installed: ${currentVersion}). Run: takomi refresh pi.${note}`, "info");
155
+ }
156
+ } catch {
157
+ // Best-effort only. Delayed version checks must never affect startup or usage.
158
+ }
159
+ })();
160
+ }, delayMs).unref?.();
161
+ }
95
162
 
96
163
  const ThinkingSchema = Type.Union([
97
164
  Type.Literal("off"),
@@ -821,7 +888,6 @@ export default function takomiRuntime(pi: ExtensionAPI) {
821
888
  case "review":
822
889
  state.autoOrch = false;
823
890
  state.planMode = true;
824
- state.launchMode = "manual";
825
891
  state.role = "review";
826
892
  state.stage = undefined;
827
893
  state.workflow = undefined;
@@ -1365,6 +1431,7 @@ ${stateJson}`
1365
1431
 
1366
1432
  pi.on("session_start", async (_event, ctx) => {
1367
1433
  runtimeCtx = ctx;
1434
+ scheduleDelayedPiVersionCheck(ctx);
1368
1435
  activeProfile = await loadTakomiProfile(ctx.cwd);
1369
1436
  activeSubagentLabel = undefined;
1370
1437
  activeSubagentAgent = undefined;
@@ -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 };
@@ -1,4 +1,5 @@
1
- import { promises as fs } from 'node:fs';
1
+ import { createReadStream, promises as fs } from 'node:fs';
2
+ import readline from 'node:readline';
2
3
  import os from 'os';
3
4
  import path from 'path';
4
5
 
@@ -120,8 +121,14 @@ async function scanPiSessions(root, source, events, sessionRows = [], taskRows =
120
121
  for (const file of await files(root)) {
121
122
  let provider = 'unknown', model = 'unknown', session = path.basename(file, '.jsonl'), cwd = '', currentTask = null;
122
123
  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: [] };
123
- const text = await fs.readFile(file, 'utf8').catch(() => '');
124
- for (const line of text.split(/\r?\n/)) {
124
+ let lines;
125
+ try {
126
+ lines = readline.createInterface({ input: createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
127
+ } catch {
128
+ continue;
129
+ }
130
+ try {
131
+ for await (const line of lines) {
125
132
  const obj = safeJson(line); if (!obj) continue;
126
133
  if (obj.timestamp) { row.start ||= obj.timestamp; row.end = obj.timestamp; addTimestamp(row.activityTimestamps, obj.timestamp); }
127
134
  if (obj.type === 'session') { session = obj.id || session; cwd = obj.cwd || cwd; row.key = session; row.session = session; row.cwd = cwd; }
@@ -164,6 +171,9 @@ async function scanPiSessions(root, source, events, sessionRows = [], taskRows =
164
171
  }
165
172
  const u = msg && msg.usage;
166
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) });
174
+ }
175
+ } catch {
176
+ continue;
167
177
  }
168
178
  pushTask(taskRows, currentTask);
169
179
  row.activeMs = activeDuration(row.activityTimestamps);
@@ -81,9 +81,9 @@ export function renderRuntimeStatus(theme: Theme, state: RuntimeHudState): strin
81
81
  const sourceLabel = source === "manual" ? theme.fg("warning", "manual") : theme.fg("success", source);
82
82
  const role = state.stage && state.role !== state.stage ? theme.fg("dim", state.role) : "";
83
83
  const plan = state.planMode ? theme.fg("warning", "plan") : theme.fg("dim", "direct");
84
- const gate = state.launchMode === "manual" ? theme.fg("warning", "review-gate") : "";
84
+ const gate = state.launchMode === "manual" ? theme.fg("warning", "review-gate") : theme.fg("success", "auto-gate");
85
85
  const subagents = state.subagentsEnabled === false ? theme.fg("error", "subagents:off") : "";
86
- return [theme.fg("accent", "Takomi"), sourceLabel, stageBadge, role, gate || plan, subagents].filter(Boolean).join(" ");
86
+ return [theme.fg("accent", "Takomi"), sourceLabel, stageBadge, role, gate, plan, subagents].filter(Boolean).join(" ");
87
87
  }
88
88
 
89
89
  export function renderRuntimeWidget(theme: Theme, state: RuntimeHudState): string[] {
@@ -95,7 +95,7 @@ export function renderRuntimeWidget(theme: Theme, state: RuntimeHudState): strin
95
95
  sourceLabel,
96
96
  badge(theme, primary, stageTone(state.stage)),
97
97
  state.stage && state.role !== state.stage ? theme.fg("dim", `role:${state.role}`) : "",
98
- state.launchMode === "manual" ? theme.fg("warning", "review-gate") : "",
98
+ state.launchMode === "manual" ? theme.fg("warning", "review-gate") : theme.fg("success", "auto-gate"),
99
99
  state.planMode ? theme.fg("warning", "plan") : theme.fg("dim", "direct"),
100
100
  state.subagentsEnabled === false ? theme.fg("error", "subagents:off") : theme.fg("dim", "subagents:on"),
101
101
  state.workflow ? theme.fg("dim", `wf:${state.workflow}`) : "",
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { renderTakomiSubagentCall, renderTakomiSubagentResult } from "./native-render";
4
+ import { loadPiSubagentsInternals } from "./pi-subagents-internal";
4
5
  import { executeTakomiSubagentTool } from "./tool-runner";
5
6
 
6
7
  const ChecklistItemSchema = Type.Object({
@@ -47,8 +48,8 @@ const SubagentParameters = Type.Object({
47
48
  clarify: Type.Optional(Type.Boolean({ description: "Show the native pi-subagents TUI to preview/edit before execution. Especially useful for chains; requires an interactive Pi TUI." })),
48
49
  chain: Type.Optional(Type.Array(TaskSchema, { description: "Sequential chain of subagent tasks" })),
49
50
  agentScope: Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("project"), Type.Literal("both")])),
50
- confirmProjectAgents: Type.Optional(Type.Boolean({ description: "Prompt before running project-local agents. Default: true." })),
51
- overrideUserBlock: Type.Optional(Type.Boolean({ description: "Only set true when the user explicitly approves retrying after a blocked/cancelled/review-gated launch." })),
51
+ // Project-agent confirmation and hard-stop overrides are enforced server-side;
52
+ // they are intentionally not model-callable parameters.
52
53
  });
53
54
 
54
55
  function registerSubagentTool(pi: ExtensionAPI): void {
@@ -63,7 +64,7 @@ function registerSubagentTool(pi: ExtensionAPI): void {
63
64
  "Set clarify=true when the user asks to preview/edit a subagent run in the native Pi TUI before launch.",
64
65
  "Use model, fallbackModels, and thinking only when deliberate; otherwise let the agent/profile defaults apply.",
65
66
  "If review sends work back to the same agent, reuse the same conversationId for continuity.",
66
- "If a launch is blocked, cancelled, paused, or review-gated, do not retry automatically; wait for the user's next prompt. Use overrideUserBlock only after explicit user approval.",
67
+ "If a launch is blocked, cancelled, paused, or review-gated, do not retry automatically; wait for the user's next prompt."
67
68
  ],
68
69
  parameters: SubagentParameters,
69
70
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -74,6 +75,11 @@ function registerSubagentTool(pi: ExtensionAPI): void {
74
75
  });
75
76
  }
76
77
 
77
- export default function takomiSubagents(pi: ExtensionAPI) {
78
+ export default async function takomiSubagents(pi: ExtensionAPI) {
79
+ // Preload the native pi-subagents renderer before tool registration. Rendering
80
+ // callbacks are synchronous, so if we only lazy-load internals during execution
81
+ // a completed takomi_subagent result can fall back to Takomi's plain text
82
+ // renderer and lose the native compact/expanded details shown by `subagent`.
83
+ await loadPiSubagentsInternals();
78
84
  registerSubagentTool(pi);
79
85
  }
@@ -38,28 +38,148 @@ function resultText(result: ToolResult): string {
38
38
  : JSON.stringify((result as any)?.details ?? {}, null, 2);
39
39
  }
40
40
 
41
- function summarizeCollapsedResult(text: string, status: string, theme: Theme): string {
41
+ function extractPolicyNames(text: string): string[] {
42
42
  const policyMatch = text.match(/Required policies:\n((?:- .+\n?)+)/);
43
- const policies = policyMatch?.[1]
43
+ return policyMatch?.[1]
44
44
  ?.split("\n")
45
45
  .map((line) => line.replace(/^[-\s]+/, "").trim())
46
46
  .filter(Boolean) ?? [];
47
+ }
48
+
49
+ function summarizeCollapsedResult(text: string, status: string, theme: Theme): string {
50
+ const policies = extractPolicyNames(text);
47
51
  const lineCount = text ? text.split(/\r?\n/).length : 0;
48
52
  const label = policies.length > 0
49
53
  ? `policy context loaded: ${policies.join(", ")}`
50
54
  : `${lineCount} result line${lineCount === 1 ? "" : "s"} hidden`;
51
- const icon = status === "failed" ? "⚠" : "✓";
52
- const color = status === "failed" ? "warning" : "success";
55
+ const icon = status === "failed" ? "⚠" : status === "running" ? "…" : "✓";
56
+ const color = status === "failed" ? "warning" : status === "running" ? "accent" : "success";
53
57
  return `${theme.fg(color, `${icon} takomi_subagent ${status}`)}${theme.fg("dim", ` · ${label} (ctrl+o to expand)`)}`;
54
58
  }
55
59
 
60
+ function isPolicyGateBlock(text: string): boolean {
61
+ return /^Blocked\s+takomi_subagent:\s+required policy context had not been loaded yet\./m.test(text)
62
+ && /\nRequired policies:\n/.test(text)
63
+ && /\nLoaded policy context:\n/.test(text);
64
+ }
65
+
66
+ function renderPolicyGateBlock(text: string, expanded: boolean | undefined, theme: Theme): string {
67
+ const policies = extractPolicyNames(text);
68
+ const lineCount = text ? text.split(/\r?\n/).length : 0;
69
+ const policyLabel = policies.length ? policies.join(", ") : "required policy";
70
+ if (!expanded) {
71
+ return [
72
+ theme.fg("warning", "⚠ takomi_subagent blocked"),
73
+ theme.fg("dim", `Required policy context loaded for this session: ${policyLabel}.`),
74
+ theme.fg("dim", `Retry the original tool call. ${lineCount} policy lines hidden (ctrl+o to expand).`),
75
+ ].join("\n");
76
+ }
77
+ return [
78
+ theme.fg("warning", "⚠ takomi_subagent blocked"),
79
+ theme.fg("dim", "Policy context was loaded and passed back to the model; retry the original call."),
80
+ theme.fg("dim", "Press ctrl+o again to collapse."),
81
+ "",
82
+ text,
83
+ ].join("\n");
84
+ }
85
+
86
+ function recentOutputLines(value: unknown): string[] {
87
+ if (Array.isArray(value)) return value.map(String).filter(Boolean).slice(-3);
88
+ if (typeof value === "string") return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(-3);
89
+ return [];
90
+ }
91
+
92
+ function normalizeLiveStatus(value: string | undefined): string | undefined {
93
+ if (!value) return undefined;
94
+ if (value === "complete") return "completed";
95
+ if (value === "in-progress") return "running";
96
+ if (value === "timed-out") return "failed";
97
+ return value;
98
+ }
99
+
100
+ function rawLiveStatus(row: any): string {
101
+ const explicit = normalizeLiveStatus(
102
+ typeof row?.progress?.status === "string" ? row.progress.status
103
+ : typeof row?.status === "string" ? row.status
104
+ : undefined,
105
+ );
106
+ if (explicit) return explicit;
107
+
108
+ const exitCode = typeof row?.exitCode === "number" ? row.exitCode
109
+ : typeof row?.code === "number" ? row.code
110
+ : undefined;
111
+ if (exitCode === 0) return "completed";
112
+ if (exitCode === undefined || exitCode === null || exitCode === -1) return "running";
113
+ return "failed";
114
+ }
115
+
116
+ function isLiveTerminalStatus(status: string): boolean {
117
+ return ["completed", "failed", "blocked", "detached", "paused"].includes(status);
118
+ }
119
+
120
+ function displayLiveStatus(status: string, allRowsTerminal: boolean): string {
121
+ // Native pi-subagents can emit one last partial update after the child process
122
+ // reaches exitCode 0 but before the takomi_subagent tool result has actually
123
+ // settled. In that window the tool card is still partial/running, so showing a
124
+ // child as "completed" is misleading. Call it finalizing until Pi renders the
125
+ // real final result.
126
+ if (allRowsTerminal && status === "completed") return "finalizing";
127
+ return status;
128
+ }
129
+
130
+ function livePartialText(result: ToolResult, theme: Theme): string {
131
+ const details = (result as any)?.details ?? {};
132
+ const results = Array.isArray(details.results) ? details.results : [];
133
+ const progress = Array.isArray(details.progress) ? details.progress : [];
134
+ const rows = results.length ? results : progress;
135
+ const rawStatuses = rows.map(rawLiveStatus);
136
+ const allRowsTerminal = rows.length > 0 && rawStatuses.every(isLiveTerminalStatus);
137
+ const titleStatus = allRowsTerminal ? "finalizing" : "running";
138
+ const lines = [
139
+ theme.fg("toolTitle", theme.bold(`takomi_subagent ${titleStatus}`)),
140
+ theme.fg("dim", "Live detail is bounded while streaming so manual scroll/ctrl+o does not jump on every token."),
141
+ ];
142
+
143
+ if (!rows.length) {
144
+ const text = resultText(result).split(/\r?\n/).find((line) => line.trim())?.trim();
145
+ lines.push(theme.fg("dim", text || "Waiting for subagent progress…"));
146
+ return lines.join("\n");
147
+ }
148
+
149
+ rows.slice(0, 6).forEach((row: any, index: number) => {
150
+ const status = displayLiveStatus(rawStatuses[index] ?? rawLiveStatus(row), allRowsTerminal);
151
+ const agent = row.agent ?? `task ${index + 1}`;
152
+ const task = String(row.task ?? "").replace(/\s+/g, " ").trim();
153
+ const currentTool = row.currentTool ?? row.progress?.currentTool;
154
+ const tokens = row.tokens ?? row.progress?.tokens ?? row.usage?.output;
155
+ const tail = recentOutputLines(row.recentOutput ?? row.progress?.recentOutput ?? row.finalOutput ?? row.output);
156
+ lines.push(theme.fg("accent", `${index + 1}. ${agent} ${theme.fg("dim", `[${status}]`)}`));
157
+ if (task) lines.push(theme.fg("dim", ` ${task.slice(0, 140)}${task.length > 140 ? "…" : ""}`));
158
+ if (currentTool || tokens) lines.push(theme.fg("muted", ` ${[currentTool ? `tool:${currentTool}` : "", tokens ? `tokens:${tokens}` : ""].filter(Boolean).join(" | ")}`));
159
+ for (const line of tail) lines.push(theme.fg("dim", ` › ${line.slice(0, 160)}${line.length > 160 ? "…" : ""}`));
160
+ });
161
+ if (rows.length > 6) lines.push(theme.fg("muted", `… ${rows.length - 6} more running item${rows.length - 6 === 1 ? "" : "s"}`));
162
+ lines.push(theme.fg("muted", "Final output will use the normal expandable native subagent renderer."));
163
+ return lines.join("\n");
164
+ }
165
+
56
166
  export function renderTakomiSubagentResult(result: ToolResult, options: { expanded?: boolean; isPartial?: boolean }, theme: Theme, context: any): any {
167
+ const status = ((result as any)?.isError || context?.isError) ? "failed" : options.isPartial ? "running" : "completed";
168
+ const text = resultText(result);
169
+
170
+ if (isPolicyGateBlock(text)) {
171
+ return new Text(renderPolicyGateBlock(text, options.expanded, theme), 0, 0);
172
+ }
173
+
174
+ if (options.isPartial) {
175
+ if (options.expanded) return new Text(livePartialText(result, theme), 0, 0);
176
+ return new Text(summarizeCollapsedResult(text, status, theme), 0, 0);
177
+ }
178
+
57
179
  const native = renderNativeSubagentResult(result, options, theme, context);
58
180
  if (native) return native;
59
181
 
60
- const status = (result as any)?.isError ? "failed" : "completed";
61
- const text = resultText(result);
62
- if (!options.expanded && !options.isPartial) {
182
+ if (!options.expanded) {
63
183
  return new Text(summarizeCollapsedResult(text, status, theme), 0, 0);
64
184
  }
65
185
  return new Text(`${theme.fg("toolTitle", theme.bold(`takomi_subagent ${status}`))}\n${text || "No result content."}`, 0, 0);