takomi 2.1.34 → 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.
@@ -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) => {
@@ -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);
@@ -47,8 +47,8 @@ const SubagentParameters = Type.Object({
47
47
  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
48
  chain: Type.Optional(Type.Array(TaskSchema, { description: "Sequential chain of subagent tasks" })),
49
49
  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." })),
50
+ // Project-agent confirmation and hard-stop overrides are enforced server-side;
51
+ // they are intentionally not model-callable parameters.
52
52
  });
53
53
 
54
54
  function registerSubagentTool(pi: ExtensionAPI): void {
@@ -63,7 +63,7 @@ function registerSubagentTool(pi: ExtensionAPI): void {
63
63
  "Set clarify=true when the user asks to preview/edit a subagent run in the native Pi TUI before launch.",
64
64
  "Use model, fallbackModels, and thinking only when deliberate; otherwise let the agent/profile defaults apply.",
65
65
  "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.",
66
+ "If a launch is blocked, cancelled, paused, or review-gated, do not retry automatically; wait for the user's next prompt."
67
67
  ],
68
68
  parameters: SubagentParameters,
69
69
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -48,18 +48,63 @@ function summarizeCollapsedResult(text: string, status: string, theme: Theme): s
48
48
  const label = policies.length > 0
49
49
  ? `policy context loaded: ${policies.join(", ")}`
50
50
  : `${lineCount} result line${lineCount === 1 ? "" : "s"} hidden`;
51
- const icon = status === "failed" ? "⚠" : "✓";
52
- const color = status === "failed" ? "warning" : "success";
51
+ const icon = status === "failed" ? "⚠" : status === "running" ? "…" : "✓";
52
+ const color = status === "failed" ? "warning" : status === "running" ? "accent" : "success";
53
53
  return `${theme.fg(color, `${icon} takomi_subagent ${status}`)}${theme.fg("dim", ` · ${label} (ctrl+o to expand)`)}`;
54
54
  }
55
55
 
56
+ function recentOutputLines(value: unknown): string[] {
57
+ if (Array.isArray(value)) return value.map(String).filter(Boolean).slice(-3);
58
+ if (typeof value === "string") return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(-3);
59
+ return [];
60
+ }
61
+
62
+ function livePartialText(result: ToolResult, theme: Theme): string {
63
+ const details = (result as any)?.details ?? {};
64
+ const results = Array.isArray(details.results) ? details.results : [];
65
+ const progress = Array.isArray(details.progress) ? details.progress : [];
66
+ const lines = [
67
+ theme.fg("toolTitle", theme.bold("takomi_subagent running")),
68
+ theme.fg("dim", "Live detail is bounded while streaming so manual scroll/ctrl+o does not jump on every token."),
69
+ ];
70
+
71
+ const rows = results.length ? results : progress;
72
+ if (!rows.length) {
73
+ const text = resultText(result).split(/\r?\n/).find((line) => line.trim())?.trim();
74
+ lines.push(theme.fg("dim", text || "Waiting for subagent progress…"));
75
+ return lines.join("\n");
76
+ }
77
+
78
+ rows.slice(0, 6).forEach((row: any, index: number) => {
79
+ const status = row.status ?? (row.exitCode === 0 ? "completed" : row.exitCode === -1 ? "running" : row.exitCode === undefined ? "running" : "failed");
80
+ const agent = row.agent ?? `task ${index + 1}`;
81
+ const task = String(row.task ?? "").replace(/\s+/g, " ").trim();
82
+ const currentTool = row.currentTool ?? row.progress?.currentTool;
83
+ const tokens = row.tokens ?? row.progress?.tokens ?? row.usage?.output;
84
+ const tail = recentOutputLines(row.recentOutput ?? row.progress?.recentOutput ?? row.finalOutput ?? row.output);
85
+ lines.push(theme.fg("accent", `${index + 1}. ${agent} ${theme.fg("dim", `[${status}]`)}`));
86
+ if (task) lines.push(theme.fg("dim", ` ${task.slice(0, 140)}${task.length > 140 ? "…" : ""}`));
87
+ if (currentTool || tokens) lines.push(theme.fg("muted", ` ${[currentTool ? `tool:${currentTool}` : "", tokens ? `tokens:${tokens}` : ""].filter(Boolean).join(" | ")}`));
88
+ for (const line of tail) lines.push(theme.fg("dim", ` › ${line.slice(0, 160)}${line.length > 160 ? "…" : ""}`));
89
+ });
90
+ if (rows.length > 6) lines.push(theme.fg("muted", `… ${rows.length - 6} more running item${rows.length - 6 === 1 ? "" : "s"}`));
91
+ lines.push(theme.fg("muted", "Final output will use the normal expandable native subagent renderer."));
92
+ return lines.join("\n");
93
+ }
94
+
56
95
  export function renderTakomiSubagentResult(result: ToolResult, options: { expanded?: boolean; isPartial?: boolean }, theme: Theme, context: any): any {
96
+ const status = (result as any)?.isError ? "failed" : options.isPartial ? "running" : "completed";
97
+ const text = resultText(result);
98
+
99
+ if (options.isPartial) {
100
+ if (options.expanded) return new Text(livePartialText(result, theme), 0, 0);
101
+ return new Text(summarizeCollapsedResult(text, status, theme), 0, 0);
102
+ }
103
+
57
104
  const native = renderNativeSubagentResult(result, options, theme, context);
58
105
  if (native) return native;
59
106
 
60
- const status = (result as any)?.isError ? "failed" : "completed";
61
- const text = resultText(result);
62
- if (!options.expanded && !options.isPartial) {
107
+ if (!options.expanded) {
63
108
  return new Text(summarizeCollapsedResult(text, status, theme), 0, 0);
64
109
  }
65
110
  return new Text(`${theme.fg("toolTitle", theme.bold(`takomi_subagent ${status}`))}\n${text || "No result content."}`, 0, 0);
@@ -75,7 +75,7 @@ function resolveTasks(params: TakomiSubagentToolParams): TakomiSubagentToolTask[
75
75
  fallbackModels: params.fallbackModels,
76
76
  thinking: params.thinking,
77
77
  conversationId: params.conversationId,
78
- cwd: params.cwd,
78
+ cwd: undefined,
79
79
  checklist: params.checklist,
80
80
  }];
81
81
  }
@@ -109,6 +109,26 @@ function modelWithThinking(model: string | undefined, thinking: string | undefin
109
109
  return `${model}:${level}`;
110
110
  }
111
111
 
112
+ function isPathInside(root: string, candidate: string): boolean {
113
+ const relative = path.relative(root, candidate);
114
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
115
+ }
116
+
117
+ function resolveRelativeCwd(root: string, value: string | undefined, label: string): string {
118
+ const lexicalRoot = path.resolve(root);
119
+ const lexicalCandidate = value
120
+ ? path.isAbsolute(value) ? path.resolve(value) : path.resolve(lexicalRoot, value)
121
+ : lexicalRoot;
122
+ if (!isPathInside(lexicalRoot, lexicalCandidate)) throw new Error(`${label} escapes the current workspace.`);
123
+
124
+ const realRoot = fs.realpathSync(lexicalRoot);
125
+ const realCandidate = fs.realpathSync(lexicalCandidate);
126
+ const stat = fs.statSync(realCandidate);
127
+ if (!stat.isDirectory()) throw new Error(`${label} must be a directory inside the current workspace.`);
128
+ if (!isPathInside(realRoot, realCandidate)) throw new Error(`${label} escapes the current workspace.`);
129
+ return realCandidate;
130
+ }
131
+
112
132
  function safeConversationSlug(value: string): string {
113
133
  return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "conversation";
114
134
  }
@@ -164,13 +184,14 @@ function agentNameSet(discoverPiAgents: any, cwd: string): Set<string> {
164
184
  return new Set(discoverUnifiedAgents(discoverPiAgents, cwd, "both").agents.map((agent) => agent.name));
165
185
  }
166
186
 
167
- function mapSingleTask(task: TakomiSubagentToolTask, names: Set<string>) {
187
+ function mapSingleTask(task: TakomiSubagentToolTask, names: Set<string>, rootCwd: string) {
168
188
  const resolvedAgent = resolveAgentName(task.agent, new Map([...names].map((name) => [name, { name } as any])));
169
189
  return {
170
190
  agent: resolvedAgent,
171
191
  task: buildTakomiTaskPrompt({ ...task, agent: resolvedAgent }),
172
- cwd: task.cwd,
192
+ cwd: resolveRelativeCwd(rootCwd, task.cwd, "task.cwd"),
173
193
  model: modelWithThinking(task.model, task.thinking),
194
+ fallbackModels: task.fallbackModels,
174
195
  skill: task.skills?.length ? task.skills : undefined,
175
196
  };
176
197
  }
@@ -193,13 +214,14 @@ function toSubagentParams(params: TakomiSubagentToolParams, rootCwd: string, dis
193
214
 
194
215
  if (mode === "single") {
195
216
  const task = tasks[0]!;
196
- const mapped = mapSingleTask(task, names);
217
+ const mapped = mapSingleTask(task, names, rootCwd);
197
218
  return {
198
219
  ...base,
199
220
  agent: mapped.agent,
200
221
  task: mapped.task,
201
- cwd: task.cwd ? path.resolve(rootCwd, task.cwd) : rootCwd,
222
+ cwd: mapped.cwd,
202
223
  model: mapped.model,
224
+ fallbackModels: mapped.fallbackModels,
203
225
  skill: mapped.skill,
204
226
  };
205
227
  }
@@ -207,19 +229,20 @@ function toSubagentParams(params: TakomiSubagentToolParams, rootCwd: string, dis
207
229
  if (mode === "parallel") {
208
230
  return {
209
231
  ...base,
210
- tasks: tasks.map((task) => mapSingleTask(task, names)),
232
+ tasks: tasks.map((task) => mapSingleTask(task, names, rootCwd)),
211
233
  };
212
234
  }
213
235
 
214
236
  return {
215
237
  ...base,
216
238
  chain: tasks.map((task) => {
217
- const mapped = mapSingleTask(task, names);
239
+ const mapped = mapSingleTask(task, names, rootCwd);
218
240
  return {
219
241
  agent: mapped.agent,
220
242
  task: mapped.task,
221
- cwd: task.cwd,
243
+ cwd: mapped.cwd,
222
244
  model: mapped.model,
245
+ fallbackModels: mapped.fallbackModels,
223
246
  skill: mapped.skill,
224
247
  };
225
248
  }),
@@ -264,7 +287,7 @@ export function createTakomiPiSubagentsEngine(pi: ExtensionAPI) {
264
287
  onUpdate: ToolUpdate | undefined,
265
288
  ctx: ExtensionContext,
266
289
  ): Promise<AgentToolResult<Details>> {
267
- const rootCwd = params.cwd ? path.resolve(ctx.cwd, params.cwd) : ctx.cwd;
290
+ const rootCwd = resolveRelativeCwd(ctx.cwd, params.cwd, "cwd");
268
291
  const { executor, discoverPiAgents } = await getExecutor();
269
292
  const subagentParams = toSubagentParams(params, rootCwd, discoverPiAgents);
270
293
  return executor.execute(id, subagentParams, signal ?? NEVER_ABORT, onUpdate, ctx);
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs/promises";
1
2
  import path from "node:path";
2
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
  import type { TakomiThinkingLevel } from "../../../src/pi-takomi-core";
@@ -30,8 +31,6 @@ export type TakomiSubagentToolParams = Partial<TakomiSubagentToolTask> & {
30
31
  previewOnly?: boolean;
31
32
  clarify?: boolean;
32
33
  agentScope?: TakomiAgentScope;
33
- confirmProjectAgents?: boolean;
34
- overrideUserBlock?: boolean;
35
34
  };
36
35
 
37
36
  type ToolUpdate = (partial: {
@@ -66,6 +65,10 @@ function hasProjectAgents(tasks: Array<{ agent: string }>, agents: Map<string, T
66
65
  return tasks.some((task) => agents.get(task.agent)?.source === "project");
67
66
  }
68
67
 
68
+ function hostTrustsProjectAgents(): boolean {
69
+ return /^(1|true|yes)$/i.test(process.env.TAKOMI_TRUST_PROJECT_AGENTS || "");
70
+ }
71
+
69
72
  function hardStopStore(pi: ExtensionAPI): Map<string, HardStopRecord> {
70
73
  const existing = RECENT_HARD_STOPS.get(pi);
71
74
  if (existing) return existing;
@@ -115,6 +118,31 @@ function consumeExpiredHardStop(pi: ExtensionAPI, fingerprint: string): HardStop
115
118
  return record;
116
119
  }
117
120
 
121
+ function isPathInside(root: string, candidate: string): boolean {
122
+ const relative = path.relative(root, candidate);
123
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
124
+ }
125
+
126
+ async function resolveRelativeCwd(root: string, value: string | undefined, label: string): Promise<string> {
127
+ const lexicalRoot = path.resolve(root);
128
+ const lexicalCandidate = value
129
+ ? path.isAbsolute(value) ? path.resolve(value) : path.resolve(lexicalRoot, value)
130
+ : lexicalRoot;
131
+ if (!isPathInside(lexicalRoot, lexicalCandidate)) throw new Error(`${label} escapes the current workspace.`);
132
+
133
+ const [realRoot, realCandidate] = await Promise.all([fs.realpath(lexicalRoot), fs.realpath(lexicalCandidate)]);
134
+ const stat = await fs.stat(realCandidate);
135
+ if (!stat.isDirectory()) throw new Error(`${label} must be a directory inside the current workspace.`);
136
+ if (!isPathInside(realRoot, realCandidate)) throw new Error(`${label} escapes the current workspace.`);
137
+ return realCandidate;
138
+ }
139
+
140
+ async function validateTaskCwds(root: string, tasks: TakomiSubagentToolTask[]): Promise<void> {
141
+ for (const [index, task] of tasks.entries()) {
142
+ await resolveRelativeCwd(root, task.cwd, `tasks[${index}].cwd`);
143
+ }
144
+ }
145
+
118
146
  function getTextContent(result: any): string {
119
147
  return (result?.content ?? [])
120
148
  .map((item: any) => item?.type === "text" && typeof item.text === "string" ? item.text : "")
@@ -184,7 +212,7 @@ function resolveTasks(params: TakomiSubagentToolParams): TakomiSubagentToolTask[
184
212
  fallbackModels: params.fallbackModels,
185
213
  thinking: params.thinking,
186
214
  conversationId: params.conversationId,
187
- cwd: params.cwd,
215
+ cwd: undefined,
188
216
  checklist: params.checklist,
189
217
  }];
190
218
  }
@@ -198,17 +226,31 @@ export async function executeTakomiSubagentTool(
198
226
  ctx: ExtensionContext,
199
227
  ) {
200
228
  const engine = getEngine(pi);
201
- const rootCwd = params.cwd ? path.resolve(ctx.cwd, params.cwd) : ctx.cwd;
229
+ let rootCwd: string;
230
+ try {
231
+ rootCwd = await resolveRelativeCwd(ctx.cwd, params.cwd, "cwd");
232
+ } catch (error) {
233
+ const message = error instanceof Error ? error.message : String(error);
234
+ return textResult(message, { results: [], agentScope: params.agentScope ?? "both" }, true);
235
+ }
202
236
  const profile = await loadTakomiProfile(rootCwd);
203
237
  const agentScope = params.agentScope ?? "both";
204
238
  const agents = discoverTakomiAgents(rootCwd, agentScope);
205
239
  const byName = new Map<string, TakomiAgentConfig>(agents.map((agent) => [agent.name, agent]));
206
240
  const mode = resolveMode(params);
207
241
  const routingSnapshot = await loadTakomiModelRoutingSnapshot(rootCwd);
208
- const tasks = resolveTasks(params).map((task) => applyTakomiRoutingDefaults({
209
- ...task,
210
- agent: resolveAgentName(task.agent, byName),
211
- }, routingSnapshot));
242
+ let tasks: TakomiSubagentToolTask[];
243
+ try {
244
+ const rawTasks = resolveTasks(params);
245
+ await validateTaskCwds(rootCwd, rawTasks);
246
+ tasks = rawTasks.map((task) => applyTakomiRoutingDefaults({
247
+ ...task,
248
+ agent: resolveAgentName(task.agent, byName),
249
+ }, routingSnapshot));
250
+ } catch (error) {
251
+ const message = error instanceof Error ? error.message : String(error);
252
+ return textResult(message, { results: [], availableAgents: agents.map((agent) => agent.name), agentScope }, true);
253
+ }
212
254
 
213
255
  if (!mode) {
214
256
  return textResult(
@@ -223,19 +265,23 @@ export async function executeTakomiSubagentTool(
223
265
 
224
266
  const fingerprint = createRunFingerprint(rootCwd, mode, tasks);
225
267
  const recentHardStop = consumeExpiredHardStop(pi, fingerprint);
226
- if (recentHardStop && !params.overrideUserBlock) {
268
+ if (recentHardStop) {
227
269
  return hardStopResult(
228
270
  `Subagent launch blocked: the same request was already stopped (${recentHardStop.reason}).\n${recentHardStop.message}`,
229
271
  { results: [], availableAgents: agents.map((agent) => agent.name), agentScope, mode, blockedAt: recentHardStop.at, reason: recentHardStop.reason },
230
272
  );
231
273
  }
232
- if (params.overrideUserBlock) {
233
- hardStopStore(pi).delete(fingerprint);
234
- }
235
274
 
236
- if (params.confirmProjectAgents !== false && ctx.hasUI && hasProjectAgents(tasks, byName)) {
275
+ const projectAgentsTrusted = hostTrustsProjectAgents();
276
+ if (!projectAgentsTrusted && hasProjectAgents(tasks, byName)) {
237
277
  const names = tasks.map((task) => byName.get(task.agent)).filter((agent): agent is TakomiAgentConfig => agent?.source === "project").map((agent) => agent.name);
238
- const ok = await ctx.ui.confirm("Run project-local Takomi agents?", `Agents: ${[...new Set(names)].join(", ")}\n\nProject agents are repo-controlled. Continue only for trusted repositories.`);
278
+ const uniqueNames = [...new Set(names)].join(", ");
279
+ if (!ctx.hasUI) {
280
+ const message = `Blocked: project-local Takomi agents require interactive approval. Agents: ${uniqueNames}`;
281
+ rememberHardStop(pi, fingerprint, "project-agent-approval-required", message);
282
+ return hardStopResult(message, { results: [], agentScope, mode });
283
+ }
284
+ const ok = await ctx.ui.confirm("Run project-local Takomi agents?", `Agents: ${uniqueNames}\n\nProject agents are repo-controlled. Continue only for trusted repositories.`);
239
285
  if (!ok) {
240
286
  const message = "Canceled: project-local agents not approved.";
241
287
  rememberHardStop(pi, fingerprint, "project-agent-denied", message);
@@ -270,7 +316,7 @@ export async function executeTakomiSubagentTool(
270
316
  }
271
317
  try {
272
318
  const nativeParams: TakomiSubagentToolParams = mode === "single"
273
- ? { ...params, agent: tasks[0]!.agent, task: tasks[0]!.task, agentScope }
319
+ ? { ...params, ...tasks[0]!, agentScope }
274
320
  : mode === "parallel"
275
321
  ? { ...params, tasks, agentScope }
276
322
  : { ...params, chain: tasks, agentScope };