@heyhuynhgiabuu/pi-task 0.1.5 → 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 (48) hide show
  1. package/CHANGELOG.md +116 -4
  2. package/README.md +16 -11
  3. package/dist/constants.d.ts +4 -0
  4. package/dist/constants.js +4 -0
  5. package/dist/conversation.d.ts +76 -21
  6. package/dist/conversation.js +280 -70
  7. package/dist/helpers.d.ts +8 -8
  8. package/dist/helpers.js +34 -15
  9. package/dist/index.d.ts +6 -23
  10. package/dist/index.js +233 -634
  11. package/dist/lifecycle/completion.d.ts +3 -0
  12. package/dist/lifecycle/completion.js +50 -0
  13. package/dist/lifecycle/index.d.ts +5 -0
  14. package/dist/lifecycle/index.js +5 -0
  15. package/dist/lifecycle/polling.d.ts +16 -0
  16. package/dist/lifecycle/polling.js +61 -0
  17. package/dist/lifecycle/restore.d.ts +2 -0
  18. package/dist/lifecycle/restore.js +34 -0
  19. package/dist/lifecycle/toolStats.d.ts +2 -0
  20. package/dist/lifecycle/toolStats.js +17 -0
  21. package/dist/lifecycle/widget.d.ts +8 -0
  22. package/dist/lifecycle/widget.js +75 -0
  23. package/dist/session-text.d.ts +11 -2
  24. package/dist/session-text.js +78 -2
  25. package/dist/subagent/buildArgv.d.ts +1 -0
  26. package/dist/subagent/buildArgv.js +1 -1
  27. package/dist/subagent/runSdk.js +50 -26
  28. package/dist/subagent/tmux.d.ts +12 -9
  29. package/dist/subagent/tmux.js +107 -44
  30. package/dist/subagent/waitCompletion.d.ts +5 -5
  31. package/dist/subagent/waitCompletion.js +32 -41
  32. package/dist/task-widget.d.ts +21 -0
  33. package/dist/task-widget.js +122 -0
  34. package/dist/tool/index.d.ts +5 -0
  35. package/dist/tool/index.js +5 -0
  36. package/dist/tool/prompt.d.ts +8 -0
  37. package/dist/tool/prompt.js +17 -0
  38. package/dist/tool/renderCall.d.ts +3 -0
  39. package/dist/tool/renderCall.js +12 -0
  40. package/dist/tool/renderResult.d.ts +8 -0
  41. package/dist/tool/renderResult.js +51 -0
  42. package/dist/tool/schema.d.ts +8 -0
  43. package/dist/tool/schema.js +24 -0
  44. package/dist/tool/taskComplete.d.ts +8 -0
  45. package/dist/tool/taskComplete.js +65 -0
  46. package/dist/types.d.ts +54 -0
  47. package/dist/types.js +1 -0
  48. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,458 +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 { mkdir, writeFile } from "node:fs/promises";
18
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
19
- import { execFileSync } from "node:child_process";
20
17
  import { randomUUID } from "node:crypto";
18
+ import { existsSync } from "node:fs";
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 { getArtifactsDir, normalizeConversationId, readConversationMetadata, readConversationRegistry, renderConversationSessions, taskArtifactName, taskIdFromArtifactName, writeConversationArtifacts, writeConversationRegistry, } from "./conversation.js";
25
- import { Text, truncateToWidth } from "@earendil-works/pi-tui";
26
- 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";
22
+ import { buildAgentToolSelection } from "./agent-tools.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";
27
27
  import { runSdkSubagent } from "./subagent/runSdk.js";
28
28
  import { checkTaskCompletion, waitForTaskCompletion as waitForSessionTaskCompletion, } from "./subagent/waitCompletion.js";
29
- import { buildAgentToolSelection } from "./agent-tools.js";
29
+ import { hasTmux, killAgentPane, paneExists, setPaneRemainOnExit, splitWindowPane, wrapWithPaneExitWatcher, } from "./subagent/tmux.js";
30
+ import { buildTaskPrompt, createTaskCompleteRenderer, renderCall, renderResult, taskParametersSchema, } from "./tool/index.js";
30
31
  // ─── Constants ───────────────────────────────────────────────────────────────
31
32
  const BUNDLED_AGENT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "agents");
32
- const BACKGROUND_CHECK_MS = 10_000; // poll every 10 sec
33
- const COUNT_POLL_MS = 3_000; // update toolcall counts every 3 sec
34
- const TASK_TIMEOUT_MS = 30 * 60 * 1_000; // 30 minutes
35
33
  // Conversation helpers live in ./conversation.js.
36
- function readRegistry(piDir) {
37
- const path = join(piDir, "task-registry.json");
38
- try {
39
- return JSON.parse(readFileSync(path, "utf-8"));
40
- }
41
- catch {
42
- return [];
43
- }
44
- }
45
- function writeRegistry(piDir, entries) {
46
- const path = join(piDir, "task-registry.json");
47
- writeFileSync(path, JSON.stringify(entries, null, 2), "utf-8");
48
- }
49
- // ─── Tmux Helpers ────────────────────────────────────────────────────────────
50
- function tmuxCmd(args) {
51
- return execFileSync("tmux", args, {
52
- encoding: "utf-8",
53
- stdio: ["ignore", "pipe", "pipe"],
54
- }).trim();
55
- }
56
- function hasTmux() {
57
- try {
58
- execFileSync("tmux", ["-V"], { stdio: "ignore" });
59
- return true;
60
- }
61
- catch {
62
- return false;
63
- }
64
- }
65
- function paneExists(paneId) {
66
- try {
67
- return tmuxCmd(["list-panes", "-a", "-F", "#{pane_id}"])
68
- .split("\n")
69
- .includes(paneId);
70
- }
71
- catch {
72
- return false;
73
- }
74
- }
75
- function getCurrentPaneId() {
76
- try {
77
- return tmuxCmd(["display-message", "-p", "#{pane_id}"]);
78
- }
79
- catch {
80
- return null;
81
- }
82
- }
83
- function getCurrentPaneSize(targetPane) {
84
- try {
85
- const args = ["display-message", "-p", "#{pane_width} #{pane_height}"];
86
- if (targetPane)
87
- args.splice(1, 0, "-t", targetPane);
88
- const raw = tmuxCmd(args);
89
- const [widthRaw, heightRaw] = raw.trim().split(/\s+/, 2);
90
- const width = Number(widthRaw);
91
- const height = Number(heightRaw);
92
- if (!Number.isFinite(width) || !Number.isFinite(height))
93
- return null;
94
- return { width, height };
95
- }
96
- catch {
97
- return null;
98
- }
99
- }
100
- function splitWindowPane(cwd, command) {
101
- const originalPane = getCurrentPaneId();
102
- const paneSize = getCurrentPaneSize(originalPane);
103
- const direction = chooseTmuxSplitDirection(paneSize?.width ?? 0, paneSize?.height ?? 0);
104
- const paneId = tmuxCmd(buildTmuxSplitWindowArgs(cwd, command, direction, originalPane));
105
- return { paneId, originalPane };
106
- }
107
- function killAgentPane(paneId, originalPane) {
108
- if (paneId) {
109
- try {
110
- if (paneExists(paneId))
111
- tmuxCmd(["kill-pane", "-t", paneId]);
112
- }
113
- catch {
114
- /* ignore */
115
- }
116
- }
117
- if (originalPane) {
118
- try {
119
- tmuxCmd(["select-pane", "-t", originalPane]);
120
- }
121
- catch {
122
- /* ignore */
123
- }
124
- }
125
- }
126
- // ─── Process a completed task (sendMessage + registry cleanup) ──────────────
127
- function completeTask(pi, id, task, content, phase, piDir) {
128
- // Kill the tmux pane if still alive
129
- killAgentPane(task.paneId, task.originalPane);
130
- const parsed = parseResultXml(content);
131
- const durationMs = Date.now() - task.startedAt;
132
- // Send completion notification
133
- pi.sendMessage({
134
- customType: "task-complete",
135
- content: `Background task ${id} (${task.agentType}) ${phase}.\n\nResult:\n${content}`,
136
- display: true,
137
- details: {
138
- task_id: id,
139
- agent_type: task.agentType,
140
- description: task.description,
141
- phase,
142
- status: phase,
143
- result: content,
144
- summary: parsed.summary,
145
- findings: parsed.findings,
146
- confidence: parsed.confidence,
147
- duration_ms: durationMs,
148
- tool_uses: task.toolUses,
149
- turn_count: task.turns,
150
- },
151
- }, {
152
- triggerTurn: true,
153
- deliverAs: "followUp",
154
- });
155
- // Remove from registry
156
- const entries = readRegistry(piDir).filter((e) => e.id !== id);
157
- writeRegistry(piDir, entries);
158
- }
159
34
  // ─── Extension Entry Point ──────────────────────────────────────────────────
160
35
  export default function (pi) {
161
36
  // Prevent recursive loading
162
37
  if (process.env.PI_TASK_TOOL_DISABLED === "1")
163
38
  return;
164
39
  // ── Background task tracker ────────────────────────────────────────────
40
+ const { piDir } = discoverAgents(process.cwd(), BUNDLED_AGENT_DIR);
165
41
  const backgroundTasks = new Map();
166
42
  const foregroundTasks = new Map();
167
- let widgetCtx = null;
43
+ const taskWidget = createTaskWidgetController(foregroundTasks, backgroundTasks);
44
+ const { ensureTaskWidget, clearTaskWidgetIfIdle } = taskWidget;
168
45
  // ── Restore active tasks from registry on load ──────────────────────────
169
- const { piDir } = discoverAgents(process.cwd());
170
- const registry = readRegistry(piDir);
171
- const staleIds = [];
172
- for (const entry of registry) {
173
- // Only restore if artifact dir still exists
174
- if (!existsSync(entry.dir)) {
175
- staleIds.push(entry.id);
176
- continue;
177
- }
178
- // Check if tmux pane is still alive
179
- const paneAlive = entry.paneId ? paneExists(entry.paneId) : false;
180
- if (!paneAlive) {
181
- staleIds.push(entry.id);
182
- continue;
183
- }
184
- const bgtask = {
185
- dir: entry.dir,
186
- agentType: entry.agentType,
187
- sessionName: entry.sessionName,
188
- paneId: entry.paneId,
189
- originalPane: null,
190
- description: entry.description,
191
- startedAt: entry.startedAt,
192
- toolUses: 0,
193
- turns: 0,
194
- conversationId: entry.conversationId,
195
- recentCalls: [],
196
- };
197
- backgroundTasks.set(entry.id, bgtask);
198
- }
199
- if (staleIds.length) {
200
- writeRegistry(piDir, registry.filter((e) => !staleIds.includes(e.id)));
201
- }
46
+ restoreActiveBackgroundTasks(piDir, backgroundTasks);
202
47
  // ── Widget / timer setup ───────────────────────────────────────────────
203
- let widgetTimer = null;
204
- function stopWidget() {
205
- if (widgetTimer) {
206
- clearInterval(widgetTimer);
207
- widgetTimer = null;
208
- }
209
- }
210
- const countInterval = setInterval(() => {
211
- for (const task of [
212
- ...foregroundTasks.values(),
213
- ...backgroundTasks.values(),
214
- ]) {
215
- const sessionDir = join(task.dir, "sessions");
216
- // Single walk: counts + recent tool-call history with status
217
- const { toolUses, turns, recent } = readRecentToolCalls(sessionDir, 12);
218
- task.toolUses = toolUses;
219
- task.turns = turns;
220
- task.recentCalls = recent;
221
- }
222
- }, COUNT_POLL_MS);
223
- /**
224
- * Render a streaming view of one active subagent. Layout per task:
225
- *
226
- * ⠋ Scout — SDK docs • 1m 0s 11 toolcalls (themed: accent + dim)
227
- * ├─ ✓ websearch Model Context Protocol 2026 (green/success)
228
- * ├─ ✓ codesearch MCP reference server typescript
229
- * ├─ ✗ bash curl -sL "https://api.github.com..." (red/error)
230
- * └─ ⠹ read /Users/.../scout.md (yellow/warning, animates)
231
- *
232
- * The header caret and in-progress tool marks share the same spinner
233
- * frame set (rotates every WIDGET_RENDER_MS based on wall-clock time,
234
- * so the animation cadence is stable regardless of TUI render rate).
235
- */
236
- // Theme reference is captured at setWidget time so renderWidget can use it.
237
- // We don't import the Theme type because it's not exported; structural typing
238
- // via `any` here is safe — the c() helper only calls `theme(color, text)`.
239
- let widgetTheme = null;
240
- // 8-frame Braille spinner. 80ms cadence = 12.5 FPS, which is the human
241
- // perception threshold for "smooth motion" (below ~10 FPS the brain
242
- // sees discrete steps; above ~12 FPS it reads as continuous rotation).
243
- // Full rotation: 8 × 80ms = 640ms. Used for both per-tool in-progress
244
- // marks AND the header caret (the "agent is active" indicator).
245
- const WIDGET_SPINNER_FRAMES = [
246
- "\u280B",
247
- "\u2819",
248
- "\u2838",
249
- "\u2834",
250
- "\u2826",
251
- "\u2827",
252
- "\u2807",
253
- "\u280F",
254
- ];
255
- const WIDGET_CARET_FRAMES = WIDGET_SPINNER_FRAMES;
256
- const WIDGET_RENDER_MS = 80;
257
- const WIDGET_MAX_TOOL_LINES = 12;
258
- const WIDGET_MAX_WIDTH = 120;
259
- const TREE_MIDDLE = "\u251C\u2500"; // ├─
260
- const TREE_LAST = "\u2514\u2500"; // └─
261
- function c(color, text) {
262
- // widgetTheme is a Theme object with a .fg(color, text) method,
263
- // not a callable. Calling it as a function throws "widgetTheme is not
264
- // a function" which the outer try/catch in renderWidget swallows.
265
- return widgetTheme ? widgetTheme.fg(color, text) : text;
266
- }
267
- function renderWidget(width) {
268
- // Defensive: never let a render exception kill the TUI. If anything
269
- // throws (theme lookup miss, malformed session JSONL, etc.), fall
270
- // back to a minimal single-line summary so the TUI stays alive.
271
- try {
272
- return renderWidgetInner(width);
273
- }
274
- catch (err) {
275
- const msg = err instanceof Error ? err.message : String(err);
276
- const active = [
277
- ...Array.from(foregroundTasks.entries()),
278
- ...Array.from(backgroundTasks.entries()),
279
- ];
280
- if (active.length === 0)
281
- return [];
282
- const [, task] = active[0];
283
- return [
284
- truncateToWidth(`${task.agentType} \u2022 ${formatMs(Date.now() - task.startedAt)} (render error: ${msg})`, Math.min(width, WIDGET_MAX_WIDTH)),
285
- ];
286
- }
287
- }
288
- function ensureTaskWidget(targetCtx) {
289
- if (widgetCtx || targetCtx.mode !== "tui")
290
- return;
291
- widgetCtx = targetCtx;
292
- targetCtx.ui.setWidget("task", (tui, theme) => {
293
- widgetTheme = theme ?? null;
294
- widgetTimer = setInterval(() => tui.requestRender(), WIDGET_RENDER_MS);
295
- // Don't keep the process alive just for the widget refresh.
296
- widgetTimer.unref?.();
297
- return {
298
- render: (width) => renderWidget(width),
299
- invalidate: () => { },
300
- dispose: () => {
301
- widgetTheme = null;
302
- stopWidget();
303
- },
304
- };
305
- });
306
- }
307
- function clearTaskWidgetIfIdle() {
308
- if (foregroundTasks.size > 0 || backgroundTasks.size > 0)
309
- return;
310
- stopWidget();
311
- if (widgetCtx) {
312
- widgetCtx.ui.setWidget("task", undefined);
313
- widgetCtx = null;
314
- }
315
- }
316
- function renderWidgetInner(width) {
317
- const active = [
318
- ...Array.from(foregroundTasks.entries()),
319
- ...Array.from(backgroundTasks.entries()),
320
- ];
321
- if (active.length === 0)
322
- return [];
323
- const now = Date.now();
324
- const maxWidth = Math.min(width, WIDGET_MAX_WIDTH);
325
- const tick = Math.floor(now / WIDGET_RENDER_MS);
326
- const spinner = WIDGET_SPINNER_FRAMES[tick % WIDGET_SPINNER_FRAMES.length];
327
- const caret = WIDGET_CARET_FRAMES[tick % WIDGET_CARET_FRAMES.length];
328
- const lines = [];
329
- for (const [, task] of active) {
330
- const agentName = task.agentType.charAt(0).toUpperCase() + task.agentType.slice(1);
331
- const elapsed = formatMs(now - task.startedAt);
332
- const total = task.toolUses > 0 ? ` ${task.turns || task.toolUses} toolcalls` : "";
333
- const description = task.description ? ` — ${task.description}` : "";
334
- // Header: ▼ <Agent> — <description> • 1m 0s 11 toolcalls
335
- const header = c("accent", caret) +
336
- " " +
337
- c("toolTitle", agentName) +
338
- c("dim", `${description} \u2022 ${elapsed}${total}`);
339
- lines.push(truncateToWidth(header, maxWidth));
340
- const recent = task.recentCalls ?? [];
341
- if (recent.length > 0) {
342
- const slice = recent.slice(-WIDGET_MAX_TOOL_LINES);
343
- slice.forEach((tc, idx) => {
344
- const isLast = idx === slice.length - 1;
345
- const connector = isLast ? TREE_LAST : TREE_MIDDLE;
346
- const isInProgress = tc.status === "in_progress";
347
- const markChar = isInProgress
348
- ? spinner
349
- : tc.status === "error"
350
- ? "\u2717"
351
- : "\u2713";
352
- const markColor = isInProgress
353
- ? "warning"
354
- : tc.status === "error"
355
- ? "error"
356
- : "success";
357
- const detailStr = tc.detail ? ` ${tc.detail}` : "";
358
- const line = " " +
359
- c("dim", connector) +
360
- " " +
361
- c(markColor, markChar) +
362
- " " +
363
- c("text", tc.name) +
364
- c("dim", detailStr);
365
- lines.push(truncateToWidth(line, maxWidth));
366
- });
367
- }
368
- lines.push("");
369
- }
370
- return lines;
371
- }
48
+ const countInterval = startToolStatsPolling(foregroundTasks, backgroundTasks, COUNT_POLL_MS);
372
49
  // ── Polling loop (background task completion, pane death, timeout) ──────
373
- const checkInterval = setInterval(async () => {
374
- if (backgroundTasks.size === 0) {
375
- clearTaskWidgetIfIdle();
376
- return;
377
- }
378
- const now = Date.now();
379
- const ids = Array.from(backgroundTasks.keys());
380
- for (const id of ids) {
381
- const task = backgroundTasks.get(id);
382
- if (!task)
383
- continue;
384
- backgroundTasks.delete(id); // Remove atomically
385
- // ── Check timeout ────────────────────────────────────────────
386
- if (now - task.startedAt > TASK_TIMEOUT_MS) {
387
- killAgentPane(task.paneId, task.originalPane);
388
- completeTask(pi, id, task, "Task timed out after 30 minutes", "timeout", piDir);
389
- continue;
390
- }
391
- const snapshot = await checkTaskCompletion({
392
- resultPath: join(task.dir, "RESULT.md"),
393
- sessionDir: task.dir,
394
- sessionName: task.sessionName,
395
- paneId: task.paneId,
396
- });
397
- if (snapshot.status === "running") {
398
- backgroundTasks.set(id, task);
399
- continue;
400
- }
401
- const phase = snapshot.status === "completed" ? "done" : "failed";
402
- completeTask(pi, id, task, snapshot.content, phase, piDir);
403
- }
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,
404
63
  }, BACKGROUND_CHECK_MS);
405
64
  // ── Cleanup on shutdown ────────────────────────────────────────────────
406
65
  pi.on("session_shutdown", () => {
407
66
  clearInterval(checkInterval);
408
67
  clearInterval(countInterval);
409
- stopWidget();
410
- if (widgetCtx) {
411
- widgetCtx.ui.setWidget("task", undefined);
412
- widgetCtx = null;
413
- }
68
+ taskWidget.dispose();
414
69
  });
415
70
  // ── Custom notification renderer ───────────────────────────────────────
416
- pi.registerMessageRenderer?.("task-complete", (message, { expanded }, theme) => {
417
- const d = message.details;
418
- if (!d)
419
- return undefined;
420
- const agentType = d.agent_type || "";
421
- const desc = d.description || "";
422
- const summary = d.summary || "";
423
- const findings = d.findings || "";
424
- const confidence = d.confidence || "";
425
- const durationMs = d.duration_ms || 0;
426
- const toolUses = d.tool_uses || 0;
427
- const turns = d.turn_count || 0;
428
- let line = theme.fg("accent", agentType);
429
- if (desc)
430
- line += theme.fg("dim", ` - ${desc}`);
431
- const useStr = toolUses > 0 ? `${turns || toolUses} toolcalls` : "";
432
- const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
433
- const statsParts = [useStr, durStr].filter(Boolean);
434
- if (statsParts.length) {
435
- line += "\n" + theme.fg("dim", statsParts.join(" • "));
436
- }
437
- const confStr = confidence ? confidence.toUpperCase() : "";
438
- if (confStr && (statsParts.length || expanded)) {
439
- const confColor = confidence === "high"
440
- ? "success"
441
- : confidence === "low"
442
- ? "error"
443
- : "accent";
444
- line += "\n" + theme.fg(confColor, `[${confStr}]`);
445
- }
446
- if (expanded) {
447
- if (summary)
448
- line += "\n" + theme.fg("muted", summary);
449
- if (findings)
450
- line += "\n" + theme.fg("dim", findings);
451
- }
452
- if (!line.trim())
453
- return undefined;
454
- return new Text(line, 0, 0);
455
- });
71
+ pi.registerMessageRenderer?.("task-complete", createTaskCompleteRenderer());
456
72
  // ── Tool Registration ──────────────────────────────────────────────────
457
73
  pi.registerTool({
458
74
  name: "task",
@@ -469,27 +85,7 @@ export default function (pi) {
469
85
  "After delegated work completes, read changed files, review diff, verify scope, and run relevant checks",
470
86
  "Send the user a concise summary of the result since the agent's output is not user-visible",
471
87
  ],
472
- parameters: Type.Object({
473
- agent_type: Type.String({
474
- description: "The type of specialist agent to use for this task",
475
- }),
476
- prompt: Type.String({
477
- description: "The complete task for the agent to perform. Be detailed and self-contained.",
478
- }),
479
- description: Type.String({
480
- description: "A short (3-5 word) summary of the task",
481
- }),
482
- task_id: Type.Optional(Type.String({
483
- description: "Resume an existing background task by id instead of starting a new task.",
484
- })),
485
- conversation_id: Type.Optional(Type.String({
486
- description: "Durable specialist conversation id. Reuses .pi/artifacts/task-<id>/sessions when called again.",
487
- })),
488
- background: Type.Optional(Type.Boolean({
489
- 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.",
490
- default: true,
491
- })),
492
- }),
88
+ parameters: taskParametersSchema(),
493
89
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
494
90
  const { agents, piDir } = discoverAgents(ctx.cwd, BUNDLED_AGENT_DIR);
495
91
  const parentToolNames = pi
@@ -515,14 +111,11 @@ export default function (pi) {
515
111
  }
516
112
  // ── Resolve task identity: new, task resume, or conversation resume ──
517
113
  const conversationId = normalizeConversationId(params.conversation_id);
518
- const conversationRegistry = conversationId
519
- ? readConversationRegistry(piDir)
114
+ const taskSessionsRegistry = conversationId
115
+ ? readTaskSessionsRegistry(piDir)
520
116
  : {};
521
- const registeredArtifact = conversationId
522
- ? conversationRegistry[conversationId]
523
- : undefined;
524
- const registeredTaskId = registeredArtifact
525
- ? taskIdFromArtifactName(registeredArtifact)
117
+ const registeredTaskId = conversationId
118
+ ? taskSessionsRegistry[conversationId]?.task_id
526
119
  : undefined;
527
120
  if (params.task_id &&
528
121
  registeredTaskId &&
@@ -531,7 +124,7 @@ export default function (pi) {
531
124
  content: [
532
125
  {
533
126
  type: "text",
534
- text: `conversation_id "${conversationId}" maps to ${taskArtifactName(registeredTaskId)}, not ${taskArtifactName(params.task_id)}. Omit task_id or use the mapped task id.`,
127
+ text: `conversation_id "${conversationId}" maps to ${registeredTaskId}, not ${params.task_id}. Omit task_id or use the mapped task id.`,
535
128
  },
536
129
  ],
537
130
  details: {
@@ -543,37 +136,21 @@ export default function (pi) {
543
136
  }
544
137
  let id;
545
138
  let sessionName;
546
- let artifactDir;
547
- let resultPath;
548
139
  let resume = false;
140
+ let resumeSessionRef;
141
+ const artifactsDir = join(piDir, "artifacts");
549
142
  if (registeredTaskId) {
550
143
  id = registeredTaskId;
551
- sessionName = taskArtifactName(id);
552
- artifactDir = join(getArtifactsDir(piDir), sessionName);
553
- resultPath = join(artifactDir, "RESULT.md");
554
- if (!existsSync(artifactDir)) {
555
- return {
556
- content: [
557
- {
558
- type: "text",
559
- text: `conversation_id "${conversationId}" points to missing artifact directory: ${artifactDir}`,
560
- },
561
- ],
562
- details: {
563
- phase: "failed",
564
- error: "Conversation artifact dir missing",
565
- conversation_id: conversationId,
566
- },
567
- isError: true,
568
- };
569
- }
570
- const metadata = readConversationMetadata(join(artifactDir, "metadata.json"));
571
- if (metadata?.agent_type && metadata.agent_type !== agent.name) {
144
+ sessionName = conversationId ?? `task-${id}`;
145
+ const block = readTaskBlock(piDir, id);
146
+ const previousMetadata = parseMetadataFromBody(block?.body);
147
+ const metadataAgent = previousMetadata?.agent_type;
148
+ if (metadataAgent && metadataAgent !== agent.name) {
572
149
  return {
573
150
  content: [
574
151
  {
575
152
  type: "text",
576
- text: `conversation_id "${conversationId}" belongs to agent "${metadata.agent_type}", not "${agent.name}". Use the original agent_type or start a different conversation_id.`,
153
+ text: `conversation_id "${conversationId}" belongs to agent "${metadataAgent}", not "${agent.name}". Use the original agent_type or start a different conversation_id.`,
577
154
  },
578
155
  ],
579
156
  details: {
@@ -590,7 +167,7 @@ export default function (pi) {
590
167
  entry?.paneId &&
591
168
  paneExists(entry.paneId)) {
592
169
  const bgtask = {
593
- dir: artifactDir,
170
+ dir: artifactsDir,
594
171
  agentType: entry.agentType,
595
172
  sessionName,
596
173
  paneId: entry.paneId,
@@ -607,7 +184,7 @@ export default function (pi) {
607
184
  content: [
608
185
  {
609
186
  type: "text",
610
- text: `Resumed conversation "${conversationId}" via ${taskArtifactName(id)}. The subagent is running in background and will notify on completion.`,
187
+ text: `Resumed conversation "${conversationId}" via ${sessionName}. The subagent is running in background and will notify on completion.`,
611
188
  },
612
189
  ],
613
190
  details: {
@@ -622,15 +199,31 @@ export default function (pi) {
622
199
  }
623
200
  }
624
201
  else if (params.task_id) {
625
- // Look up the task in the persistent registry
202
+ // Look up active tasks first, then durable completed-session history.
626
203
  const entries = readRegistry(piDir);
627
- const entry = entries.find((e) => e.id === params.task_id);
204
+ let entry = entries.find((e) => e.id === params.task_id || e.sessionName === params.task_id) ??
205
+ findTaskSessionHistory(piDir, params.task_id) ??
206
+ findJsonlSessionByName(piDir, params.task_id, agent.name);
207
+ // Older history entries were written before we stored the
208
+ // actual JSONL path needed by `pi --session`. Repair them by
209
+ // resolving the display session name to a session file.
210
+ if (entry && !entry.sessionRef) {
211
+ const discovered = findJsonlSessionByName(piDir, entry.sessionName, entry.agentType);
212
+ if (discovered?.sessionRef) {
213
+ entry = { ...entry, sessionRef: discovered.sessionRef };
214
+ upsertTaskSessionHistory(piDir, {
215
+ ...entry,
216
+ status: "done",
217
+ background: false,
218
+ });
219
+ }
220
+ }
628
221
  if (!entry) {
629
222
  return {
630
223
  content: [
631
224
  {
632
225
  type: "text",
633
- text: `Unknown task_id: "${params.task_id}". No task with that ID found in the registry.`,
226
+ text: `Unknown task_id: "${params.task_id}". No active or completed task session with that ID/session name was found.`,
634
227
  },
635
228
  ],
636
229
  details: {
@@ -655,23 +248,23 @@ export default function (pi) {
655
248
  isError: true,
656
249
  };
657
250
  }
658
- // Resume: reuse existing artifact dir and session name
251
+ // Resume: reuse the existing session name; runtime files are
252
+ // flat in artifactsDir, no per-task subdir.
659
253
  id = entry.id;
660
254
  sessionName = entry.sessionName;
661
- artifactDir = entry.dir;
662
- resultPath = join(artifactDir, "RESULT.md");
663
255
  resume = true;
256
+ resumeSessionRef = entry.sessionRef;
664
257
  // If background and pane still alive, reattach to tracker
665
258
  if (params.background !== false &&
666
259
  entry.paneId &&
667
260
  paneExists(entry.paneId)) {
668
261
  const bgtask = {
669
- dir: artifactDir,
670
- agentType: agent.name,
262
+ dir: artifactsDir,
263
+ agentType: entry.agentType,
671
264
  sessionName,
672
265
  paneId: entry.paneId,
673
266
  originalPane: null,
674
- description: params.description || entry.agentType,
267
+ description: params.description || entry.description,
675
268
  startedAt: entry.startedAt,
676
269
  toolUses: 0,
677
270
  turns: 0,
@@ -688,21 +281,33 @@ export default function (pi) {
688
281
  ],
689
282
  details: {
690
283
  task_id: id,
691
- agent_type: agent.name,
692
- description: params.description,
284
+ agent_type: entry.agentType,
285
+ description: params.description || entry.description,
693
286
  conversation_id: entry.conversationId ?? conversationId,
694
287
  tmux_session: sessionName,
695
288
  background: true,
696
289
  },
697
290
  };
698
291
  }
292
+ if (!resumeSessionRef) {
293
+ return {
294
+ content: [
295
+ {
296
+ type: "text",
297
+ text: `Task "${params.task_id}" was found, but its session JSONL file could not be resolved. Cannot resume without a --session file path.`,
298
+ },
299
+ ],
300
+ details: {
301
+ phase: "failed",
302
+ error: "Task session file missing",
303
+ },
304
+ isError: true,
305
+ };
306
+ }
699
307
  }
700
308
  else {
701
309
  id = `${Date.now().toString(36)}-${randomUUID().slice(0, 4)}`;
702
- sessionName = taskArtifactName(id);
703
- artifactDir = join(getArtifactsDir(piDir), sessionName);
704
- await mkdir(artifactDir, { recursive: true });
705
- resultPath = join(artifactDir, "RESULT.md");
310
+ sessionName = conversationId ?? `task-${id}`;
706
311
  }
707
312
  if (conversationId && !hasTmux()) {
708
313
  return {
@@ -721,58 +326,36 @@ export default function (pi) {
721
326
  };
722
327
  }
723
328
  if (conversationId) {
724
- await mkdir(artifactDir, { recursive: true });
725
- conversationRegistry[conversationId] = taskArtifactName(id);
726
- writeConversationRegistry(piDir, conversationRegistry);
727
- writeConversationArtifacts({
728
- taskDir: artifactDir,
729
- taskId: id,
730
- conversationId,
731
- agentType: agent.name,
732
- sessionDir: join(artifactDir, "sessions"),
733
- sessionName,
734
- prompt: params.prompt,
735
- });
329
+ await mkdir(artifactsDir, { recursive: true });
330
+ const taskSessionsRegistry = readTaskSessionsRegistry(piDir);
331
+ taskSessionsRegistry[conversationId] = {
332
+ task_id: id,
333
+ session_file: `${artifactsDir}/${id}`,
334
+ };
335
+ writeTaskSessionsRegistry(piDir, taskSessionsRegistry);
736
336
  }
737
337
  const descText = params.description || "";
738
338
  const isBackground = params.background ?? TASK_BACKGROUND_DEFAULT;
739
339
  // default true
740
- // ── Write durable task context ──────────────────────────────────────
741
- const contextPath = join(artifactDir, "CONTEXT.md");
742
- const contextContent = [
743
- `# Task: ${descText}`,
744
- "",
745
- `## Agent`,
746
- `${agent.name} (${agent.source})`,
747
- "",
748
- `## Instructions`,
749
- params.prompt,
750
- "",
751
- `## Working Directory`,
752
- ctx.cwd,
753
- "",
754
- `## Output`,
755
- `Write your result to ${resultPath}`,
756
- "",
757
- "Use this format:",
758
- "",
759
- "```",
760
- TASK_RESULT_XML_INSTRUCTIONS,
761
- "```",
762
- ].join("\n");
763
- await writeFile(contextPath, contextContent, "utf-8");
764
- const promptContent = [
765
- `Read ${contextPath} for your task.`,
766
- `Write your findings/output to ${resultPath}`,
767
- "",
768
- "Format:",
769
- TASK_RESULT_XML_INSTRUCTIONS,
770
- ].join("\n");
771
- const sessionDir = join(artifactDir, "sessions");
340
+ // ── Build the prompt (instructions are inlined; no CONTEXT.md file) ─
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);
772
349
  await mkdir(sessionDir, { recursive: true });
773
350
  // ─── Build and run the sub-agent pi process ──────────────────────────
774
- const piArgs = buildPiArgs(agent, sessionName, sessionDir, promptContent, resume, parentToolNames);
351
+ const piArgs = buildPiArgs(agent, sessionName, sessionDir, promptContent, resume, parentToolNames, resumeSessionRef);
775
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);
776
359
  const toolSelection = buildAgentToolSelection({
777
360
  tools: agent.tools,
778
361
  disallowedTools: agent.disallowedTools,
@@ -792,7 +375,7 @@ export default function (pi) {
792
375
  const foregroundTask = isBackground
793
376
  ? undefined
794
377
  : {
795
- dir: artifactDir,
378
+ dir: artifactsDir,
796
379
  agentType: agent.name,
797
380
  sessionName,
798
381
  originalPane: null,
@@ -807,11 +390,13 @@ export default function (pi) {
807
390
  foregroundTasks.set(id, foregroundTask);
808
391
  ensureTaskWidget(ctx);
809
392
  }
810
- // Prefer tmux for observability, but fall back to the SDK in headless/CI/RPC.
811
- 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) {
812
397
  if (isBackground) {
813
398
  const bgtask = {
814
- dir: artifactDir,
399
+ dir: artifactsDir,
815
400
  agentType: agent.name,
816
401
  sessionName,
817
402
  originalPane: null,
@@ -830,18 +415,22 @@ export default function (pi) {
830
415
  sessionName,
831
416
  startedAt: bgtask.startedAt,
832
417
  piDir,
833
- dir: artifactDir,
418
+ dir: artifactsDir,
834
419
  conversationId,
835
420
  };
836
421
  const entries = readRegistry(piDir);
837
422
  entries.push(entry);
838
423
  writeRegistry(piDir, entries);
424
+ upsertTaskSessionHistory(piDir, {
425
+ ...entry,
426
+ status: "running",
427
+ background: true,
428
+ });
839
429
  pi.appendEntry("task-registry", entry);
840
430
  ensureTaskWidget(ctx);
841
431
  void runSdkFallback()
842
432
  .then(async ({ output }) => {
843
433
  const finalOutput = output || "SDK subagent completed without assistant text.";
844
- await writeFile(resultPath, finalOutput, "utf-8");
845
434
  backgroundTasks.delete(id);
846
435
  clearTaskWidgetIfIdle();
847
436
  completeTask(pi, id, bgtask, finalOutput, "done", piDir);
@@ -856,14 +445,12 @@ export default function (pi) {
856
445
  content: [
857
446
  {
858
447
  type: "text",
859
- text: `Task ${id} started with SDK backend (tmux unavailable).`,
448
+ text: `Task ${id} started with SDK backend.`,
860
449
  },
861
450
  ],
862
451
  details: {
863
452
  task_id: id,
864
453
  background: true,
865
- backend: "sdk",
866
- result_path: resultPath,
867
454
  conversation_id: conversationId,
868
455
  },
869
456
  };
@@ -871,14 +458,23 @@ export default function (pi) {
871
458
  try {
872
459
  const { output, sessionPath } = await runSdkFallback();
873
460
  const finalOutput = output || "SDK subagent completed without assistant text.";
874
- await writeFile(resultPath, finalOutput, "utf-8");
461
+ if (conversationId) {
462
+ writeConversationArtifacts({
463
+ piDir,
464
+ taskId: id,
465
+ conversationId,
466
+ agentType: agent.name,
467
+ sessionFile: sessionPath ?? "unknown",
468
+ prompt: params.prompt,
469
+ result: finalOutput,
470
+ });
471
+ }
875
472
  return {
876
473
  content: [{ type: "text", text: finalOutput }],
877
474
  details: {
878
475
  phase: "done",
879
476
  backend: "sdk",
880
477
  session_path: sessionPath,
881
- result_path: resultPath,
882
478
  conversation_id: conversationId,
883
479
  },
884
480
  };
@@ -903,12 +499,15 @@ export default function (pi) {
903
499
  }
904
500
  }
905
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}`);
906
504
  let paneId;
907
505
  let originalPane;
908
506
  try {
909
- const splitResult = splitWindowPane(ctx.cwd, `cd ${shellQuote(ctx.cwd)} && ${shellCommand}`);
507
+ const splitResult = splitWindowPane(ctx.cwd, tmuxCommand);
910
508
  paneId = splitResult.paneId;
911
509
  originalPane = splitResult.originalPane;
510
+ setPaneRemainOnExit(paneId, true);
912
511
  if (foregroundTask) {
913
512
  foregroundTask.paneId = paneId;
914
513
  foregroundTask.originalPane = originalPane;
@@ -930,38 +529,101 @@ export default function (pi) {
930
529
  }
931
530
  // ── FOREGROUND MODE: block until result, return directly ────────────
932
531
  if (!isBackground) {
933
- const startedAt = Date.now();
532
+ const startedAt = foregroundTask?.startedAt ?? Date.now();
533
+ upsertTaskSessionHistory(piDir, {
534
+ id,
535
+ agentType: agent.name,
536
+ description: descText,
537
+ sessionName,
538
+ startedAt,
539
+ paneId,
540
+ piDir,
541
+ dir: artifactsDir,
542
+ conversationId,
543
+ status: "running",
544
+ background: false,
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 });
934
570
  const completion = await waitForSessionTaskCompletion({
935
- resultPath,
936
571
  sessionDir,
937
572
  sessionName,
938
573
  paneId,
939
574
  signal,
940
- timeoutMs: 30 * 60 * 1000,
575
+ timeoutMs: TASK_TIMEOUT_MS,
576
+ pollMs: 1000,
577
+ sinceMs: startedAt,
941
578
  });
579
+ clearInterval(toolProgressInterval);
580
+ signal?.removeEventListener("abort", onAbort);
942
581
  const content = completion.content;
943
582
  const phase = completion.status === "completed"
944
583
  ? "done"
945
584
  : completion.status === "cancelled"
946
585
  ? "cancelled"
947
586
  : "failed";
948
- killAgentPane(paneId, originalPane);
587
+ const completedSessionRef = findJsonlSessionByName(piDir, sessionName, agent.name)?.sessionRef;
588
+ upsertTaskSessionHistory(piDir, {
589
+ id,
590
+ agentType: agent.name,
591
+ description: descText,
592
+ sessionName,
593
+ startedAt,
594
+ paneId,
595
+ piDir,
596
+ dir: artifactsDir,
597
+ conversationId,
598
+ sessionRef: completedSessionRef,
599
+ status: phase,
600
+ completedAt: Date.now(),
601
+ background: false,
602
+ });
603
+ if (phase === "done") {
604
+ killAgentPane(paneId, originalPane);
605
+ }
949
606
  foregroundTasks.delete(id);
950
607
  clearTaskWidgetIfIdle();
608
+ if (conversationId) {
609
+ writeConversationArtifacts({
610
+ piDir,
611
+ taskId: id,
612
+ conversationId,
613
+ agentType: agent.name,
614
+ sessionFile: `${sessionDir}/${sessionName}`,
615
+ prompt: params.prompt,
616
+ result: content,
617
+ });
618
+ }
951
619
  const parsed = parseResultXml(content);
952
620
  const durationMs = Date.now() - startedAt;
953
- const { toolUses, turns } = countToolUses(sessionDir);
621
+ const { toolUses, turns } = countToolUses(sessionDir, sessionName);
954
622
  return {
955
623
  content: [
956
624
  {
957
625
  type: "text",
958
- text: [
959
- `${parsed.status || "done"}: ${parsed.summary || content.slice(0, 300)}`,
960
- toolUses > 0 ? `\n${turns || toolUses} toolcalls` : "",
961
- durationMs >= 1000 ? `\n${formatMs(durationMs)}` : "",
962
- ]
963
- .filter(Boolean)
964
- .join(""),
626
+ text: parsed.summary || content.trim(),
965
627
  },
966
628
  ],
967
629
  details: {
@@ -969,7 +631,7 @@ export default function (pi) {
969
631
  agent_type: agent.name,
970
632
  description: descText,
971
633
  phase,
972
- status: phase === "done" ? parsed.status || "done" : phase,
634
+ status: "done",
973
635
  summary: parsed.summary || "",
974
636
  findings: parsed.findings || "",
975
637
  evidence: parsed.evidence || "",
@@ -984,7 +646,7 @@ export default function (pi) {
984
646
  }
985
647
  // ── BACKGROUND MODE (default): add to tracker, return immediately ─────
986
648
  const bgtask = {
987
- dir: artifactDir,
649
+ dir: artifactsDir,
988
650
  agentType: agent.name,
989
651
  sessionName,
990
652
  paneId,
@@ -1003,16 +665,21 @@ export default function (pi) {
1003
665
  agentType: agent.name,
1004
666
  description: descText,
1005
667
  sessionName,
1006
- startedAt: Date.now(),
668
+ startedAt: bgtask.startedAt,
1007
669
  paneId,
1008
670
  piDir,
1009
- dir: artifactDir,
671
+ dir: artifactsDir,
1010
672
  conversationId,
1011
673
  };
1012
674
  // Write to JSON registry for on-load restore
1013
675
  const entries = readRegistry(piDir);
1014
676
  entries.push(entry);
1015
677
  writeRegistry(piDir, entries);
678
+ upsertTaskSessionHistory(piDir, {
679
+ ...entry,
680
+ status: "running",
681
+ background: true,
682
+ });
1016
683
  // Also persist to session store via appendEntry (audit trail)
1017
684
  pi.appendEntry("task-registry", entry);
1018
685
  // ── Abort signal handling ──────────────────────────────────────────
@@ -1024,13 +691,7 @@ export default function (pi) {
1024
691
  // Clean registry
1025
692
  const remaining = readRegistry(piDir).filter((e) => e.id !== id);
1026
693
  writeRegistry(piDir, remaining);
1027
- if (backgroundTasks.size === 0) {
1028
- stopWidget();
1029
- if (widgetCtx) {
1030
- widgetCtx.ui.setWidget("task", undefined);
1031
- widgetCtx = null;
1032
- }
1033
- }
694
+ clearTaskWidgetIfIdle();
1034
695
  }, { once: true });
1035
696
  }
1036
697
  // ── Sticky widget ──────────────────────────────────────────────────
@@ -1043,7 +704,7 @@ export default function (pi) {
1043
704
  taskId: id,
1044
705
  agentType: agent.name,
1045
706
  tmuxSession: sessionName,
1046
- artifactDir,
707
+ artifactDir: artifactsDir,
1047
708
  }),
1048
709
  },
1049
710
  ],
@@ -1056,70 +717,8 @@ export default function (pi) {
1056
717
  },
1057
718
  };
1058
719
  },
1059
- renderCall(args, theme, _context) {
1060
- const agentName = args.agent_type || "...";
1061
- const desc = args.description || "";
1062
- let text = theme.fg("toolTitle", "");
1063
- text += theme.fg("accent", agentName);
1064
- if (desc)
1065
- text += theme.fg("dim", ` - ${desc}`);
1066
- return new Text(text, 0, 0);
1067
- },
1068
- renderResult(result, { expanded }, theme, _context) {
1069
- const d = result.details;
1070
- if (!d)
1071
- return new Text("", 0, 0);
1072
- if (d.background) {
1073
- return new Text("", 0, 0);
1074
- }
1075
- if (d.phase === "timeout" ||
1076
- d.phase === "aborted" ||
1077
- d.phase === "failed") {
1078
- const line = theme.fg("error", "x") + " " + theme.fg("dim", `[${d.phase}]`);
1079
- return new Text(line, 0, 0);
1080
- }
1081
- const isError = d.status === "failure" ||
1082
- d.status === "blocked" ||
1083
- d.status === "unknown" ||
1084
- d.status === "timeout" ||
1085
- d.status === "failed";
1086
- const durationMs = d.duration_ms || 0;
1087
- const toolUses = d.tool_uses || 0;
1088
- const turns = d.turn_count || 0;
1089
- const useStr = toolUses > 0 ? `${turns || toolUses} toolcalls` : "";
1090
- const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
1091
- const statsParts = [useStr, durStr].filter(Boolean);
1092
- const statsStr = statsParts.length
1093
- ? " " + theme.fg("dim", statsParts.join(" • "))
1094
- : "";
1095
- const icon = isError ? theme.fg("error", "x") : theme.fg("success", "✓");
1096
- const statusLabel = d.status && d.status !== "done" ? d.status : "done";
1097
- let line = icon +
1098
- " " +
1099
- theme.fg(isError ? "error" : "success", statusLabel) +
1100
- statsStr;
1101
- if (expanded) {
1102
- const s = d.summary || "";
1103
- const f = d.findings || "";
1104
- const e = d.evidence || "";
1105
- if (s)
1106
- line += "\n" + theme.fg("muted", s);
1107
- if (f)
1108
- line += "\n" + theme.fg("dim", f);
1109
- if (e)
1110
- line += "\n" + theme.fg("muted", "Evidence: ") + theme.fg("dim", e);
1111
- }
1112
- else {
1113
- const preview = (d.summary || "").slice(0, 80);
1114
- if (preview)
1115
- line += "\n" + theme.fg("dim", ` ⎿ ${preview}`);
1116
- else
1117
- line +=
1118
- "\n" +
1119
- theme.fg("dim", ` ⎿ ${isError ? d.status || "error" : "Done"}`);
1120
- }
1121
- return new Text(line, 0, 0);
1122
- },
720
+ renderCall,
721
+ renderResult,
1123
722
  });
1124
723
  pi.registerCommand("task-sessions", {
1125
724
  description: "List durable pi-task conversations",