@heyhuynhgiabuu/pi-task 0.1.6 → 0.2.0

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 (43) hide show
  1. package/CHANGELOG.md +67 -3
  2. package/dist/constants.d.ts +4 -0
  3. package/dist/constants.js +4 -0
  4. package/dist/conversation.d.ts +8 -0
  5. package/dist/conversation.js +96 -1
  6. package/dist/helpers.d.ts +2 -2
  7. package/dist/helpers.js +4 -9
  8. package/dist/index.d.ts +6 -23
  9. package/dist/index.js +91 -589
  10. package/dist/lifecycle/completion.d.ts +3 -0
  11. package/dist/lifecycle/completion.js +50 -0
  12. package/dist/lifecycle/index.d.ts +5 -0
  13. package/dist/lifecycle/index.js +5 -0
  14. package/dist/lifecycle/polling.d.ts +16 -0
  15. package/dist/lifecycle/polling.js +61 -0
  16. package/dist/lifecycle/restore.d.ts +2 -0
  17. package/dist/lifecycle/restore.js +34 -0
  18. package/dist/lifecycle/toolStats.d.ts +2 -0
  19. package/dist/lifecycle/toolStats.js +17 -0
  20. package/dist/lifecycle/widget.d.ts +8 -0
  21. package/dist/lifecycle/widget.js +75 -0
  22. package/dist/session-text.d.ts +9 -0
  23. package/dist/session-text.js +50 -0
  24. package/dist/subagent/runSdk.js +50 -26
  25. package/dist/subagent/tmux.d.ts +12 -9
  26. package/dist/subagent/tmux.js +107 -44
  27. package/dist/subagent/waitCompletion.d.ts +4 -5
  28. package/dist/subagent/waitCompletion.js +27 -43
  29. package/dist/tool/index.d.ts +5 -0
  30. package/dist/tool/index.js +5 -0
  31. package/dist/tool/prompt.d.ts +8 -0
  32. package/dist/tool/prompt.js +17 -0
  33. package/dist/tool/renderCall.d.ts +3 -0
  34. package/dist/tool/renderCall.js +12 -0
  35. package/dist/tool/renderResult.d.ts +8 -0
  36. package/dist/tool/renderResult.js +51 -0
  37. package/dist/tool/schema.d.ts +8 -0
  38. package/dist/tool/schema.js +24 -0
  39. package/dist/tool/taskComplete.d.ts +8 -0
  40. package/dist/tool/taskComplete.js +65 -0
  41. package/dist/types.d.ts +54 -0
  42. package/dist/types.js +1 -0
  43. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,470 +1,74 @@
1
1
  /**
2
2
  * Task Tool — Delegate complex work to specialist agents.
3
3
  *
4
- * Spawns pi CLI in a tmux split pane (so you can watch it live) and
5
- * detects completion via RESULT.md polling. On completion, tool call
6
- * count and duration are reported as a notification.
4
+ * Spawns pi CLI in a tmux split pane (foreground) or background.
5
+ * Completion is detected from the subagent's final assistant message
6
+ * in the persistent session JSONL (stopReason gating). The final message
7
+ * is the authoritative result; no RESULT.md is used.
7
8
  *
8
9
  * Three agent sources:
9
10
  * - .pi/agents/*.md project-local agents
10
11
  * - ~/.pi/agent/agents/*.md user-global agents (fallback)
11
12
  *
12
13
  * P0: Persistent task registry (appendEntry + JSON), --session resume,
13
- * sendMessage completion notification.
14
- * P1: Foreground mode (background:false, inline subprocess), pane death
15
- * detection, 30-minute timeout.
14
+ * sendMessage completion notification, Ctrl+O expand/collapse.
15
+ * P1: Foreground mode (background:false), pane death detection, timeout.
16
16
  */
17
- import { execFileSync } from "node:child_process";
18
17
  import { randomUUID } from "node:crypto";
19
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { existsSync } from "node:fs";
20
19
  import { mkdir } from "node:fs/promises";
21
20
  import { dirname, join } from "node:path";
22
21
  import { fileURLToPath } from "node:url";
23
- import { Type } from "@sinclair/typebox";
24
- import { Text, truncateToWidth } from "@earendil-works/pi-tui";
25
22
  import { buildAgentToolSelection } from "./agent-tools.js";
26
- import { normalizeConversationId, parseMetadataFromBody, readTaskBlock, readTaskSessionsRegistry, renderConversationSessions, writeConversationArtifacts, writeTaskSessionsRegistry, } from "./conversation.js";
27
- import { TASK_BACKGROUND_DEFAULT, TASK_RESULT_XML_INSTRUCTIONS, TASK_TOOL_DESCRIPTION, buildTmuxSplitWindowArgs, chooseTmuxSplitDirection, formatBackgroundReceipt, buildPiArgs, parseResultXml, formatMs, shellQuote, discoverAgents, formatAgentList, countToolUses, readRecentToolCalls, } from "./helpers.js";
23
+ import { BACKGROUND_CHECK_MS, COUNT_POLL_MS, MAX_POLL_ERRORS, TASK_TIMEOUT_MS, } from "./constants.js";
24
+ import { findJsonlSessionByName, normalizeConversationId, parseMetadataFromBody, readTaskBlock, findTaskSessionHistory, readRegistry, readTaskSessionsRegistry, renderConversationSessions, upsertTaskSessionHistory, writeConversationArtifacts, writeRegistry, writeTaskSessionsRegistry, } from "./conversation.js";
25
+ import { TASK_BACKGROUND_DEFAULT, TASK_TOOL_DESCRIPTION, buildPiArgs, countToolUses, discoverAgents, formatAgentList, formatBackgroundReceipt, parseResultXml, shellQuote, } from "./helpers.js";
26
+ import { completeTask, createTaskWidgetController, restoreActiveBackgroundTasks, startBackgroundPolling, startToolStatsPolling, } from "./lifecycle/index.js";
28
27
  import { runSdkSubagent } from "./subagent/runSdk.js";
29
28
  import { checkTaskCompletion, waitForTaskCompletion as waitForSessionTaskCompletion, } from "./subagent/waitCompletion.js";
30
- import { renderTaskWidget, TASK_WIDGET_RENDER_MS, } from "./task-widget.js";
29
+ import { hasTmux, killAgentPane, paneExists, setPaneRemainOnExit, splitWindowPane, wrapWithPaneExitWatcher, } from "./subagent/tmux.js";
30
+ import { buildTaskPrompt, createTaskCompleteRenderer, renderCall, renderResult, taskParametersSchema, } from "./tool/index.js";
31
31
  // ─── Constants ───────────────────────────────────────────────────────────────
32
32
  const BUNDLED_AGENT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "agents");
33
- const BACKGROUND_CHECK_MS = 10_000; // poll every 10 sec
34
- const COUNT_POLL_MS = 3_000; // update toolcall counts every 3 sec
35
- const TASK_TIMEOUT_MS = 30 * 60 * 1_000; // 30 minutes
36
33
  // Conversation helpers live in ./conversation.js.
37
- function readRegistry(piDir) {
38
- const path = join(piDir, "task-registry.json");
39
- try {
40
- return JSON.parse(readFileSync(path, "utf-8"));
41
- }
42
- catch {
43
- return [];
44
- }
45
- }
46
- function writeRegistry(piDir, entries) {
47
- const path = join(piDir, "task-registry.json");
48
- writeFileSync(path, JSON.stringify(entries, null, 2), "utf-8");
49
- }
50
- function readTaskSessionHistory(piDir) {
51
- const path = join(piDir, "task-session-history.json");
52
- try {
53
- return JSON.parse(readFileSync(path, "utf-8"));
54
- }
55
- catch {
56
- return [];
57
- }
58
- }
59
- function writeTaskSessionHistory(piDir, entries) {
60
- const path = join(piDir, "task-session-history.json");
61
- writeFileSync(path, JSON.stringify(entries, null, 2), "utf-8");
62
- }
63
- function upsertTaskSessionHistory(piDir, entry) {
64
- const entries = readTaskSessionHistory(piDir);
65
- const index = entries.findIndex((existing) => existing.id === entry.id);
66
- if (index >= 0) {
67
- entries[index] = { ...entries[index], ...entry };
68
- }
69
- else {
70
- entries.push(entry);
71
- }
72
- writeTaskSessionHistory(piDir, entries);
73
- }
74
- function findTaskSessionHistory(piDir, idOrSessionName) {
75
- return readTaskSessionHistory(piDir).find((entry) => entry.id === idOrSessionName || entry.sessionName === idOrSessionName);
76
- }
77
- function findJsonlSessionByName(piDir, sessionName, agentType) {
78
- const artifactsDir = join(piDir, "artifacts");
79
- const sessionDir = join(artifactsDir, "sessions");
80
- try {
81
- if (!existsSync(sessionDir))
82
- return undefined;
83
- const files = readdirSync(sessionDir)
84
- .filter((file) => file.endsWith(".jsonl"))
85
- .sort()
86
- .reverse();
87
- for (const file of files) {
88
- const content = readFileSync(join(sessionDir, file), "utf-8");
89
- let startedAt = Date.now();
90
- for (const rawLine of content.split("\n")) {
91
- const line = rawLine.trim();
92
- if (!line)
93
- continue;
94
- try {
95
- const entry = JSON.parse(line);
96
- if (entry.type === "session" && entry.timestamp) {
97
- const parsed = Date.parse(entry.timestamp);
98
- if (Number.isFinite(parsed))
99
- startedAt = parsed;
100
- }
101
- if (entry.type === "session_info") {
102
- const name = entry.name ?? entry.session_info?.name;
103
- if (name === sessionName) {
104
- return {
105
- id: sessionName,
106
- agentType,
107
- description: `Resumed session ${sessionName}`,
108
- sessionName,
109
- sessionRef: join(sessionDir, file),
110
- startedAt,
111
- piDir,
112
- dir: artifactsDir,
113
- status: "done",
114
- background: false,
115
- };
116
- }
117
- break;
118
- }
119
- }
120
- catch {
121
- // Skip malformed lines
122
- }
123
- }
124
- }
125
- }
126
- catch {
127
- return undefined;
128
- }
129
- return undefined;
130
- }
131
- // ─── Tmux Helpers ────────────────────────────────────────────────────────────
132
- function tmuxCmd(args) {
133
- return execFileSync("tmux", args, {
134
- encoding: "utf-8",
135
- stdio: ["ignore", "pipe", "pipe"],
136
- }).trim();
137
- }
138
- function hasTmux() {
139
- try {
140
- execFileSync("tmux", ["-V"], { stdio: "ignore" });
141
- return true;
142
- }
143
- catch {
144
- return false;
145
- }
146
- }
147
- function paneExists(paneId) {
148
- try {
149
- return tmuxCmd(["list-panes", "-a", "-F", "#{pane_id}"])
150
- .split("\n")
151
- .includes(paneId);
152
- }
153
- catch {
154
- return false;
155
- }
156
- }
157
- function getCurrentPaneId() {
158
- try {
159
- return tmuxCmd(["display-message", "-p", "#{pane_id}"]);
160
- }
161
- catch {
162
- return null;
163
- }
164
- }
165
- function getCurrentPaneSize(targetPane) {
166
- try {
167
- const args = ["display-message", "-p", "#{pane_width} #{pane_height}"];
168
- if (targetPane)
169
- args.splice(1, 0, "-t", targetPane);
170
- const raw = tmuxCmd(args);
171
- const [widthRaw, heightRaw] = raw.trim().split(/\s+/, 2);
172
- const width = Number(widthRaw);
173
- const height = Number(heightRaw);
174
- if (!Number.isFinite(width) || !Number.isFinite(height))
175
- return null;
176
- return { width, height };
177
- }
178
- catch {
179
- return null;
180
- }
181
- }
182
- function splitWindowPane(cwd, command) {
183
- const originalPane = getCurrentPaneId();
184
- const paneSize = getCurrentPaneSize(originalPane);
185
- const direction = chooseTmuxSplitDirection(paneSize?.width ?? 0, paneSize?.height ?? 0);
186
- const paneId = tmuxCmd(buildTmuxSplitWindowArgs(cwd, command, direction, originalPane));
187
- return { paneId, originalPane };
188
- }
189
- function killAgentPane(paneId, originalPane) {
190
- if (paneId) {
191
- try {
192
- if (paneExists(paneId))
193
- tmuxCmd(["kill-pane", "-t", paneId]);
194
- }
195
- catch {
196
- /* ignore */
197
- }
198
- }
199
- if (originalPane) {
200
- try {
201
- tmuxCmd(["select-pane", "-t", originalPane]);
202
- }
203
- catch {
204
- /* ignore */
205
- }
206
- }
207
- }
208
- // ─── Process a completed task (sendMessage + registry cleanup) ──────────────
209
- function completeTask(pi, id, task, content, phase, piDir) {
210
- // Kill the tmux pane if still alive
211
- killAgentPane(task.paneId, task.originalPane);
212
- const parsed = parseResultXml(content);
213
- const durationMs = Date.now() - task.startedAt;
214
- const completedSessionRef = findJsonlSessionByName(piDir, task.sessionName, task.agentType)?.sessionRef;
215
- upsertTaskSessionHistory(piDir, {
216
- id,
217
- agentType: task.agentType,
218
- description: task.description,
219
- sessionName: task.sessionName,
220
- startedAt: task.startedAt,
221
- paneId: task.paneId,
222
- piDir,
223
- dir: task.dir,
224
- conversationId: task.conversationId,
225
- sessionRef: completedSessionRef,
226
- status: phase,
227
- completedAt: Date.now(),
228
- background: true,
229
- });
230
- // Send completion notification
231
- pi.sendMessage({
232
- customType: "task-complete",
233
- content: `Background task ${id} (${task.agentType}) ${phase}.\n\nResult:\n${content}`,
234
- display: true,
235
- details: {
236
- task_id: id,
237
- agent_type: task.agentType,
238
- description: task.description,
239
- phase,
240
- status: phase,
241
- result: content,
242
- summary: parsed.summary,
243
- findings: parsed.findings,
244
- confidence: parsed.confidence,
245
- duration_ms: durationMs,
246
- tool_uses: task.toolUses,
247
- turn_count: task.turns,
248
- },
249
- }, {
250
- triggerTurn: true,
251
- deliverAs: "followUp",
252
- });
253
- // Remove from registry
254
- const entries = readRegistry(piDir).filter((e) => e.id !== id);
255
- writeRegistry(piDir, entries);
256
- }
257
34
  // ─── Extension Entry Point ──────────────────────────────────────────────────
258
35
  export default function (pi) {
259
36
  // Prevent recursive loading
260
37
  if (process.env.PI_TASK_TOOL_DISABLED === "1")
261
38
  return;
262
39
  // ── Background task tracker ────────────────────────────────────────────
40
+ const { piDir } = discoverAgents(process.cwd(), BUNDLED_AGENT_DIR);
263
41
  const backgroundTasks = new Map();
264
42
  const foregroundTasks = new Map();
265
- let widgetCtx = null;
43
+ const taskWidget = createTaskWidgetController(foregroundTasks, backgroundTasks);
44
+ const { ensureTaskWidget, clearTaskWidgetIfIdle } = taskWidget;
266
45
  // ── Restore active tasks from registry on load ──────────────────────────
267
- const { piDir } = discoverAgents(process.cwd());
268
- const registry = readRegistry(piDir);
269
- const staleIds = [];
270
- for (const entry of registry) {
271
- // Only restore if artifact dir still exists
272
- if (!existsSync(entry.dir)) {
273
- staleIds.push(entry.id);
274
- continue;
275
- }
276
- // Check if tmux pane is still alive
277
- const paneAlive = entry.paneId ? paneExists(entry.paneId) : false;
278
- if (!paneAlive) {
279
- staleIds.push(entry.id);
280
- continue;
281
- }
282
- const bgtask = {
283
- dir: entry.dir,
284
- agentType: entry.agentType,
285
- sessionName: entry.sessionName,
286
- paneId: entry.paneId,
287
- originalPane: null,
288
- description: entry.description,
289
- startedAt: entry.startedAt,
290
- toolUses: 0,
291
- turns: 0,
292
- conversationId: entry.conversationId,
293
- recentCalls: [],
294
- };
295
- backgroundTasks.set(entry.id, bgtask);
296
- }
297
- if (staleIds.length) {
298
- writeRegistry(piDir, registry.filter((e) => !staleIds.includes(e.id)));
299
- }
46
+ restoreActiveBackgroundTasks(piDir, backgroundTasks);
300
47
  // ── Widget / timer setup ───────────────────────────────────────────────
301
- let widgetTimer = null;
302
- function stopWidget() {
303
- if (widgetTimer) {
304
- clearInterval(widgetTimer);
305
- widgetTimer = null;
306
- }
307
- }
308
- const countInterval = setInterval(() => {
309
- for (const task of [
310
- ...foregroundTasks.values(),
311
- ...backgroundTasks.values(),
312
- ]) {
313
- const sessionDir = join(task.dir, "sessions");
314
- // Single walk: counts + recent tool-call history with status
315
- const { toolUses, turns, recent } = readRecentToolCalls(sessionDir, 12, task.sessionName);
316
- task.toolUses = toolUses;
317
- task.turns = turns;
318
- task.recentCalls = recent;
319
- }
320
- }, COUNT_POLL_MS);
321
- // Theme reference is captured at setWidget time so renderWidget can use it.
322
- let widgetTheme = null;
323
- function renderWidget(width) {
324
- // Defensive: never let a render exception kill the TUI. If anything
325
- // throws (theme lookup miss, malformed session JSONL, etc.), fall
326
- // back to a minimal single-line summary so the TUI stays alive.
327
- try {
328
- return renderTaskWidget({
329
- foregroundTasks: foregroundTasks.entries(),
330
- backgroundTasks: backgroundTasks.entries(),
331
- foregroundCount: foregroundTasks.size,
332
- backgroundCount: backgroundTasks.size,
333
- width,
334
- theme: widgetTheme,
335
- });
336
- }
337
- catch (err) {
338
- const msg = err instanceof Error ? err.message : String(err);
339
- const active = [
340
- ...Array.from(foregroundTasks.entries()),
341
- ...Array.from(backgroundTasks.entries()),
342
- ];
343
- if (active.length === 0)
344
- return [];
345
- const [, task] = active[0];
346
- return [
347
- truncateToWidth(`${task.agentType} \u2022 ${formatMs(Date.now() - task.startedAt)} (render error: ${msg})`, Math.min(width, 120)),
348
- ];
349
- }
350
- }
351
- function ensureTaskWidget(targetCtx) {
352
- if (widgetCtx || targetCtx.mode !== "tui")
353
- return;
354
- widgetCtx = targetCtx;
355
- targetCtx.ui.setWidget("task", (tui, theme) => {
356
- widgetTheme = theme ?? null;
357
- widgetTimer = setInterval(() => tui.requestRender(), TASK_WIDGET_RENDER_MS);
358
- // Don't keep the process alive just for the widget refresh.
359
- widgetTimer.unref?.();
360
- return {
361
- render: (width) => renderWidget(width),
362
- invalidate: () => { },
363
- dispose: () => {
364
- widgetTheme = null;
365
- stopWidget();
366
- },
367
- };
368
- });
369
- }
370
- function clearTaskWidgetIfIdle() {
371
- if (foregroundTasks.size > 0 || backgroundTasks.size > 0)
372
- return;
373
- stopWidget();
374
- if (widgetCtx) {
375
- widgetCtx.ui.setWidget("task", undefined);
376
- widgetCtx = null;
377
- }
378
- }
48
+ const countInterval = startToolStatsPolling(foregroundTasks, backgroundTasks, COUNT_POLL_MS);
379
49
  // ── Polling loop (background task completion, pane death, timeout) ──────
380
- const checkInterval = setInterval(async () => {
381
- if (backgroundTasks.size === 0) {
382
- clearTaskWidgetIfIdle();
383
- return;
384
- }
385
- const now = Date.now();
386
- const ids = Array.from(backgroundTasks.keys());
387
- for (const id of ids) {
388
- const task = backgroundTasks.get(id);
389
- if (!task)
390
- continue;
391
- // ── Check timeout ────────────────────────────────────────────
392
- if (now - task.startedAt > TASK_TIMEOUT_MS) {
393
- killAgentPane(task.paneId, task.originalPane);
394
- backgroundTasks.delete(id);
395
- clearTaskWidgetIfIdle();
396
- completeTask(pi, id, task, "Task timed out after 30 minutes", "timeout", piDir);
397
- continue;
398
- }
399
- const snapshot = await checkTaskCompletion({
400
- resultPath: join(task.dir, "RESULT.md"),
401
- sessionDir: join(task.dir, "sessions"),
402
- sessionName: task.sessionName,
403
- paneId: task.paneId,
404
- sinceMs: task.startedAt,
405
- });
406
- if (snapshot.status === "running") {
407
- continue;
408
- }
409
- const phase = snapshot.status === "completed" ? "done" : "failed";
410
- backgroundTasks.delete(id);
411
- clearTaskWidgetIfIdle();
412
- completeTask(pi, id, task, snapshot.content, phase, piDir);
413
- }
50
+ const checkInterval = startBackgroundPolling({
51
+ backgroundTasks,
52
+ checkTaskCompletion,
53
+ killAgentPane: (paneId, originalPane) => {
54
+ if (paneId)
55
+ killAgentPane(paneId, originalPane);
56
+ },
57
+ clearTaskWidgetIfIdle,
58
+ completeTask,
59
+ TASK_TIMEOUT_MS,
60
+ MAX_POLL_ERRORS,
61
+ piDir,
62
+ pi,
414
63
  }, BACKGROUND_CHECK_MS);
415
64
  // ── Cleanup on shutdown ────────────────────────────────────────────────
416
65
  pi.on("session_shutdown", () => {
417
66
  clearInterval(checkInterval);
418
67
  clearInterval(countInterval);
419
- stopWidget();
420
- if (widgetCtx) {
421
- widgetCtx.ui.setWidget("task", undefined);
422
- widgetCtx = null;
423
- }
68
+ taskWidget.dispose();
424
69
  });
425
70
  // ── Custom notification renderer ───────────────────────────────────────
426
- pi.registerMessageRenderer?.("task-complete", (message, { expanded }, theme) => {
427
- const d = message.details;
428
- if (!d)
429
- return undefined;
430
- const agentType = d.agent_type || "";
431
- const desc = d.description || "";
432
- const summary = d.summary || "";
433
- const findings = d.findings || "";
434
- const confidence = d.confidence || "";
435
- const durationMs = d.duration_ms || 0;
436
- const toolUses = d.tool_uses || 0;
437
- let line = " " + theme.fg("accent", agentType);
438
- if (desc)
439
- line += theme.fg("dim", ` - ${desc}`);
440
- const useStr = toolUses > 0 ? `${toolUses} toolcalls` : "";
441
- const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
442
- const statsParts = [useStr, durStr].filter(Boolean);
443
- const statsText = statsParts.join(" • ");
444
- const confStr = confidence ? confidence.toUpperCase() : "";
445
- const confColor = confidence === "high"
446
- ? "success"
447
- : confidence === "low"
448
- ? "error"
449
- : "accent";
450
- if (statsText || confStr) {
451
- line += "\n ";
452
- if (confStr)
453
- line += theme.fg(confColor, `[${confStr}]`);
454
- if (statsText)
455
- line += (confStr ? " " : "") + theme.fg("dim", statsText);
456
- }
457
- if (expanded) {
458
- if (summary)
459
- line += "\n " + theme.fg("muted", summary);
460
- if (findings)
461
- line += "\n " + theme.fg("dim", findings);
462
- }
463
- if (!line.trim())
464
- return undefined;
465
- const subtleBg = (text) => `\x1b[48;2;30;28;44m${text}\x1b[0m`;
466
- return new Text(line, 0, 1, subtleBg);
467
- });
71
+ pi.registerMessageRenderer?.("task-complete", createTaskCompleteRenderer());
468
72
  // ── Tool Registration ──────────────────────────────────────────────────
469
73
  pi.registerTool({
470
74
  name: "task",
@@ -481,27 +85,7 @@ export default function (pi) {
481
85
  "After delegated work completes, read changed files, review diff, verify scope, and run relevant checks",
482
86
  "Send the user a concise summary of the result since the agent's output is not user-visible",
483
87
  ],
484
- parameters: Type.Object({
485
- agent_type: Type.String({
486
- description: "The type of specialist agent to use for this task",
487
- }),
488
- prompt: Type.String({
489
- description: "The complete task for the agent to perform. Be detailed and self-contained.",
490
- }),
491
- description: Type.String({
492
- description: "A short (3-5 word) summary of the task",
493
- }),
494
- task_id: Type.Optional(Type.String({
495
- description: "Resume an existing background task by id instead of starting a new task.",
496
- })),
497
- conversation_id: Type.Optional(Type.String({
498
- description: "Durable specialist conversation id. Reuses .pi/artifacts/task-<id>/sessions when called again.",
499
- })),
500
- background: Type.Optional(Type.Boolean({
501
- description: "Run in background (async). You will be notified when it completes. DO NOT sleep, poll, ask the task for status, or duplicate its work while it runs in background.",
502
- default: true,
503
- })),
504
- }),
88
+ parameters: taskParametersSchema(),
505
89
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
506
90
  const { agents, piDir } = discoverAgents(ctx.cwd, BUNDLED_AGENT_DIR);
507
91
  const parentToolNames = pi
@@ -552,30 +136,12 @@ export default function (pi) {
552
136
  }
553
137
  let id;
554
138
  let sessionName;
555
- let resultPath;
556
139
  let resume = false;
557
140
  let resumeSessionRef;
558
141
  const artifactsDir = join(piDir, "artifacts");
559
142
  if (registeredTaskId) {
560
143
  id = registeredTaskId;
561
144
  sessionName = conversationId ?? `task-${id}`;
562
- resultPath = join(artifactsDir, `RESULT-${id}.md`);
563
- if (!existsSync(resultPath)) {
564
- return {
565
- content: [
566
- {
567
- type: "text",
568
- text: `conversation_id "${conversationId}" has no prior result file at ${resultPath}. Cannot resume.`,
569
- },
570
- ],
571
- details: {
572
- phase: "failed",
573
- error: "Conversation result missing",
574
- conversation_id: conversationId,
575
- },
576
- isError: true,
577
- };
578
- }
579
145
  const block = readTaskBlock(piDir, id);
580
146
  const previousMetadata = parseMetadataFromBody(block?.body);
581
147
  const metadataAgent = previousMetadata?.agent_type;
@@ -686,7 +252,6 @@ export default function (pi) {
686
252
  // flat in artifactsDir, no per-task subdir.
687
253
  id = entry.id;
688
254
  sessionName = entry.sessionName;
689
- resultPath = join(artifactsDir, `RESULT-${id}.md`);
690
255
  resume = true;
691
256
  resumeSessionRef = entry.sessionRef;
692
257
  // If background and pane still alive, reattach to tracker
@@ -743,7 +308,6 @@ export default function (pi) {
743
308
  else {
744
309
  id = `${Date.now().toString(36)}-${randomUUID().slice(0, 4)}`;
745
310
  sessionName = conversationId ?? `task-${id}`;
746
- resultPath = join(artifactsDir, `RESULT-${id}.md`);
747
311
  }
748
312
  if (conversationId && !hasTmux()) {
749
313
  return {
@@ -774,32 +338,24 @@ export default function (pi) {
774
338
  const isBackground = params.background ?? TASK_BACKGROUND_DEFAULT;
775
339
  // default true
776
340
  // ── Build the prompt (instructions are inlined; no CONTEXT.md file) ─
777
- const promptContent = [
778
- `# Task: ${descText}`,
779
- "",
780
- `## Agent`,
781
- `${agent.name} (${agent.source})`,
782
- "",
783
- `## Instructions`,
784
- params.prompt,
785
- "",
786
- `## Working Directory`,
787
- ctx.cwd,
788
- "",
789
- `## Output`,
790
- "Your final assistant message is the result. End with a clear summary of what you did and any findings. No file write is required.",
791
- "",
792
- "Use this format for the summary:",
793
- "",
794
- "```",
795
- TASK_RESULT_XML_INSTRUCTIONS,
796
- "```",
797
- ].join("\n");
798
- const sessionDir = join(artifactsDir, "sessions");
341
+ const promptContent = buildTaskPrompt({
342
+ description: descText,
343
+ agentName: agent.name,
344
+ agentSource: agent.source,
345
+ prompt: params.prompt,
346
+ cwd: ctx.cwd,
347
+ });
348
+ const sessionDir = join(artifactsDir, "sessions", id);
799
349
  await mkdir(sessionDir, { recursive: true });
800
350
  // ─── Build and run the sub-agent pi process ──────────────────────────
801
351
  const piArgs = buildPiArgs(agent, sessionName, sessionDir, promptContent, resume, parentToolNames, resumeSessionRef);
802
352
  const envPrefix = `PI_TASK_TOOL_DISABLED=1`;
353
+ const forceTmuxBackend = process.env.PI_TASK_BACKEND === "tmux" ||
354
+ process.env.PI_TASK_USE_TMUX_BACKEND === "1";
355
+ const forceSdkBackend = process.env.PI_TASK_BACKEND === "sdk" ||
356
+ process.env.PI_TASK_USE_SDK_BACKEND === "1";
357
+ const tmuxAvailable = hasTmux();
358
+ const useSdkBackend = forceSdkBackend || (!forceTmuxBackend && !tmuxAvailable);
803
359
  const toolSelection = buildAgentToolSelection({
804
360
  tools: agent.tools,
805
361
  disallowedTools: agent.disallowedTools,
@@ -834,8 +390,10 @@ export default function (pi) {
834
390
  foregroundTasks.set(id, foregroundTask);
835
391
  ensureTaskWidget(ctx);
836
392
  }
837
- // Prefer tmux for observability, but fall back to the SDK in headless/CI/RPC.
838
- if (!hasTmux()) {
393
+ // Prefer tmux when the parent Pi is running inside tmux so users can watch
394
+ // the subagent's interactive Pi TUI. Fall back to the SDK only when tmux is
395
+ // unavailable, or when explicitly forced with PI_TASK_BACKEND=sdk.
396
+ if (useSdkBackend) {
839
397
  if (isBackground) {
840
398
  const bgtask = {
841
399
  dir: artifactsDir,
@@ -887,14 +445,12 @@ export default function (pi) {
887
445
  content: [
888
446
  {
889
447
  type: "text",
890
- text: `Task ${id} started with SDK backend (tmux unavailable).`,
448
+ text: `Task ${id} started with SDK backend.`,
891
449
  },
892
450
  ],
893
451
  details: {
894
452
  task_id: id,
895
453
  background: true,
896
- backend: "sdk",
897
- result_path: resultPath,
898
454
  conversation_id: conversationId,
899
455
  },
900
456
  };
@@ -919,7 +475,6 @@ export default function (pi) {
919
475
  phase: "done",
920
476
  backend: "sdk",
921
477
  session_path: sessionPath,
922
- result_path: resultPath,
923
478
  conversation_id: conversationId,
924
479
  },
925
480
  };
@@ -944,12 +499,15 @@ export default function (pi) {
944
499
  }
945
500
  }
946
501
  const shellCommand = `${envPrefix} pi ${piArgs.map((a) => shellQuote(a)).join(" ")}`;
502
+ const sessionFile = join(sessionDir, sessionName + ".jsonl");
503
+ const tmuxCommand = wrapWithPaneExitWatcher(sessionFile, `cd ${shellQuote(ctx.cwd)} && ${shellCommand}`);
947
504
  let paneId;
948
505
  let originalPane;
949
506
  try {
950
- const splitResult = splitWindowPane(ctx.cwd, `cd ${shellQuote(ctx.cwd)} && ${shellCommand}`);
507
+ const splitResult = splitWindowPane(ctx.cwd, tmuxCommand);
951
508
  paneId = splitResult.paneId;
952
509
  originalPane = splitResult.originalPane;
510
+ setPaneRemainOnExit(paneId, true);
953
511
  if (foregroundTask) {
954
512
  foregroundTask.paneId = paneId;
955
513
  foregroundTask.originalPane = originalPane;
@@ -985,8 +543,31 @@ export default function (pi) {
985
543
  status: "running",
986
544
  background: false,
987
545
  });
546
+ // Poll tool-call progress while waiting for completion
547
+ let lastToolCalls = -1;
548
+ const onAbort = () => clearInterval(toolProgressInterval);
549
+ const toolProgressInterval = setInterval(() => {
550
+ try {
551
+ const stats = countToolUses(sessionDir, sessionName);
552
+ if (stats.toolUses > 0 && stats.toolUses !== lastToolCalls) {
553
+ lastToolCalls = stats.toolUses;
554
+ _onUpdate?.({
555
+ content: [
556
+ {
557
+ type: "text",
558
+ text: `${stats.toolUses} tool call${stats.toolUses !== 1 ? "s" : ""}`,
559
+ },
560
+ ],
561
+ details: { toolCalls: stats.toolUses },
562
+ });
563
+ }
564
+ }
565
+ catch {
566
+ // session file may not exist yet
567
+ }
568
+ }, COUNT_POLL_MS);
569
+ signal?.addEventListener("abort", onAbort, { once: true });
988
570
  const completion = await waitForSessionTaskCompletion({
989
- resultPath,
990
571
  sessionDir,
991
572
  sessionName,
992
573
  paneId,
@@ -995,6 +576,8 @@ export default function (pi) {
995
576
  pollMs: 1000,
996
577
  sinceMs: startedAt,
997
578
  });
579
+ clearInterval(toolProgressInterval);
580
+ signal?.removeEventListener("abort", onAbort);
998
581
  const content = completion.content;
999
582
  const phase = completion.status === "completed"
1000
583
  ? "done"
@@ -1017,7 +600,9 @@ export default function (pi) {
1017
600
  completedAt: Date.now(),
1018
601
  background: false,
1019
602
  });
1020
- killAgentPane(paneId, originalPane);
603
+ if (phase === "done") {
604
+ killAgentPane(paneId, originalPane);
605
+ }
1021
606
  foregroundTasks.delete(id);
1022
607
  clearTaskWidgetIfIdle();
1023
608
  if (conversationId) {
@@ -1038,13 +623,7 @@ export default function (pi) {
1038
623
  content: [
1039
624
  {
1040
625
  type: "text",
1041
- text: [
1042
- `${parsed.status || "done"}: ${parsed.summary || content.slice(0, 300)}`,
1043
- toolUses > 0 ? `\n${toolUses} toolcalls` : "",
1044
- durationMs >= 1000 ? `\n${formatMs(durationMs)}` : "",
1045
- ]
1046
- .filter(Boolean)
1047
- .join(""),
626
+ text: parsed.summary || content.trim(),
1048
627
  },
1049
628
  ],
1050
629
  details: {
@@ -1052,7 +631,7 @@ export default function (pi) {
1052
631
  agent_type: agent.name,
1053
632
  description: descText,
1054
633
  phase,
1055
- status: phase === "done" ? parsed.status || "done" : phase,
634
+ status: "done",
1056
635
  summary: parsed.summary || "",
1057
636
  findings: parsed.findings || "",
1058
637
  evidence: parsed.evidence || "",
@@ -1112,13 +691,7 @@ export default function (pi) {
1112
691
  // Clean registry
1113
692
  const remaining = readRegistry(piDir).filter((e) => e.id !== id);
1114
693
  writeRegistry(piDir, remaining);
1115
- if (backgroundTasks.size === 0) {
1116
- stopWidget();
1117
- if (widgetCtx) {
1118
- widgetCtx.ui.setWidget("task", undefined);
1119
- widgetCtx = null;
1120
- }
1121
- }
694
+ clearTaskWidgetIfIdle();
1122
695
  }, { once: true });
1123
696
  }
1124
697
  // ── Sticky widget ──────────────────────────────────────────────────
@@ -1144,79 +717,8 @@ export default function (pi) {
1144
717
  },
1145
718
  };
1146
719
  },
1147
- renderCall(args, theme, _context) {
1148
- const agentName = args.agent_type || "...";
1149
- const desc = args.description || "";
1150
- let text = theme.fg("toolTitle", "");
1151
- text += theme.fg("accent", agentName);
1152
- if (desc)
1153
- text += theme.fg("dim", ` - ${desc}`);
1154
- return new Text(text, 0, 0);
1155
- },
1156
- renderResult(result, { expanded }, theme, _context) {
1157
- const d = result.details;
1158
- if (!d)
1159
- return new Text("", 0, 0);
1160
- if (d.background) {
1161
- const toolUses = d.tool_uses || 0;
1162
- const durationMs = d.duration_ms || 0;
1163
- const confidence = d.confidence || "";
1164
- const useStr = toolUses > 0 ? `${toolUses} toolcalls` : "";
1165
- const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
1166
- const statsParts = [useStr, durStr].filter(Boolean);
1167
- const statsText = statsParts.join(" \u2022 ");
1168
- const confStr = confidence ? `[${confidence.toUpperCase()}]` : "";
1169
- const statsLine = [confStr, statsText].filter(Boolean).join(" ");
1170
- const subtleBg = (text) => `\x1b[48;2;30;28;44m${text}\x1b[0m`;
1171
- return new Text(statsLine ? " " + theme.fg("dim", statsLine.trim()) : "", 0, 0, subtleBg);
1172
- }
1173
- if (d.phase === "timeout" ||
1174
- d.phase === "aborted" ||
1175
- d.phase === "failed") {
1176
- const line = theme.fg("error", "x") + " " + theme.fg("dim", `[${d.phase}]`);
1177
- return new Text(line, 0, 0);
1178
- }
1179
- const isError = d.status === "failure" ||
1180
- d.status === "blocked" ||
1181
- d.status === "unknown" ||
1182
- d.status === "timeout" ||
1183
- d.status === "failed";
1184
- const durationMs = d.duration_ms || 0;
1185
- const toolUses = d.tool_uses || 0;
1186
- const useStr = toolUses > 0 ? `${toolUses} toolcalls` : "";
1187
- const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
1188
- const statsParts = [useStr, durStr].filter(Boolean);
1189
- const statsStr = statsParts.length
1190
- ? " " + theme.fg("dim", statsParts.join(" • "))
1191
- : "";
1192
- const icon = isError ? theme.fg("error", "x") : theme.fg("success", "✓");
1193
- const statusLabel = d.status && d.status !== "done" ? d.status : "done";
1194
- let line = icon +
1195
- " " +
1196
- theme.fg(isError ? "error" : "success", statusLabel) +
1197
- statsStr;
1198
- if (expanded) {
1199
- const s = d.summary || "";
1200
- const f = d.findings || "";
1201
- const e = d.evidence || "";
1202
- if (s)
1203
- line += "\n" + theme.fg("muted", s);
1204
- if (f)
1205
- line += "\n" + theme.fg("dim", f);
1206
- if (e)
1207
- line += "\n" + theme.fg("muted", "Evidence: ") + theme.fg("dim", e);
1208
- }
1209
- else {
1210
- const preview = (d.summary || "").slice(0, 80);
1211
- if (preview)
1212
- line += "\n" + theme.fg("dim", ` ⎿ ${preview}`);
1213
- else
1214
- line +=
1215
- "\n" +
1216
- theme.fg("dim", ` ⎿ ${isError ? d.status || "error" : "Done"}`);
1217
- }
1218
- return new Text(line, 0, 0);
1219
- },
720
+ renderCall,
721
+ renderResult,
1220
722
  });
1221
723
  pi.registerCommand("task-sessions", {
1222
724
  description: "List durable pi-task conversations",