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.
- package/.pi/extensions/takomi-runtime/context-panel.ts +37 -13
- package/.pi/extensions/takomi-runtime/model-routing-defaults.ts +35 -5
- package/.pi/extensions/takomi-runtime/routing-policy.ts +35 -7
- package/.pi/extensions/takomi-runtime/takomi-stats.js +13 -3
- package/.pi/extensions/takomi-subagents/index.ts +3 -3
- package/.pi/extensions/takomi-subagents/native-render.ts +50 -5
- package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +32 -9
- package/.pi/extensions/takomi-subagents/tool-runner.ts +61 -15
- package/assets/.agent/skills/photo-book-builder/SKILL.md +51 -7
- package/package.json +18 -2
- package/src/cli.js +41 -11
- package/src/harness.js +12 -42
- package/src/owned-tree.js +86 -0
- package/src/pi-installer.js +12 -36
- package/src/store.js +3 -40
- package/src/takomi-stats.js +14 -3
- package/src/utils.js +81 -44
|
@@ -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
|
|
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.
|
|
318
|
-
const
|
|
319
|
-
const active = formatDuration(
|
|
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
|
-
|
|
426
|
-
this.state.
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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 =
|
|
157
|
-
|
|
158
|
-
|
|
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 =
|
|
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 =
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 =
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
319
|
+
? { ...params, ...tasks[0]!, agentScope }
|
|
274
320
|
: mode === "parallel"
|
|
275
321
|
? { ...params, tasks, agentScope }
|
|
276
322
|
: { ...params, chain: tasks, agentScope };
|