@mindfoldhq/trellis 0.6.0-beta.17 → 0.6.0-beta.19

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 (76) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/channel/adapters/claude.d.ts +7 -16
  3. package/dist/commands/channel/adapters/claude.d.ts.map +1 -1
  4. package/dist/commands/channel/adapters/claude.js +19 -25
  5. package/dist/commands/channel/adapters/claude.js.map +1 -1
  6. package/dist/commands/channel/adapters/codex.d.ts +5 -1
  7. package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
  8. package/dist/commands/channel/adapters/codex.js +8 -15
  9. package/dist/commands/channel/adapters/codex.js.map +1 -1
  10. package/dist/commands/channel/adapters/index.d.ts +6 -1
  11. package/dist/commands/channel/adapters/index.d.ts.map +1 -1
  12. package/dist/commands/channel/adapters/index.js +12 -6
  13. package/dist/commands/channel/adapters/index.js.map +1 -1
  14. package/dist/commands/channel/guard.d.ts +150 -0
  15. package/dist/commands/channel/guard.d.ts.map +1 -0
  16. package/dist/commands/channel/guard.js +474 -0
  17. package/dist/commands/channel/guard.js.map +1 -0
  18. package/dist/commands/channel/index.d.ts +1 -1
  19. package/dist/commands/channel/index.d.ts.map +1 -1
  20. package/dist/commands/channel/index.js +38 -10
  21. package/dist/commands/channel/index.js.map +1 -1
  22. package/dist/commands/channel/interrupt.d.ts +10 -0
  23. package/dist/commands/channel/interrupt.d.ts.map +1 -0
  24. package/dist/commands/channel/interrupt.js +22 -0
  25. package/dist/commands/channel/interrupt.js.map +1 -0
  26. package/dist/commands/channel/messages.d.ts +0 -1
  27. package/dist/commands/channel/messages.d.ts.map +1 -1
  28. package/dist/commands/channel/messages.js +2 -6
  29. package/dist/commands/channel/messages.js.map +1 -1
  30. package/dist/commands/channel/run.d.ts +0 -1
  31. package/dist/commands/channel/run.d.ts.map +1 -1
  32. package/dist/commands/channel/run.js +5 -12
  33. package/dist/commands/channel/run.js.map +1 -1
  34. package/dist/commands/channel/send.d.ts +0 -2
  35. package/dist/commands/channel/send.d.ts.map +1 -1
  36. package/dist/commands/channel/send.js +0 -2
  37. package/dist/commands/channel/send.js.map +1 -1
  38. package/dist/commands/channel/spawn.d.ts +10 -0
  39. package/dist/commands/channel/spawn.d.ts.map +1 -1
  40. package/dist/commands/channel/spawn.js +57 -7
  41. package/dist/commands/channel/spawn.js.map +1 -1
  42. package/dist/commands/channel/supervisor/idle.d.ts +46 -0
  43. package/dist/commands/channel/supervisor/idle.d.ts.map +1 -0
  44. package/dist/commands/channel/supervisor/idle.js +72 -0
  45. package/dist/commands/channel/supervisor/idle.js.map +1 -0
  46. package/dist/commands/channel/supervisor/inbox.d.ts +4 -4
  47. package/dist/commands/channel/supervisor/inbox.d.ts.map +1 -1
  48. package/dist/commands/channel/supervisor/inbox.js +22 -22
  49. package/dist/commands/channel/supervisor/inbox.js.map +1 -1
  50. package/dist/commands/channel/supervisor/shutdown.d.ts +3 -1
  51. package/dist/commands/channel/supervisor/shutdown.d.ts.map +1 -1
  52. package/dist/commands/channel/supervisor/shutdown.js +4 -1
  53. package/dist/commands/channel/supervisor/shutdown.js.map +1 -1
  54. package/dist/commands/channel/supervisor/turns.d.ts +11 -0
  55. package/dist/commands/channel/supervisor/turns.d.ts.map +1 -1
  56. package/dist/commands/channel/supervisor/turns.js +19 -2
  57. package/dist/commands/channel/supervisor/turns.js.map +1 -1
  58. package/dist/commands/channel/supervisor.d.ts +6 -0
  59. package/dist/commands/channel/supervisor.d.ts.map +1 -1
  60. package/dist/commands/channel/supervisor.js +43 -3
  61. package/dist/commands/channel/supervisor.js.map +1 -1
  62. package/dist/commands/channel/wait.d.ts +0 -1
  63. package/dist/commands/channel/wait.d.ts.map +1 -1
  64. package/dist/commands/channel/wait.js +0 -1
  65. package/dist/commands/channel/wait.js.map +1 -1
  66. package/dist/migrations/manifests/0.5.16.json +9 -0
  67. package/dist/migrations/manifests/0.5.17.json +9 -0
  68. package/dist/migrations/manifests/0.6.0-beta.18.json +16 -0
  69. package/dist/migrations/manifests/0.6.0-beta.19.json +9 -0
  70. package/dist/templates/codex/config.toml +5 -3
  71. package/dist/templates/pi/extensions/trellis/index.ts.txt +1339 -913
  72. package/dist/templates/pi/settings.json +0 -9
  73. package/dist/templates/trellis/config.yaml +20 -0
  74. package/dist/templates/trellis/scripts/common/task_store.py +55 -7
  75. package/dist/templates/trellis/workflow.md +1 -0
  76. package/package.json +2 -2
@@ -1,16 +1,15 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { delimiter, dirname, join, resolve } from "node:path";
3
+ import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
5
 
6
+ // ── Types ──────────────────────────────────────────────────────────────
6
7
  type JsonObject = Record<string, unknown>;
7
8
  type TextContent = { type: "text"; text: string };
8
-
9
9
  interface PiToolResult {
10
10
  content: TextContent[];
11
- details?: JsonObject;
11
+ details?: unknown;
12
12
  }
13
-
14
13
  interface PiExtensionContext {
15
14
  hasUI?: boolean;
16
15
  sessionManager?: {
@@ -18,1065 +17,1389 @@ interface PiExtensionContext {
18
17
  getSessionFile?: () => string | undefined;
19
18
  };
20
19
  ui?: {
21
- notify?: (message: string, type?: "info" | "warning" | "error") => void;
20
+ notify?: (msg: string, type?: "info" | "warning" | "error") => void;
22
21
  };
23
22
  }
24
-
25
- interface PiBeforeAgentStartEvent {
26
- systemPrompt?: string;
27
- }
28
-
29
- interface PiContextEvent {
30
- messages?: unknown[];
31
- }
32
-
33
- interface PiToolCallEvent {
34
- toolName?: string;
35
- input?: JsonObject;
36
- }
37
-
38
23
  interface SubagentInput {
39
24
  agent?: string;
40
25
  prompt?: string;
41
26
  mode?: "single" | "parallel" | "chain";
42
27
  prompts?: string[];
43
28
  model?: string;
44
- thinking?: ThinkingLevel;
29
+ thinking?: string;
45
30
  }
46
-
47
- type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
48
-
49
31
  interface AgentConfig {
50
32
  model?: string;
51
- thinking?: ThinkingLevel;
52
- // Parsed for pi-subagents-compatible agent files; Pi CLI has no documented fallback-model flag to pass through here.
33
+ thinking?: string;
53
34
  fallbackModels: string[];
54
35
  }
55
-
56
- interface AgentDefinition {
57
- content: string;
58
- config: AgentConfig;
59
- }
60
-
61
36
  interface PiRunConfig {
62
37
  model?: string;
63
- thinking?: ThinkingLevel;
38
+ thinking?: string;
64
39
  }
65
40
 
41
+ // ── Lazy-load pi-tui (avoid failing top-level imports) ─────────────────
42
+ let _piTui: {
43
+ visibleWidth?: (s: string) => number;
44
+ truncateToWidth?: (s: string, w: number, ellipsis?: string) => string;
45
+ } | null = null;
46
+ function piTui() {
47
+ if (!_piTui) {
48
+ try {
49
+ _piTui = require("@earendil-works/pi-tui");
50
+ } catch {
51
+ _piTui = {};
52
+ }
53
+ }
54
+ return _piTui;
55
+ }
56
+ function trunc(s: string, w: number) {
57
+ const t = piTui();
58
+ return t.truncateToWidth
59
+ ? t.truncateToWidth(s, w, "…")
60
+ : s.length <= w
61
+ ? s
62
+ : w > 1
63
+ ? s.slice(0, w - 1) + "…"
64
+ : s.slice(0, w);
65
+ }
66
+
67
+ // ── Constants ─────────────────────────────────────────────────────────
66
68
  const TRELLIS_AGENT_JSONL: Record<string, string> = {
67
69
  "trellis-implement": "implement.jsonl",
68
70
  implement: "implement.jsonl",
69
71
  "trellis-check": "check.jsonl",
70
72
  check: "check.jsonl",
71
73
  };
74
+ const MAX_STDOUT = 8 * 1024 * 1024;
75
+ const MAX_STDERR = 1024 * 1024;
76
+ const MAX_TAIL = 256 * 1024;
77
+ const MAX_LINE_BUFFER = 1024 * 1024;
78
+ const MAX_TOOL_ARG_CHARS = 2048;
79
+ const MAX_TOOLS = 256;
80
+ const MAX_PARALLEL_PROMPTS = 6;
81
+ const ABORT_KILL_GRACE_MS = 1500;
82
+ const SESSION_OVERVIEW_TIMEOUT_MS = 1500;
83
+ const THROTTLE_MS = 500;
84
+
85
+ // ── State types ───────────────────────────────────────────────────────
86
+ type RunStatus = "pending" | "running" | "succeeded" | "failed" | "cancelled";
87
+ type ToolStatus = "running" | "succeeded" | "failed";
88
+
89
+ interface Usage {
90
+ input: number;
91
+ output: number;
92
+ cacheRead: number;
93
+ cacheWrite: number;
94
+ cost: number;
95
+ ctxTokens: number;
96
+ turns: number;
97
+ }
98
+ interface ToolTrace {
99
+ id: string;
100
+ name: string;
101
+ args: string;
102
+ status: ToolStatus;
103
+ startedAt: number;
104
+ finishedAt?: number;
105
+ }
106
+ interface RunState {
107
+ id: string;
108
+ agent: string;
109
+ prompt: string;
110
+ step?: number;
111
+ status: RunStatus;
112
+ startedAt?: number;
113
+ finishedAt?: number;
114
+ finalText: string;
115
+ textTail: string;
116
+ thinkingTail: string;
117
+ stderrTail: string;
118
+ tools: ToolTrace[];
119
+ usage: Usage;
120
+ model?: string;
121
+ thinking?: string;
122
+ errorMessage?: string;
123
+ }
124
+ interface ProgressDetails {
125
+ kind: "trellis-subagent-progress";
126
+ agent: string;
127
+ mode: "single" | "parallel" | "chain";
128
+ startedAt: number;
129
+ updatedAt: number;
130
+ final: boolean;
131
+ runs: RunState[];
132
+ }
72
133
 
73
- function findProjectRoot(startDir: string): string {
74
- let current = resolve(startDir);
75
- while (true) {
76
- if (
77
- existsSync(join(current, ".trellis")) ||
78
- existsSync(join(current, ".pi"))
79
- ) {
80
- return current;
81
- }
82
- const parent = dirname(current);
83
- if (parent === current) return resolve(startDir);
84
- current = parent;
134
+ // ── Native partial-update card state ──────────────────────────────────
135
+ interface NativeCardHandle {
136
+ state: JsonObject;
137
+ invalidate: () => void;
138
+ updatedAt: number;
139
+ }
140
+ const MAX_NATIVE_CARDS = 20;
141
+ const nativeCards = new Map<string, NativeCardHandle>();
142
+ let activeSubagentToolCallId: string | null = null;
143
+ function rememberNativeCard(id: string, card: NativeCardHandle) {
144
+ nativeCards.set(id, card);
145
+ const active = activeSubagentToolCallId
146
+ ? nativeCards.get(activeSubagentToolCallId)
147
+ : undefined;
148
+ if (!active || card.updatedAt >= active.updatedAt)
149
+ activeSubagentToolCallId = id;
150
+ for (const key of nativeCards.keys()) {
151
+ if (nativeCards.size <= MAX_NATIVE_CARDS) break;
152
+ if (key !== activeSubagentToolCallId) nativeCards.delete(key);
85
153
  }
86
154
  }
87
-
88
- function readText(path: string): string {
155
+ function totalUsage(d: ProgressDetails): Usage {
156
+ const u: Usage = {
157
+ input: 0,
158
+ output: 0,
159
+ cacheRead: 0,
160
+ cacheWrite: 0,
161
+ cost: 0,
162
+ ctxTokens: 0,
163
+ turns: 0,
164
+ };
165
+ for (const r of d.runs) {
166
+ u.input += r.usage.input;
167
+ u.output += r.usage.output;
168
+ u.cacheRead += r.usage.cacheRead;
169
+ u.cacheWrite += r.usage.cacheWrite;
170
+ u.cost += r.usage.cost;
171
+ u.ctxTokens = Math.max(u.ctxTokens, r.usage.ctxTokens);
172
+ u.turns += r.usage.turns;
173
+ }
174
+ return u;
175
+ }
176
+ function activeRun(d: ProgressDetails) {
177
+ return d.runs.find((r) => r.status === "running") ?? d.runs.at(-1);
178
+ }
179
+ function toolArgs(t: ToolTrace) {
89
180
  try {
90
- return readFileSync(path, "utf-8");
181
+ return JSON.parse(t.args) as Record<string, unknown>;
91
182
  } catch {
92
- return "";
183
+ return {};
93
184
  }
94
185
  }
95
-
96
- function splitMarkdownFrontmatter(content: string): {
97
- frontmatter: string;
98
- body: string;
99
- } {
100
- const normalized = content.replace(/^\uFEFF/, "");
101
- const match = normalized.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
102
- return match
103
- ? { frontmatter: match[1] ?? "", body: normalized.slice(match[0].length) }
104
- : { frontmatter: "", body: normalized };
186
+ function bashCommand(t: ToolTrace) {
187
+ const a = toolArgs(t);
188
+ return String(a.command || "").toLowerCase();
105
189
  }
106
-
107
- function stripMarkdownFrontmatter(content: string): string {
108
- return splitMarkdownFrontmatter(content).body.trimStart();
190
+ function isSearchTool(t: ToolTrace) {
191
+ return t.name === "read" || t.name === "grep" || t.name === "find";
109
192
  }
110
-
111
- function isJsonObject(value: unknown): value is JsonObject {
112
- return typeof value === "object" && value !== null && !Array.isArray(value);
193
+ function isMutationTool(t: ToolTrace) {
194
+ return t.name === "edit" || t.name === "write";
113
195
  }
114
-
115
- function stringValue(value: unknown): string | null {
116
- return typeof value === "string" && value.trim() ? value.trim() : null;
196
+ function isValidationCommand(t: ToolTrace) {
197
+ const c = bashCommand(t);
198
+ return /\b(test|typecheck|lint|build|gofmt|go test|npm run|pnpm|vitest|tsc)\b/.test(
199
+ c,
200
+ );
117
201
  }
118
-
119
- const THINKING_LEVELS = [
120
- "off",
121
- "minimal",
122
- "low",
123
- "medium",
124
- "high",
125
- "xhigh",
126
- ] as const satisfies readonly ThinkingLevel[];
127
- const THINKING_SUFFIX_RE = /:(?:off|minimal|low|medium|high|xhigh)$/i;
128
-
129
- function normalizeThinking(value: unknown): ThinkingLevel | undefined {
130
- const raw = stringValue(value)?.toLowerCase();
131
- if (!raw) return undefined;
132
- return THINKING_LEVELS.includes(raw as ThinkingLevel)
133
- ? (raw as ThinkingLevel)
134
- : undefined;
202
+ function isInspectionCommand(t: ToolTrace) {
203
+ const c = bashCommand(t);
204
+ return /\b(rg|grep|find|git diff|git status|ls|tree)\b/.test(c);
135
205
  }
136
-
137
- function parseFrontmatterScalar(value: string): string | null {
138
- const trimmed = value.trim();
139
- if (
140
- !trimmed ||
141
- trimmed === "|" ||
142
- trimmed === ">" ||
143
- trimmed === "[]" ||
144
- trimmed === "null" ||
145
- trimmed === "~"
146
- ) {
147
- return null;
206
+ function thinkingIntent(text: string) {
207
+ const s = text.toLowerCase();
208
+ if (/error|failed|failure|panic|exception|报错|失败|错误|异常/.test(s))
209
+ return "Analyzing failure cause";
210
+ if (/test|verify|check|typecheck|lint|验证|测试|检查/.test(s))
211
+ return "Planning verification steps";
212
+ if (/plan|approach|design|strategy|方案|计划|思路|设计/.test(s))
213
+ return "Structuring the implementation approach";
214
+ if (/implement|change|edit|modify|refactor|实现|修改|重构/.test(s))
215
+ return "Reasoning through code changes";
216
+ if (/inspect|search|locate|read|context|定位|搜索|阅读|上下文/.test(s))
217
+ return "Locating relevant context";
218
+ return "";
219
+ }
220
+ function behaviorSummary(r: RunState) {
221
+ if (r.status === "succeeded") return "Task completed and result returned";
222
+ if (r.status === "failed")
223
+ return "Task failed and error details were retained";
224
+
225
+ const runningTool = r.tools.findLast((t) => t.status === "running");
226
+ if (runningTool) {
227
+ if (isMutationTool(runningTool)) return "Applying the plan to code";
228
+ if (runningTool.name === "bash" && isValidationCommand(runningTool))
229
+ return "Verifying whether the implementation passes";
230
+ if (runningTool.name === "bash" && isInspectionCommand(runningTool))
231
+ return "Inspecting current code state";
232
+ if (isSearchTool(runningTool)) return "Locating relevant code and context";
233
+ if (runningTool.name === "bash")
234
+ return "Validating assumptions with commands";
235
+ return "Using tools to advance the task";
148
236
  }
237
+
238
+ const recent = r.tools.slice(-5);
239
+ if (recent.some((t) => t.status === "failed"))
240
+ return "Investigating tool or command failure";
241
+ if (recent.some(isMutationTool)) return "Reviewing recent changes";
242
+ if (recent.some((t) => t.name === "bash" && isValidationCommand(t)))
243
+ return "Analyzing verification results";
149
244
  if (
150
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
151
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
152
- ) {
153
- return trimmed.slice(1, -1).trim() || null;
154
- }
155
- return trimmed;
245
+ recent.length >= 2 &&
246
+ recent.every(
247
+ (t) => isSearchTool(t) || (t.name === "bash" && isInspectionCommand(t)),
248
+ )
249
+ )
250
+ return "Mapping code structure and impact";
251
+
252
+ const intent = thinkingIntent(`${r.thinkingTail}\n${r.textTail}`);
253
+ if (intent) return intent;
254
+ if (!r.tools.length) return "Understanding the task and planning execution";
255
+ return "Advancing the task and preparing next steps";
156
256
  }
157
-
158
- function parseInlineList(value: string): string[] {
159
- const trimmed = value.trim();
160
- if (!trimmed || trimmed === "[]") return [];
161
- const body =
162
- trimmed.startsWith("[") && trimmed.endsWith("]")
163
- ? trimmed.slice(1, -1)
164
- : trimmed;
165
- return body
166
- .split(",")
167
- .map((item) => parseFrontmatterScalar(item))
168
- .filter((item): item is string => !!item);
257
+ function progressState(d: ProgressDetails) {
258
+ const running = d.runs.filter((r) => r.status === "running").length;
259
+ const failed = d.runs.some((r) => r.status === "failed");
260
+ return failed
261
+ ? "failed"
262
+ : d.final
263
+ ? "completed"
264
+ : running
265
+ ? `${running} running`
266
+ : "pending";
169
267
  }
170
-
171
- function readIndentedList(
268
+ function progressDone(d: ProgressDetails) {
269
+ return d.runs.filter((r) => r.status !== "pending" && r.status !== "running")
270
+ .length;
271
+ }
272
+ function summaryText(text: string) {
273
+ return `${text.trim().replace(/[。.!?…]+$/u, "")}...`;
274
+ }
275
+ function splitModelThinking(model?: string, fallbackThinking?: string) {
276
+ const m = model?.match(/^(.*):(off|minimal|low|medium|high|xhigh)$/i);
277
+ return {
278
+ model: m ? m[1] : model,
279
+ thinking: (m?.[2] ?? fallbackThinking)?.toLowerCase(),
280
+ };
281
+ }
282
+ function modelLabel(r: RunState) {
283
+ const { model, thinking } = splitModelThinking(r.model, r.thinking);
284
+ if (!model) return undefined;
285
+ return thinking && thinking !== "off" ? `${model}(${thinking})` : model;
286
+ }
287
+ function applyRunConfig(r: RunState, cfg: PiRunConfig) {
288
+ const parsed = splitModelThinking(cfg.model, cfg.thinking);
289
+ r.model = parsed.model;
290
+ r.thinking = parsed.thinking;
291
+ }
292
+ function runElapsed(d: ProgressDetails, r: RunState) {
293
+ const start = r.startedAt ?? d.startedAt;
294
+ const end =
295
+ r.finishedAt ?? (r.status === "running" ? Date.now() : d.updatedAt);
296
+ return fmtDur(Math.max(0, end - start));
297
+ }
298
+ function runHeader(d: ProgressDetails, r: RunState) {
299
+ const usage = fmtUsage(r.usage, modelLabel(r)) || fmtUsage(totalUsage(d));
300
+ return `${r.agent} · ${progressDone(d)}/${d.runs.length} done · ${progressState(d)} · ${runElapsed(d, r)}${usage ? ` · ${usage}` : ""}`;
301
+ }
302
+ function renderRunBlock(
172
303
  lines: string[],
173
- startIndex: number,
174
- ): { values: string[]; nextIndex: number } {
175
- const values: string[] = [];
176
- let index = startIndex + 1;
177
- while (index < lines.length) {
178
- const line = lines[index] ?? "";
179
- if (/^[A-Za-z][A-Za-z0-9_-]*\s*:/.test(line)) break;
180
- const item = line.match(/^\s*-\s*(.*)$/);
181
- if (item) {
182
- const scalar = parseFrontmatterScalar(item[1] ?? "");
183
- if (scalar) values.push(scalar);
184
- }
185
- index += 1;
304
+ d: ProgressDetails,
305
+ run: RunState,
306
+ expanded: boolean,
307
+ ) {
308
+ const step = run.step ? `step ${run.step} · ` : "";
309
+ lines.push(` - ${step}${runHeader(d, run)}`);
310
+ const summary = behaviorSummary(run);
311
+ if (summary) lines.push(` › ${summaryText(summary)}`);
312
+ const visibleTools = expanded ? run.tools.slice(-8) : run.tools.slice(-1);
313
+ for (const t of visibleTools)
314
+ lines.push(` ${toolIcon(t.status)} ${toolBrief(t)}`);
315
+ if (expanded && run.errorMessage) {
316
+ lines.push(` ✗ ${oneLine(run.errorMessage, 120)}`);
186
317
  }
187
- return { values, nextIndex: index - 1 };
188
318
  }
189
-
190
- function parseAgentConfig(content: string): AgentConfig {
191
- const config: AgentConfig = { fallbackModels: [] };
192
- const { frontmatter } = splitMarkdownFrontmatter(content);
193
- const lines = frontmatter.split(/\r?\n/);
194
-
195
- for (let index = 0; index < lines.length; index += 1) {
196
- const match = (lines[index] ?? "").match(
197
- /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/,
198
- );
199
- if (!match) continue;
200
-
201
- const key = match[1] ?? "";
202
- const value = match[2] ?? "";
203
- if (key === "model") {
204
- config.model = parseFrontmatterScalar(value) ?? undefined;
205
- } else if (key === "thinking") {
206
- config.thinking = normalizeThinking(parseFrontmatterScalar(value));
207
- } else if (key === "fallbackModels" || key === "fallback_models") {
208
- if (value.trim()) {
209
- config.fallbackModels = parseInlineList(value);
210
- } else {
211
- const result = readIndentedList(lines, index);
212
- config.fallbackModels = result.values;
213
- index = result.nextIndex;
214
- }
215
- }
319
+ function renderProgressCard(
320
+ d: ProgressDetails,
321
+ expanded: boolean,
322
+ w: number,
323
+ ): string[] {
324
+ const r = activeRun(d);
325
+ if (!r) return [];
326
+ const spinner = ["◐", "◓", "", "◒"][Math.floor(Date.now() / 250) % 4]!;
327
+ const icon = d.final
328
+ ? d.runs.some((x) => x.status === "failed")
329
+ ? "✗"
330
+ : "✓"
331
+ : spinner;
332
+ const totalElapsed = fmtDur(
333
+ (d.final ? d.updatedAt : Date.now()) - d.startedAt,
334
+ );
335
+ const lines: string[] = [
336
+ `${icon} subagent ${d.mode} · total ${totalElapsed}`,
337
+ ];
338
+
339
+ if (!expanded) {
340
+ renderRunBlock(lines, d, r, false);
341
+ lines.push(" Alt+O expand latest subagent card");
342
+ return lines.map((l) => trunc(l, w));
216
343
  }
217
344
 
218
- return config;
345
+ for (const run of d.runs) renderRunBlock(lines, d, run, true);
346
+ lines.push(" Alt+O collapse latest subagent card");
347
+ const max = 48;
348
+ const shown =
349
+ lines.length > max
350
+ ? [
351
+ ...lines.slice(0, max - 1),
352
+ ` … ${lines.length - max + 1} lines hidden`,
353
+ ]
354
+ : lines;
355
+ return shown.map((l) => trunc(l, w));
219
356
  }
220
-
221
- function modelHasThinkingSuffix(model: string): boolean {
222
- return THINKING_SUFFIX_RE.test(model.trim());
223
- }
224
-
225
- function buildPiModelArgs(config: PiRunConfig): string[] {
226
- const model = stringValue(config.model);
227
- const thinking = normalizeThinking(config.thinking);
228
- if (model) {
229
- return [
230
- "--model",
231
- thinking && !modelHasThinkingSuffix(model)
232
- ? `${model}:${thinking}`
233
- : model,
234
- ];
235
- }
236
- return thinking ? ["--thinking", thinking] : [];
357
+ function progressKey(d: ProgressDetails) {
358
+ return d.runs
359
+ .map((r) => {
360
+ const t = r.tools.at(-1);
361
+ return [
362
+ r.id,
363
+ r.status,
364
+ r.tools.length,
365
+ t?.id ?? "",
366
+ t?.status ?? "",
367
+ r.usage.turns,
368
+ r.usage.input,
369
+ r.usage.output,
370
+ r.usage.cacheRead,
371
+ r.usage.cacheWrite,
372
+ r.usage.ctxTokens,
373
+ r.model ?? "",
374
+ r.thinking ?? "",
375
+ r.errorMessage ?? "",
376
+ ].join("~");
377
+ })
378
+ .join("|");
237
379
  }
238
380
 
239
- function resolveSubagentRunConfig(
240
- input: SubagentInput,
241
- agentConfig: AgentConfig,
242
- ): PiRunConfig {
243
- return {
244
- model: stringValue(input.model) ?? agentConfig.model,
245
- thinking: normalizeThinking(input.thinking) ?? agentConfig.thinking,
246
- };
381
+ // ── Utilities ─────────────────────────────────────────────────────────
382
+ function isObj(v: unknown): v is JsonObject {
383
+ return typeof v === "object" && v !== null && !Array.isArray(v);
247
384
  }
248
-
249
- function sanitizeKey(raw: string): string {
250
- return raw
251
- .trim()
252
- .replace(/[^A-Za-z0-9._-]+/g, "_")
253
- .replace(/^[._-]+|[._-]+$/g, "")
254
- .slice(0, 160);
385
+ function str(v: unknown): string | null {
386
+ return typeof v === "string" && v.trim() ? v.trim() : null;
255
387
  }
256
-
257
- function hashValue(raw: string): string {
258
- return createHash("sha256").update(raw).digest("hex").slice(0, 24);
388
+ function num(v: unknown): number {
389
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
259
390
  }
260
-
261
- interface PiInvocation {
262
- command: string;
263
- argsPrefix: string[];
391
+ function hash(s: string) {
392
+ return createHash("sha256").update(s).digest("hex").slice(0, 24);
264
393
  }
265
-
266
- const PI_CLI_JS_SEGMENTS = [
267
- "node_modules",
268
- "@mariozechner",
269
- "pi-coding-agent",
270
- "dist",
271
- "cli.js",
272
- ];
273
- const MAX_SUBAGENT_STDOUT_BYTES = 8 * 1024 * 1024;
274
- const MAX_SUBAGENT_STDERR_BYTES = 1024 * 1024;
275
-
276
- // Nested agents can emit unbounded output; keep the tail so diagnostics survive without growing memory indefinitely.
277
- class BoundedBufferCollector {
278
- private chunks: Buffer[] = [];
279
- private length = 0;
280
- private truncatedBytes = 0;
281
-
282
- constructor(private readonly maxBytes: number) {}
283
-
284
- append(chunk: Buffer): void {
285
- const data = chunk;
286
- if (data.length >= this.maxBytes) {
287
- this.truncatedBytes += this.length + data.length - this.maxBytes;
288
- this.chunks = [data.subarray(data.length - this.maxBytes)];
289
- this.length = this.maxBytes;
290
- return;
291
- }
292
-
293
- this.chunks.push(data);
294
- this.length += data.length;
295
-
296
- while (this.length > this.maxBytes) {
297
- const first = this.chunks[0];
298
- if (!first) break;
299
- const overflow = this.length - this.maxBytes;
300
- if (first.length <= overflow) {
301
- this.chunks.shift();
302
- this.length -= first.length;
303
- this.truncatedBytes += first.length;
304
- } else {
305
- this.chunks[0] = first.subarray(overflow);
306
- this.length -= overflow;
307
- this.truncatedBytes += overflow;
308
- break;
309
- }
310
- }
311
- }
312
-
313
- toString(): string {
314
- const body = Buffer.concat(this.chunks, this.length).toString("utf-8");
315
- return this.truncatedBytes
316
- ? `[${this.truncatedBytes} bytes truncated]\n${body}`
317
- : body;
394
+ function readText(p: string) {
395
+ try {
396
+ return readFileSync(p, "utf-8");
397
+ } catch {
398
+ return "";
318
399
  }
319
400
  }
320
-
321
- function isExistingFile(path: string): boolean {
401
+ function exists(p: string) {
322
402
  try {
323
- return statSync(path).isFile();
403
+ return statSync(p).isFile();
324
404
  } catch {
325
405
  return false;
326
406
  }
327
407
  }
328
-
329
- function uniqueStrings(values: string[]): string[] {
330
- const seen = new Set<string>();
331
- const unique: string[] = [];
332
- for (const value of values) {
333
- if (!value || seen.has(value)) continue;
334
- seen.add(value);
335
- unique.push(value);
336
- }
337
- return unique;
338
- }
339
-
340
- function candidatePiCliJsPaths(): string[] {
341
- const candidates: string[] = [];
342
-
343
- for (const arg of process.argv) {
344
- if (/pi-coding-agent[\\/]dist[\\/]cli\.js$/i.test(arg)) {
345
- candidates.push(resolve(arg));
346
- }
347
- }
348
-
349
- const npmPrefix =
350
- stringValue(process.env.npm_config_prefix) ??
351
- stringValue(process.env.NPM_CONFIG_PREFIX);
352
- if (npmPrefix) {
353
- candidates.push(join(npmPrefix, ...PI_CLI_JS_SEGMENTS));
354
- candidates.push(join(npmPrefix, "lib", ...PI_CLI_JS_SEGMENTS));
355
- }
356
-
357
- const appData = stringValue(process.env.APPDATA);
358
- if (appData) {
359
- candidates.push(join(appData, "npm", ...PI_CLI_JS_SEGMENTS));
360
- }
361
-
362
- const pathValue = process.env.PATH ?? process.env.Path ?? "";
363
- for (const pathEntry of pathValue.split(delimiter)) {
364
- const entry = pathEntry.trim();
365
- if (!entry) continue;
366
- candidates.push(join(entry, ...PI_CLI_JS_SEGMENTS));
367
- candidates.push(join(dirname(entry), ...PI_CLI_JS_SEGMENTS));
368
- candidates.push(join(dirname(entry), "lib", ...PI_CLI_JS_SEGMENTS));
369
- }
370
-
371
- return uniqueStrings(candidates);
372
- }
373
-
374
- function resolvePiInvocation(): PiInvocation {
375
- const envCli = stringValue(process.env.TRELLIS_PI_CLI_JS);
376
- if (envCli) {
377
- const cliJs = resolve(envCli);
378
- if (!isExistingFile(cliJs)) {
379
- throw new Error(`TRELLIS_PI_CLI_JS points to a missing file: ${cliJs}`);
380
- }
381
- return { command: process.execPath, argsPrefix: [cliJs] };
382
- }
383
-
384
- for (const cliJs of candidatePiCliJsPaths()) {
385
- if (isExistingFile(cliJs)) {
386
- return { command: process.execPath, argsPrefix: [cliJs] };
387
- }
388
- }
389
-
390
- return { command: "pi", argsPrefix: [] };
391
- }
392
-
393
- function createProcessContextKey(projectRoot: string): string {
394
- return `pi_process_${hashValue(
395
- [projectRoot, process.pid, Date.now(), randomBytes(8).toString("hex")].join(
396
- ":",
397
- ),
398
- )}`;
408
+ function shellQuote(v: string) {
409
+ return `'${v.replace(/'/g, `'\\''`)}'`;
399
410
  }
400
-
401
- function callString(
402
- callback: (() => string | undefined) | undefined,
403
- ): string | null {
404
- if (!callback) return null;
411
+ function callStr(cb: (() => string | undefined) | undefined): string | null {
412
+ if (!cb) return null;
405
413
  try {
406
- return stringValue(callback());
414
+ return str(cb());
407
415
  } catch {
408
416
  return null;
409
417
  }
410
418
  }
411
-
412
- function lookupString(data: unknown, keys: string[]): string | null {
413
- if (!isJsonObject(data)) return null;
414
- for (const key of keys) {
415
- const value = stringValue(data[key]);
416
- if (value) return value;
419
+ function lookupStr(data: unknown, keys: string[]): string | null {
420
+ if (!isObj(data)) return null;
421
+ for (const k of keys) {
422
+ const v = str(data[k]);
423
+ if (v) return v;
417
424
  }
418
- for (const nestedKey of [
425
+ for (const nk of [
419
426
  "input",
420
427
  "properties",
421
428
  "event",
422
429
  "hook_input",
423
430
  "hookInput",
424
431
  ]) {
425
- const nested = data[nestedKey];
426
- const value = lookupString(nested, keys);
427
- if (value) return value;
432
+ const nested = data[nk];
433
+ const v = lookupStr(nested, keys);
434
+ if (v) return v;
428
435
  }
429
436
  return null;
430
437
  }
431
-
432
- function extractTextContent(content: unknown): string {
438
+ function cmdHasTrellisCtx(cmd: string) {
439
+ const t = cmd.trimStart();
440
+ return (
441
+ /^export\s+TRELLIS_CONTEXT_ID=/.test(t) ||
442
+ /^TRELLIS_CONTEXT_ID=/.test(t) ||
443
+ /^env\s+.*TRELLIS_CONTEXT_ID=/.test(t)
444
+ );
445
+ }
446
+ function fmtDur(ms: number) {
447
+ if (ms < 1000) return `${ms}ms`;
448
+ const s = Math.floor(ms / 1000);
449
+ if (s < 60) return `${s}s`;
450
+ return `${Math.floor(s / 60)}m${s % 60}s`;
451
+ }
452
+ function fmtNum(n: number) {
453
+ if (!n) return "0";
454
+ if (Math.abs(n) < 1000) return `${n}`;
455
+ if (Math.abs(n) < 1000000) return `${(n / 1000).toFixed(1)}k`;
456
+ return `${(n / 1000000).toFixed(1)}m`;
457
+ }
458
+ function fmtUsage(u: Usage, m?: string) {
459
+ const p: string[] = [];
460
+ if (u.turns) p.push(`${u.turns}t`);
461
+ if (u.input) p.push(`↑${fmtNum(u.input)}`);
462
+ if (u.output) p.push(`↓${fmtNum(u.output)}`);
463
+ if (u.cost) p.push(`$${u.cost.toFixed(3)}`);
464
+ if (u.ctxTokens) p.push(`ctx:${fmtNum(u.ctxTokens)}`);
465
+ if (m) p.push(m);
466
+ return p.join(" ");
467
+ }
468
+ function statusIcon(s: RunStatus) {
469
+ return s === "pending"
470
+ ? "○"
471
+ : s === "running"
472
+ ? "●"
473
+ : s === "succeeded"
474
+ ? "✓"
475
+ : s === "failed"
476
+ ? "✗"
477
+ : "⊘";
478
+ }
479
+ function toolIcon(s: ToolStatus) {
480
+ return s === "running" ? "•" : s === "succeeded" ? "✓" : "✗";
481
+ }
482
+ function latest(text: string, n: number) {
483
+ return text
484
+ .split(/\r?\n/)
485
+ .map((l) => l.trimEnd())
486
+ .filter((l) => l.trim())
487
+ .slice(-n);
488
+ }
489
+ function appendTail(cur: string, next: string, max: number) {
490
+ if (!next) return cur;
491
+ const c = cur + next;
492
+ return c.length <= max ? c : c.slice(-max);
493
+ }
494
+ function extractText(content: unknown): string {
433
495
  if (typeof content === "string") return content;
434
496
  if (!Array.isArray(content)) return "";
435
-
436
497
  return content
437
- .map((block) => {
438
- if (!isJsonObject(block)) return "";
439
- return block.type === "text" && typeof block.text === "string"
440
- ? block.text
441
- : "";
442
- })
498
+ .map((b) =>
499
+ isObj(b) && b.type === "text" && typeof b.text === "string" ? b.text : "",
500
+ )
443
501
  .join("");
444
502
  }
445
-
446
- function extractFinalAssistantText(output: string): string | null {
447
- let finalText = "";
448
-
449
- for (const line of output.split(/\r?\n/)) {
450
- const trimmed = line.trim();
451
- if (!trimmed) continue;
452
-
453
- try {
454
- const event = JSON.parse(trimmed) as JsonObject;
455
- const message = isJsonObject(event.message) ? event.message : null;
456
- if (message?.role !== "assistant") continue;
457
-
458
- const text = extractTextContent(message.content);
459
- if (text) finalText = text;
460
- } catch {
461
- // Pi can print non-JSON diagnostics around structured output; keep scanning.
462
- }
463
- }
464
-
465
- return finalText || null;
503
+ function extractThinking(content: unknown): string {
504
+ if (!Array.isArray(content)) return "";
505
+ return content
506
+ .map((b) =>
507
+ isObj(b) && b.type === "thinking" && typeof b.thinking === "string"
508
+ ? b.thinking
509
+ : "",
510
+ )
511
+ .join("\n");
466
512
  }
467
-
468
- function formatPiOutput(stdout: string, stderr: string): string {
469
- return extractFinalAssistantText(stdout) ?? (stdout || stderr);
513
+ function newUsage(): Usage {
514
+ return {
515
+ input: 0,
516
+ output: 0,
517
+ cacheRead: 0,
518
+ cacheWrite: 0,
519
+ cost: 0,
520
+ ctxTokens: 0,
521
+ turns: 0,
522
+ };
470
523
  }
471
-
472
- function normalizeTaskRef(raw: string): string | null {
473
- let normalized = raw.trim().replace(/\\/g, "/");
474
- if (!normalized) return null;
475
- while (normalized.startsWith("./")) normalized = normalized.slice(2);
476
- if (normalized.startsWith("tasks/")) normalized = `.trellis/${normalized}`;
477
- return normalized;
524
+ function newRun(
525
+ id: string,
526
+ agent: string,
527
+ prompt: string,
528
+ step?: number,
529
+ ): RunState {
530
+ return {
531
+ id,
532
+ agent,
533
+ prompt: trunc(prompt.replace(/\s+/g, " ").trim(), 120) || "(empty)",
534
+ step,
535
+ status: "pending",
536
+ finalText: "",
537
+ textTail: "",
538
+ thinkingTail: "",
539
+ stderrTail: "",
540
+ tools: [],
541
+ usage: newUsage(),
542
+ };
478
543
  }
479
-
480
- function taskRefToDir(projectRoot: string, taskRef: string): string {
481
- if (taskRef.startsWith("/")) return taskRef;
482
- if (taskRef.startsWith(".trellis/")) return join(projectRoot, taskRef);
483
- return join(projectRoot, ".trellis", "tasks", taskRef);
544
+ function cloneProgress(d: ProgressDetails): ProgressDetails {
545
+ return {
546
+ ...d,
547
+ runs: d.runs.map((r) => ({
548
+ ...r,
549
+ tools: r.tools.map((t) => ({ ...t })),
550
+ usage: { ...r.usage },
551
+ })),
552
+ };
484
553
  }
485
554
 
486
- function sessionFileHasCurrentTask(path: string): boolean {
487
- try {
488
- const context = JSON.parse(readText(path)) as JsonObject;
489
- return !!normalizeTaskRef(stringValue(context.current_task) ?? "");
490
- } catch {
491
- return false;
492
- }
555
+ function oneLine(v: unknown, max = 80) {
556
+ return String(v || "...")
557
+ .replace(/\s+/g, " ")
558
+ .trim()
559
+ .slice(0, max);
493
560
  }
494
-
495
- function activeRuntimeContextKeys(projectRoot: string): string[] {
496
- const sessionsDir = join(projectRoot, ".trellis", ".runtime", "sessions");
497
- try {
498
- return readdirSync(sessionsDir, { withFileTypes: true })
499
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
500
- .map((entry) => entry.name.slice(0, -".json".length))
501
- .filter((key) =>
502
- sessionFileHasCurrentTask(join(sessionsDir, `${key}.json`)),
503
- );
504
- } catch {
505
- return [];
506
- }
561
+ function summarizeToolArgs(name: string, args: unknown): string {
562
+ const a = isObj(args) ? args : {};
563
+ const summary: JsonObject = {};
564
+ if ("path" in a) summary.path = oneLine(a.path, 240);
565
+ if ("file_path" in a) summary.file_path = oneLine(a.file_path, 240);
566
+ if ("command" in a) summary.command = oneLine(a.command, 240);
567
+ if ("pattern" in a) summary.pattern = oneLine(a.pattern, 120);
568
+ if ("limit" in a) summary.limit = a.limit;
569
+ if ("offset" in a) summary.offset = a.offset;
570
+ if (name === "edit" && Array.isArray(a.edits))
571
+ summary.edits = `${a.edits.length} edit(s)`;
572
+ if (name === "write" && "content" in a)
573
+ summary.content = `<${String(a.content ?? "").length} chars>`;
574
+ const json = JSON.stringify(
575
+ Object.keys(summary).length ? summary : { tool: name },
576
+ );
577
+ return json.length <= MAX_TOOL_ARG_CHARS
578
+ ? json
579
+ : json.slice(0, MAX_TOOL_ARG_CHARS);
507
580
  }
508
-
509
- function adoptExistingContextKey(
510
- projectRoot: string,
511
- contextKey: string,
512
- ): string {
513
- const sessionsDir = join(projectRoot, ".trellis", ".runtime", "sessions");
514
- if (sessionFileHasCurrentTask(join(sessionsDir, `${contextKey}.json`))) {
515
- return contextKey;
516
- }
517
-
518
- const keys = activeRuntimeContextKeys(projectRoot);
519
- const processKeys = keys.filter((key) => key.startsWith("pi_process_"));
520
- const candidates = processKeys.length ? processKeys : keys;
521
- return candidates.length === 1 ? candidates[0] : contextKey;
581
+ function toolBrief(t: ToolTrace): string {
582
+ const a = toolArgs(t);
583
+ if (t.name === "read") return `read: ${oneLine(a.path || a.file_path, 80)}`;
584
+ if (t.name === "bash") return `bash: ${oneLine(a.command, 60)}`;
585
+ if (t.name === "write") return `write: ${oneLine(a.path || a.file_path, 80)}`;
586
+ if (t.name === "edit") return `edit: ${oneLine(a.path || a.file_path, 80)}`;
587
+ if (t.name === "grep") return `grep: ${oneLine(a.pattern, 50)}`;
588
+ if (t.name === "find") return `find: ${oneLine(a.pattern || "*", 50)}`;
589
+ return oneLine(t.name, 50);
522
590
  }
523
591
 
524
- function resolveContextKey(
525
- input: unknown,
526
- ctx?: PiExtensionContext,
527
- fallback?: string | null,
528
- ): string | null {
529
- const override = stringValue(process.env.TRELLIS_CONTEXT_ID);
530
- if (override) return sanitizeKey(override) || hashValue(override);
592
+ // ── Pi CLI path resolution ────────────────────────────────────────────
593
+ const PI_CLI_SEGMENTS = [
594
+ ["node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js"],
595
+ ["node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js"],
596
+ ];
531
597
 
532
- const sessionId =
533
- callString(ctx?.sessionManager?.getSessionId) ??
534
- stringValue(process.env.PI_SESSION_ID) ??
535
- stringValue(process.env.PI_SESSIONID) ??
536
- lookupString(input, ["session_id", "sessionId", "sessionID"]);
537
- if (sessionId) return `pi_${sanitizeKey(sessionId) || hashValue(sessionId)}`;
598
+ function resolvePiCli(): { command: string; args: string[] } {
599
+ const envCli = str(process.env.TRELLIS_PI_CLI_JS);
600
+ if (envCli) {
601
+ const p = resolve(envCli);
602
+ if (!exists(p)) throw new Error(`TRELLIS_PI_CLI_JS missing: ${p}`);
603
+ return { command: process.execPath, args: [p] };
604
+ }
605
+ const candidates: string[] = [];
606
+ for (const arg of process.argv)
607
+ if (/pi-coding-agent[\\/]dist[\\/]cli\.js$/i.test(arg))
608
+ candidates.push(resolve(arg));
609
+ const prefix =
610
+ str(process.env.npm_config_prefix) ?? str(process.env.NPM_CONFIG_PREFIX);
611
+ const appData = str(process.env.APPDATA);
612
+ const pathVal = process.env.PATH ?? process.env.Path ?? "";
613
+ const addBase = (base: string) => {
614
+ for (const seg of PI_CLI_SEGMENTS) candidates.push(join(base, ...seg));
615
+ };
616
+ if (prefix) {
617
+ addBase(prefix);
618
+ addBase(join(prefix, "lib"));
619
+ }
620
+ if (appData) addBase(join(appData, "npm"));
621
+ for (const entry of pathVal.split(delimiter)) {
622
+ const e = entry.trim();
623
+ if (!e) continue;
624
+ addBase(e);
625
+ addBase(dirname(e));
626
+ addBase(join(dirname(e), "lib"));
627
+ }
628
+ for (const c of [...new Set(candidates)])
629
+ if (exists(c)) return { command: process.execPath, args: [c] };
630
+ return { command: "pi", args: [] };
631
+ }
538
632
 
539
- const transcriptPath =
540
- callString(ctx?.sessionManager?.getSessionFile) ??
541
- lookupString(input, ["transcript_path", "transcriptPath", "transcript"]);
542
- if (transcriptPath) return `pi_transcript_${hashValue(transcriptPath)}`;
633
+ function resolveRunCfg(
634
+ input: SubagentInput,
635
+ agentCfg: AgentConfig,
636
+ inheritedThinking?: string,
637
+ ): PiRunConfig {
638
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
639
+ const normalize = (v: unknown): string | undefined => {
640
+ const s = typeof v === "string" && v.trim() ? v.trim().toLowerCase() : "";
641
+ return THINKING_LEVELS.includes(s) ? s : undefined;
642
+ };
643
+ const suffixRe = /:(off|minimal|low|medium|high|xhigh)$/i;
644
+ const inputModel = str(input.model);
645
+ const agentModel = agentCfg.model;
646
+ const rawModel = inputModel ?? agentModel;
647
+ const inputSuffixThinking = normalize(inputModel?.match(suffixRe)?.[1]);
648
+ const agentSuffixThinking = normalize(agentModel?.match(suffixRe)?.[1]);
649
+ const baseModel = rawModel?.replace(suffixRe, "");
650
+ const thinking =
651
+ normalize(input.thinking) ??
652
+ inputSuffixThinking ??
653
+ normalize(agentCfg.thinking) ??
654
+ agentSuffixThinking ??
655
+ normalize(inheritedThinking);
656
+ if (baseModel && thinking && thinking !== "off")
657
+ return { model: `${baseModel}:${thinking}`, thinking };
658
+ return { model: baseModel || rawModel, thinking };
659
+ }
543
660
 
544
- return fallback ?? null;
661
+ function buildPiArgs(cfg: PiRunConfig): string[] {
662
+ const args = ["--mode", "json", "-p", "--no-session"];
663
+ if (cfg.model)
664
+ args.push(
665
+ "--model",
666
+ cfg.thinking && cfg.thinking !== "off" && !cfg.model.includes(":")
667
+ ? `${cfg.model}:${cfg.thinking}`
668
+ : cfg.model,
669
+ );
670
+ else if (cfg.thinking && cfg.thinking !== "off")
671
+ args.push("--thinking", cfg.thinking);
672
+ return args;
545
673
  }
546
674
 
547
- function readCurrentTask(
548
- projectRoot: string,
549
- platformInput?: unknown,
550
- ctx?: PiExtensionContext,
551
- contextKeyOverride?: string | null,
552
- ): string | null {
553
- const contextKey =
554
- contextKeyOverride ?? resolveContextKey(platformInput, ctx);
555
- if (contextKey) {
556
- try {
557
- const rawContext = readText(
558
- join(
559
- projectRoot,
560
- ".trellis",
561
- ".runtime",
562
- "sessions",
563
- `${contextKey}.json`,
564
- ),
565
- );
566
- const context = JSON.parse(rawContext) as JsonObject;
567
- const taskRef = normalizeTaskRef(stringValue(context.current_task) ?? "");
568
- if (taskRef) return taskRefToDir(projectRoot, taskRef);
569
- } catch {
570
- // Missing or malformed session context means no active task.
675
+ // ── BoundedBufferCollector ─────────────────────────────────────────────
676
+ class BBC {
677
+ private c: Buffer[] = [];
678
+ private len = 0;
679
+ private trunc = 0;
680
+ constructor(private max: number) {}
681
+ append(b: Buffer) {
682
+ if (b.length >= this.max) {
683
+ this.trunc += this.len + b.length - this.max;
684
+ this.c = [b.subarray(b.length - this.max)];
685
+ this.len = this.max;
686
+ return;
687
+ }
688
+ this.c.push(b);
689
+ this.len += b.length;
690
+ while (this.len > this.max) {
691
+ const f = this.c[0]!;
692
+ if (f.length <= this.len - this.max) {
693
+ this.c.shift();
694
+ this.len -= f.length;
695
+ this.trunc += f.length;
696
+ } else {
697
+ const ov = this.len - this.max;
698
+ this.c[0] = f.subarray(ov);
699
+ this.len -= ov;
700
+ this.trunc += ov;
701
+ break;
702
+ }
571
703
  }
572
704
  }
573
-
574
- return null;
705
+ toString() {
706
+ const body = Buffer.concat(this.c, this.len).toString("utf-8");
707
+ return this.trunc ? `[${this.trunc} bytes truncated]\n${body}` : body;
708
+ }
575
709
  }
576
710
 
577
- function readJsonlFiles(
578
- projectRoot: string,
579
- taskDir: string,
580
- jsonlName: string,
581
- ): string {
582
- const jsonlPath = join(taskDir, jsonlName);
583
- const lines = readText(jsonlPath).split(/\r?\n/);
584
- const chunks: string[] = [];
585
-
586
- for (const line of lines) {
587
- const trimmed = line.trim();
588
- if (!trimmed) continue;
589
- try {
590
- const row = JSON.parse(trimmed) as JsonObject;
591
- const file = typeof row.file === "string" ? row.file : "";
592
- if (!file) continue;
593
- const content = readText(join(projectRoot, file));
594
- if (content) {
595
- chunks.push(`## ${file}\n\n${content}`);
711
+ // ── Trellis Context ────────────────────────────────────────────────────
712
+ function findRoot(start: string): string {
713
+ let c = resolve(start);
714
+ while (true) {
715
+ if (existsSync(join(c, ".trellis")) || existsSync(join(c, ".pi"))) return c;
716
+ const p = dirname(c);
717
+ if (p === c) return resolve(start);
718
+ c = p;
719
+ }
720
+ }
721
+ function splitFM(c: string) {
722
+ const m = c.replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
723
+ return m
724
+ ? { fm: m[1] ?? "", body: c.slice(m[0].length) }
725
+ : { fm: "", body: c };
726
+ }
727
+ function stripFM(c: string) {
728
+ return splitFM(c).body.trimStart();
729
+ }
730
+ function parseAgentFM(c: string): AgentConfig {
731
+ const cfg: AgentConfig = { fallbackModels: [] };
732
+ const { fm } = splitFM(c);
733
+ const lines = fm.split(/\r?\n/);
734
+ for (let i = 0; i < lines.length; i++) {
735
+ const m = (lines[i] ?? "").match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
736
+ if (!m) continue;
737
+ const k = m[1] ?? "",
738
+ v = m[2] ?? "";
739
+ if (k === "model")
740
+ cfg.model = v.trim().replace(/^["']|["']$/g, "") || undefined;
741
+ else if (k === "thinking")
742
+ cfg.thinking = (v.trim().replace(/^["']|["']$/g, "") || undefined) as
743
+ | string
744
+ | undefined;
745
+ else if (k === "fallbackModels" || k === "fallback_models") {
746
+ if (v.trim()) {
747
+ cfg.fallbackModels = v
748
+ .trim()
749
+ .replace(/^\[|\]$/g, "")
750
+ .split(",")
751
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
752
+ .filter(Boolean);
753
+ } else {
754
+ i++;
755
+ while (i < lines.length && /^\s+-\s/.test(lines[i] ?? "")) {
756
+ const item = (lines[i] ?? "")
757
+ .trim()
758
+ .replace(/^-\s+/, "")
759
+ .replace(/^["']|["']$/g, "");
760
+ if (item) cfg.fallbackModels.push(item);
761
+ i++;
762
+ }
763
+ i--;
596
764
  }
597
- } catch {
598
- // Seed rows and malformed lines must not block sub-agent startup.
599
765
  }
600
766
  }
601
-
602
- return chunks.join("\n\n---\n\n");
767
+ return cfg;
603
768
  }
604
769
 
605
- function buildTrellisContext(
606
- projectRoot: string,
607
- agent: string,
608
- platformInput?: unknown,
609
- ctx?: PiExtensionContext,
610
- contextKey?: string | null,
611
- ): string {
612
- const taskDir = readCurrentTask(projectRoot, platformInput, ctx, contextKey);
613
- if (!taskDir) {
614
- return "No active Trellis task found. Read .trellis/ before proceeding.";
615
- }
616
-
617
- const prd = readText(join(taskDir, "prd.md"));
618
- const design = readText(join(taskDir, "design.md"));
619
- const implementPlan = readText(join(taskDir, "implement.md"));
620
- const jsonlName = TRELLIS_AGENT_JSONL[agent] ?? "";
621
- const specContext = jsonlName
622
- ? readJsonlFiles(projectRoot, taskDir, jsonlName)
623
- : "";
624
-
625
- return [
626
- "## Trellis Task Context",
627
- `Task directory: ${taskDir}`,
628
- "",
629
- "### prd.md",
630
- prd || "(missing)",
631
- design ? "\n### design.md\n" + design : "",
632
- implementPlan ? "\n### implement.md\n" + implementPlan : "",
633
- specContext ? "\n### Curated Spec / Research Context\n" + specContext : "",
634
- ].join("\n");
770
+ function contextKey(input?: unknown, ctx?: PiExtensionContext): string | null {
771
+ const ov = str(process.env.TRELLIS_CONTEXT_ID);
772
+ if (ov) return ov.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 160) || hash(ov);
773
+ const sessionId =
774
+ callStr(ctx?.sessionManager?.getSessionId) ??
775
+ str(process.env.PI_SESSION_ID) ??
776
+ str(process.env.PI_SESSIONID) ??
777
+ lookupStr(input, ["session_id", "sessionId", "sessionID"]);
778
+ if (sessionId)
779
+ return `pi_${sessionId.replace(/[^A-Za-z0-9._-]+/g, "_") || hash(sessionId)}`;
780
+ const transcriptPath =
781
+ callStr(ctx?.sessionManager?.getSessionFile) ??
782
+ lookupStr(input, ["transcript_path", "transcriptPath", "transcript"]);
783
+ if (transcriptPath) return `pi_transcript_${hash(transcriptPath)}`;
784
+ return null;
635
785
  }
636
786
 
637
- // ---------------------------------------------------------------------------
638
- // Workflow-state breadcrumb (TypeScript port of the shared workflow-state
639
- // hook used by class-1 platforms).
640
- //
641
- // Pi is extension-backed and MUST NOT receive Python hook scripts under .pi/.
642
- // We therefore parse `.trellis/workflow.md` `[workflow-state:STATUS]...
643
- // [/workflow-state:STATUS]` blocks directly in TypeScript and emit the
644
- // per-turn `<workflow-state>` breadcrumb in `before_agent_start` and `input`.
645
- // Tag regex mirrors the shared parser so the breadcrumb body stays
646
- // byte-identical with hook-driven platforms.
647
- // ---------------------------------------------------------------------------
648
-
649
- const WORKFLOW_STATE_TAG_RE =
650
- /\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g;
651
-
652
- function loadWorkflowBreadcrumbs(projectRoot: string): Record<string, string> {
653
- const workflow = readText(join(projectRoot, ".trellis", "workflow.md"));
654
- if (!workflow) return {};
655
- const result: Record<string, string> = {};
656
- for (const match of workflow.matchAll(WORKFLOW_STATE_TAG_RE)) {
657
- const status = match[1] ?? "";
658
- const body = (match[2] ?? "").trim();
659
- if (status && body) result[status] = body;
787
+ function readTaskDir(root: string, key: string | null): string | null {
788
+ if (!key) return null;
789
+ try {
790
+ const ctx = JSON.parse(
791
+ readText(join(root, ".trellis", ".runtime", "sessions", `${key}.json`)),
792
+ ) as JsonObject;
793
+ let ref = str(ctx.current_task);
794
+ if (!ref) return null;
795
+ ref = ref;
796
+ ref = ref.replace(/\\/g, "/").replace(/^\.\//, "");
797
+ if (ref.startsWith("tasks/")) ref = `.trellis/${ref}`;
798
+ return ref.startsWith(".trellis/")
799
+ ? join(root, ref)
800
+ : isAbsolute(ref)
801
+ ? ref
802
+ : join(root, ".trellis", "tasks", ref);
803
+ } catch {
804
+ return null;
660
805
  }
661
- return result;
662
806
  }
663
-
664
- function readActiveTaskStatus(
665
- projectRoot: string,
666
- taskDir: string,
667
- ): { taskId: string; status: string } | null {
807
+ function sessionHasTask(root: string, key: string): boolean {
668
808
  try {
669
- const data = JSON.parse(
670
- readText(join(taskDir, "task.json")),
809
+ const ctx = JSON.parse(
810
+ readText(join(root, ".trellis", ".runtime", "sessions", `${key}.json`)),
671
811
  ) as JsonObject;
672
- const status = stringValue(data.status);
673
- if (!status) return null;
674
- const id = stringValue(data.id) ?? taskDir.split(/[\\/]/).pop() ?? "";
675
- return { taskId: id, status };
812
+ return !!str(ctx.current_task);
676
813
  } catch {
677
- return null;
814
+ return false;
678
815
  }
679
816
  }
680
-
681
- function buildWorkflowStateBreadcrumb(
682
- projectRoot: string,
683
- contextKey: string | null,
684
- ): string {
685
- const templates = loadWorkflowBreadcrumbs(projectRoot);
686
- const taskDir = readCurrentTask(
687
- projectRoot,
688
- undefined,
689
- undefined,
690
- contextKey,
691
- );
692
- let header: string;
693
- let lookupKey: string;
694
- if (!taskDir) {
695
- header = "Status: no_task";
696
- lookupKey = "no_task";
697
- } else {
698
- const info = readActiveTaskStatus(projectRoot, taskDir);
699
- if (!info) {
700
- header = "Status: no_task";
701
- lookupKey = "no_task";
702
- } else {
703
- header = `Task: ${info.taskId} (${info.status})`;
704
- lookupKey = info.status;
705
- }
817
+ function adoptKey(root: string, key: string): string {
818
+ if (sessionHasTask(root, key)) return key;
819
+ try {
820
+ const dir = join(root, ".trellis", ".runtime", "sessions");
821
+ const keys = readdirSync(dir)
822
+ .filter(
823
+ (f) => f.endsWith(".json") && sessionHasTask(root, f.slice(0, -5)),
824
+ )
825
+ .map((f) => f.slice(0, -5));
826
+ const proc = keys.filter((k) => k.startsWith("pi_process_"));
827
+ const cands = proc.length ? proc : keys;
828
+ return cands.length === 1 ? cands[0]! : key;
829
+ } catch {
830
+ return key;
706
831
  }
707
- const body = templates[lookupKey] ?? "Refer to workflow.md for current step.";
708
- return `<workflow-state>\n${header}\n${body}\n</workflow-state>`;
709
832
  }
710
833
 
711
- // ---------------------------------------------------------------------------
712
- // Session overview (developer / git branch / active tasks)
713
- //
714
- // Spawns `python3 .trellis/scripts/get_context.py` (the same script other
715
- // platform session-start hooks invoke) to keep developer/git/active-task
716
- // summary byte-identical with class-1 platforms. Failure is non-fatal — we
717
- // emit an empty overview rather than block the conversation.
718
- // ---------------------------------------------------------------------------
719
-
720
- const SESSION_OVERVIEW_TIMEOUT_MS = 5000;
721
-
722
- function pythonExecutable(): string {
723
- const override = stringValue(process.env.TRELLIS_PYTHON);
724
- if (override) return override;
725
- return process.platform === "win32" ? "python" : "python3";
834
+ // ── Workflow State Breadcrumb ─────────────────────────────────────────
835
+ const WF_RE =
836
+ /\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g;
837
+ function workflowBreadcrumb(root: string, key: string | null): string {
838
+ const wf = readText(join(root, ".trellis", "workflow.md"));
839
+ if (!wf) return "";
840
+ const templates: Record<string, string> = {};
841
+ for (const m of wf.matchAll(WF_RE)) {
842
+ const s = m[1] ?? "",
843
+ b = (m[2] ?? "").trim();
844
+ if (s && b) templates[s] = b;
845
+ }
846
+ const dir = readTaskDir(root, key);
847
+ let header = "Status: no_task",
848
+ lookup = "no_task";
849
+ if (dir) {
850
+ try {
851
+ const d = JSON.parse(readText(join(dir, "task.json"))) as JsonObject;
852
+ const status = str(d.status) ?? "";
853
+ const id = str(d.id) ?? dir.split(/[\\/]/).pop() ?? "";
854
+ if (status) {
855
+ header = `Task: ${id} (${status})`;
856
+ lookup = status;
857
+ }
858
+ } catch {}
859
+ }
860
+ const body = templates[lookup] ?? "Refer to workflow.md for current step.";
861
+ return `<workflow-state>\n${header}\n${body}\n</workflow-state>`;
726
862
  }
727
863
 
728
- function buildSessionOverview(
729
- projectRoot: string,
730
- contextKey: string | null,
731
- ): string {
732
- const script = join(projectRoot, ".trellis", "scripts", "get_context.py");
733
- if (!isExistingFile(script)) return "";
864
+ // ── Session Overview ───────────────────────────────────────────────────
865
+ function sessionOverview(root: string, key: string | null): string {
866
+ const script = join(root, ".trellis", "scripts", "get_context.py");
867
+ if (!exists(script)) return "";
734
868
  try {
735
- const result = spawnSync(pythonExecutable(), [script], {
736
- cwd: projectRoot,
737
- env: contextKey
738
- ? { ...process.env, TRELLIS_CONTEXT_ID: contextKey }
739
- : process.env,
869
+ const py = process.platform === "win32" ? "python" : "python3";
870
+ const result = spawnSync(py, [script], {
871
+ cwd: root,
872
+ env: key ? { ...process.env, TRELLIS_CONTEXT_ID: key } : process.env,
740
873
  encoding: "utf-8",
741
874
  timeout: SESSION_OVERVIEW_TIMEOUT_MS,
742
875
  windowsHide: true,
743
876
  });
744
877
  if (result.status !== 0) return "";
745
878
  const stdout = (result.stdout ?? "").trim();
746
- if (!stdout) return "";
747
- return `<session-overview>\n${stdout}\n</session-overview>`;
879
+ return stdout ? `<session-overview>\n${stdout}\n</session-overview>` : "";
748
880
  } catch {
749
881
  return "";
750
882
  }
751
883
  }
752
884
 
753
- // Per-turn cache so input + before_agent_start in the same turn don't double-spawn.
754
- class TurnContextCache {
755
- private key: string | null = null;
756
- private timestamp = 0;
757
- private workflowState = "";
758
- private sessionOverview = "";
759
- // Refresh window: per-turn injections that fire close together share a
760
- // single python3 spawn; anything older than this re-runs the resolver.
761
- private static readonly TTL_MS = 1500;
762
-
763
- get(
764
- projectRoot: string,
765
- contextKey: string | null,
766
- ): { workflowState: string; sessionOverview: string } {
767
- const now = Date.now();
768
- if (this.key === contextKey && now - this.timestamp < TurnContextCache.TTL_MS) {
769
- return {
770
- workflowState: this.workflowState,
771
- sessionOverview: this.sessionOverview,
772
- };
885
+ function buildContext(root: string, agent: string, key: string | null): string {
886
+ const dir = readTaskDir(root, key);
887
+ if (!dir)
888
+ return "No active Trellis task found. Read .trellis/ before proceeding.";
889
+ const prd = readText(join(dir, "prd.md"));
890
+ const design = readText(join(dir, "design.md"));
891
+ const impl = readText(join(dir, "implement.md"));
892
+ const jsonlName = TRELLIS_AGENT_JSONL[agent] ?? "";
893
+ let spec = "";
894
+ if (jsonlName) {
895
+ const chunks: string[] = [];
896
+ for (const line of readText(join(dir, jsonlName)).split(/\r?\n/)) {
897
+ const t = line.trim();
898
+ if (!t) continue;
899
+ try {
900
+ const r = JSON.parse(t) as JsonObject;
901
+ const f = typeof r.file === "string" ? r.file : "";
902
+ if (f) {
903
+ const c = readText(join(root, f));
904
+ if (c) chunks.push(`## ${f}\n\n${c}`);
905
+ }
906
+ } catch {}
773
907
  }
774
- this.workflowState = buildWorkflowStateBreadcrumb(projectRoot, contextKey);
775
- this.sessionOverview = buildSessionOverview(projectRoot, contextKey);
776
- this.key = contextKey;
777
- this.timestamp = now;
778
- return {
779
- workflowState: this.workflowState,
780
- sessionOverview: this.sessionOverview,
781
- };
908
+ spec = chunks.join("\n\n---\n\n");
782
909
  }
910
+ return [
911
+ `## Trellis Task Context`,
912
+ `Task directory: ${dir}`,
913
+ "",
914
+ "### prd.md",
915
+ prd || "(missing)",
916
+ design ? "\n### design.md\n" + design : "",
917
+ impl ? "\n### implement.md\n" + impl : "",
918
+ spec ? "\n### Curated Spec / Research Context\n" + spec : "",
919
+ ].join("\n");
783
920
  }
784
921
 
785
- // ---------------------------------------------------------------------------
786
- // Sub-agent dispatch protocol snippet (registered with the `subagent` tool).
787
- // Mirrors the [workflow-state:in_progress] dispatch protocol text in
788
- // trellis/workflow.md so the AI sees the same `Active task: <path>` rule
789
- // whether it reads workflow.md, the per-turn breadcrumb, or the tool prompt.
790
- // ---------------------------------------------------------------------------
791
-
792
- const SUBAGENT_DISPATCH_PROTOCOL = `Sub-agent dispatch protocol (Trellis): your dispatch prompt MUST start with one line "Active task: <task path from \`task.py current\`>" before any other instructions. No exceptions. On class-2 platforms (codex / copilot / gemini / qoder) the sub-agent depends on this line because there is no hook to inject task context. On class-1 platforms (claude / cursor / opencode / kiro / codebuddy / droid) and on Pi, the line is the canonical fallback when hook/extension injection misses. trellis-research uses the line to know which {task_dir}/research/ to write into.
793
-
794
- Wrong: prompt: "implement the new feature"
795
- Correct: prompt: "Active task: .trellis/tasks/05-09-pi-workflow-state-injection\\n\\nImplement the new feature ..."`;
796
-
797
- function normalizeAgentName(agent: string): string {
798
- return agent.startsWith("trellis-") ? agent : `trellis-${agent}`;
922
+ function normalizeAgent(agent: string | undefined): string {
923
+ const name = agent ?? "trellis-implement";
924
+ return name.startsWith("trellis-") ? name : `trellis-${name}`;
799
925
  }
800
926
 
801
- function readAgentDefinition(
802
- projectRoot: string,
803
- agent: string,
804
- ): AgentDefinition {
805
- const normalized = agent.startsWith("trellis-") ? agent : `trellis-${agent}`;
806
- const raw = readText(join(projectRoot, ".pi", "agents", `${normalized}.md`));
807
- return {
808
- content: stripMarkdownFrontmatter(raw),
809
- config: parseAgentConfig(raw),
810
- };
927
+ function isTrellisAgent(root: string, agent: string): boolean {
928
+ return existsSync(join(root, ".pi", "agents", `${agent}.md`));
811
929
  }
812
930
 
813
- function commandStartsWithTrellisContext(command: string): boolean {
814
- const trimmed = command.trimStart();
815
- return (
816
- /^export\s+TRELLIS_CONTEXT_ID=/.test(trimmed) ||
817
- /^TRELLIS_CONTEXT_ID=/.test(trimmed) ||
818
- /^env\s+.*\bTRELLIS_CONTEXT_ID=/.test(trimmed)
819
- );
931
+ function buildPrompt(
932
+ root: string,
933
+ input: SubagentInput,
934
+ key: string | null,
935
+ ): string {
936
+ const agent = normalizeAgent(input.agent);
937
+ const raw = readText(join(root, ".pi", "agents", `${agent}.md`));
938
+ const def = stripFM(raw);
939
+ const ctx = buildContext(root, agent, key);
940
+ return [
941
+ "## Trellis Agent Definition",
942
+ def || "(missing)",
943
+ "",
944
+ ctx,
945
+ "",
946
+ "## Delegated Task",
947
+ input.prompt ?? "",
948
+ ].join("\n");
820
949
  }
821
950
 
822
- function shellQuote(value: string): string {
823
- return `'${value.replace(/'/g, `'\\''`)}'`;
951
+ // ── Event parsing ─────────────────────────────────────────────────────
952
+ function parseJsonEvent(line: string): JsonObject | null {
953
+ const t = line.trim();
954
+ if (!t) return null;
955
+ const i = t.indexOf("{");
956
+ if (i < 0) return null;
957
+ try {
958
+ const p = JSON.parse(t.slice(i));
959
+ return isObj(p) ? p : null;
960
+ } catch {
961
+ return null;
962
+ }
824
963
  }
825
964
 
826
- function injectTrellisContextIntoBash(
827
- event: unknown,
828
- contextKey: string,
829
- ): boolean {
830
- const toolCall = event as PiToolCallEvent;
831
- if (toolCall.toolName !== "bash" || !isJsonObject(toolCall.input)) {
832
- return false;
965
+ function applyEvent(r: RunState, evt: JsonObject): boolean {
966
+ const type = typeof evt.type === "string" ? evt.type : "";
967
+ if (!type) return false;
968
+ if (type === "agent_start" || type === "turn_start") {
969
+ r.status = "running";
970
+ r.startedAt ??= Date.now();
971
+ return true;
833
972
  }
834
-
835
- const rawCommand = toolCall.input.command;
836
- if (typeof rawCommand !== "string" || !rawCommand.trim()) {
973
+ if (type === "message_update") {
974
+ const ae = isObj(evt.assistantMessageEvent)
975
+ ? evt.assistantMessageEvent
976
+ : null;
977
+ if (!ae || typeof ae.delta !== "string") return false;
978
+ if (ae.type === "thinking_delta") {
979
+ r.thinkingTail = appendTail(r.thinkingTail, ae.delta, MAX_TAIL);
980
+ return true;
981
+ }
982
+ if (ae.type === "text_delta") {
983
+ r.textTail = appendTail(r.textTail, ae.delta, MAX_TAIL);
984
+ return true;
985
+ }
837
986
  return false;
838
987
  }
839
- if (commandStartsWithTrellisContext(rawCommand)) {
840
- return false;
988
+ if (type === "message_end" && isObj(evt.message)) {
989
+ const msg = evt.message;
990
+ if (msg.role !== "assistant") return false;
991
+ r.usage.turns += 1;
992
+ const u = isObj(msg.usage) ? msg.usage : null;
993
+ const cost = isObj(u?.cost) ? u.cost : null;
994
+ r.usage.input += num(u?.input);
995
+ r.usage.output += num(u?.output);
996
+ r.usage.cacheRead += num(u?.cacheRead);
997
+ r.usage.cacheWrite += num(u?.cacheWrite);
998
+ r.usage.cost += num(cost?.total);
999
+ r.usage.ctxTokens = num(u?.totalTokens);
1000
+ const thinking = extractThinking(msg.content);
1001
+ if (thinking) r.thinkingTail = appendTail("", thinking, MAX_TAIL);
1002
+ const text = extractText(msg.content);
1003
+ if (text) {
1004
+ r.finalText = text;
1005
+ r.textTail = appendTail("", text, MAX_TAIL);
1006
+ }
1007
+ if (typeof msg.model === "string") {
1008
+ const parsed = splitModelThinking(msg.model, r.thinking);
1009
+ r.model = parsed.model;
1010
+ r.thinking = parsed.thinking;
1011
+ }
1012
+ if (typeof msg.errorMessage === "string") r.errorMessage = msg.errorMessage;
1013
+ return true;
1014
+ }
1015
+ if (type === "tool_execution_start") {
1016
+ const id =
1017
+ typeof evt.toolCallId === "string"
1018
+ ? evt.toolCallId
1019
+ : hash(`${Date.now()}`);
1020
+ const name = typeof evt.toolName === "string" ? evt.toolName : "tool";
1021
+ const args = summarizeToolArgs(name, evt.args);
1022
+ const existing = r.tools.findIndex((t) => t.id === id);
1023
+ if (existing >= 0)
1024
+ r.tools[existing] = { ...r.tools[existing]!, args, status: "running" };
1025
+ else
1026
+ r.tools.push({
1027
+ id,
1028
+ name,
1029
+ args,
1030
+ status: "running",
1031
+ startedAt: Date.now(),
1032
+ });
1033
+ if (r.tools.length > MAX_TOOLS)
1034
+ r.tools.splice(0, r.tools.length - MAX_TOOLS);
1035
+ return true;
1036
+ }
1037
+ if (type === "tool_execution_end") {
1038
+ const id = typeof evt.toolCallId === "string" ? evt.toolCallId : "";
1039
+ const idx = r.tools.findIndex((t) => t.id === id);
1040
+ if (idx >= 0)
1041
+ r.tools[idx] = {
1042
+ ...r.tools[idx]!,
1043
+ status: evt.isError ? "failed" : "succeeded",
1044
+ finishedAt: Date.now(),
1045
+ };
1046
+ return true;
1047
+ }
1048
+ if (type === "agent_end") {
1049
+ r.finishedAt = Date.now();
1050
+ if (r.status === "running" || r.status === "pending")
1051
+ r.status = "succeeded";
1052
+ return true;
841
1053
  }
1054
+ return false;
1055
+ }
842
1056
 
843
- toolCall.input.command = `export TRELLIS_CONTEXT_ID=${shellQuote(contextKey)}; ${rawCommand}`;
844
- return true;
1057
+ function finalize(r: RunState, fallback: string): string {
1058
+ return r.finalText || fallback.trim() || r.stderrTail.trim();
1059
+ }
1060
+ function formatPiOutput(stdout: string, stderr: string): string {
1061
+ let ft = "";
1062
+ for (const line of stdout.split(/\r?\n/)) {
1063
+ const t = line.trim();
1064
+ if (!t) continue;
1065
+ try {
1066
+ const evt = JSON.parse(t) as JsonObject;
1067
+ const msg = isObj(evt.message) ? evt.message : null;
1068
+ if (msg?.role === "assistant") {
1069
+ const txt = extractText(msg.content);
1070
+ if (txt) ft = txt;
1071
+ }
1072
+ } catch {}
1073
+ }
1074
+ return ft || stdout || stderr;
845
1075
  }
846
1076
 
1077
+ // ── runPi: subprocess execution + event processing ───────────────────
847
1078
  function runPi(
848
- projectRoot: string,
1079
+ root: string,
849
1080
  prompt: string,
850
- runConfig: PiRunConfig,
851
- contextKey?: string | null,
1081
+ cfg: PiRunConfig,
1082
+ state: RunState,
1083
+ emit: () => void,
1084
+ key?: string | null,
852
1085
  signal?: AbortSignal,
853
- ): Promise<string> {
854
- return new Promise((resolvePromise, reject) => {
1086
+ ): Promise<{ output: string; failed: boolean }> {
1087
+ return new Promise((resolve) => {
855
1088
  if (signal?.aborted) {
856
- reject(new Error("pi subagent cancelled"));
1089
+ state.status = "cancelled";
1090
+ state.errorMessage = "cancelled";
1091
+ state.finishedAt = Date.now();
1092
+ emit();
1093
+ resolve({ output: "cancelled", failed: true });
857
1094
  return;
858
1095
  }
859
-
860
- const invocation = resolvePiInvocation();
861
- const modelArgs = buildPiModelArgs(runConfig);
862
- const child = spawn(
863
- invocation.command,
864
- [
865
- ...invocation.argsPrefix,
866
- "--mode",
867
- "text",
868
- ...modelArgs,
869
- "-p",
870
- "--no-session",
871
- ],
872
- {
873
- cwd: projectRoot,
874
- env: contextKey
875
- ? { ...process.env, TRELLIS_CONTEXT_ID: contextKey }
876
- : process.env,
877
- stdio: ["pipe", "pipe", "pipe"],
878
- windowsHide: true,
879
- },
880
- );
881
-
882
- const stdout = new BoundedBufferCollector(MAX_SUBAGENT_STDOUT_BYTES);
883
- const stderr = new BoundedBufferCollector(MAX_SUBAGENT_STDERR_BYTES);
1096
+ const inv = resolvePiCli();
1097
+ const childEnv = {
1098
+ ...process.env,
1099
+ TRELLIS_SUBAGENT_CHILD: "1",
1100
+ ...(key ? { TRELLIS_CONTEXT_ID: key } : {}),
1101
+ };
1102
+ const cli = spawn(inv.command, [...inv.args, ...buildPiArgs(cfg)], {
1103
+ cwd: root,
1104
+ env: childEnv,
1105
+ stdio: ["pipe", "pipe", "pipe"],
1106
+ windowsHide: true,
1107
+ });
1108
+ const stdout = new BBC(MAX_STDOUT);
1109
+ const stderr = new BBC(MAX_STDERR);
1110
+ let buf = "";
884
1111
  let settled = false;
885
1112
  let aborted = false;
886
-
887
- const abortChild = (): void => {
1113
+ let killTimer: ReturnType<typeof setTimeout> | null = null;
1114
+ const abort = () => {
888
1115
  aborted = true;
889
- child.kill();
1116
+ cli.kill();
1117
+ killTimer = setTimeout(() => {
1118
+ if (!settled && cli.exitCode === null) cli.kill("SIGKILL");
1119
+ }, ABORT_KILL_GRACE_MS);
1120
+ killTimer?.unref?.();
890
1121
  };
891
-
892
- const cleanup = (): void => {
893
- signal?.removeEventListener("abort", abortChild);
894
- };
895
-
896
- const fail = (error: Error): void => {
1122
+ const done = (v: { output: string; failed: boolean }) => {
897
1123
  if (settled) return;
898
1124
  settled = true;
899
- cleanup();
900
- reject(error);
1125
+ if (killTimer) clearTimeout(killTimer);
1126
+ signal?.removeEventListener("abort", abort);
1127
+ emit();
1128
+ resolve(v);
901
1129
  };
902
-
903
- const succeed = (value: string): void => {
904
- if (settled) return;
905
- settled = true;
906
- cleanup();
907
- resolvePromise(value);
1130
+ signal?.addEventListener("abort", abort, { once: true });
1131
+ state.status = "running";
1132
+ state.startedAt = Date.now();
1133
+ emit();
1134
+ const processLine = (line: string) => {
1135
+ const evt = parseJsonEvent(line);
1136
+ if (evt && applyEvent(state, evt)) emit();
908
1137
  };
909
-
910
- signal?.addEventListener("abort", abortChild, { once: true });
911
-
912
- child.stdout?.on("data", (chunk: Buffer) => stdout.append(chunk));
913
- child.stderr?.on("data", (chunk: Buffer) => stderr.append(chunk));
914
- child.stdin?.on("error", (error: Error & { code?: string }) => {
915
- if (!aborted && error.code !== "EPIPE") fail(error);
1138
+ cli.stdout?.on("data", (d: Buffer) => {
1139
+ stdout.append(d);
1140
+ buf += d.toString("utf-8");
1141
+ if (buf.length > MAX_LINE_BUFFER) buf = buf.slice(-MAX_LINE_BUFFER);
1142
+ const lines = buf.split(/\r?\n/);
1143
+ buf = lines.pop() ?? "";
1144
+ for (const l of lines) processLine(l);
1145
+ });
1146
+ cli.stderr?.on("data", (d: Buffer) => {
1147
+ stderr.append(d);
1148
+ state.stderrTail = appendTail(
1149
+ state.stderrTail,
1150
+ d.toString("utf-8"),
1151
+ MAX_TAIL,
1152
+ );
916
1153
  });
917
- child.on("error", fail);
918
- child.on("close", (code) => {
1154
+ cli.stdin?.on("error", (e: Error & { code?: string }) => {
1155
+ if (!aborted && e.code !== "EPIPE")
1156
+ done({ output: e.message, failed: true });
1157
+ });
1158
+ cli.on("error", (e) => {
1159
+ state.status = aborted ? "cancelled" : "failed";
1160
+ state.errorMessage = e instanceof Error ? e.message : String(e);
1161
+ state.finishedAt = Date.now();
1162
+ done({ output: finalize(state, state.errorMessage), failed: true });
1163
+ });
1164
+ cli.on("close", (code) => {
1165
+ if (buf.trim()) processLine(buf);
919
1166
  const out = stdout.toString();
920
1167
  const err = stderr.toString();
1168
+ state.stderrTail = appendTail("", err, MAX_TAIL);
1169
+ state.finishedAt = Date.now();
921
1170
  if (aborted) {
922
- fail(new Error("pi subagent cancelled"));
923
- } else if (code === 0) {
924
- succeed(formatPiOutput(out, err));
925
- } else {
926
- fail(
927
- new Error(err || out || `pi exited with code ${code ?? "unknown"}`),
928
- );
1171
+ state.status = "cancelled";
1172
+ state.errorMessage = "cancelled";
1173
+ done({ output: finalize(state, "cancelled"), failed: true });
1174
+ return;
1175
+ }
1176
+ if (code === 0) {
1177
+ if (state.status === "pending" || state.status === "running")
1178
+ state.status = "succeeded";
1179
+ done({
1180
+ output: finalize(state, formatPiOutput(out, err)),
1181
+ failed: false,
1182
+ });
1183
+ return;
929
1184
  }
1185
+ state.status = "failed";
1186
+ state.errorMessage = err || out || `exit ${code ?? "?"}`;
1187
+ done({ output: finalize(state, state.errorMessage), failed: true });
930
1188
  });
931
-
932
- child.stdin?.end(prompt);
1189
+ cli.stdin?.end(prompt);
933
1190
  });
934
1191
  }
935
1192
 
936
- function buildSubagentPrompt(
937
- projectRoot: string,
938
- input: SubagentInput,
939
- contextKey?: string | null,
940
- agentName?: string,
941
- agentDefinition?: AgentDefinition,
942
- ): string {
943
- const normalized =
944
- agentName ?? normalizeAgentName(input.agent ?? "trellis-implement");
945
- const definition =
946
- agentDefinition ?? readAgentDefinition(projectRoot, normalized);
947
- const context = buildTrellisContext(
948
- projectRoot,
949
- normalized,
950
- input,
951
- undefined,
952
- contextKey,
953
- );
954
- const prompt = input.prompt ?? "";
955
-
956
- return [
957
- "## Trellis Agent Definition",
958
- definition.content || "(missing agent definition)",
959
- "",
960
- context,
961
- "",
962
- "## Delegated Task",
963
- prompt,
964
- ].join("\n");
965
- }
966
-
1193
+ // ── runSubagent: orchestrate single/parallel/chain via native partial updates ──
967
1194
  async function runSubagent(
968
- projectRoot: string,
1195
+ root: string,
969
1196
  input: SubagentInput,
970
- contextKey?: string | null,
1197
+ key: string | null,
971
1198
  signal?: AbortSignal,
972
- ): Promise<string> {
973
- const agentName = normalizeAgentName(input.agent ?? "trellis-implement");
974
- const agentDefinition = readAgentDefinition(projectRoot, agentName);
975
- const runConfig = resolveSubagentRunConfig(input, agentDefinition.config);
1199
+ onUpdate?: (r: PiToolResult) => void,
1200
+ inheritedThinking?: string,
1201
+ ): Promise<{ output: string; details: ProgressDetails; failed: boolean }> {
1202
+ const agentName = normalizeAgent(input.agent);
1203
+ const agentRaw = readText(join(root, ".pi", "agents", `${agentName}.md`));
1204
+ const agentCfg = parseAgentFM(agentRaw);
1205
+ const runCfg = resolveRunCfg(input, agentCfg, inheritedThinking);
976
1206
  const mode = input.mode ?? "single";
977
- if (mode === "parallel") {
978
- const prompts = input.prompts ?? (input.prompt ? [input.prompt] : []);
979
- const outputs = await Promise.all(
980
- prompts.map((prompt) =>
981
- runPi(
982
- projectRoot,
983
- buildSubagentPrompt(
984
- projectRoot,
985
- { ...input, prompt },
986
- contextKey,
987
- agentName,
988
- agentDefinition,
989
- ),
990
- runConfig,
991
- contextKey,
992
- signal,
993
- ),
994
- ),
995
- );
996
- return outputs.join("\n\n---\n\n");
997
- }
1207
+ const startedAt = Date.now();
1208
+ const details: ProgressDetails = {
1209
+ kind: "trellis-subagent-progress",
1210
+ agent: agentName,
1211
+ mode,
1212
+ startedAt,
1213
+ updatedAt: startedAt,
1214
+ final: false,
1215
+ runs: [],
1216
+ };
1217
+ let lastEmit = 0;
1218
+ let lastPartialKey = "";
1219
+ let closed = false;
1220
+ const pushPartial = (force = false) => {
1221
+ if (closed || !onUpdate) return;
1222
+ const key = progressKey(details);
1223
+ if (!force && key === lastPartialKey) return;
1224
+ lastPartialKey = key;
1225
+ onUpdate({
1226
+ // Keep native partial content stable; renderResult owns the visible progress UI.
1227
+ content: [{ type: "text", text: "subagent running" }],
1228
+ details: cloneProgress(details),
1229
+ });
1230
+ };
1231
+ const emit = (force = false) => {
1232
+ const now = Date.now();
1233
+ if (!force && now - lastEmit < THROTTLE_MS) return;
1234
+ lastEmit = now;
1235
+ details.updatedAt = now;
1236
+ pushPartial(force);
1237
+ };
1238
+ const finish = (output: string, failed: boolean) => {
1239
+ closed = true;
1240
+ details.final = true;
1241
+ details.updatedAt = Date.now();
1242
+ return { output, details: cloneProgress(details), failed };
1243
+ };
998
1244
 
999
- if (mode === "chain") {
1000
- let previous = "";
1001
- const prompts = input.prompts ?? (input.prompt ? [input.prompt] : []);
1002
- for (const prompt of prompts) {
1003
- previous = await runPi(
1004
- projectRoot,
1005
- buildSubagentPrompt(
1006
- projectRoot,
1007
- {
1008
- ...input,
1009
- prompt: previous
1010
- ? `${prompt}\n\nPrevious output:\n${previous}`
1011
- : prompt,
1012
- },
1013
- contextKey,
1014
- agentName,
1015
- agentDefinition,
1245
+ try {
1246
+ if (mode === "parallel") {
1247
+ const prompts = input.prompts ?? (input.prompt ? [input.prompt] : []);
1248
+ details.runs = prompts.map((p, i) => {
1249
+ const r = newRun(`${agentName}-${i + 1}`, agentName, p);
1250
+ applyRunConfig(r, runCfg);
1251
+ return r;
1252
+ });
1253
+ emit(true);
1254
+ const results = await Promise.all(
1255
+ prompts.map((p, i) =>
1256
+ runPi(
1257
+ root,
1258
+ buildPrompt(root, { ...input, prompt: p }, key),
1259
+ runCfg,
1260
+ details.runs[i]!,
1261
+ emit,
1262
+ key,
1263
+ signal,
1264
+ ),
1016
1265
  ),
1017
- runConfig,
1018
- contextKey,
1019
- signal,
1266
+ );
1267
+ return finish(
1268
+ results.map((r) => r.output).join("\n\n---\n\n"),
1269
+ results.some((r) => r.failed),
1020
1270
  );
1021
1271
  }
1022
- return previous;
1272
+ if (mode === "chain") {
1273
+ let prev = "";
1274
+ let failed = false;
1275
+ for (let i = 0; i < (input.prompts?.length ?? 1); i++) {
1276
+ const p = input.prompts?.[i] ?? input.prompt ?? "";
1277
+ const rs = newRun(`${agentName}-${i + 1}`, agentName, p, i + 1);
1278
+ applyRunConfig(rs, runCfg);
1279
+ details.runs.push(rs);
1280
+ emit(true);
1281
+ const result = await runPi(
1282
+ root,
1283
+ buildPrompt(
1284
+ root,
1285
+ {
1286
+ ...input,
1287
+ prompt: prev ? `${p}\n\nPrevious output:\n${prev}` : p,
1288
+ },
1289
+ key,
1290
+ ),
1291
+ runCfg,
1292
+ rs,
1293
+ emit,
1294
+ key,
1295
+ signal,
1296
+ );
1297
+ prev = result.output;
1298
+ failed = failed || result.failed;
1299
+ if (result.failed) break;
1300
+ }
1301
+ return finish(prev, failed);
1302
+ }
1303
+ const rs = newRun(`${agentName}-1`, agentName, input.prompt ?? "");
1304
+ applyRunConfig(rs, runCfg);
1305
+ details.runs = [rs];
1306
+ emit(true);
1307
+ const result = await runPi(
1308
+ root,
1309
+ buildPrompt(root, input, key),
1310
+ runCfg,
1311
+ rs,
1312
+ emit,
1313
+ key,
1314
+ signal,
1315
+ );
1316
+ return finish(result.output, result.failed);
1317
+ } catch (e) {
1318
+ const message = e instanceof Error ? e.message : String(e);
1319
+ const r = activeRun(details);
1320
+ if (r) {
1321
+ r.status = "failed";
1322
+ r.errorMessage = message;
1323
+ r.finishedAt = Date.now();
1324
+ }
1325
+ return finish(message, true);
1023
1326
  }
1024
-
1025
- return runPi(
1026
- projectRoot,
1027
- buildSubagentPrompt(
1028
- projectRoot,
1029
- input,
1030
- contextKey,
1031
- agentName,
1032
- agentDefinition,
1033
- ),
1034
- runConfig,
1035
- contextKey,
1036
- signal,
1037
- );
1038
1327
  }
1039
1328
 
1329
+ // ── Extension ──────────────────────────────────────────────────────────
1040
1330
  export default function trellisExtension(pi: {
1041
1331
  registerTool?: (tool: JsonObject) => void;
1332
+ registerShortcut?: (
1333
+ key: string,
1334
+ opts: {
1335
+ description?: string;
1336
+ handler: (ctx: PiExtensionContext) => unknown;
1337
+ },
1338
+ ) => void;
1042
1339
  on?: (
1043
1340
  event: string,
1044
1341
  handler: (event: unknown, ctx?: PiExtensionContext) => unknown,
1045
1342
  ) => void;
1046
- cwd?: string;
1343
+ getThinkingLevel?: () => string;
1047
1344
  }): void {
1048
- const projectRoot = findProjectRoot(pi.cwd ?? process.cwd());
1049
- const processContextKey = createProcessContextKey(projectRoot);
1050
- let currentContextKey: string | null = null;
1051
- const turnContextCache = new TurnContextCache();
1345
+ if (process.env.TRELLIS_SUBAGENT_CHILD === "1") return;
1346
+ const root = findRoot(process.cwd());
1347
+ const procKey = `pi_process_${hash([root, process.pid, Date.now(), randomBytes(8).toString("hex")].join(":"))}`;
1348
+ let curKey: string | null = null;
1349
+
1350
+ const getKey = (input?: unknown, ctx?: PiExtensionContext) => {
1351
+ const k = adoptKey(root, contextKey(input, ctx) ?? curKey ?? procKey);
1352
+ curKey = k;
1353
+ return k;
1354
+ };
1052
1355
 
1053
- const buildPerTurnInjection = (contextKey: string | null): string => {
1054
- const { workflowState, sessionOverview } = turnContextCache.get(
1055
- projectRoot,
1056
- contextKey,
1057
- );
1058
- return [workflowState, sessionOverview].filter(Boolean).join("\n\n");
1356
+ // Per-turn cache to avoid double-spawning python
1357
+ let turnCache: {
1358
+ key: string | null;
1359
+ ts: number;
1360
+ wf: string;
1361
+ ov: string;
1362
+ } | null = null;
1363
+ const getTurnCtx = (k: string | null) => {
1364
+ const now = Date.now();
1365
+ if (turnCache && turnCache.key === k && now - turnCache.ts < 1500)
1366
+ return turnCache;
1367
+ turnCache = {
1368
+ key: k,
1369
+ ts: now,
1370
+ wf: workflowBreadcrumb(root, k),
1371
+ ov: sessionOverview(root, k),
1372
+ };
1373
+ return turnCache;
1059
1374
  };
1060
1375
 
1061
- const getContextKey = (input?: unknown, ctx?: PiExtensionContext): string => {
1062
- const resolvedContextKey = resolveContextKey(
1063
- input,
1064
- ctx,
1065
- currentContextKey ?? processContextKey,
1066
- );
1067
- currentContextKey = adoptExistingContextKey(
1068
- projectRoot,
1069
- resolvedContextKey ?? processContextKey,
1070
- );
1071
- return currentContextKey;
1376
+ // Toggle only the latest subagent native card; do not use Pi global tool expansion.
1377
+ const toggleDetail = (ctx: PiExtensionContext) => {
1378
+ const id = activeSubagentToolCallId;
1379
+ const card = id ? nativeCards.get(id) : undefined;
1380
+ if (!card) {
1381
+ ctx.ui?.notify?.("No subagent card to toggle yet.", "warning");
1382
+ return;
1383
+ }
1384
+ card.state.localExpanded = card.state.localExpanded !== true;
1385
+ card.invalidate();
1072
1386
  };
1073
1387
 
1388
+ pi.registerShortcut?.("alt+o", {
1389
+ description: "Toggle latest subagent card details",
1390
+ handler: async (ctx: PiExtensionContext) => toggleDetail(ctx),
1391
+ });
1392
+
1393
+ // Tool registration
1074
1394
  pi.registerTool?.({
1075
- name: "subagent",
1076
- label: "Subagent",
1395
+ name: "trellis_subagent",
1396
+ label: "Trellis Subagent",
1077
1397
  description: "Run a Trellis project sub-agent with active task context.",
1078
- promptSnippet: SUBAGENT_DISPATCH_PROTOCOL,
1079
- promptGuidelines: SUBAGENT_DISPATCH_PROTOCOL,
1398
+ promptSnippet:
1399
+ 'Sub-agent dispatch protocol (Trellis): your dispatch prompt MUST start with one line "Active task: <task path from `task.py current`>" before any other instructions.',
1400
+ promptGuidelines: [
1401
+ 'Use subagent for task delegation. Your dispatch prompt MUST start with "Active task: <task path from `task.py current`>".',
1402
+ ],
1080
1403
  parameters: {
1081
1404
  type: "object",
1082
1405
  properties: {
@@ -1089,15 +1412,11 @@ export default function trellisExtension(pi: {
1089
1412
  type: "string",
1090
1413
  description: "Task prompt for the sub-agent.",
1091
1414
  },
1092
- mode: {
1093
- type: "string",
1094
- enum: ["single", "parallel", "chain"],
1095
- description: "Delegation mode.",
1096
- },
1415
+ mode: { type: "string", enum: ["single", "parallel", "chain"] },
1097
1416
  prompts: {
1098
1417
  type: "array",
1099
1418
  items: { type: "string" },
1100
- description: "Prompts for parallel or chain mode.",
1419
+ maxItems: MAX_PARALLEL_PROMPTS,
1101
1420
  },
1102
1421
  model: {
1103
1422
  type: "string",
@@ -1106,69 +1425,176 @@ export default function trellisExtension(pi: {
1106
1425
  },
1107
1426
  thinking: {
1108
1427
  type: "string",
1109
- enum: ["off", "minimal", "low", "medium", "high", "xhigh"],
1110
1428
  description:
1111
1429
  "Optional Pi thinking level override for the child sub-agent process.",
1430
+ enum: ["off", "minimal", "low", "medium", "high", "xhigh"],
1112
1431
  },
1113
1432
  },
1114
- required: ["prompt"],
1115
1433
  },
1116
1434
  execute: async (
1117
- _toolCallId: string,
1435
+ id: string,
1118
1436
  input: SubagentInput,
1119
- _signal?: AbortSignal,
1120
- _onUpdate?: (partialResult: PiToolResult) => void,
1437
+ signal?: AbortSignal,
1438
+ onUpdate?: (r: PiToolResult) => void,
1121
1439
  ctx?: PiExtensionContext,
1122
- ): Promise<PiToolResult> => {
1123
- const contextKey = getContextKey(input, ctx);
1124
- const output = await runSubagent(projectRoot, input, contextKey, _signal);
1440
+ ) => {
1441
+ activeSubagentToolCallId = id;
1442
+ const agentName = normalizeAgent(input.agent);
1443
+ if (!isTrellisAgent(root, agentName)) {
1444
+ return {
1445
+ content: [
1446
+ {
1447
+ type: "text",
1448
+ text:
1449
+ "`trellis_subagent` is only for Trellis workflow agents with a definition file in .pi/agents/.\n\n" +
1450
+ `No definition found for: ${agentName}\n\n` +
1451
+ "For general-purpose sub-agents, use one of these community tools:\n" +
1452
+ "- `subagent` tool from npm:pi-subagents (nicobailon/pi-subagents)\n" +
1453
+ "- `Agent` tool from npm:@tintinweb/pi-subagents\n\n" +
1454
+ "If neither is installed, ask the user to either:\n" +
1455
+ `- Create .pi/agents/${agentName}.md for your custom Trellis agent\n` +
1456
+ "- Install a community subagent package: pi install -l npm:@tintinweb/pi-subagents",
1457
+ },
1458
+ ],
1459
+ details: { agent: agentName, error: "not a trellis workflow agent" },
1460
+ };
1461
+ }
1462
+ const mode = input.mode ?? "single";
1463
+ const prompt = input.prompt?.trim();
1464
+ const prompts = input.prompts?.map((p) => p.trim()).filter(Boolean);
1465
+ if (mode === "single" && !prompt)
1466
+ throw new Error("subagent prompt is required for single mode");
1467
+ if (
1468
+ (mode === "parallel" || mode === "chain") &&
1469
+ !prompt &&
1470
+ !prompts?.length
1471
+ )
1472
+ throw new Error(
1473
+ "subagent prompt or prompts are required for parallel/chain mode",
1474
+ );
1475
+ if (
1476
+ mode === "parallel" &&
1477
+ prompts &&
1478
+ prompts.length > MAX_PARALLEL_PROMPTS
1479
+ )
1480
+ throw new Error(
1481
+ `subagent parallel mode supports at most ${MAX_PARALLEL_PROMPTS} prompts`,
1482
+ );
1483
+ const cleanInput: SubagentInput = {
1484
+ ...input,
1485
+ prompt,
1486
+ prompts: prompts?.length ? prompts : undefined,
1487
+ };
1488
+ const key = getKey(cleanInput, ctx);
1489
+ const inheritedThinking = pi.getThinkingLevel?.();
1490
+ const result = await runSubagent(
1491
+ root,
1492
+ cleanInput,
1493
+ key,
1494
+ signal,
1495
+ onUpdate,
1496
+ inheritedThinking,
1497
+ );
1498
+ return {
1499
+ content: [{ type: "text", text: result.output }],
1500
+ details: result.details,
1501
+ };
1502
+ },
1503
+ // Hide the call renderer so the native card only shows result/progress once.
1504
+ renderCall: () => ({
1505
+ render() {
1506
+ return [];
1507
+ },
1508
+ invalidate() {},
1509
+ }),
1510
+ renderResult: (
1511
+ result: PiToolResult,
1512
+ _opts?: { expanded?: boolean; isPartial?: boolean },
1513
+ _theme?: unknown,
1514
+ context?: unknown,
1515
+ ) => {
1516
+ const ctxObj = isObj(context) ? context : null;
1517
+ const toolCallId = str(ctxObj?.toolCallId);
1518
+ const state = isObj(ctxObj?.state) ? (ctxObj.state as JsonObject) : null;
1519
+ const invalidate =
1520
+ typeof ctxObj?.invalidate === "function"
1521
+ ? (ctxObj.invalidate as () => void)
1522
+ : null;
1523
+ const isProgress =
1524
+ isObj(result.details) &&
1525
+ result.details.kind === "trellis-subagent-progress";
1526
+ if (toolCallId && state && invalidate) {
1527
+ const updatedAt = isProgress
1528
+ ? (result.details as ProgressDetails).updatedAt
1529
+ : Date.now();
1530
+ rememberNativeCard(toolCallId, { state, invalidate, updatedAt });
1531
+ }
1125
1532
  return {
1126
- content: [{ type: "text", text: output }],
1127
- details: {
1128
- agent: input.agent ?? "trellis-implement",
1129
- mode: input.mode ?? "single",
1533
+ render(w: number) {
1534
+ if (isProgress) {
1535
+ const expanded = state?.localExpanded === true;
1536
+ return renderProgressCard(
1537
+ result.details as ProgressDetails,
1538
+ expanded,
1539
+ w,
1540
+ );
1541
+ }
1542
+ return [trunc(result.content?.[0]?.text ?? "(no output)", w)];
1130
1543
  },
1544
+ invalidate() {},
1131
1545
  };
1132
1546
  },
1133
1547
  });
1134
1548
 
1549
+ // Events
1135
1550
  pi.on?.("session_start", (event, ctx) => {
1136
- getContextKey(event, ctx);
1551
+ getKey(event, ctx);
1137
1552
  ctx?.ui?.notify?.(
1138
1553
  "Trellis project context is available. Use /trellis-continue to resume the current task.",
1139
1554
  "info",
1140
1555
  );
1141
1556
  });
1557
+ pi.on?.("session_shutdown", () => {
1558
+ nativeCards.clear();
1559
+ activeSubagentToolCallId = null;
1560
+ });
1561
+ pi.on?.("tool_call", (event, ctx) => {
1562
+ const k = getKey(event, ctx);
1563
+ const ev = event as { toolName?: string; input?: JsonObject };
1564
+ if (
1565
+ ev.toolName === "bash" &&
1566
+ isObj(ev.input) &&
1567
+ typeof ev.input.command === "string" &&
1568
+ !cmdHasTrellisCtx(ev.input.command)
1569
+ )
1570
+ ev.input.command = `export TRELLIS_CONTEXT_ID=${shellQuote(k)}; ${ev.input.command}`;
1571
+ });
1572
+ // Preserve progress details from execute(); mark failed subagent results through
1573
+ // the official tool_result patch hook instead of throwing away renderer details.
1574
+ pi.on?.("tool_result", (event) => {
1575
+ const ev = event as { toolName?: string; details?: unknown };
1576
+ if (
1577
+ ev.toolName === "trellis_subagent" &&
1578
+ isObj(ev.details) &&
1579
+ ev.details.kind === "trellis-subagent-progress" &&
1580
+ Array.isArray(ev.details.runs) &&
1581
+ ev.details.runs.some(
1582
+ (r) => isObj(r) && (r.status === "failed" || r.status === "cancelled"),
1583
+ )
1584
+ )
1585
+ return { isError: true };
1586
+ return undefined;
1587
+ });
1142
1588
  pi.on?.("before_agent_start", (event, ctx) => {
1143
- const contextKey = getContextKey(event, ctx);
1144
- const current = (event as PiBeforeAgentStartEvent).systemPrompt ?? "";
1145
- const context = buildTrellisContext(
1146
- projectRoot,
1147
- "trellis-implement",
1148
- event,
1149
- ctx,
1150
- contextKey,
1151
- );
1152
- const perTurn = buildPerTurnInjection(contextKey);
1589
+ const k = getKey(event, ctx);
1590
+ const cur = (event as { systemPrompt?: string }).systemPrompt ?? "";
1591
+ const ctxText = buildContext(root, "trellis-implement", k);
1592
+ const { wf, ov } = getTurnCtx(k);
1153
1593
  return {
1154
- systemPrompt: [current, context, perTurn].filter(Boolean).join("\n\n"),
1594
+ systemPrompt: [cur, ctxText, wf, ov].filter(Boolean).join("\n\n"),
1155
1595
  };
1156
1596
  });
1157
1597
  pi.on?.("context", (event, ctx) => {
1158
- getContextKey(event, ctx);
1159
- const messages = (event as PiContextEvent).messages;
1160
- return Array.isArray(messages) ? { messages } : undefined;
1161
- });
1162
- pi.on?.("input", (event, ctx) => {
1163
- const contextKey = getContextKey(event, ctx);
1164
- const additionalContext = buildPerTurnInjection(contextKey);
1165
- return additionalContext
1166
- ? { action: "continue", additionalContext, systemPrompt: additionalContext }
1167
- : { action: "continue" };
1168
- });
1169
- pi.on?.("tool_call", (event, ctx) => {
1170
- const contextKey = getContextKey(event, ctx);
1171
- injectTrellisContextIntoBash(event, contextKey);
1172
- return undefined;
1598
+ getKey(event, ctx);
1173
1599
  });
1174
1600
  }