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.
- package/.pi/extensions/takomi-runtime/context-panel.ts +37 -13
- package/.pi/extensions/takomi-runtime/index.ts +68 -1
- 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-runtime/ui.ts +3 -3
- package/.pi/extensions/takomi-subagents/index.ts +10 -4
- package/.pi/extensions/takomi-subagents/native-render.ts +127 -7
- package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +32 -9
- package/.pi/extensions/takomi-subagents/tool-runner.ts +75 -17
- package/README.md +11 -7
- package/assets/.agent/skills/photo-book-builder/SKILL.md +51 -7
- package/package.json +18 -2
- package/src/cli.js +137 -23
- package/src/harness.js +63 -71
- package/src/owned-tree.js +114 -0
- package/src/pi-harness.js +53 -24
- package/src/pi-installer.js +12 -36
- package/src/store.js +5 -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) => {
|
|
@@ -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 =
|
|
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);
|
|
@@ -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
|
|
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
|
-
|
|
51
|
-
|
|
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.
|
|
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
|
|
41
|
+
function extractPolicyNames(text: string): string[] {
|
|
42
42
|
const policyMatch = text.match(/Required policies:\n((?:- .+\n?)+)/);
|
|
43
|
-
|
|
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
|
-
|
|
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);
|