@heyhuynhgiabuu/pi-task 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,969 @@
1
+ /**
2
+ * Task Tool — Delegate complex work to specialist agents.
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.
7
+ *
8
+ * Three agent sources:
9
+ * - .pi/agents/*.md project-local agents
10
+ * - ~/.pi/agent/agents/*.md user-global agents (fallback)
11
+ *
12
+ * 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.
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
+ import { randomUUID } from "node:crypto";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { Type } from "@sinclair/typebox";
24
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
25
+ import { TASK_BACKGROUND_DEFAULT, TASK_RESULT_XML_INSTRUCTIONS, TASK_TOOL_DESCRIPTION, buildTmuxSendKeysArgs, formatBackgroundReceipt, buildPiArgs, parseResultXml, formatMs, shellQuote, discoverAgents, formatAgentList, countToolUses, readRecentToolCalls, } from "./helpers.js";
26
+ import { runSdkSubagent } from "./subagent/runSdk.js";
27
+ import { checkTaskCompletion, waitForTaskCompletion as waitForSessionTaskCompletion, } from "./subagent/waitCompletion.js";
28
+ import { buildAgentToolSelection } from "./agent-tools.js";
29
+ // ─── Constants ───────────────────────────────────────────────────────────────
30
+ const BUNDLED_AGENT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "agents");
31
+ const BACKGROUND_CHECK_MS = 10_000; // poll every 10 sec
32
+ const COUNT_POLL_MS = 3_000; // update toolcall counts every 3 sec
33
+ const TASK_TIMEOUT_MS = 30 * 60 * 1_000; // 30 minutes
34
+ // ─── Registry helpers (durable JSON) ─────────────────────────────────────────
35
+ function readRegistry(piDir) {
36
+ const path = join(piDir, "task-registry.json");
37
+ try {
38
+ return JSON.parse(readFileSync(path, "utf-8"));
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ }
44
+ function writeRegistry(piDir, entries) {
45
+ const path = join(piDir, "task-registry.json");
46
+ writeFileSync(path, JSON.stringify(entries, null, 2), "utf-8");
47
+ }
48
+ // ─── Tmux Helpers ────────────────────────────────────────────────────────────
49
+ function tmuxCmd(args) {
50
+ return execFileSync("tmux", args, {
51
+ encoding: "utf-8",
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ }).trim();
54
+ }
55
+ function hasTmux() {
56
+ try {
57
+ execFileSync("tmux", ["-V"], { stdio: "ignore" });
58
+ return true;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ function paneExists(paneId) {
65
+ try {
66
+ return tmuxCmd(["list-panes", "-a", "-F", "#{pane_id}"])
67
+ .split("\n")
68
+ .includes(paneId);
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ function getCurrentPaneId() {
75
+ try {
76
+ return tmuxCmd(["display-message", "-p", "#{pane_id}"]);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ function splitWindowPane(cwd, command) {
83
+ const originalPane = getCurrentPaneId();
84
+ const paneId = tmuxCmd([
85
+ "split-window",
86
+ "-h",
87
+ "-P",
88
+ "-F",
89
+ "#{pane_id}",
90
+ "-c",
91
+ cwd,
92
+ ]);
93
+ execFileSync("tmux", buildTmuxSendKeysArgs(paneId, command), {
94
+ stdio: "ignore",
95
+ });
96
+ return { paneId, originalPane };
97
+ }
98
+ function killAgentPane(paneId, originalPane) {
99
+ if (paneId) {
100
+ try {
101
+ if (paneExists(paneId))
102
+ tmuxCmd(["kill-pane", "-t", paneId]);
103
+ }
104
+ catch {
105
+ /* ignore */
106
+ }
107
+ }
108
+ if (originalPane) {
109
+ try {
110
+ tmuxCmd(["select-pane", "-t", originalPane]);
111
+ }
112
+ catch {
113
+ /* ignore */
114
+ }
115
+ }
116
+ }
117
+ // ─── Process a completed task (sendMessage + registry cleanup) ──────────────
118
+ function completeTask(pi, id, task, content, phase, piDir) {
119
+ // Kill the tmux pane if still alive
120
+ killAgentPane(task.paneId, task.originalPane);
121
+ const parsed = parseResultXml(content);
122
+ const durationMs = Date.now() - task.startedAt;
123
+ // Send completion notification
124
+ pi.sendMessage({
125
+ customType: "task-complete",
126
+ content: `Background task ${id} (${task.agentType}) ${phase}.\n\nResult:\n${content}`,
127
+ display: true,
128
+ details: {
129
+ task_id: id,
130
+ agent_type: task.agentType,
131
+ description: task.description,
132
+ phase,
133
+ status: phase,
134
+ result: content,
135
+ summary: parsed.summary,
136
+ findings: parsed.findings,
137
+ confidence: parsed.confidence,
138
+ duration_ms: durationMs,
139
+ tool_uses: task.toolUses,
140
+ turn_count: task.turns,
141
+ },
142
+ }, {
143
+ triggerTurn: true,
144
+ deliverAs: "followUp",
145
+ });
146
+ // Remove from registry
147
+ const entries = readRegistry(piDir).filter((e) => e.id !== id);
148
+ writeRegistry(piDir, entries);
149
+ }
150
+ // ─── Extension Entry Point ──────────────────────────────────────────────────
151
+ export default function (pi) {
152
+ // Prevent recursive loading
153
+ if (process.env.PI_TASK_TOOL_DISABLED === "1")
154
+ return;
155
+ // ── Background task tracker ────────────────────────────────────────────
156
+ const backgroundTasks = new Map();
157
+ const foregroundTasks = new Map();
158
+ let widgetCtx = null;
159
+ // ── Restore active tasks from registry on load ──────────────────────────
160
+ const { piDir } = discoverAgents(process.cwd());
161
+ const registry = readRegistry(piDir);
162
+ const staleIds = [];
163
+ for (const entry of registry) {
164
+ // Only restore if artifact dir still exists
165
+ if (!existsSync(entry.dir)) {
166
+ staleIds.push(entry.id);
167
+ continue;
168
+ }
169
+ // Check if tmux pane is still alive
170
+ const paneAlive = entry.paneId ? paneExists(entry.paneId) : false;
171
+ if (!paneAlive) {
172
+ staleIds.push(entry.id);
173
+ continue;
174
+ }
175
+ const bgtask = {
176
+ dir: entry.dir,
177
+ agentType: entry.agentType,
178
+ sessionName: entry.sessionName,
179
+ paneId: entry.paneId,
180
+ originalPane: null,
181
+ description: entry.description,
182
+ startedAt: entry.startedAt,
183
+ toolUses: 0,
184
+ turns: 0,
185
+ recentCalls: [],
186
+ };
187
+ backgroundTasks.set(entry.id, bgtask);
188
+ }
189
+ if (staleIds.length) {
190
+ writeRegistry(piDir, registry.filter((e) => !staleIds.includes(e.id)));
191
+ }
192
+ // ── Widget / timer setup ───────────────────────────────────────────────
193
+ let widgetTimer = null;
194
+ function stopWidget() {
195
+ if (widgetTimer) {
196
+ clearInterval(widgetTimer);
197
+ widgetTimer = null;
198
+ }
199
+ }
200
+ const countInterval = setInterval(() => {
201
+ for (const task of [
202
+ ...foregroundTasks.values(),
203
+ ...backgroundTasks.values(),
204
+ ]) {
205
+ const sessionDir = join(task.dir, "sessions");
206
+ // Single walk: counts + recent tool-call history with status
207
+ const { toolUses, turns, recent } = readRecentToolCalls(sessionDir, 12);
208
+ task.toolUses = toolUses;
209
+ task.turns = turns;
210
+ task.recentCalls = recent;
211
+ }
212
+ }, COUNT_POLL_MS);
213
+ /**
214
+ * Render a streaming view of one active subagent. Layout per task:
215
+ *
216
+ * ⠋ Scout — SDK docs • 1m 0s 11 toolcalls (themed: accent + dim)
217
+ * ├─ ✓ websearch Model Context Protocol 2026 (green/success)
218
+ * ├─ ✓ codesearch MCP reference server typescript
219
+ * ├─ ✗ bash curl -sL "https://api.github.com..." (red/error)
220
+ * └─ ⠹ read /Users/.../scout.md (yellow/warning, animates)
221
+ *
222
+ * The header caret and in-progress tool marks share the same spinner
223
+ * frame set (rotates every WIDGET_RENDER_MS based on wall-clock time,
224
+ * so the animation cadence is stable regardless of TUI render rate).
225
+ */
226
+ // Theme reference is captured at setWidget time so renderWidget can use it.
227
+ // We don't import the Theme type because it's not exported; structural typing
228
+ // via `any` here is safe — the c() helper only calls `theme(color, text)`.
229
+ let widgetTheme = null;
230
+ // 8-frame Braille spinner. 80ms cadence = 12.5 FPS, which is the human
231
+ // perception threshold for "smooth motion" (below ~10 FPS the brain
232
+ // sees discrete steps; above ~12 FPS it reads as continuous rotation).
233
+ // Full rotation: 8 × 80ms = 640ms. Used for both per-tool in-progress
234
+ // marks AND the header caret (the "agent is active" indicator).
235
+ const WIDGET_SPINNER_FRAMES = [
236
+ "\u280B",
237
+ "\u2819",
238
+ "\u2838",
239
+ "\u2834",
240
+ "\u2826",
241
+ "\u2827",
242
+ "\u2807",
243
+ "\u280F",
244
+ ];
245
+ const WIDGET_CARET_FRAMES = WIDGET_SPINNER_FRAMES;
246
+ const WIDGET_RENDER_MS = 80;
247
+ const WIDGET_MAX_TOOL_LINES = 12;
248
+ const WIDGET_MAX_WIDTH = 120;
249
+ const TREE_MIDDLE = "\u251C\u2500"; // ├─
250
+ const TREE_LAST = "\u2514\u2500"; // └─
251
+ function c(color, text) {
252
+ // widgetTheme is a Theme object with a .fg(color, text) method,
253
+ // not a callable. Calling it as a function throws "widgetTheme is not
254
+ // a function" which the outer try/catch in renderWidget swallows.
255
+ return widgetTheme ? widgetTheme.fg(color, text) : text;
256
+ }
257
+ function renderWidget(width) {
258
+ // Defensive: never let a render exception kill the TUI. If anything
259
+ // throws (theme lookup miss, malformed session JSONL, etc.), fall
260
+ // back to a minimal single-line summary so the TUI stays alive.
261
+ try {
262
+ return renderWidgetInner(width);
263
+ }
264
+ catch (err) {
265
+ const msg = err instanceof Error ? err.message : String(err);
266
+ const active = [
267
+ ...Array.from(foregroundTasks.entries()),
268
+ ...Array.from(backgroundTasks.entries()),
269
+ ];
270
+ if (active.length === 0)
271
+ return [];
272
+ const [, task] = active[0];
273
+ return [
274
+ truncateToWidth(`${task.agentType} \u2022 ${formatMs(Date.now() - task.startedAt)} (render error: ${msg})`, Math.min(width, WIDGET_MAX_WIDTH)),
275
+ ];
276
+ }
277
+ }
278
+ function ensureTaskWidget(targetCtx) {
279
+ if (widgetCtx || targetCtx.mode !== "tui")
280
+ return;
281
+ widgetCtx = targetCtx;
282
+ targetCtx.ui.setWidget("task", (tui, theme) => {
283
+ widgetTheme = theme ?? null;
284
+ widgetTimer = setInterval(() => tui.requestRender(), WIDGET_RENDER_MS);
285
+ // Don't keep the process alive just for the widget refresh.
286
+ widgetTimer.unref?.();
287
+ return {
288
+ render: (width) => renderWidget(width),
289
+ invalidate: () => { },
290
+ dispose: () => {
291
+ widgetTheme = null;
292
+ stopWidget();
293
+ },
294
+ };
295
+ });
296
+ }
297
+ function clearTaskWidgetIfIdle() {
298
+ if (foregroundTasks.size > 0 || backgroundTasks.size > 0)
299
+ return;
300
+ stopWidget();
301
+ if (widgetCtx) {
302
+ widgetCtx.ui.setWidget("task", undefined);
303
+ widgetCtx = null;
304
+ }
305
+ }
306
+ function renderWidgetInner(width) {
307
+ const active = [
308
+ ...Array.from(foregroundTasks.entries()),
309
+ ...Array.from(backgroundTasks.entries()),
310
+ ];
311
+ if (active.length === 0)
312
+ return [];
313
+ const now = Date.now();
314
+ const maxWidth = Math.min(width, WIDGET_MAX_WIDTH);
315
+ const tick = Math.floor(now / WIDGET_RENDER_MS);
316
+ const spinner = WIDGET_SPINNER_FRAMES[tick % WIDGET_SPINNER_FRAMES.length];
317
+ const caret = WIDGET_CARET_FRAMES[tick % WIDGET_CARET_FRAMES.length];
318
+ const lines = [];
319
+ for (const [, task] of active) {
320
+ const agentName = task.agentType.charAt(0).toUpperCase() + task.agentType.slice(1);
321
+ const elapsed = formatMs(now - task.startedAt);
322
+ const total = task.toolUses > 0 ? ` ${task.turns || task.toolUses} toolcalls` : "";
323
+ const description = task.description ? ` — ${task.description}` : "";
324
+ // Header: ▼ <Agent> — <description> • 1m 0s 11 toolcalls
325
+ const header = c("accent", caret) +
326
+ " " +
327
+ c("toolTitle", agentName) +
328
+ c("dim", `${description} \u2022 ${elapsed}${total}`);
329
+ lines.push(truncateToWidth(header, maxWidth));
330
+ const recent = task.recentCalls ?? [];
331
+ if (recent.length > 0) {
332
+ const slice = recent.slice(-WIDGET_MAX_TOOL_LINES);
333
+ slice.forEach((tc, idx) => {
334
+ const isLast = idx === slice.length - 1;
335
+ const connector = isLast ? TREE_LAST : TREE_MIDDLE;
336
+ const isInProgress = tc.status === "in_progress";
337
+ const markChar = isInProgress
338
+ ? spinner
339
+ : tc.status === "error"
340
+ ? "\u2717"
341
+ : "\u2713";
342
+ const markColor = isInProgress
343
+ ? "warning"
344
+ : tc.status === "error"
345
+ ? "error"
346
+ : "success";
347
+ const detailStr = tc.detail ? ` ${tc.detail}` : "";
348
+ const line = " " +
349
+ c("dim", connector) +
350
+ " " +
351
+ c(markColor, markChar) +
352
+ " " +
353
+ c("text", tc.name) +
354
+ c("dim", detailStr);
355
+ lines.push(truncateToWidth(line, maxWidth));
356
+ });
357
+ }
358
+ lines.push("");
359
+ }
360
+ return lines;
361
+ }
362
+ // ── Polling loop (background task completion, pane death, timeout) ──────
363
+ const checkInterval = setInterval(async () => {
364
+ if (backgroundTasks.size === 0) {
365
+ clearTaskWidgetIfIdle();
366
+ return;
367
+ }
368
+ const now = Date.now();
369
+ const ids = Array.from(backgroundTasks.keys());
370
+ for (const id of ids) {
371
+ const task = backgroundTasks.get(id);
372
+ if (!task)
373
+ continue;
374
+ backgroundTasks.delete(id); // Remove atomically
375
+ // ── Check timeout ────────────────────────────────────────────
376
+ if (now - task.startedAt > TASK_TIMEOUT_MS) {
377
+ killAgentPane(task.paneId, task.originalPane);
378
+ completeTask(pi, id, task, "Task timed out after 30 minutes", "timeout", piDir);
379
+ continue;
380
+ }
381
+ const snapshot = await checkTaskCompletion({
382
+ resultPath: join(task.dir, "RESULT.md"),
383
+ sessionDir: task.dir,
384
+ sessionName: task.sessionName,
385
+ paneId: task.paneId,
386
+ });
387
+ if (snapshot.status === "running") {
388
+ backgroundTasks.set(id, task);
389
+ continue;
390
+ }
391
+ const phase = snapshot.status === "completed" ? "done" : "failed";
392
+ completeTask(pi, id, task, snapshot.content, phase, piDir);
393
+ }
394
+ }, BACKGROUND_CHECK_MS);
395
+ // ── Cleanup on shutdown ────────────────────────────────────────────────
396
+ pi.on("session_shutdown", () => {
397
+ clearInterval(checkInterval);
398
+ clearInterval(countInterval);
399
+ stopWidget();
400
+ if (widgetCtx) {
401
+ widgetCtx.ui.setWidget("task", undefined);
402
+ widgetCtx = null;
403
+ }
404
+ });
405
+ // ── Custom notification renderer ───────────────────────────────────────
406
+ pi.registerMessageRenderer?.("task-complete", (message, { expanded }, theme) => {
407
+ const d = message.details;
408
+ if (!d)
409
+ return undefined;
410
+ const agentType = d.agent_type || "";
411
+ const desc = d.description || "";
412
+ const summary = d.summary || "";
413
+ const findings = d.findings || "";
414
+ const confidence = d.confidence || "";
415
+ const durationMs = d.duration_ms || 0;
416
+ const toolUses = d.tool_uses || 0;
417
+ const turns = d.turn_count || 0;
418
+ let line = theme.fg("accent", agentType);
419
+ if (desc)
420
+ line += theme.fg("dim", ` - ${desc}`);
421
+ const useStr = toolUses > 0 ? `${turns || toolUses} toolcalls` : "";
422
+ const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
423
+ const statsParts = [useStr, durStr].filter(Boolean);
424
+ if (statsParts.length) {
425
+ line += "\n" + theme.fg("dim", statsParts.join(" • "));
426
+ }
427
+ const confStr = confidence ? confidence.toUpperCase() : "";
428
+ if (confStr && (statsParts.length || expanded)) {
429
+ const confColor = confidence === "high"
430
+ ? "success"
431
+ : confidence === "low"
432
+ ? "error"
433
+ : "accent";
434
+ line += "\n" + theme.fg(confColor, `[${confStr}]`);
435
+ }
436
+ if (expanded) {
437
+ if (summary)
438
+ line += "\n" + theme.fg("muted", summary);
439
+ if (findings)
440
+ line += "\n" + theme.fg("dim", findings);
441
+ }
442
+ if (!line.trim())
443
+ return undefined;
444
+ return new Text(line, 0, 0);
445
+ });
446
+ // ── Tool Registration ──────────────────────────────────────────────────
447
+ pi.registerTool({
448
+ name: "task",
449
+ label: "Task",
450
+ description: TASK_TOOL_DESCRIPTION,
451
+ promptSnippet: "Delegate work to a specialist agent via the task tool",
452
+ promptGuidelines: [
453
+ "Delegate complex multi-step work to a specialist agent when the work benefits from isolated context",
454
+ "Launch multiple agents concurrently by making multiple tool calls in a single message",
455
+ "Do NOT duplicate work you've delegated — wait for the result or work on non-overlapping tasks",
456
+ "Use agent_type to route to the right specialist",
457
+ "Tell the agent whether to write code or just research",
458
+ "For background tasks: DO NOT sleep, poll, or check on progress. You'll be notified",
459
+ "After delegated work completes, read changed files, review diff, verify scope, and run relevant checks",
460
+ "Send the user a concise summary of the result since the agent's output is not user-visible",
461
+ ],
462
+ parameters: Type.Object({
463
+ agent_type: Type.String({
464
+ description: "The type of specialist agent to use for this task",
465
+ }),
466
+ prompt: Type.String({
467
+ description: "The complete task for the agent to perform. Be detailed and self-contained.",
468
+ }),
469
+ description: Type.String({
470
+ description: "A short (3-5 word) summary of the task",
471
+ }),
472
+ task_id: Type.Optional(Type.String({
473
+ description: "Resume a previous task by ID (continues the same subagent session with its prior context instead of creating a fresh one)",
474
+ })),
475
+ background: Type.Optional(Type.Boolean({
476
+ 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.",
477
+ default: true,
478
+ })),
479
+ }),
480
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
481
+ const { agents, piDir } = discoverAgents(ctx.cwd, BUNDLED_AGENT_DIR);
482
+ const parentToolNames = pi
483
+ .getAllTools()
484
+ .map((tool) => tool.name)
485
+ .filter(Boolean);
486
+ const agent = agents.find((a) => a.name === params.agent_type);
487
+ if (!agent) {
488
+ const list = formatAgentList(agents);
489
+ return {
490
+ content: [
491
+ {
492
+ type: "text",
493
+ text: `Unknown agent: "${params.agent_type}".\nAvailable agents:\n${list}`,
494
+ },
495
+ ],
496
+ details: {
497
+ phase: "failed",
498
+ error: `Unknown agent: ${params.agent_type}`,
499
+ },
500
+ isError: true,
501
+ };
502
+ }
503
+ // ── Resolve task identity: new or resume ───────────────────────────
504
+ let id;
505
+ let sessionName;
506
+ let artifactDir;
507
+ let resultPath;
508
+ let resume = false;
509
+ if (params.task_id) {
510
+ // Look up the task in the persistent registry
511
+ const entries = readRegistry(piDir);
512
+ const entry = entries.find((e) => e.id === params.task_id);
513
+ if (!entry) {
514
+ return {
515
+ content: [
516
+ {
517
+ type: "text",
518
+ text: `Unknown task_id: "${params.task_id}". No task with that ID found in the registry.`,
519
+ },
520
+ ],
521
+ details: {
522
+ phase: "failed",
523
+ error: `Unknown task_id: ${params.task_id}`,
524
+ },
525
+ isError: true,
526
+ };
527
+ }
528
+ if (!existsSync(entry.dir)) {
529
+ return {
530
+ content: [
531
+ {
532
+ type: "text",
533
+ text: `Task "${params.task_id}" artifact directory no longer exists: ${entry.dir}`,
534
+ },
535
+ ],
536
+ details: {
537
+ phase: "failed",
538
+ error: "Task artifact dir missing",
539
+ },
540
+ isError: true,
541
+ };
542
+ }
543
+ // Resume: reuse existing artifact dir and session name
544
+ id = entry.id;
545
+ sessionName = entry.sessionName;
546
+ artifactDir = entry.dir;
547
+ resultPath = join(artifactDir, "RESULT.md");
548
+ resume = true;
549
+ // If background and pane still alive, reattach to tracker
550
+ if (params.background !== false &&
551
+ entry.paneId &&
552
+ paneExists(entry.paneId)) {
553
+ const bgtask = {
554
+ dir: artifactDir,
555
+ agentType: agent.name,
556
+ sessionName,
557
+ paneId: entry.paneId,
558
+ originalPane: null,
559
+ description: params.description || entry.agentType,
560
+ startedAt: entry.startedAt,
561
+ toolUses: 0,
562
+ turns: 0,
563
+ recentCalls: [],
564
+ };
565
+ backgroundTasks.set(id, bgtask);
566
+ return {
567
+ content: [
568
+ {
569
+ type: "text",
570
+ text: `Resumed task "${params.task_id}". The subagent is running in background and will notify on completion.`,
571
+ },
572
+ ],
573
+ details: {
574
+ task_id: id,
575
+ agent_type: agent.name,
576
+ description: params.description,
577
+ tmux_session: sessionName,
578
+ background: true,
579
+ },
580
+ };
581
+ }
582
+ }
583
+ else {
584
+ id = `${Date.now().toString(36)}-${randomUUID().slice(0, 4)}`;
585
+ sessionName = `task-${id}`;
586
+ artifactDir = join(piDir, "artifacts", sessionName);
587
+ await mkdir(artifactDir, { recursive: true });
588
+ resultPath = join(artifactDir, "RESULT.md");
589
+ }
590
+ const descText = params.description || "";
591
+ const isBackground = params.background ?? TASK_BACKGROUND_DEFAULT;
592
+ // default true
593
+ // ── Write durable task context ──────────────────────────────────────
594
+ const contextPath = join(artifactDir, "CONTEXT.md");
595
+ const contextContent = [
596
+ `# Task: ${descText}`,
597
+ "",
598
+ `## Agent`,
599
+ `${agent.name} (${agent.source})`,
600
+ "",
601
+ `## Instructions`,
602
+ params.prompt,
603
+ "",
604
+ `## Working Directory`,
605
+ ctx.cwd,
606
+ "",
607
+ `## Output`,
608
+ `Write your result to ${resultPath}`,
609
+ "",
610
+ "Use this format:",
611
+ "",
612
+ "```",
613
+ TASK_RESULT_XML_INSTRUCTIONS,
614
+ "```",
615
+ ].join("\n");
616
+ await writeFile(contextPath, contextContent, "utf-8");
617
+ const promptContent = [
618
+ `Read ${contextPath} for your task.`,
619
+ `Write your findings/output to ${resultPath}`,
620
+ "",
621
+ "Format:",
622
+ TASK_RESULT_XML_INSTRUCTIONS,
623
+ ].join("\n");
624
+ const sessionDir = join(artifactDir, "sessions");
625
+ await mkdir(sessionDir, { recursive: true });
626
+ // ─── Build and run the sub-agent pi process ──────────────────────────
627
+ const piArgs = buildPiArgs(agent, sessionName, sessionDir, promptContent, resume, parentToolNames);
628
+ const envPrefix = `PI_TASK_TOOL_DISABLED=1`;
629
+ const toolSelection = buildAgentToolSelection({
630
+ tools: agent.tools,
631
+ disallowedTools: agent.disallowedTools,
632
+ parentToolNames,
633
+ });
634
+ const runSdkFallback = async () => runSdkSubagent({
635
+ prompt: promptContent,
636
+ agent,
637
+ cwd: ctx.cwd,
638
+ ctx,
639
+ model: agent.model,
640
+ thinkingLevel: agent.thinking,
641
+ tools: toolSelection.tools,
642
+ excludeTools: toolSelection.excludeTools,
643
+ systemPrompt: agent.body,
644
+ });
645
+ const foregroundTask = isBackground
646
+ ? undefined
647
+ : {
648
+ dir: artifactDir,
649
+ agentType: agent.name,
650
+ sessionName,
651
+ originalPane: null,
652
+ description: descText,
653
+ startedAt: Date.now(),
654
+ toolUses: 0,
655
+ turns: 0,
656
+ recentCalls: [],
657
+ };
658
+ if (foregroundTask) {
659
+ foregroundTasks.set(id, foregroundTask);
660
+ ensureTaskWidget(ctx);
661
+ }
662
+ // Prefer tmux for observability, but fall back to the SDK in headless/CI/RPC.
663
+ if (!hasTmux()) {
664
+ if (isBackground) {
665
+ const bgtask = {
666
+ dir: artifactDir,
667
+ agentType: agent.name,
668
+ sessionName,
669
+ originalPane: null,
670
+ description: descText,
671
+ startedAt: Date.now(),
672
+ toolUses: 0,
673
+ turns: 0,
674
+ recentCalls: [],
675
+ };
676
+ backgroundTasks.set(id, bgtask);
677
+ const entry = {
678
+ id,
679
+ agentType: agent.name,
680
+ description: descText,
681
+ sessionName,
682
+ startedAt: bgtask.startedAt,
683
+ piDir,
684
+ dir: artifactDir,
685
+ };
686
+ const entries = readRegistry(piDir);
687
+ entries.push(entry);
688
+ writeRegistry(piDir, entries);
689
+ pi.appendEntry("task-registry", entry);
690
+ ensureTaskWidget(ctx);
691
+ void runSdkFallback()
692
+ .then(async ({ output }) => {
693
+ const finalOutput = output || "SDK subagent completed without assistant text.";
694
+ await writeFile(resultPath, finalOutput, "utf-8");
695
+ backgroundTasks.delete(id);
696
+ clearTaskWidgetIfIdle();
697
+ completeTask(pi, id, bgtask, finalOutput, "done", piDir);
698
+ })
699
+ .catch((error) => {
700
+ const message = error instanceof Error ? error.message : String(error);
701
+ backgroundTasks.delete(id);
702
+ clearTaskWidgetIfIdle();
703
+ completeTask(pi, id, bgtask, `Task ${id} failed: ${message}`, "failed", piDir);
704
+ });
705
+ return {
706
+ content: [
707
+ {
708
+ type: "text",
709
+ text: `Task ${id} started with SDK backend (tmux unavailable).`,
710
+ },
711
+ ],
712
+ details: {
713
+ task_id: id,
714
+ background: true,
715
+ backend: "sdk",
716
+ result_path: resultPath,
717
+ },
718
+ };
719
+ }
720
+ try {
721
+ const { output, sessionPath } = await runSdkFallback();
722
+ const finalOutput = output || "SDK subagent completed without assistant text.";
723
+ await writeFile(resultPath, finalOutput, "utf-8");
724
+ return {
725
+ content: [{ type: "text", text: finalOutput }],
726
+ details: {
727
+ phase: "done",
728
+ backend: "sdk",
729
+ session_path: sessionPath,
730
+ result_path: resultPath,
731
+ },
732
+ };
733
+ }
734
+ catch (error) {
735
+ const message = error instanceof Error ? error.message : String(error);
736
+ return {
737
+ content: [
738
+ { type: "text", text: `SDK task failed: ${message}` },
739
+ ],
740
+ details: {
741
+ phase: "failed",
742
+ backend: "sdk",
743
+ error: message,
744
+ },
745
+ isError: true,
746
+ };
747
+ }
748
+ finally {
749
+ foregroundTasks.delete(id);
750
+ clearTaskWidgetIfIdle();
751
+ }
752
+ }
753
+ const shellCommand = `${envPrefix} pi ${piArgs.map((a) => shellQuote(a)).join(" ")}`;
754
+ let paneId;
755
+ let originalPane;
756
+ try {
757
+ const splitResult = splitWindowPane(ctx.cwd, `cd ${shellQuote(ctx.cwd)} && ${shellCommand}`);
758
+ paneId = splitResult.paneId;
759
+ originalPane = splitResult.originalPane;
760
+ if (foregroundTask) {
761
+ foregroundTask.paneId = paneId;
762
+ foregroundTask.originalPane = originalPane;
763
+ }
764
+ }
765
+ catch {
766
+ foregroundTasks.delete(id);
767
+ clearTaskWidgetIfIdle();
768
+ return {
769
+ content: [
770
+ {
771
+ type: "text",
772
+ text: "Failed to create tmux split pane for the agent.",
773
+ },
774
+ ],
775
+ details: { phase: "failed", error: "tmux split failed" },
776
+ isError: true,
777
+ };
778
+ }
779
+ // ── FOREGROUND MODE: block until result, return directly ────────────
780
+ if (!isBackground) {
781
+ const startedAt = Date.now();
782
+ const completion = await waitForSessionTaskCompletion({
783
+ resultPath,
784
+ sessionDir,
785
+ sessionName,
786
+ paneId,
787
+ signal,
788
+ timeoutMs: 30 * 60 * 1000,
789
+ });
790
+ const content = completion.content;
791
+ const phase = completion.status === "completed"
792
+ ? "done"
793
+ : completion.status === "cancelled"
794
+ ? "cancelled"
795
+ : "failed";
796
+ killAgentPane(paneId, originalPane);
797
+ foregroundTasks.delete(id);
798
+ clearTaskWidgetIfIdle();
799
+ const parsed = parseResultXml(content);
800
+ const durationMs = Date.now() - startedAt;
801
+ const { toolUses, turns } = countToolUses(sessionDir);
802
+ return {
803
+ content: [
804
+ {
805
+ type: "text",
806
+ text: [
807
+ `${parsed.status || "done"}: ${parsed.summary || content.slice(0, 300)}`,
808
+ toolUses > 0 ? `\n${turns || toolUses} toolcalls` : "",
809
+ durationMs >= 1000 ? `\n${formatMs(durationMs)}` : "",
810
+ ]
811
+ .filter(Boolean)
812
+ .join(""),
813
+ },
814
+ ],
815
+ details: {
816
+ task_id: id,
817
+ agent_type: agent.name,
818
+ description: descText,
819
+ phase,
820
+ status: phase === "done" ? parsed.status || "done" : phase,
821
+ summary: parsed.summary || "",
822
+ findings: parsed.findings || "",
823
+ evidence: parsed.evidence || "",
824
+ confidence: parsed.confidence || "",
825
+ duration_ms: durationMs,
826
+ tool_uses: toolUses,
827
+ turn_count: turns,
828
+ background: false,
829
+ },
830
+ };
831
+ }
832
+ // ── BACKGROUND MODE (default): add to tracker, return immediately ─────
833
+ const bgtask = {
834
+ dir: artifactDir,
835
+ agentType: agent.name,
836
+ sessionName,
837
+ paneId,
838
+ originalPane,
839
+ description: descText,
840
+ startedAt: Date.now(),
841
+ toolUses: 0,
842
+ turns: 0,
843
+ recentCalls: [],
844
+ };
845
+ backgroundTasks.set(id, bgtask);
846
+ // ── P0: Persistent registry ────────────────────────────────────────
847
+ const entry = {
848
+ id,
849
+ agentType: agent.name,
850
+ description: descText,
851
+ sessionName,
852
+ startedAt: Date.now(),
853
+ paneId,
854
+ piDir,
855
+ dir: artifactDir,
856
+ };
857
+ // Write to JSON registry for on-load restore
858
+ const entries = readRegistry(piDir);
859
+ entries.push(entry);
860
+ writeRegistry(piDir, entries);
861
+ // Also persist to session store via appendEntry (audit trail)
862
+ pi.appendEntry("task-registry", entry);
863
+ // ── Abort signal handling ──────────────────────────────────────────
864
+ if (signal) {
865
+ signal.addEventListener("abort", () => {
866
+ killAgentPane(paneId, originalPane);
867
+ backgroundTasks.delete(id);
868
+ clearTaskWidgetIfIdle();
869
+ // Clean registry
870
+ const remaining = readRegistry(piDir).filter((e) => e.id !== id);
871
+ writeRegistry(piDir, remaining);
872
+ if (backgroundTasks.size === 0) {
873
+ stopWidget();
874
+ if (widgetCtx) {
875
+ widgetCtx.ui.setWidget("task", undefined);
876
+ widgetCtx = null;
877
+ }
878
+ }
879
+ }, { once: true });
880
+ }
881
+ // ── Sticky widget ──────────────────────────────────────────────────
882
+ ensureTaskWidget(ctx);
883
+ return {
884
+ content: [
885
+ {
886
+ type: "text",
887
+ text: formatBackgroundReceipt({
888
+ taskId: id,
889
+ agentType: agent.name,
890
+ tmuxSession: sessionName,
891
+ artifactDir,
892
+ }),
893
+ },
894
+ ],
895
+ details: {
896
+ task_id: id,
897
+ agent_type: agent.name,
898
+ description: descText,
899
+ tmux_session: sessionName,
900
+ background: true,
901
+ },
902
+ };
903
+ },
904
+ renderCall(args, theme, _context) {
905
+ const agentName = args.agent_type || "...";
906
+ const desc = args.description || "";
907
+ let text = theme.fg("toolTitle", "");
908
+ text += theme.fg("accent", agentName);
909
+ if (desc)
910
+ text += theme.fg("dim", ` - ${desc}`);
911
+ return new Text(text, 0, 0);
912
+ },
913
+ renderResult(result, { expanded }, theme, _context) {
914
+ const d = result.details;
915
+ if (!d)
916
+ return new Text("", 0, 0);
917
+ if (d.background) {
918
+ return new Text("", 0, 0);
919
+ }
920
+ if (d.phase === "timeout" ||
921
+ d.phase === "aborted" ||
922
+ d.phase === "failed") {
923
+ const line = theme.fg("error", "x") + " " + theme.fg("dim", `[${d.phase}]`);
924
+ return new Text(line, 0, 0);
925
+ }
926
+ const isError = d.status === "failure" ||
927
+ d.status === "blocked" ||
928
+ d.status === "unknown" ||
929
+ d.status === "timeout" ||
930
+ d.status === "failed";
931
+ const durationMs = d.duration_ms || 0;
932
+ const toolUses = d.tool_uses || 0;
933
+ const turns = d.turn_count || 0;
934
+ const useStr = toolUses > 0 ? `${turns || toolUses} toolcalls` : "";
935
+ const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
936
+ const statsParts = [useStr, durStr].filter(Boolean);
937
+ const statsStr = statsParts.length
938
+ ? " " + theme.fg("dim", statsParts.join(" • "))
939
+ : "";
940
+ const icon = isError ? theme.fg("error", "x") : theme.fg("success", "✓");
941
+ const statusLabel = d.status && d.status !== "done" ? d.status : "done";
942
+ let line = icon +
943
+ " " +
944
+ theme.fg(isError ? "error" : "success", statusLabel) +
945
+ statsStr;
946
+ if (expanded) {
947
+ const s = d.summary || "";
948
+ const f = d.findings || "";
949
+ const e = d.evidence || "";
950
+ if (s)
951
+ line += "\n" + theme.fg("muted", s);
952
+ if (f)
953
+ line += "\n" + theme.fg("dim", f);
954
+ if (e)
955
+ line += "\n" + theme.fg("muted", "Evidence: ") + theme.fg("dim", e);
956
+ }
957
+ else {
958
+ const preview = (d.summary || "").slice(0, 80);
959
+ if (preview)
960
+ line += "\n" + theme.fg("dim", ` ⎿ ${preview}`);
961
+ else
962
+ line +=
963
+ "\n" +
964
+ theme.fg("dim", ` ⎿ ${isError ? d.status || "error" : "Done"}`);
965
+ }
966
+ return new Text(line, 0, 0);
967
+ },
968
+ });
969
+ }