takomi 2.1.33 → 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.
@@ -1,4 +1,5 @@
1
- import { promises as fs } from 'node:fs';
1
+ import { createReadStream, promises as fs } from 'node:fs';
2
+ import readline from 'node:readline';
2
3
  import os from 'os';
3
4
  import path from 'path';
4
5
 
@@ -120,8 +121,14 @@ async function scanPiSessions(root, source, events, sessionRows = [], taskRows =
120
121
  for (const file of await files(root)) {
121
122
  let provider = 'unknown', model = 'unknown', session = path.basename(file, '.jsonl'), cwd = '', currentTask = null;
122
123
  const row = { key: session, session, source, file, project: projectKey(file), cwd, start: '', end: '', turns: 0, messages: 0, toolCalls: 0, subagentCalls: 0, roles: new Map(), stages: new Map(), workflows: new Map(), activeMs: 0, activityTimestamps: [] };
123
- const text = await fs.readFile(file, 'utf8').catch(() => '');
124
- for (const line of text.split(/\r?\n/)) {
124
+ let lines;
125
+ try {
126
+ lines = readline.createInterface({ input: createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
127
+ } catch {
128
+ continue;
129
+ }
130
+ try {
131
+ for await (const line of lines) {
125
132
  const obj = safeJson(line); if (!obj) continue;
126
133
  if (obj.timestamp) { row.start ||= obj.timestamp; row.end = obj.timestamp; addTimestamp(row.activityTimestamps, obj.timestamp); }
127
134
  if (obj.type === 'session') { session = obj.id || session; cwd = obj.cwd || cwd; row.key = session; row.session = session; row.cwd = cwd; }
@@ -164,6 +171,9 @@ async function scanPiSessions(root, source, events, sessionRows = [], taskRows =
164
171
  }
165
172
  const u = msg && msg.usage;
166
173
  if (u) events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'usage', input: +u.input||0, cache: +u.cacheRead||0, output: +u.output||0, total: +u.totalTokens||0, cost: cost(model, +u.input||0, +u.cacheRead||0, +u.output||0, true) });
174
+ }
175
+ } catch {
176
+ continue;
167
177
  }
168
178
  pushTask(taskRows, currentTask);
169
179
  row.activeMs = activeDuration(row.activityTimestamps);
@@ -27,6 +27,8 @@ export type RuntimeHudState = {
27
27
  activeSessionId?: string;
28
28
  launchMode?: string;
29
29
  subagentsEnabled?: boolean;
30
+ modeSource?: "idle" | "manual" | "model" | "board";
31
+ modeReason?: string;
30
32
  };
31
33
 
32
34
  type Tone = "accent" | "warning" | "success" | "error" | "muted" | "dim" | "thinkingMinimal";
@@ -70,24 +72,30 @@ function badge(theme: Theme, label: string, tone: Tone): string {
70
72
  }
71
73
 
72
74
  export function renderRuntimeStatus(theme: Theme, state: RuntimeHudState): string {
75
+ const source = state.modeSource ?? "idle";
76
+ if (!state.enabled) return [theme.fg("accent", "Takomi"), theme.fg("dim", "off")].join(" ");
77
+ if (source === "idle") return [theme.fg("accent", "Takomi"), theme.fg("dim", "idle")].join(" ");
78
+
73
79
  const primary = state.stage ?? state.role;
74
80
  const stageBadge = badge(theme, primary, stageTone(state.stage));
75
- const auto = state.autoOrch ? theme.fg("accent", "auto") : theme.fg("dim", "manual");
76
- const gate = state.launchMode === "manual" ? theme.fg("warning", "review-gate") : theme.fg("accent", "auto-gate");
81
+ const sourceLabel = source === "manual" ? theme.fg("warning", "manual") : theme.fg("success", source);
82
+ const role = state.stage && state.role !== state.stage ? theme.fg("dim", state.role) : "";
77
83
  const plan = state.planMode ? theme.fg("warning", "plan") : theme.fg("dim", "direct");
78
- const subagents = state.subagentsEnabled === false ? theme.fg("error", "subagents:off") : theme.fg("dim", "subagents:on");
79
- return [theme.fg("accent", "Takomi"), stageBadge, theme.fg("dim", `role:${state.role}`), auto, gate, plan, subagents].join(" ");
84
+ const gate = state.launchMode === "manual" ? theme.fg("warning", "review-gate") : "";
85
+ const subagents = state.subagentsEnabled === false ? theme.fg("error", "subagents:off") : "";
86
+ return [theme.fg("accent", "Takomi"), sourceLabel, stageBadge, role, gate || plan, subagents].filter(Boolean).join(" ");
80
87
  }
81
88
 
82
89
  export function renderRuntimeWidget(theme: Theme, state: RuntimeHudState): string[] {
83
- if (!state.enabled) return [];
90
+ if (!state.enabled || (state.modeSource ?? "idle") === "idle") return [];
84
91
  const primary = state.stage ?? state.role;
92
+ const sourceLabel = state.modeSource === "manual" ? theme.fg("warning", "manual") : theme.fg("success", state.modeSource ?? "model");
85
93
  const parts = [
86
94
  theme.fg("accent", "Takomi"),
95
+ sourceLabel,
87
96
  badge(theme, primary, stageTone(state.stage)),
88
- theme.fg("dim", `role:${state.role}`),
89
- state.autoOrch ? theme.fg("accent", "auto") : theme.fg("dim", "manual"),
90
- state.launchMode === "manual" ? theme.fg("warning", "review-gate") : theme.fg("accent", "auto-gate"),
97
+ state.stage && state.role !== state.stage ? theme.fg("dim", `role:${state.role}`) : "",
98
+ state.launchMode === "manual" ? theme.fg("warning", "review-gate") : "",
91
99
  state.planMode ? theme.fg("warning", "plan") : theme.fg("dim", "direct"),
92
100
  state.subagentsEnabled === false ? theme.fg("error", "subagents:off") : theme.fg("dim", "subagents:on"),
93
101
  state.workflow ? theme.fg("dim", `wf:${state.workflow}`) : "",
@@ -115,7 +123,7 @@ export class TakomiFooterComponent implements Component {
115
123
  this.unsubscribeBranchChange();
116
124
  }
117
125
 
118
- invalidate(): void {}
126
+ invalidate(): void { }
119
127
 
120
128
  render(width: number): string[] {
121
129
  const state = this.getState();
@@ -141,8 +149,7 @@ export class TakomiFooterComponent implements Component {
141
149
  .filter(([key]) => key !== "takomi-runtime")
142
150
  .map(([, value]) => value)
143
151
  .filter(Boolean);
144
- const runtimeStatus = renderRuntimeStatus(this.theme, state);
145
- const left = [runtimeStatus, ...extensionStatuses].join(this.theme.fg("dim", " | "));
152
+ const left = extensionStatuses.join(this.theme.fg("dim", " | "));
146
153
  const branch = this.footerData.getGitBranch();
147
154
  const rightText = [this.ctx.model?.id || "no-model", branch ? `git:${branch}` : ""].filter(Boolean).join(" | ");
148
155
  const right = this.theme.fg("dim", rightText);
@@ -44,10 +44,11 @@ const SubagentParameters = Type.Object({
44
44
  tasks: Type.Optional(Type.Array(TaskSchema, { description: "Parallel subagent tasks" })),
45
45
  confirmLaunch: Type.Optional(Type.Boolean({ description: "Required to launch immediately in manual Takomi launch mode" })),
46
46
  previewOnly: Type.Optional(Type.Boolean({ description: "Return the delegation plan without launching" })),
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." })),
47
48
  chain: Type.Optional(Type.Array(TaskSchema, { description: "Sequential chain of subagent tasks" })),
48
49
  agentScope: Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("project"), Type.Literal("both")])),
49
- confirmProjectAgents: Type.Optional(Type.Boolean({ description: "Prompt before running project-local agents. Default: true." })),
50
- overrideUserBlock: Type.Optional(Type.Boolean({ description: "Only set true when the user explicitly approves retrying after a blocked/cancelled/review-gated launch." })),
50
+ // Project-agent confirmation and hard-stop overrides are enforced server-side;
51
+ // they are intentionally not model-callable parameters.
51
52
  });
52
53
 
53
54
  function registerSubagentTool(pi: ExtensionAPI): void {
@@ -59,9 +60,10 @@ function registerSubagentTool(pi: ExtensionAPI): void {
59
60
  promptGuidelines: [
60
61
  "Use this tool during orchestration when a specialist should handle a task.",
61
62
  "Use tasks for independent parallel work and chain for dependent handoffs with {previous}.",
63
+ "Set clarify=true when the user asks to preview/edit a subagent run in the native Pi TUI before launch.",
62
64
  "Use model, fallbackModels, and thinking only when deliberate; otherwise let the agent/profile defaults apply.",
63
65
  "If review sends work back to the same agent, reuse the same conversationId for continuity.",
64
- "If a launch is blocked, cancelled, paused, or review-gated, do not retry automatically; wait for the user's next prompt. Use overrideUserBlock only after explicit user approval.",
66
+ "If a launch is blocked, cancelled, paused, or review-gated, do not retry automatically; wait for the user's next prompt."
65
67
  ],
66
68
  parameters: SubagentParameters,
67
69
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -30,15 +30,82 @@ export function renderTakomiSubagentCall(params: TakomiSubagentToolParams, theme
30
30
  );
31
31
  }
32
32
 
33
- export function renderTakomiSubagentResult(result: ToolResult, options: { expanded?: boolean; isPartial?: boolean }, theme: Theme, context: any): any {
34
- const native = renderNativeSubagentResult(result, options, theme, context);
35
- if (native) return native;
36
-
37
- const status = (result as any)?.isError ? "failed" : "completed";
38
- const text = typeof (result as any)?.content === "string"
33
+ function resultText(result: ToolResult): string {
34
+ return typeof (result as any)?.content === "string"
39
35
  ? (result as any).content
40
36
  : Array.isArray((result as any)?.content)
41
37
  ? (result as any).content.map((part: any) => part?.text ?? "").filter(Boolean).join("\n")
42
38
  : JSON.stringify((result as any)?.details ?? {}, null, 2);
39
+ }
40
+
41
+ function summarizeCollapsedResult(text: string, status: string, theme: Theme): string {
42
+ const policyMatch = text.match(/Required policies:\n((?:- .+\n?)+)/);
43
+ const policies = policyMatch?.[1]
44
+ ?.split("\n")
45
+ .map((line) => line.replace(/^[-\s]+/, "").trim())
46
+ .filter(Boolean) ?? [];
47
+ const lineCount = text ? text.split(/\r?\n/).length : 0;
48
+ const label = policies.length > 0
49
+ ? `policy context loaded: ${policies.join(", ")}`
50
+ : `${lineCount} result line${lineCount === 1 ? "" : "s"} hidden`;
51
+ const icon = status === "failed" ? "⚠" : status === "running" ? "…" : "✓";
52
+ const color = status === "failed" ? "warning" : status === "running" ? "accent" : "success";
53
+ return `${theme.fg(color, `${icon} takomi_subagent ${status}`)}${theme.fg("dim", ` · ${label} (ctrl+o to expand)`)}`;
54
+ }
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
+
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
+
104
+ const native = renderNativeSubagentResult(result, options, theme, context);
105
+ if (native) return native;
106
+
107
+ if (!options.expanded) {
108
+ return new Text(summarizeCollapsedResult(text, status, theme), 0, 0);
109
+ }
43
110
  return new Text(`${theme.fg("toolTitle", theme.bold(`takomi_subagent ${status}`))}\n${text || "No result content."}`, 0, 0);
44
111
  }
@@ -75,7 +75,7 @@ function resolveTasks(params: TakomiSubagentToolParams): TakomiSubagentToolTask[
75
75
  fallbackModels: params.fallbackModels,
76
76
  thinking: params.thinking,
77
77
  conversationId: params.conversationId,
78
- cwd: params.cwd,
78
+ cwd: undefined,
79
79
  checklist: params.checklist,
80
80
  }];
81
81
  }
@@ -109,6 +109,26 @@ function modelWithThinking(model: string | undefined, thinking: string | undefin
109
109
  return `${model}:${level}`;
110
110
  }
111
111
 
112
+ function isPathInside(root: string, candidate: string): boolean {
113
+ const relative = path.relative(root, candidate);
114
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
115
+ }
116
+
117
+ function resolveRelativeCwd(root: string, value: string | undefined, label: string): string {
118
+ const lexicalRoot = path.resolve(root);
119
+ const lexicalCandidate = value
120
+ ? path.isAbsolute(value) ? path.resolve(value) : path.resolve(lexicalRoot, value)
121
+ : lexicalRoot;
122
+ if (!isPathInside(lexicalRoot, lexicalCandidate)) throw new Error(`${label} escapes the current workspace.`);
123
+
124
+ const realRoot = fs.realpathSync(lexicalRoot);
125
+ const realCandidate = fs.realpathSync(lexicalCandidate);
126
+ const stat = fs.statSync(realCandidate);
127
+ if (!stat.isDirectory()) throw new Error(`${label} must be a directory inside the current workspace.`);
128
+ if (!isPathInside(realRoot, realCandidate)) throw new Error(`${label} escapes the current workspace.`);
129
+ return realCandidate;
130
+ }
131
+
112
132
  function safeConversationSlug(value: string): string {
113
133
  return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "conversation";
114
134
  }
@@ -164,13 +184,14 @@ function agentNameSet(discoverPiAgents: any, cwd: string): Set<string> {
164
184
  return new Set(discoverUnifiedAgents(discoverPiAgents, cwd, "both").agents.map((agent) => agent.name));
165
185
  }
166
186
 
167
- function mapSingleTask(task: TakomiSubagentToolTask, names: Set<string>) {
187
+ function mapSingleTask(task: TakomiSubagentToolTask, names: Set<string>, rootCwd: string) {
168
188
  const resolvedAgent = resolveAgentName(task.agent, new Map([...names].map((name) => [name, { name } as any])));
169
189
  return {
170
190
  agent: resolvedAgent,
171
191
  task: buildTakomiTaskPrompt({ ...task, agent: resolvedAgent }),
172
- cwd: task.cwd,
192
+ cwd: resolveRelativeCwd(rootCwd, task.cwd, "task.cwd"),
173
193
  model: modelWithThinking(task.model, task.thinking),
194
+ fallbackModels: task.fallbackModels,
174
195
  skill: task.skills?.length ? task.skills : undefined,
175
196
  };
176
197
  }
@@ -186,20 +207,21 @@ function toSubagentParams(params: TakomiSubagentToolParams, rootCwd: string, dis
186
207
  cwd: rootCwd,
187
208
  context: "fresh" as const,
188
209
  async: false,
189
- clarify: false,
210
+ clarify: params.clarify === true,
190
211
  includeProgress: true,
191
212
  sessionDir: stableConversationSessionDir(rootCwd, tasks),
192
213
  };
193
214
 
194
215
  if (mode === "single") {
195
216
  const task = tasks[0]!;
196
- const mapped = mapSingleTask(task, names);
217
+ const mapped = mapSingleTask(task, names, rootCwd);
197
218
  return {
198
219
  ...base,
199
220
  agent: mapped.agent,
200
221
  task: mapped.task,
201
- cwd: task.cwd ? path.resolve(rootCwd, task.cwd) : rootCwd,
222
+ cwd: mapped.cwd,
202
223
  model: mapped.model,
224
+ fallbackModels: mapped.fallbackModels,
203
225
  skill: mapped.skill,
204
226
  };
205
227
  }
@@ -207,19 +229,20 @@ function toSubagentParams(params: TakomiSubagentToolParams, rootCwd: string, dis
207
229
  if (mode === "parallel") {
208
230
  return {
209
231
  ...base,
210
- tasks: tasks.map((task) => mapSingleTask(task, names)),
232
+ tasks: tasks.map((task) => mapSingleTask(task, names, rootCwd)),
211
233
  };
212
234
  }
213
235
 
214
236
  return {
215
237
  ...base,
216
238
  chain: tasks.map((task) => {
217
- const mapped = mapSingleTask(task, names);
239
+ const mapped = mapSingleTask(task, names, rootCwd);
218
240
  return {
219
241
  agent: mapped.agent,
220
242
  task: mapped.task,
221
- cwd: task.cwd,
243
+ cwd: mapped.cwd,
222
244
  model: mapped.model,
245
+ fallbackModels: mapped.fallbackModels,
223
246
  skill: mapped.skill,
224
247
  };
225
248
  }),
@@ -264,7 +287,7 @@ export function createTakomiPiSubagentsEngine(pi: ExtensionAPI) {
264
287
  onUpdate: ToolUpdate | undefined,
265
288
  ctx: ExtensionContext,
266
289
  ): Promise<AgentToolResult<Details>> {
267
- const rootCwd = params.cwd ? path.resolve(ctx.cwd, params.cwd) : ctx.cwd;
290
+ const rootCwd = resolveRelativeCwd(ctx.cwd, params.cwd, "cwd");
268
291
  const { executor, discoverPiAgents } = await getExecutor();
269
292
  const subagentParams = toSubagentParams(params, rootCwd, discoverPiAgents);
270
293
  return executor.execute(id, subagentParams, signal ?? NEVER_ABORT, onUpdate, ctx);
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs/promises";
1
2
  import path from "node:path";
2
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
  import type { TakomiThinkingLevel } from "../../../src/pi-takomi-core";
@@ -28,9 +29,8 @@ export type TakomiSubagentToolParams = Partial<TakomiSubagentToolTask> & {
28
29
  chain?: TakomiSubagentToolTask[];
29
30
  confirmLaunch?: boolean;
30
31
  previewOnly?: boolean;
32
+ clarify?: boolean;
31
33
  agentScope?: TakomiAgentScope;
32
- confirmProjectAgents?: boolean;
33
- overrideUserBlock?: boolean;
34
34
  };
35
35
 
36
36
  type ToolUpdate = (partial: {
@@ -65,6 +65,10 @@ function hasProjectAgents(tasks: Array<{ agent: string }>, agents: Map<string, T
65
65
  return tasks.some((task) => agents.get(task.agent)?.source === "project");
66
66
  }
67
67
 
68
+ function hostTrustsProjectAgents(): boolean {
69
+ return /^(1|true|yes)$/i.test(process.env.TAKOMI_TRUST_PROJECT_AGENTS || "");
70
+ }
71
+
68
72
  function hardStopStore(pi: ExtensionAPI): Map<string, HardStopRecord> {
69
73
  const existing = RECENT_HARD_STOPS.get(pi);
70
74
  if (existing) return existing;
@@ -114,6 +118,31 @@ function consumeExpiredHardStop(pi: ExtensionAPI, fingerprint: string): HardStop
114
118
  return record;
115
119
  }
116
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
+
117
146
  function getTextContent(result: any): string {
118
147
  return (result?.content ?? [])
119
148
  .map((item: any) => item?.type === "text" && typeof item.text === "string" ? item.text : "")
@@ -183,7 +212,7 @@ function resolveTasks(params: TakomiSubagentToolParams): TakomiSubagentToolTask[
183
212
  fallbackModels: params.fallbackModels,
184
213
  thinking: params.thinking,
185
214
  conversationId: params.conversationId,
186
- cwd: params.cwd,
215
+ cwd: undefined,
187
216
  checklist: params.checklist,
188
217
  }];
189
218
  }
@@ -197,17 +226,31 @@ export async function executeTakomiSubagentTool(
197
226
  ctx: ExtensionContext,
198
227
  ) {
199
228
  const engine = getEngine(pi);
200
- const rootCwd = params.cwd ? path.resolve(ctx.cwd, params.cwd) : ctx.cwd;
229
+ let rootCwd: string;
230
+ try {
231
+ rootCwd = await resolveRelativeCwd(ctx.cwd, params.cwd, "cwd");
232
+ } catch (error) {
233
+ const message = error instanceof Error ? error.message : String(error);
234
+ return textResult(message, { results: [], agentScope: params.agentScope ?? "both" }, true);
235
+ }
201
236
  const profile = await loadTakomiProfile(rootCwd);
202
237
  const agentScope = params.agentScope ?? "both";
203
238
  const agents = discoverTakomiAgents(rootCwd, agentScope);
204
239
  const byName = new Map<string, TakomiAgentConfig>(agents.map((agent) => [agent.name, agent]));
205
240
  const mode = resolveMode(params);
206
241
  const routingSnapshot = await loadTakomiModelRoutingSnapshot(rootCwd);
207
- const tasks = resolveTasks(params).map((task) => applyTakomiRoutingDefaults({
208
- ...task,
209
- agent: resolveAgentName(task.agent, byName),
210
- }, routingSnapshot));
242
+ let tasks: TakomiSubagentToolTask[];
243
+ try {
244
+ const rawTasks = resolveTasks(params);
245
+ await validateTaskCwds(rootCwd, rawTasks);
246
+ tasks = rawTasks.map((task) => applyTakomiRoutingDefaults({
247
+ ...task,
248
+ agent: resolveAgentName(task.agent, byName),
249
+ }, routingSnapshot));
250
+ } catch (error) {
251
+ const message = error instanceof Error ? error.message : String(error);
252
+ return textResult(message, { results: [], availableAgents: agents.map((agent) => agent.name), agentScope }, true);
253
+ }
211
254
 
212
255
  if (!mode) {
213
256
  return textResult(
@@ -222,19 +265,23 @@ export async function executeTakomiSubagentTool(
222
265
 
223
266
  const fingerprint = createRunFingerprint(rootCwd, mode, tasks);
224
267
  const recentHardStop = consumeExpiredHardStop(pi, fingerprint);
225
- if (recentHardStop && !params.overrideUserBlock) {
268
+ if (recentHardStop) {
226
269
  return hardStopResult(
227
270
  `Subagent launch blocked: the same request was already stopped (${recentHardStop.reason}).\n${recentHardStop.message}`,
228
271
  { results: [], availableAgents: agents.map((agent) => agent.name), agentScope, mode, blockedAt: recentHardStop.at, reason: recentHardStop.reason },
229
272
  );
230
273
  }
231
- if (params.overrideUserBlock) {
232
- hardStopStore(pi).delete(fingerprint);
233
- }
234
274
 
235
- if (params.confirmProjectAgents !== false && ctx.hasUI && hasProjectAgents(tasks, byName)) {
275
+ const projectAgentsTrusted = hostTrustsProjectAgents();
276
+ if (!projectAgentsTrusted && hasProjectAgents(tasks, byName)) {
236
277
  const names = tasks.map((task) => byName.get(task.agent)).filter((agent): agent is TakomiAgentConfig => agent?.source === "project").map((agent) => agent.name);
237
- const ok = await ctx.ui.confirm("Run project-local Takomi agents?", `Agents: ${[...new Set(names)].join(", ")}\n\nProject agents are repo-controlled. Continue only for trusted repositories.`);
278
+ const uniqueNames = [...new Set(names)].join(", ");
279
+ if (!ctx.hasUI) {
280
+ const message = `Blocked: project-local Takomi agents require interactive approval. Agents: ${uniqueNames}`;
281
+ rememberHardStop(pi, fingerprint, "project-agent-approval-required", message);
282
+ return hardStopResult(message, { results: [], agentScope, mode });
283
+ }
284
+ const ok = await ctx.ui.confirm("Run project-local Takomi agents?", `Agents: ${uniqueNames}\n\nProject agents are repo-controlled. Continue only for trusted repositories.`);
238
285
  if (!ok) {
239
286
  const message = "Canceled: project-local agents not approved.";
240
287
  rememberHardStop(pi, fingerprint, "project-agent-denied", message);
@@ -269,7 +316,7 @@ export async function executeTakomiSubagentTool(
269
316
  }
270
317
  try {
271
318
  const nativeParams: TakomiSubagentToolParams = mode === "single"
272
- ? { ...params, agent: tasks[0]!.agent, task: tasks[0]!.task, agentScope }
319
+ ? { ...params, ...tasks[0]!, agentScope }
273
320
  : mode === "parallel"
274
321
  ? { ...params, tasks, agentScope }
275
322
  : { ...params, chain: tasks, agentScope };
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: photo-book-builder
3
3
  description: >
4
- Automates high-resolution photo book creation, including chronological sorting, orientation balancing (landscape vs. portrait), print-ready full-bleed masonry layout generation, and cover art upscaling. Use this skill when asked to: (1) Organize a set of photos into chronological pages, (2) Generate 12" x 30" open spreads (9000 x 3600 px at 300 DPI) with exact gutter margins (150 px vertical center gutter) and custom margins, (3) Create or assemble full-bleed masonry grid layouts, (4) Upscale front, back, or briefcase box covers using high-quality LANCZOS interpolation.
4
+ Automates high-resolution photo book creation and correction passes, including chronological sorting, orientation balancing, print-ready full-bleed masonry layout generation, existing layout crop corrections, contact-sheet/cell mapping, and cover art upscaling. Use this skill when asked to: (1) Organize photos into page folders, (2) Generate or regenerate 12" x 30" open spreads (9000 x 3600 px at 300 DPI) with gutter margins, (3) Create or adapt full-bleed masonry grid layouts with project-specific photo counts, (4) Correct existing exports by raising/lowering images or adjusting per-image crop framing without overwriting originals, (5) Upscale front, back, or briefcase box covers using high-quality LANCZOS interpolation.
5
5
  ---
6
6
 
7
7
  # Photo Book Builder
@@ -10,7 +10,7 @@ This skill provides a complete system for organizing, structuring, and generatin
10
10
 
11
11
  ## Core Capabilities
12
12
 
13
- The photo book builder relies on four main stages:
13
+ The photo book builder supports five main stages:
14
14
 
15
15
  ```mermaid
16
16
  graph TD
@@ -18,6 +18,8 @@ graph TD
18
18
  B --> C[Stage 2 & 3: Layout Generation]
19
19
  C -->|create_full_bleed_layouts.py| D[9000x3600 px Print Spreads]
20
20
  E[Stage 4: Cover Upscaling] -->|upscale_covers.py| F[Print-Ready Cover JPEGs]
21
+ D --> G[Stage 5: Correction Pass]
22
+ G -->|custom renderer or patched generator| H[New Corrected Export Folder]
21
23
  ```
22
24
 
23
25
  ---
@@ -50,6 +52,8 @@ Layout configurations are determined dynamically based on photo counts and orien
50
52
  * **Template 6A (6 Photos: 5L, 1P)**: Two landscape rows (4 total), one portrait + landscape col.
51
53
  * **Template 6B (6 Photos: 4L, 2P)**: Two large landscape rows (2 total), one double-portrait + double-landscape col.
52
54
 
55
+ These templates are defaults, not hard limits. When a project already has a custom generator, page folders, or spreads with a different number of photos (for example 23-24 images per spread), preserve that project's generator and layout rules. Do not force the default 5/6-photo half-page templates onto an existing custom book.
56
+
53
57
  For a complete coordinate reference and details on column configurations, see [layout_templates.md](references/layout_templates.md).
54
58
 
55
59
  ---
@@ -58,6 +62,8 @@ For a complete coordinate reference and details on column configurations, see [l
58
62
  The layout compiler reads the page directories, detects photo orientations on the fly, applies the coordinate grids, crops photos to fill cells (using centered LANCZOS fit-scale), and merges them into $9000 \times 3600$ px JPEG spreads.
59
63
  * **Page Swapping**: Alternating layout orientation avoids monotonous designs. Left page columns swap if `page_num % 2 == 0`, and right page columns swap if `page_num % 3 == 0`.
60
64
 
65
+ When regenerating an existing project, first inspect local scripts in the delivery/project folder. If a project-specific script exists, prefer adapting it over the bundled default scripts. Avoid rerunning any organizer/reorganizer that flattens or moves page folders unless the user explicitly asks for a new organization pass.
66
+
61
67
  **Execution**:
62
68
  Compile the print-ready layouts:
63
69
  ```bash
@@ -79,6 +85,44 @@ python scripts/upscale_covers.py --src "path/to/cover.png" --out "path/to/upscal
79
85
 
80
86
  ---
81
87
 
88
+ ### Stage 5: Existing Layout Correction Pass
89
+ Use this when the user provides screenshots or notes such as "blue means raise the image" and "red means take it down" for already-exported spreads.
90
+
91
+ **Correction workflow**:
92
+ 1. Identify the current source workspace, existing export folder, and correction screenshots.
93
+ 2. Inspect the local layout generator and `photo_organization_map.json` if present.
94
+ 3. Create a contact sheet or map preview that overlays each cell with its source filename and rectangle. This prevents guessing from screenshots.
95
+ 4. Translate annotations into per-image corrections:
96
+ * Blue / "raise image up": move the visible subject up in the cell. In crop-ratio terms this usually means increasing vertical crop offset when there is extra source height below the crop.
97
+ * Red / "take it down": move the visible subject down in the cell. In crop-ratio terms this usually means decreasing vertical crop offset when there is extra source height above the crop.
98
+ * If the crop is already pinned at the top or bottom, use a small top-anchored or bottom-anchored overscale instead of adding filler or painting pixels. Start tiny, e.g. `1.006` to `1.012`; larger values can overshoot.
99
+ 5. Render to a new export folder. Never overwrite the original export unless the user explicitly asks.
100
+ 6. Verify file count, output dimensions, and a reduced review sheet of corrected spreads.
101
+
102
+ **Per-image correction patterns**:
103
+ ```python
104
+ CROP_OVERRIDES = {
105
+ "DSC01234.jpg": {"y_ratio": 0.30}, # raise visible subject
106
+ "DSC05678.jpg": {"y_ratio": 0.00}, # lower visible subject
107
+ }
108
+ ```
109
+
110
+ For edge-pinned images where ratio changes cannot move the frame:
111
+ ```python
112
+ TOP_ANCHORED_OVERSCALE = {
113
+ "DSC01234.jpg": 1.010, # tiny downward nudge without filler
114
+ }
115
+ ```
116
+
117
+ Keep corrections local to filenames/cells. Avoid global crop changes unless every affected image needs the same behavior.
118
+
119
+ **Safety rules for correction passes**:
120
+ * Preserve existing page folders and mapping files.
121
+ * Prefer a renderer that reads existing pages and writes new spreads over a script that reorganizes files.
122
+ * Use output folder names like `10 - layouts - crop-corrections-YYYYMMDD`.
123
+ * Keep a short correction log with source filenames, correction type, output folder, and verification result.
124
+ * On Windows, avoid giant one-shot patches or command lines; keep scripts and path lists small.
125
+
82
126
  ## Revert Option
83
127
  If you need to restart the process or undo the page folder classification, run the revert tool to move photos back to the root workspace directory and remove empty page folders:
84
128
  ```bash
@@ -88,9 +132,9 @@ python scripts/revert_organization.py --workspace-dir "path/to/workspace"
88
132
  ## Bundled Resources
89
133
 
90
134
  * **Scripts**:
91
- * [organize_photos.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/organize_photos.py) - Groups and balances photo directories.
92
- * [create_full_bleed_layouts.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/create_full_bleed_layouts.py) - Assembles masonry grid pages into print-ready spreads.
93
- * [upscale_covers.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/upscale_covers.py) - General-purpose upscaling script.
94
- * [revert_organization.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/revert_organization.py) - Restores files back to flat source directories.
135
+ * [organize_photos.py](scripts/organize_photos.py) - Groups and balances photo directories.
136
+ * [create_full_bleed_layouts.py](scripts/create_full_bleed_layouts.py) - Assembles masonry grid pages into print-ready spreads.
137
+ * [upscale_covers.py](scripts/upscale_covers.py) - General-purpose upscaling script.
138
+ * [revert_organization.py](scripts/revert_organization.py) - Restores files back to flat source directories.
95
139
  * **References**:
96
- * [layout_templates.md](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/references/layout_templates.md) - Exact coordinates, dimensions, and grid cells.
140
+ * [layout_templates.md](references/layout_templates.md) - Exact coordinates, dimensions, and grid cells.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "takomi",
3
- "version": "2.1.33",
3
+ "version": "2.1.35",
4
4
  "description": "🎯 Stop wrestling with AI. Start building with purpose. The artisan's toolkit for agent workflows, Codex skills, and original Takomi capabilities like 21st.dev integration.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,9 @@
23
23
  ],
24
24
  "scripts": {
25
25
  "postinstall": "node src/postinstall.js",
26
- "test": "node scripts/test-skill-selection.js",
26
+ "test": "npm run test:typecheck && npm run test:regressions && npm run test:skills",
27
+ "test:typecheck": "tsc --noEmit",
28
+ "test:regressions": "node scripts/test-regressions.js",
27
29
  "test:skills": "node scripts/test-skill-selection.js"
28
30
  },
29
31
  "repository": {
@@ -52,6 +54,20 @@
52
54
  "picocolors": "^1.1.1",
53
55
  "prompts": "^2.4.2"
54
56
  },
57
+ "overrides": {
58
+ "protobufjs": "7.6.3",
59
+ "@protobufjs/utf8": "1.1.1",
60
+ "ws": "8.21.0",
61
+ "brace-expansion": "5.0.6"
62
+ },
63
+ "pnpm": {
64
+ "overrides": {
65
+ "protobufjs": "7.6.3",
66
+ "@protobufjs/utf8": "1.1.1",
67
+ "ws": "8.21.0",
68
+ "brace-expansion": "5.0.6"
69
+ }
70
+ },
55
71
  "bugs": {
56
72
  "url": "https://github.com/JStaRFilms/VibeCode-Protocol-Suite/issues"
57
73
  },