takomi 2.1.35 → 2.1.37

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.
Files changed (33) hide show
  1. package/.pi/README.md +6 -3
  2. package/.pi/extensions/oauth-router/commands.ts +36 -35
  3. package/.pi/extensions/oauth-router/index.ts +8 -8
  4. package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +40 -7
  5. package/.pi/extensions/takomi-context-manager/diagnostics.ts +534 -55
  6. package/.pi/extensions/takomi-context-manager/index.ts +14 -2
  7. package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +16 -4
  8. package/.pi/extensions/takomi-context-manager/policy-tools.ts +7 -0
  9. package/.pi/extensions/takomi-context-manager/prerequisite-gates.ts +2 -0
  10. package/.pi/extensions/takomi-context-manager/session-state.ts +160 -0
  11. package/.pi/extensions/takomi-context-manager/skill-registry.ts +120 -0
  12. package/.pi/extensions/takomi-context-manager/skill-tools.ts +26 -3
  13. package/.pi/extensions/takomi-context-manager/state.ts +11 -1
  14. package/.pi/extensions/takomi-context-manager/types.ts +9 -1
  15. package/.pi/extensions/takomi-runtime/command-text.ts +2 -1
  16. package/.pi/extensions/takomi-runtime/commands.ts +13 -1
  17. package/.pi/extensions/takomi-runtime/index.ts +68 -1
  18. package/.pi/extensions/takomi-runtime/ui.ts +3 -3
  19. package/.pi/extensions/takomi-subagents/index.ts +37 -4
  20. package/.pi/extensions/takomi-subagents/native-render.ts +84 -7
  21. package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +18 -3
  22. package/.pi/extensions/takomi-subagents/tool-runner.ts +52 -5
  23. package/README.md +14 -7
  24. package/assets/.agent/skills/exam-creator-skill/SKILL.md +182 -0
  25. package/assets/.agent/skills/exam-creator-skill/references/model-routing-policy.md +48 -0
  26. package/assets/.agent/skills/exam-creator-skill/references/workflow-rulebook.md +115 -0
  27. package/package.json +2 -2
  28. package/src/cli.js +134 -13
  29. package/src/doctor.js +7 -1
  30. package/src/harness.js +54 -32
  31. package/src/owned-tree.js +28 -0
  32. package/src/pi-harness.js +149 -24
  33. package/src/store.js +2 -0
@@ -6,6 +6,7 @@ import type { ContextManagerState } from "./state";
6
6
  import { recordBlocked } from "./state";
7
7
  import { resolveTakomiRoutingPolicy } from "../takomi-runtime/routing-policy";
8
8
  import { approvedModelEquivalent, isTakomiModelApproved } from "../takomi-runtime/model-routing-defaults";
9
+ import { persistReportSnapshot } from "./session-state";
9
10
 
10
11
  type Settings = {
11
12
  takomi?: { modelRoutingPolicyFile?: string };
@@ -183,19 +184,19 @@ export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerSt
183
184
  if (approved.length === 0) return;
184
185
 
185
186
  const refs = collectRequestedModelRefs(event.input);
186
- const corrections: string[] = [];
187
+ const corrections: Array<{ from: string; to: string; recovery?: string }> = [];
187
188
  for (const ref of refs) {
188
189
  if (isTakomiModelApproved(ref.value, approved)) continue;
189
190
  const equivalent = approvedModelEquivalent(ref.value, approved);
190
191
  if (equivalent) {
191
192
  setModelRef(ref, equivalent);
192
- corrections.push(`${ref.value} -> ${equivalent}`);
193
+ corrections.push({ from: ref.value, to: equivalent });
193
194
  continue;
194
195
  }
195
196
  const recovery = await askForInvalidModelRecovery(ctx, ref.value, approved);
196
197
  if (recovery.action === "retry") {
197
198
  setModelRef(ref, recovery.model);
198
- corrections.push(`${ref.value} -> ${recovery.model} (user selected recovery)`);
199
+ corrections.push({ from: ref.value, to: recovery.model, recovery: "user selected recovery" });
199
200
  continue;
200
201
  }
201
202
  const reason = [
@@ -204,12 +205,23 @@ export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerSt
204
205
  "Human selected stop. The agent turn has been aborted; wait for the user's next prompt.",
205
206
  ].join("\n");
206
207
  recordBlocked(state, event.toolName, reason);
208
+ persistReportSnapshot(pi, state, "model-policy-gate-block");
207
209
  ctx.abort?.();
208
210
  return { block: true, reason };
209
211
  }
210
212
 
211
213
  if (corrections.length > 0) {
212
- ctx.ui.notify(`Takomi context manager corrected subagent model routing:\n- ${corrections.join("\n- ")}\n\nBe careful to follow /takomi routing policy next time.`, "warning");
214
+ const timestamp = new Date().toISOString();
215
+ state.report.timestamp = timestamp;
216
+ state.report.modelRoutingCorrections.push(...corrections.map((correction) => ({
217
+ toolName: event.toolName,
218
+ from: correction.from,
219
+ to: correction.to,
220
+ recovery: correction.recovery,
221
+ timestamp,
222
+ })));
223
+ persistReportSnapshot(pi, state, "model-policy-correction");
224
+ ctx.ui.notify(`Takomi context manager corrected subagent model routing:\n- ${corrections.map((correction) => `${correction.from} -> ${correction.to}${correction.recovery ? ` (${correction.recovery})` : ""}`).join("\n- ")}\n\nBe careful to follow /takomi routing policy next time.`, "warning");
213
225
  }
214
226
  });
215
227
 
@@ -3,6 +3,7 @@ import { Type } from "typebox";
3
3
  import type { ContextManagerState } from "./state";
4
4
  import { syncReportLedger } from "./state";
5
5
  import { renderPolicies, renderPolicyManifest } from "./policy-registry";
6
+ import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
6
7
 
7
8
  export function registerPolicyTools(pi: ExtensionAPI, state: ContextManagerState): void {
8
9
  pi.registerTool({
@@ -12,8 +13,11 @@ export function registerPolicyTools(pi: ExtensionAPI, state: ContextManagerState
12
13
  promptSnippet: "Show available context policy pack descriptions",
13
14
  parameters: Type.Object({ policies: Type.Optional(Type.Array(Type.String({ description: "Policy name to inspect" }))) }),
14
15
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
16
+ restoreReportFromSession(state, ctx);
17
+ state.report.timestamp = new Date().toISOString();
15
18
  state.report.cwd = ctx.cwd;
16
19
  state.report.toolCalls.policyManifest += 1;
20
+ persistReportSnapshot(pi, state, "policy_manifest");
17
21
  return { content: [{ type: "text", text: renderPolicyManifest(state.policies, params.policies ?? []) }], details: { requested: params.policies ?? [...state.policies.keys()] } };
18
22
  },
19
23
  });
@@ -25,10 +29,13 @@ export function registerPolicyTools(pi: ExtensionAPI, state: ContextManagerState
25
29
  promptSnippet: "Load policy packs required before sensitive tool calls",
26
30
  parameters: Type.Object({ policies: Type.Array(Type.String({ description: "Policy pack name to load" })) }),
27
31
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
32
+ restoreReportFromSession(state, ctx);
33
+ state.report.timestamp = new Date().toISOString();
28
34
  state.report.cwd = ctx.cwd;
29
35
  state.report.toolCalls.policyLoad += 1;
30
36
  const text = renderPolicies(state.policies, state.loadedPolicies, params.policies);
31
37
  syncReportLedger(state);
38
+ persistReportSnapshot(pi, state, "policy_load");
32
39
  return { content: [{ type: "text", text }], details: { requested: params.policies, loadedPolicies: [...state.loadedPolicies].sort() } };
33
40
  },
34
41
  });
@@ -3,6 +3,7 @@ import type { ContextManagerConfig } from "./types";
3
3
  import type { ContextManagerState } from "./state";
4
4
  import { recordBlocked, syncReportLedger } from "./state";
5
5
  import { renderPolicies } from "./policy-registry";
6
+ import { persistReportSnapshot } from "./session-state";
6
7
 
7
8
  function renderPolicyGateBlock(toolName: string, missing: string[], policyText: string): string {
8
9
  return [
@@ -33,6 +34,7 @@ export function installPrerequisiteGates(pi: ExtensionAPI, state: ContextManager
33
34
  syncReportLedger(state);
34
35
  const reason = renderPolicyGateBlock(event.toolName, missing, policyText);
35
36
  recordBlocked(state, event.toolName, reason);
37
+ persistReportSnapshot(pi, state, "prerequisite-gate-block");
36
38
  return { block: true, reason };
37
39
  }
38
40
  });
@@ -0,0 +1,160 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ContextManagerState } from "./state";
3
+ import type { ContextReport } from "./types";
4
+
5
+ const SNAPSHOT_TYPE = "takomi-context-manager-report";
6
+ const TOOL_NAMES = ["skill_index", "skill_manifest", "skill_load", "policy_manifest", "policy_load", "context_report"] as const;
7
+ type ToolName = typeof TOOL_NAMES[number];
8
+
9
+ type RestoreStats = {
10
+ snapshotCount: number;
11
+ toolResultCount: number;
12
+ };
13
+
14
+ function asRecord(value: unknown): Record<string, unknown> {
15
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
16
+ }
17
+
18
+ function uniqueSorted(values: Iterable<string>): string[] {
19
+ return [...new Set([...values].filter(Boolean))].sort((a, b) => a.localeCompare(b));
20
+ }
21
+
22
+ function mergeReports(base: ContextReport, restored: ContextReport | undefined): ContextReport {
23
+ if (!restored) return base;
24
+ return {
25
+ ...base,
26
+ ...restored,
27
+ toolCalls: { ...base.toolCalls, ...restored.toolCalls },
28
+ promptRewrite: { ...base.promptRewrite, ...restored.promptRewrite },
29
+ sessionRestore: { ...base.sessionRestore, ...restored.sessionRestore },
30
+ };
31
+ }
32
+
33
+ function maxToolCalls(a: ContextReport["toolCalls"], b: ContextReport["toolCalls"]): ContextReport["toolCalls"] {
34
+ return {
35
+ skillIndex: Math.max(a.skillIndex, b.skillIndex),
36
+ skillManifest: Math.max(a.skillManifest, b.skillManifest),
37
+ skillLoad: Math.max(a.skillLoad, b.skillLoad),
38
+ policyManifest: Math.max(a.policyManifest, b.policyManifest),
39
+ policyLoad: Math.max(a.policyLoad, b.policyLoad),
40
+ contextReport: Math.max(a.contextReport, b.contextReport),
41
+ };
42
+ }
43
+
44
+ function incrementTool(calls: ContextReport["toolCalls"], toolName: ToolName): void {
45
+ if (toolName === "skill_index") calls.skillIndex += 1;
46
+ if (toolName === "skill_manifest") calls.skillManifest += 1;
47
+ if (toolName === "skill_load") calls.skillLoad += 1;
48
+ if (toolName === "policy_manifest") calls.policyManifest += 1;
49
+ if (toolName === "policy_load") calls.policyLoad += 1;
50
+ if (toolName === "context_report") calls.contextReport += 1;
51
+ }
52
+
53
+ function getEntryData(entry: unknown): unknown {
54
+ const record = asRecord(entry);
55
+ if (record.type === "custom" && record.customType === SNAPSHOT_TYPE) return record.data;
56
+ const message = asRecord(record.message);
57
+ if (message.role === "custom" && message.customType === SNAPSHOT_TYPE) return message.details ?? message.content;
58
+ return undefined;
59
+ }
60
+
61
+ function getToolResult(entry: unknown): { toolName?: string; details?: Record<string, unknown> } | undefined {
62
+ const record = asRecord(entry);
63
+ if (record.type !== "message") return undefined;
64
+ const message = asRecord(record.message);
65
+ if (message.role !== "toolResult") return undefined;
66
+ return { toolName: typeof message.toolName === "string" ? message.toolName : undefined, details: asRecord(message.details) };
67
+ }
68
+
69
+ function getEntries(ctx: unknown): unknown[] {
70
+ const manager = asRecord(ctx).sessionManager as { getEntries?: () => unknown[]; getBranch?: () => unknown[] } | undefined;
71
+ try {
72
+ return manager?.getEntries?.() ?? manager?.getBranch?.() ?? [];
73
+ } catch {
74
+ return [];
75
+ }
76
+ }
77
+
78
+ function extractSnapshot(data: unknown): ContextReport | undefined {
79
+ const record = asRecord(data);
80
+ const report = asRecord(record.report);
81
+ if (!report || typeof report.timestamp !== "string") return undefined;
82
+ return report as ContextReport;
83
+ }
84
+
85
+ export function restoreReportFromSession(state: ContextManagerState, ctx: unknown): RestoreStats {
86
+ const entries = getEntries(ctx);
87
+ let latestSnapshot: ContextReport | undefined;
88
+ let snapshotCount = 0;
89
+ let toolResultCount = 0;
90
+ const countedToolCalls: ContextReport["toolCalls"] = { skillIndex: 0, skillManifest: 0, skillLoad: 0, policyManifest: 0, policyLoad: 0, contextReport: 0 };
91
+ const loadedSkills = new Set(state.report.loadedByTool);
92
+ const loadedPolicies = new Set(state.report.loadedPolicies);
93
+
94
+ for (const entry of entries) {
95
+ const snapshot = extractSnapshot(getEntryData(entry));
96
+ if (snapshot) {
97
+ snapshotCount += 1;
98
+ if (!latestSnapshot || snapshot.timestamp >= latestSnapshot.timestamp) latestSnapshot = snapshot;
99
+ }
100
+
101
+ const toolResult = getToolResult(entry);
102
+ if (!toolResult?.toolName || !(TOOL_NAMES as readonly string[]).includes(toolResult.toolName)) continue;
103
+ toolResultCount += 1;
104
+ incrementTool(countedToolCalls, toolResult.toolName as ToolName);
105
+ if (toolResult.toolName === "skill_load" && typeof toolResult.details?.skill === "string") loadedSkills.add(toolResult.details.skill);
106
+ if (toolResult.toolName === "policy_load" && Array.isArray(toolResult.details?.loadedPolicies)) {
107
+ for (const policy of toolResult.details.loadedPolicies) if (typeof policy === "string") loadedPolicies.add(policy);
108
+ }
109
+ }
110
+
111
+ state.report = mergeReports(state.report, latestSnapshot);
112
+ state.report.loadedByTool ??= [];
113
+ state.report.loadedPolicies ??= [];
114
+ state.report.blockedActions ??= [];
115
+ state.report.modelRoutingCorrections ??= [];
116
+ state.report.duplicateExtensionWarnings ??= [];
117
+ for (const skill of state.report.loadedByTool) loadedSkills.add(skill);
118
+ for (const policy of state.report.loadedPolicies) loadedPolicies.add(policy);
119
+ state.report.toolCalls = maxToolCalls(state.report.toolCalls, countedToolCalls);
120
+ state.report.loadedByTool = uniqueSorted(loadedSkills);
121
+ state.report.loadedPolicies = uniqueSorted(loadedPolicies);
122
+ state.loadedPolicies = new Set(state.report.loadedPolicies);
123
+ state.report.sessionRestore = {
124
+ attempted: true,
125
+ restored: Boolean(latestSnapshot) || toolResultCount > 0,
126
+ snapshotCount,
127
+ toolResultCount,
128
+ note: latestSnapshot
129
+ ? "Restored from Takomi context-manager snapshots and Pi tool-result history in the current session file."
130
+ : toolResultCount > 0
131
+ ? "Restored from Pi tool-result history in the current session file; no context-manager snapshot was found."
132
+ : "No prior context-manager snapshots or tool results were found in the current session file.",
133
+ };
134
+ return { snapshotCount, toolResultCount };
135
+ }
136
+
137
+ function compactReason(reason: string): string {
138
+ const firstMeaningfulLine = reason.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? reason;
139
+ if (/Loaded policy context:/i.test(reason)) return `${firstMeaningfulLine}\n[policy content omitted from persisted context-manager snapshot]`;
140
+ return reason.length > 1200 ? `${reason.slice(0, 1200)}…\n[truncated in persisted context-manager snapshot]` : reason;
141
+ }
142
+
143
+ function snapshotReport(report: ContextReport): ContextReport {
144
+ return {
145
+ ...report,
146
+ blockedActions: report.blockedActions.map((action) => ({ ...action, reason: compactReason(action.reason) })),
147
+ };
148
+ }
149
+
150
+ export function persistReportSnapshot(pi: ExtensionAPI, state: ContextManagerState, reason: string): void {
151
+ try {
152
+ pi.appendEntry(SNAPSHOT_TYPE, {
153
+ version: 1,
154
+ reason,
155
+ report: snapshotReport(state.report),
156
+ });
157
+ } catch {
158
+ // Persistence should never break agent startup or tool execution.
159
+ }
160
+ }
@@ -1,3 +1,6 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import type { SkillRecord } from "./types";
2
5
 
3
6
  export function normalizeText(value: string): string {
@@ -85,3 +88,120 @@ export function findSkill(skills: Map<string, SkillRecord>, name: string): Skill
85
88
  const matches = sortedSkills(skills).filter((skill) => normalizeName(skill.name).includes(key));
86
89
  return matches.length === 1 ? matches[0] : undefined;
87
90
  }
91
+
92
+ function parseFrontmatter(text: string): Record<string, string> {
93
+ const match = text.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
94
+ if (!match) return {};
95
+ const data: Record<string, string> = {};
96
+ for (const line of match[1].split(/\r?\n/)) {
97
+ const parsed = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
98
+ if (!parsed) continue;
99
+ data[parsed[1]] = parsed[2].replace(/^['\"]|['\"]$/g, "").trim();
100
+ }
101
+ return data;
102
+ }
103
+
104
+ async function pathExists(filePath: string): Promise<boolean> {
105
+ try {
106
+ await stat(filePath);
107
+ return true;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ async function readSkillFile(filePath: string): Promise<SkillRecord | undefined> {
114
+ try {
115
+ const text = await readFile(filePath, "utf8");
116
+ const frontmatter = parseFrontmatter(text);
117
+ const name = frontmatter.name?.trim() || path.basename(path.dirname(filePath));
118
+ const description = frontmatter.description?.trim();
119
+ if (!name || !description) return undefined;
120
+ return { name, description, location: filePath, source: "filesystem" };
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ async function collectSkillFiles(root: string, directMarkdownFiles = false, depth = 0, maxDepth = 8): Promise<string[]> {
127
+ if (depth > maxDepth || !await pathExists(root)) return [];
128
+ let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }> = [];
129
+ try {
130
+ entries = await readdir(root, { withFileTypes: true });
131
+ } catch {
132
+ return [];
133
+ }
134
+
135
+ const files: string[] = [];
136
+ if (await pathExists(path.join(root, "SKILL.md"))) files.push(path.join(root, "SKILL.md"));
137
+ if (directMarkdownFiles && depth === 0) {
138
+ for (const entry of entries) {
139
+ if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) files.push(path.join(root, entry.name));
140
+ }
141
+ }
142
+
143
+ for (const entry of entries) {
144
+ if (!entry.isDirectory()) continue;
145
+ if ([".git", "node_modules"].includes(entry.name)) continue;
146
+ files.push(...await collectSkillFiles(path.join(root, entry.name), false, depth + 1, maxDepth));
147
+ }
148
+ return [...new Set(files)];
149
+ }
150
+
151
+ async function collectPackageSkillRoots(nodeModulesRoot: string): Promise<string[]> {
152
+ if (!await pathExists(nodeModulesRoot)) return [];
153
+ const roots: string[] = [];
154
+ let packages: Array<{ name: string; isDirectory(): boolean }> = [];
155
+ try {
156
+ packages = await readdir(nodeModulesRoot, { withFileTypes: true });
157
+ } catch {
158
+ return [];
159
+ }
160
+ for (const pkg of packages) {
161
+ if (!pkg.isDirectory()) continue;
162
+ if (pkg.name.startsWith("@")) {
163
+ const scopeRoot = path.join(nodeModulesRoot, pkg.name);
164
+ let scoped: Array<{ name: string; isDirectory(): boolean }> = [];
165
+ try {
166
+ scoped = await readdir(scopeRoot, { withFileTypes: true });
167
+ } catch {
168
+ continue;
169
+ }
170
+ for (const scopedPkg of scoped) if (scopedPkg.isDirectory()) roots.push(path.join(scopeRoot, scopedPkg.name, "skills"));
171
+ continue;
172
+ }
173
+ roots.push(path.join(nodeModulesRoot, pkg.name, "skills"));
174
+ }
175
+ return roots;
176
+ }
177
+
178
+ function ancestorSkillRoots(cwd: string): string[] {
179
+ const roots: string[] = [];
180
+ let current = path.resolve(cwd || process.cwd());
181
+ const seen = new Set<string>();
182
+ while (!seen.has(current)) {
183
+ seen.add(current);
184
+ roots.push(path.join(current, ".agents", "skills"));
185
+ const parent = path.dirname(current);
186
+ if (parent === current) break;
187
+ current = parent;
188
+ }
189
+ roots.push(path.resolve(cwd || process.cwd(), ".pi", "skills"));
190
+ return roots;
191
+ }
192
+
193
+ export async function discoverSkillsFromFilesystem(cwd = process.cwd()): Promise<SkillRecord[]> {
194
+ const home = os.homedir();
195
+ const roots = [
196
+ { root: path.join(home, ".pi", "agent", "skills"), directMarkdownFiles: true },
197
+ { root: path.join(home, ".agents", "skills"), directMarkdownFiles: false },
198
+ ...ancestorSkillRoots(cwd).map((root) => ({ root, directMarkdownFiles: root.endsWith(`${path.sep}.pi${path.sep}skills`) })),
199
+ ...(await collectPackageSkillRoots(path.join(home, ".pi", "agent", "npm", "node_modules"))).map((root) => ({ root, directMarkdownFiles: false })),
200
+ ];
201
+ const skillFiles = new Set<string>();
202
+ for (const { root, directMarkdownFiles } of roots) {
203
+ for (const file of await collectSkillFiles(root, directMarkdownFiles)) skillFiles.add(file);
204
+ }
205
+ const skills = await Promise.all([...skillFiles].map(readSkillFile));
206
+ return skills.filter((skill): skill is SkillRecord => Boolean(skill));
207
+ }
@@ -3,7 +3,8 @@ import path from "node:path";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
5
  import type { ContextManagerState } from "./state";
6
- import { findSkill, normalizeName, sortedSkills } from "./skill-registry";
6
+ import { discoverSkillsFromFilesystem, findSkill, mergeSkills, normalizeName, sortedSkills } from "./skill-registry";
7
+ import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
7
8
 
8
9
  function renderSkillIndex(state: ContextManagerState): string {
9
10
  const skills = sortedSkills(state.skills);
@@ -29,6 +30,12 @@ async function loadSkillContent(location: string): Promise<string> {
29
30
  return readFile(location, "utf8");
30
31
  }
31
32
 
33
+ async function ensureSkillsDiscovered(state: ContextManagerState, cwd: string): Promise<void> {
34
+ if (state.skills.size > 0) return;
35
+ state.skills = mergeSkills(await discoverSkillsFromFilesystem(cwd));
36
+ state.report.skillCount = state.skills.size;
37
+ }
38
+
32
39
  export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState): void {
33
40
  pi.registerTool({
34
41
  name: "skill_index",
@@ -37,8 +44,12 @@ export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState)
37
44
  promptSnippet: "List available skill names only for progressive skill loading",
38
45
  parameters: Type.Object({}),
39
46
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
47
+ restoreReportFromSession(state, ctx);
48
+ await ensureSkillsDiscovered(state, ctx.cwd);
49
+ state.report.timestamp = new Date().toISOString();
40
50
  state.report.cwd = ctx.cwd;
41
51
  state.report.toolCalls.skillIndex += 1;
52
+ persistReportSnapshot(pi, state, "skill_index");
42
53
  return { content: [{ type: "text", text: renderSkillIndex(state) }], details: { skillCount: state.skills.size } };
43
54
  },
44
55
  });
@@ -50,8 +61,12 @@ export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState)
50
61
  promptSnippet: "Show selected skill descriptions and locations without full instructions",
51
62
  parameters: Type.Object({ skills: Type.Array(Type.String({ description: "Skill name to inspect" })) }),
52
63
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
64
+ restoreReportFromSession(state, ctx);
65
+ await ensureSkillsDiscovered(state, ctx.cwd);
66
+ state.report.timestamp = new Date().toISOString();
53
67
  state.report.cwd = ctx.cwd;
54
68
  state.report.toolCalls.skillManifest += 1;
69
+ persistReportSnapshot(pi, state, "skill_manifest");
55
70
  return { content: [{ type: "text", text: renderManifest(state, params.skills) }], details: { requested: params.skills } };
56
71
  },
57
72
  });
@@ -63,16 +78,24 @@ export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState)
63
78
  promptSnippet: "Load full SKILL.md instructions for one selected skill",
64
79
  parameters: Type.Object({ skill: Type.String({ description: "Exact skill name to load" }) }),
65
80
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
81
+ restoreReportFromSession(state, ctx);
82
+ await ensureSkillsDiscovered(state, ctx.cwd);
83
+ state.report.timestamp = new Date().toISOString();
66
84
  state.report.cwd = ctx.cwd;
67
85
  state.report.toolCalls.skillLoad += 1;
68
86
  const skill = findSkill(state.skills, params.skill);
69
- if (!skill?.location) return { content: [{ type: "text", text: renderManifest(state, [params.skill]) }], details: { found: false, requested: params.skill }, isError: true };
87
+ if (!skill?.location) {
88
+ persistReportSnapshot(pi, state, "skill_load_not_found");
89
+ return { content: [{ type: "text", text: renderManifest(state, [params.skill]) }], details: { found: false, requested: params.skill }, isError: true };
90
+ }
70
91
  try {
71
92
  const content = await loadSkillContent(skill.location);
72
- state.report.loadedByTool.push(skill.name);
93
+ state.report.loadedByTool = [...new Set([...state.report.loadedByTool, skill.name])].sort();
94
+ persistReportSnapshot(pi, state, "skill_load");
73
95
  return { content: [{ type: "text", text: [`Skill: ${skill.name}`, `Location: ${skill.location}`, "", content].join("\n") }], details: { found: true, skill: skill.name, location: skill.location } };
74
96
  } catch (error) {
75
97
  const message = error instanceof Error ? error.message : String(error);
98
+ persistReportSnapshot(pi, state, "skill_load_error");
76
99
  return { content: [{ type: "text", text: message }], details: { found: true, skill: skill.name, error: message }, isError: true };
77
100
  }
78
101
  },
@@ -23,7 +23,15 @@ export function createEmptyReport(): ContextReport {
23
23
  editedFiles: [],
24
24
  writtenFiles: [],
25
25
  blockedActions: [],
26
+ modelRoutingCorrections: [],
26
27
  duplicateExtensionWarnings: [],
28
+ sessionRestore: {
29
+ attempted: false,
30
+ restored: false,
31
+ snapshotCount: 0,
32
+ toolResultCount: 0,
33
+ note: "No session restore attempted yet.",
34
+ },
27
35
  promptRewrite: {
28
36
  attempted: false,
29
37
  changed: false,
@@ -63,6 +71,8 @@ export function syncReportLedger(state: ContextManagerState): void {
63
71
  }
64
72
 
65
73
  export function recordBlocked(state: ContextManagerState, toolName: string, reason: string): void {
66
- state.report.blockedActions.push({ toolName, reason, timestamp: new Date().toISOString() });
74
+ const timestamp = new Date().toISOString();
75
+ state.report.timestamp = timestamp;
76
+ state.report.blockedActions.push({ toolName, reason, timestamp });
67
77
  syncReportLedger(state);
68
78
  }
@@ -2,7 +2,7 @@ export type SkillRecord = {
2
2
  name: string;
3
3
  description?: string;
4
4
  location?: string;
5
- source: "systemPromptOptions" | "xml" | "tool";
5
+ source: "systemPromptOptions" | "xml" | "filesystem" | "tool";
6
6
  };
7
7
 
8
8
  export type CandidateContext = {
@@ -57,7 +57,15 @@ export type ContextReport = {
57
57
  editedFiles: string[];
58
58
  writtenFiles: string[];
59
59
  blockedActions: Array<{ toolName: string; reason: string; timestamp: string }>;
60
+ modelRoutingCorrections: Array<{ toolName: string; from: string; to: string; timestamp: string; recovery?: string }>;
60
61
  duplicateExtensionWarnings: Array<{ toolName: string; paths: string[] }>;
62
+ sessionRestore: {
63
+ attempted: boolean;
64
+ restored: boolean;
65
+ snapshotCount: number;
66
+ toolResultCount: number;
67
+ note: string;
68
+ };
61
69
  promptRewrite: {
62
70
  attempted: boolean;
63
71
  changed: boolean;
@@ -32,6 +32,7 @@ const SUBCOMMAND_COMPLETIONS: Record<string, TakomiCompletion[]> = {
32
32
  { value: "auto", label: "auto", description: "Continue approved plans automatically" },
33
33
  ],
34
34
  subagents: [
35
+ { value: "list", label: "list", description: "List available Takomi subagents" },
35
36
  { value: "status", label: "status", description: "Show active subagents" },
36
37
  { value: "on", label: "on", description: "Allow subagent delegation" },
37
38
  { value: "off", label: "off", description: "Disable subagent delegation" },
@@ -95,7 +96,7 @@ export function commandHelp(): string {
95
96
  "/takomi plan [title]",
96
97
  "/takomi mode <direct|orchestrate|review>",
97
98
  "/takomi gate <auto|review>",
98
- "/takomi subagents <on|off|status>",
99
+ "/takomi subagents <list|on|off|status>",
99
100
  "/takomi stats [overview|daily|models|projects|projects-full|sessions|sessions-full|tasks|tasks-full|tools|subagents|sources] [since 7d]",
100
101
  "/takomi routing [show|where]",
101
102
  "/takomi routing <policy text> # updates global policy",
@@ -9,6 +9,7 @@ import { commandHelp, completions, statusText, workflowPrompt } from "./command-
9
9
  import type { TakomiSubagentController } from "./subagent-types";
10
10
  import { previewTakomiRoutingPolicy, renderRoutingPolicyPreview, resolveTakomiRoutingPolicy, type RoutingPolicyInstallScope } from "./routing-policy";
11
11
  import { collectTakomiStats, renderTakomiStats } from "./takomi-stats.js";
12
+ import { discoverTakomiAgents } from "../takomi-subagents/agents";
12
13
 
13
14
  export type TakomiRuntimeCommandState = {
14
15
  enabled: boolean;
@@ -202,6 +203,17 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
202
203
 
203
204
  async function handleSubagents(ctx: ExtensionCommandContext, action?: string): Promise<void> {
204
205
  const controller = options.subagentController;
206
+ if (action === "list" || action === "agents") {
207
+ const agents = discoverTakomiAgents(ctx.cwd, "both");
208
+ const lines = [
209
+ "Takomi subagents:",
210
+ ...(agents.length
211
+ ? agents.map((agent) => `- ${agent.name} (${agent.source}): ${agent.description}`)
212
+ : ["- (none discovered)"]),
213
+ ];
214
+ ctx.ui.notify(lines.join("\n"), agents.length ? "info" : "warning");
215
+ return;
216
+ }
205
217
  if (action === "on" || action === "off") {
206
218
  await options.updateState(ctx, () => {
207
219
  options.getState().subagentsEnabled = action === "on";
@@ -212,7 +224,7 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
212
224
  ctx.ui.notify(statusText(options.getState(), controller), controller.hasRuns() ? "info" : "warning");
213
225
  return;
214
226
  }
215
- ctx.ui.notify("Usage: /takomi subagents <on|off|status>", "warning");
227
+ ctx.ui.notify("Usage: /takomi subagents <list|on|off|status>", "warning");
216
228
  }
217
229
 
218
230
  pi.registerCommand("takomi", {
@@ -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;