@heyhuynhgiabuu/pi-task 0.1.4 → 0.1.6

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 CHANGED
@@ -14,24 +14,26 @@
14
14
  * P1: Foreground mode (background:false, inline subprocess), pane death
15
15
  * detection, 30-minute timeout.
16
16
  */
17
- import { mkdir, writeFile } from "node:fs/promises";
18
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
19
17
  import { execFileSync } from "node:child_process";
20
18
  import { randomUUID } from "node:crypto";
19
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { mkdir } from "node:fs/promises";
21
21
  import { dirname, join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { Type } from "@sinclair/typebox";
24
24
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
25
+ import { buildAgentToolSelection } from "./agent-tools.js";
26
+ import { normalizeConversationId, parseMetadataFromBody, readTaskBlock, readTaskSessionsRegistry, renderConversationSessions, writeConversationArtifacts, writeTaskSessionsRegistry, } from "./conversation.js";
25
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";
26
28
  import { runSdkSubagent } from "./subagent/runSdk.js";
27
29
  import { checkTaskCompletion, waitForTaskCompletion as waitForSessionTaskCompletion, } from "./subagent/waitCompletion.js";
28
- import { buildAgentToolSelection } from "./agent-tools.js";
30
+ import { renderTaskWidget, TASK_WIDGET_RENDER_MS, } from "./task-widget.js";
29
31
  // ─── Constants ───────────────────────────────────────────────────────────────
30
32
  const BUNDLED_AGENT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "agents");
31
33
  const BACKGROUND_CHECK_MS = 10_000; // poll every 10 sec
32
34
  const COUNT_POLL_MS = 3_000; // update toolcall counts every 3 sec
33
35
  const TASK_TIMEOUT_MS = 30 * 60 * 1_000; // 30 minutes
34
- // ─── Registry helpers (durable JSON) ─────────────────────────────────────────
36
+ // Conversation helpers live in ./conversation.js.
35
37
  function readRegistry(piDir) {
36
38
  const path = join(piDir, "task-registry.json");
37
39
  try {
@@ -45,6 +47,87 @@ function writeRegistry(piDir, entries) {
45
47
  const path = join(piDir, "task-registry.json");
46
48
  writeFileSync(path, JSON.stringify(entries, null, 2), "utf-8");
47
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
+ }
48
131
  // ─── Tmux Helpers ────────────────────────────────────────────────────────────
49
132
  function tmuxCmd(args) {
50
133
  return execFileSync("tmux", args, {
@@ -128,6 +211,22 @@ function completeTask(pi, id, task, content, phase, piDir) {
128
211
  killAgentPane(task.paneId, task.originalPane);
129
212
  const parsed = parseResultXml(content);
130
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
+ });
131
230
  // Send completion notification
132
231
  pi.sendMessage({
133
232
  customType: "task-complete",
@@ -190,6 +289,7 @@ export default function (pi) {
190
289
  startedAt: entry.startedAt,
191
290
  toolUses: 0,
192
291
  turns: 0,
292
+ conversationId: entry.conversationId,
193
293
  recentCalls: [],
194
294
  };
195
295
  backgroundTasks.set(entry.id, bgtask);
@@ -212,62 +312,27 @@ export default function (pi) {
212
312
  ]) {
213
313
  const sessionDir = join(task.dir, "sessions");
214
314
  // Single walk: counts + recent tool-call history with status
215
- const { toolUses, turns, recent } = readRecentToolCalls(sessionDir, 12);
315
+ const { toolUses, turns, recent } = readRecentToolCalls(sessionDir, 12, task.sessionName);
216
316
  task.toolUses = toolUses;
217
317
  task.turns = turns;
218
318
  task.recentCalls = recent;
219
319
  }
220
320
  }, COUNT_POLL_MS);
221
- /**
222
- * Render a streaming view of one active subagent. Layout per task:
223
- *
224
- * ⠋ Scout — SDK docs • 1m 0s 11 toolcalls (themed: accent + dim)
225
- * ├─ ✓ websearch Model Context Protocol 2026 (green/success)
226
- * ├─ ✓ codesearch MCP reference server typescript
227
- * ├─ ✗ bash curl -sL "https://api.github.com..." (red/error)
228
- * └─ ⠹ read /Users/.../scout.md (yellow/warning, animates)
229
- *
230
- * The header caret and in-progress tool marks share the same spinner
231
- * frame set (rotates every WIDGET_RENDER_MS based on wall-clock time,
232
- * so the animation cadence is stable regardless of TUI render rate).
233
- */
234
321
  // Theme reference is captured at setWidget time so renderWidget can use it.
235
- // We don't import the Theme type because it's not exported; structural typing
236
- // via `any` here is safe — the c() helper only calls `theme(color, text)`.
237
322
  let widgetTheme = null;
238
- // 8-frame Braille spinner. 80ms cadence = 12.5 FPS, which is the human
239
- // perception threshold for "smooth motion" (below ~10 FPS the brain
240
- // sees discrete steps; above ~12 FPS it reads as continuous rotation).
241
- // Full rotation: 8 × 80ms = 640ms. Used for both per-tool in-progress
242
- // marks AND the header caret (the "agent is active" indicator).
243
- const WIDGET_SPINNER_FRAMES = [
244
- "\u280B",
245
- "\u2819",
246
- "\u2838",
247
- "\u2834",
248
- "\u2826",
249
- "\u2827",
250
- "\u2807",
251
- "\u280F",
252
- ];
253
- const WIDGET_CARET_FRAMES = WIDGET_SPINNER_FRAMES;
254
- const WIDGET_RENDER_MS = 80;
255
- const WIDGET_MAX_TOOL_LINES = 12;
256
- const WIDGET_MAX_WIDTH = 120;
257
- const TREE_MIDDLE = "\u251C\u2500"; // ├─
258
- const TREE_LAST = "\u2514\u2500"; // └─
259
- function c(color, text) {
260
- // widgetTheme is a Theme object with a .fg(color, text) method,
261
- // not a callable. Calling it as a function throws "widgetTheme is not
262
- // a function" which the outer try/catch in renderWidget swallows.
263
- return widgetTheme ? widgetTheme.fg(color, text) : text;
264
- }
265
323
  function renderWidget(width) {
266
324
  // Defensive: never let a render exception kill the TUI. If anything
267
325
  // throws (theme lookup miss, malformed session JSONL, etc.), fall
268
326
  // back to a minimal single-line summary so the TUI stays alive.
269
327
  try {
270
- return renderWidgetInner(width);
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
+ });
271
336
  }
272
337
  catch (err) {
273
338
  const msg = err instanceof Error ? err.message : String(err);
@@ -279,7 +344,7 @@ export default function (pi) {
279
344
  return [];
280
345
  const [, task] = active[0];
281
346
  return [
282
- truncateToWidth(`${task.agentType} \u2022 ${formatMs(Date.now() - task.startedAt)} (render error: ${msg})`, Math.min(width, WIDGET_MAX_WIDTH)),
347
+ truncateToWidth(`${task.agentType} \u2022 ${formatMs(Date.now() - task.startedAt)} (render error: ${msg})`, Math.min(width, 120)),
283
348
  ];
284
349
  }
285
350
  }
@@ -289,7 +354,7 @@ export default function (pi) {
289
354
  widgetCtx = targetCtx;
290
355
  targetCtx.ui.setWidget("task", (tui, theme) => {
291
356
  widgetTheme = theme ?? null;
292
- widgetTimer = setInterval(() => tui.requestRender(), WIDGET_RENDER_MS);
357
+ widgetTimer = setInterval(() => tui.requestRender(), TASK_WIDGET_RENDER_MS);
293
358
  // Don't keep the process alive just for the widget refresh.
294
359
  widgetTimer.unref?.();
295
360
  return {
@@ -311,62 +376,6 @@ export default function (pi) {
311
376
  widgetCtx = null;
312
377
  }
313
378
  }
314
- function renderWidgetInner(width) {
315
- const active = [
316
- ...Array.from(foregroundTasks.entries()),
317
- ...Array.from(backgroundTasks.entries()),
318
- ];
319
- if (active.length === 0)
320
- return [];
321
- const now = Date.now();
322
- const maxWidth = Math.min(width, WIDGET_MAX_WIDTH);
323
- const tick = Math.floor(now / WIDGET_RENDER_MS);
324
- const spinner = WIDGET_SPINNER_FRAMES[tick % WIDGET_SPINNER_FRAMES.length];
325
- const caret = WIDGET_CARET_FRAMES[tick % WIDGET_CARET_FRAMES.length];
326
- const lines = [];
327
- for (const [, task] of active) {
328
- const agentName = task.agentType.charAt(0).toUpperCase() + task.agentType.slice(1);
329
- const elapsed = formatMs(now - task.startedAt);
330
- const total = task.toolUses > 0 ? ` ${task.turns || task.toolUses} toolcalls` : "";
331
- const description = task.description ? ` — ${task.description}` : "";
332
- // Header: ▼ <Agent> — <description> • 1m 0s 11 toolcalls
333
- const header = c("accent", caret) +
334
- " " +
335
- c("toolTitle", agentName) +
336
- c("dim", `${description} \u2022 ${elapsed}${total}`);
337
- lines.push(truncateToWidth(header, maxWidth));
338
- const recent = task.recentCalls ?? [];
339
- if (recent.length > 0) {
340
- const slice = recent.slice(-WIDGET_MAX_TOOL_LINES);
341
- slice.forEach((tc, idx) => {
342
- const isLast = idx === slice.length - 1;
343
- const connector = isLast ? TREE_LAST : TREE_MIDDLE;
344
- const isInProgress = tc.status === "in_progress";
345
- const markChar = isInProgress
346
- ? spinner
347
- : tc.status === "error"
348
- ? "\u2717"
349
- : "\u2713";
350
- const markColor = isInProgress
351
- ? "warning"
352
- : tc.status === "error"
353
- ? "error"
354
- : "success";
355
- const detailStr = tc.detail ? ` ${tc.detail}` : "";
356
- const line = " " +
357
- c("dim", connector) +
358
- " " +
359
- c(markColor, markChar) +
360
- " " +
361
- c("text", tc.name) +
362
- c("dim", detailStr);
363
- lines.push(truncateToWidth(line, maxWidth));
364
- });
365
- }
366
- lines.push("");
367
- }
368
- return lines;
369
- }
370
379
  // ── Polling loop (background task completion, pane death, timeout) ──────
371
380
  const checkInterval = setInterval(async () => {
372
381
  if (backgroundTasks.size === 0) {
@@ -379,24 +388,27 @@ export default function (pi) {
379
388
  const task = backgroundTasks.get(id);
380
389
  if (!task)
381
390
  continue;
382
- backgroundTasks.delete(id); // Remove atomically
383
391
  // ── Check timeout ────────────────────────────────────────────
384
392
  if (now - task.startedAt > TASK_TIMEOUT_MS) {
385
393
  killAgentPane(task.paneId, task.originalPane);
394
+ backgroundTasks.delete(id);
395
+ clearTaskWidgetIfIdle();
386
396
  completeTask(pi, id, task, "Task timed out after 30 minutes", "timeout", piDir);
387
397
  continue;
388
398
  }
389
399
  const snapshot = await checkTaskCompletion({
390
400
  resultPath: join(task.dir, "RESULT.md"),
391
- sessionDir: task.dir,
401
+ sessionDir: join(task.dir, "sessions"),
392
402
  sessionName: task.sessionName,
393
403
  paneId: task.paneId,
404
+ sinceMs: task.startedAt,
394
405
  });
395
406
  if (snapshot.status === "running") {
396
- backgroundTasks.set(id, task);
397
407
  continue;
398
408
  }
399
409
  const phase = snapshot.status === "completed" ? "done" : "failed";
410
+ backgroundTasks.delete(id);
411
+ clearTaskWidgetIfIdle();
400
412
  completeTask(pi, id, task, snapshot.content, phase, piDir);
401
413
  }
402
414
  }, BACKGROUND_CHECK_MS);
@@ -422,34 +434,36 @@ export default function (pi) {
422
434
  const confidence = d.confidence || "";
423
435
  const durationMs = d.duration_ms || 0;
424
436
  const toolUses = d.tool_uses || 0;
425
- const turns = d.turn_count || 0;
426
- let line = theme.fg("accent", agentType);
437
+ let line = " " + theme.fg("accent", agentType);
427
438
  if (desc)
428
439
  line += theme.fg("dim", ` - ${desc}`);
429
- const useStr = toolUses > 0 ? `${turns || toolUses} toolcalls` : "";
440
+ const useStr = toolUses > 0 ? `${toolUses} toolcalls` : "";
430
441
  const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
431
442
  const statsParts = [useStr, durStr].filter(Boolean);
432
- if (statsParts.length) {
433
- line += "\n" + theme.fg("dim", statsParts.join(" • "));
434
- }
443
+ const statsText = statsParts.join(" • ");
435
444
  const confStr = confidence ? confidence.toUpperCase() : "";
436
- if (confStr && (statsParts.length || expanded)) {
437
- const confColor = confidence === "high"
438
- ? "success"
439
- : confidence === "low"
440
- ? "error"
441
- : "accent";
442
- line += "\n" + theme.fg(confColor, `[${confStr}]`);
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);
443
456
  }
444
457
  if (expanded) {
445
458
  if (summary)
446
- line += "\n" + theme.fg("muted", summary);
459
+ line += "\n " + theme.fg("muted", summary);
447
460
  if (findings)
448
- line += "\n" + theme.fg("dim", findings);
461
+ line += "\n " + theme.fg("dim", findings);
449
462
  }
450
463
  if (!line.trim())
451
464
  return undefined;
452
- return new Text(line, 0, 0);
465
+ const subtleBg = (text) => `\x1b[48;2;30;28;44m${text}\x1b[0m`;
466
+ return new Text(line, 0, 1, subtleBg);
453
467
  });
454
468
  // ── Tool Registration ──────────────────────────────────────────────────
455
469
  pi.registerTool({
@@ -478,7 +492,10 @@ export default function (pi) {
478
492
  description: "A short (3-5 word) summary of the task",
479
493
  }),
480
494
  task_id: Type.Optional(Type.String({
481
- description: "Resume a previous task by ID (continues the same subagent session with its prior context instead of creating a fresh one)",
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.",
482
499
  })),
483
500
  background: Type.Optional(Type.Boolean({
484
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.",
@@ -508,22 +525,139 @@ export default function (pi) {
508
525
  isError: true,
509
526
  };
510
527
  }
511
- // ── Resolve task identity: new or resume ───────────────────────────
528
+ // ── Resolve task identity: new, task resume, or conversation resume ──
529
+ const conversationId = normalizeConversationId(params.conversation_id);
530
+ const taskSessionsRegistry = conversationId
531
+ ? readTaskSessionsRegistry(piDir)
532
+ : {};
533
+ const registeredTaskId = conversationId
534
+ ? taskSessionsRegistry[conversationId]?.task_id
535
+ : undefined;
536
+ if (params.task_id &&
537
+ registeredTaskId &&
538
+ params.task_id !== registeredTaskId) {
539
+ return {
540
+ content: [
541
+ {
542
+ type: "text",
543
+ text: `conversation_id "${conversationId}" maps to ${registeredTaskId}, not ${params.task_id}. Omit task_id or use the mapped task id.`,
544
+ },
545
+ ],
546
+ details: {
547
+ phase: "failed",
548
+ error: "conversation_id/task_id mismatch",
549
+ },
550
+ isError: true,
551
+ };
552
+ }
512
553
  let id;
513
554
  let sessionName;
514
- let artifactDir;
515
555
  let resultPath;
516
556
  let resume = false;
517
- if (params.task_id) {
518
- // Look up the task in the persistent registry
557
+ let resumeSessionRef;
558
+ const artifactsDir = join(piDir, "artifacts");
559
+ if (registeredTaskId) {
560
+ id = registeredTaskId;
561
+ 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
+ const block = readTaskBlock(piDir, id);
580
+ const previousMetadata = parseMetadataFromBody(block?.body);
581
+ const metadataAgent = previousMetadata?.agent_type;
582
+ if (metadataAgent && metadataAgent !== agent.name) {
583
+ return {
584
+ content: [
585
+ {
586
+ type: "text",
587
+ text: `conversation_id "${conversationId}" belongs to agent "${metadataAgent}", not "${agent.name}". Use the original agent_type or start a different conversation_id.`,
588
+ },
589
+ ],
590
+ details: {
591
+ phase: "failed",
592
+ error: "conversation_id agent_type mismatch",
593
+ conversation_id: conversationId,
594
+ },
595
+ isError: true,
596
+ };
597
+ }
598
+ resume = true;
599
+ const entry = readRegistry(piDir).find((candidate) => candidate.id === id);
600
+ if (params.background !== false &&
601
+ entry?.paneId &&
602
+ paneExists(entry.paneId)) {
603
+ const bgtask = {
604
+ dir: artifactsDir,
605
+ agentType: entry.agentType,
606
+ sessionName,
607
+ paneId: entry.paneId,
608
+ originalPane: null,
609
+ description: params.description || entry.description,
610
+ startedAt: entry.startedAt,
611
+ toolUses: 0,
612
+ turns: 0,
613
+ conversationId,
614
+ recentCalls: [],
615
+ };
616
+ backgroundTasks.set(id, bgtask);
617
+ return {
618
+ content: [
619
+ {
620
+ type: "text",
621
+ text: `Resumed conversation "${conversationId}" via ${sessionName}. The subagent is running in background and will notify on completion.`,
622
+ },
623
+ ],
624
+ details: {
625
+ task_id: id,
626
+ agent_type: agent.name,
627
+ description: params.description,
628
+ conversation_id: conversationId,
629
+ tmux_session: sessionName,
630
+ background: true,
631
+ },
632
+ };
633
+ }
634
+ }
635
+ else if (params.task_id) {
636
+ // Look up active tasks first, then durable completed-session history.
519
637
  const entries = readRegistry(piDir);
520
- const entry = entries.find((e) => e.id === params.task_id);
638
+ let entry = entries.find((e) => e.id === params.task_id || e.sessionName === params.task_id) ??
639
+ findTaskSessionHistory(piDir, params.task_id) ??
640
+ findJsonlSessionByName(piDir, params.task_id, agent.name);
641
+ // Older history entries were written before we stored the
642
+ // actual JSONL path needed by `pi --session`. Repair them by
643
+ // resolving the display session name to a session file.
644
+ if (entry && !entry.sessionRef) {
645
+ const discovered = findJsonlSessionByName(piDir, entry.sessionName, entry.agentType);
646
+ if (discovered?.sessionRef) {
647
+ entry = { ...entry, sessionRef: discovered.sessionRef };
648
+ upsertTaskSessionHistory(piDir, {
649
+ ...entry,
650
+ status: "done",
651
+ background: false,
652
+ });
653
+ }
654
+ }
521
655
  if (!entry) {
522
656
  return {
523
657
  content: [
524
658
  {
525
659
  type: "text",
526
- text: `Unknown task_id: "${params.task_id}". No task with that ID found in the registry.`,
660
+ text: `Unknown task_id: "${params.task_id}". No active or completed task session with that ID/session name was found.`,
527
661
  },
528
662
  ],
529
663
  details: {
@@ -548,26 +682,28 @@ export default function (pi) {
548
682
  isError: true,
549
683
  };
550
684
  }
551
- // Resume: reuse existing artifact dir and session name
685
+ // Resume: reuse the existing session name; runtime files are
686
+ // flat in artifactsDir, no per-task subdir.
552
687
  id = entry.id;
553
688
  sessionName = entry.sessionName;
554
- artifactDir = entry.dir;
555
- resultPath = join(artifactDir, "RESULT.md");
689
+ resultPath = join(artifactsDir, `RESULT-${id}.md`);
556
690
  resume = true;
691
+ resumeSessionRef = entry.sessionRef;
557
692
  // If background and pane still alive, reattach to tracker
558
693
  if (params.background !== false &&
559
694
  entry.paneId &&
560
695
  paneExists(entry.paneId)) {
561
696
  const bgtask = {
562
- dir: artifactDir,
563
- agentType: agent.name,
697
+ dir: artifactsDir,
698
+ agentType: entry.agentType,
564
699
  sessionName,
565
700
  paneId: entry.paneId,
566
701
  originalPane: null,
567
- description: params.description || entry.agentType,
702
+ description: params.description || entry.description,
568
703
  startedAt: entry.startedAt,
569
704
  toolUses: 0,
570
705
  turns: 0,
706
+ conversationId: entry.conversationId,
571
707
  recentCalls: [],
572
708
  };
573
709
  backgroundTasks.set(id, bgtask);
@@ -580,27 +716,65 @@ export default function (pi) {
580
716
  ],
581
717
  details: {
582
718
  task_id: id,
583
- agent_type: agent.name,
584
- description: params.description,
719
+ agent_type: entry.agentType,
720
+ description: params.description || entry.description,
721
+ conversation_id: entry.conversationId ?? conversationId,
585
722
  tmux_session: sessionName,
586
723
  background: true,
587
724
  },
588
725
  };
589
726
  }
727
+ if (!resumeSessionRef) {
728
+ return {
729
+ content: [
730
+ {
731
+ type: "text",
732
+ text: `Task "${params.task_id}" was found, but its session JSONL file could not be resolved. Cannot resume without a --session file path.`,
733
+ },
734
+ ],
735
+ details: {
736
+ phase: "failed",
737
+ error: "Task session file missing",
738
+ },
739
+ isError: true,
740
+ };
741
+ }
590
742
  }
591
743
  else {
592
744
  id = `${Date.now().toString(36)}-${randomUUID().slice(0, 4)}`;
593
- sessionName = `task-${id}`;
594
- artifactDir = join(piDir, "artifacts", sessionName);
595
- await mkdir(artifactDir, { recursive: true });
596
- resultPath = join(artifactDir, "RESULT.md");
745
+ sessionName = conversationId ?? `task-${id}`;
746
+ resultPath = join(artifactsDir, `RESULT-${id}.md`);
747
+ }
748
+ if (conversationId && !hasTmux()) {
749
+ return {
750
+ content: [
751
+ {
752
+ type: "text",
753
+ text: "Durable conversations require the tmux/CLI backend so Pi can save and reopen the subagent session. Install/start tmux or omit conversation_id for a one-shot SDK task.",
754
+ },
755
+ ],
756
+ details: {
757
+ phase: "failed",
758
+ error: "tmux required for durable conversation",
759
+ conversation_id: conversationId,
760
+ },
761
+ isError: true,
762
+ };
763
+ }
764
+ if (conversationId) {
765
+ await mkdir(artifactsDir, { recursive: true });
766
+ const taskSessionsRegistry = readTaskSessionsRegistry(piDir);
767
+ taskSessionsRegistry[conversationId] = {
768
+ task_id: id,
769
+ session_file: `${artifactsDir}/${id}`,
770
+ };
771
+ writeTaskSessionsRegistry(piDir, taskSessionsRegistry);
597
772
  }
598
773
  const descText = params.description || "";
599
774
  const isBackground = params.background ?? TASK_BACKGROUND_DEFAULT;
600
775
  // default true
601
- // ── Write durable task context ──────────────────────────────────────
602
- const contextPath = join(artifactDir, "CONTEXT.md");
603
- const contextContent = [
776
+ // ── Build the prompt (instructions are inlined; no CONTEXT.md file) ─
777
+ const promptContent = [
604
778
  `# Task: ${descText}`,
605
779
  "",
606
780
  `## Agent`,
@@ -613,26 +787,18 @@ export default function (pi) {
613
787
  ctx.cwd,
614
788
  "",
615
789
  `## Output`,
616
- `Write your result to ${resultPath}`,
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.",
617
791
  "",
618
- "Use this format:",
792
+ "Use this format for the summary:",
619
793
  "",
620
794
  "```",
621
795
  TASK_RESULT_XML_INSTRUCTIONS,
622
796
  "```",
623
797
  ].join("\n");
624
- await writeFile(contextPath, contextContent, "utf-8");
625
- const promptContent = [
626
- `Read ${contextPath} for your task.`,
627
- `Write your findings/output to ${resultPath}`,
628
- "",
629
- "Format:",
630
- TASK_RESULT_XML_INSTRUCTIONS,
631
- ].join("\n");
632
- const sessionDir = join(artifactDir, "sessions");
798
+ const sessionDir = join(artifactsDir, "sessions");
633
799
  await mkdir(sessionDir, { recursive: true });
634
800
  // ─── Build and run the sub-agent pi process ──────────────────────────
635
- const piArgs = buildPiArgs(agent, sessionName, sessionDir, promptContent, resume, parentToolNames);
801
+ const piArgs = buildPiArgs(agent, sessionName, sessionDir, promptContent, resume, parentToolNames, resumeSessionRef);
636
802
  const envPrefix = `PI_TASK_TOOL_DISABLED=1`;
637
803
  const toolSelection = buildAgentToolSelection({
638
804
  tools: agent.tools,
@@ -653,7 +819,7 @@ export default function (pi) {
653
819
  const foregroundTask = isBackground
654
820
  ? undefined
655
821
  : {
656
- dir: artifactDir,
822
+ dir: artifactsDir,
657
823
  agentType: agent.name,
658
824
  sessionName,
659
825
  originalPane: null,
@@ -661,6 +827,7 @@ export default function (pi) {
661
827
  startedAt: Date.now(),
662
828
  toolUses: 0,
663
829
  turns: 0,
830
+ conversationId,
664
831
  recentCalls: [],
665
832
  };
666
833
  if (foregroundTask) {
@@ -671,7 +838,7 @@ export default function (pi) {
671
838
  if (!hasTmux()) {
672
839
  if (isBackground) {
673
840
  const bgtask = {
674
- dir: artifactDir,
841
+ dir: artifactsDir,
675
842
  agentType: agent.name,
676
843
  sessionName,
677
844
  originalPane: null,
@@ -679,6 +846,7 @@ export default function (pi) {
679
846
  startedAt: Date.now(),
680
847
  toolUses: 0,
681
848
  turns: 0,
849
+ conversationId,
682
850
  recentCalls: [],
683
851
  };
684
852
  backgroundTasks.set(id, bgtask);
@@ -689,17 +857,22 @@ export default function (pi) {
689
857
  sessionName,
690
858
  startedAt: bgtask.startedAt,
691
859
  piDir,
692
- dir: artifactDir,
860
+ dir: artifactsDir,
861
+ conversationId,
693
862
  };
694
863
  const entries = readRegistry(piDir);
695
864
  entries.push(entry);
696
865
  writeRegistry(piDir, entries);
866
+ upsertTaskSessionHistory(piDir, {
867
+ ...entry,
868
+ status: "running",
869
+ background: true,
870
+ });
697
871
  pi.appendEntry("task-registry", entry);
698
872
  ensureTaskWidget(ctx);
699
873
  void runSdkFallback()
700
874
  .then(async ({ output }) => {
701
875
  const finalOutput = output || "SDK subagent completed without assistant text.";
702
- await writeFile(resultPath, finalOutput, "utf-8");
703
876
  backgroundTasks.delete(id);
704
877
  clearTaskWidgetIfIdle();
705
878
  completeTask(pi, id, bgtask, finalOutput, "done", piDir);
@@ -722,13 +895,24 @@ export default function (pi) {
722
895
  background: true,
723
896
  backend: "sdk",
724
897
  result_path: resultPath,
898
+ conversation_id: conversationId,
725
899
  },
726
900
  };
727
901
  }
728
902
  try {
729
903
  const { output, sessionPath } = await runSdkFallback();
730
904
  const finalOutput = output || "SDK subagent completed without assistant text.";
731
- await writeFile(resultPath, finalOutput, "utf-8");
905
+ if (conversationId) {
906
+ writeConversationArtifacts({
907
+ piDir,
908
+ taskId: id,
909
+ conversationId,
910
+ agentType: agent.name,
911
+ sessionFile: sessionPath ?? "unknown",
912
+ prompt: params.prompt,
913
+ result: finalOutput,
914
+ });
915
+ }
732
916
  return {
733
917
  content: [{ type: "text", text: finalOutput }],
734
918
  details: {
@@ -736,6 +920,7 @@ export default function (pi) {
736
920
  backend: "sdk",
737
921
  session_path: sessionPath,
738
922
  result_path: resultPath,
923
+ conversation_id: conversationId,
739
924
  },
740
925
  };
741
926
  }
@@ -786,14 +971,29 @@ export default function (pi) {
786
971
  }
787
972
  // ── FOREGROUND MODE: block until result, return directly ────────────
788
973
  if (!isBackground) {
789
- const startedAt = Date.now();
974
+ const startedAt = foregroundTask?.startedAt ?? Date.now();
975
+ upsertTaskSessionHistory(piDir, {
976
+ id,
977
+ agentType: agent.name,
978
+ description: descText,
979
+ sessionName,
980
+ startedAt,
981
+ paneId,
982
+ piDir,
983
+ dir: artifactsDir,
984
+ conversationId,
985
+ status: "running",
986
+ background: false,
987
+ });
790
988
  const completion = await waitForSessionTaskCompletion({
791
989
  resultPath,
792
990
  sessionDir,
793
991
  sessionName,
794
992
  paneId,
795
993
  signal,
796
- timeoutMs: 30 * 60 * 1000,
994
+ timeoutMs: TASK_TIMEOUT_MS,
995
+ pollMs: 1000,
996
+ sinceMs: startedAt,
797
997
  });
798
998
  const content = completion.content;
799
999
  const phase = completion.status === "completed"
@@ -801,19 +1001,46 @@ export default function (pi) {
801
1001
  : completion.status === "cancelled"
802
1002
  ? "cancelled"
803
1003
  : "failed";
1004
+ const completedSessionRef = findJsonlSessionByName(piDir, sessionName, agent.name)?.sessionRef;
1005
+ upsertTaskSessionHistory(piDir, {
1006
+ id,
1007
+ agentType: agent.name,
1008
+ description: descText,
1009
+ sessionName,
1010
+ startedAt,
1011
+ paneId,
1012
+ piDir,
1013
+ dir: artifactsDir,
1014
+ conversationId,
1015
+ sessionRef: completedSessionRef,
1016
+ status: phase,
1017
+ completedAt: Date.now(),
1018
+ background: false,
1019
+ });
804
1020
  killAgentPane(paneId, originalPane);
805
1021
  foregroundTasks.delete(id);
806
1022
  clearTaskWidgetIfIdle();
1023
+ if (conversationId) {
1024
+ writeConversationArtifacts({
1025
+ piDir,
1026
+ taskId: id,
1027
+ conversationId,
1028
+ agentType: agent.name,
1029
+ sessionFile: `${sessionDir}/${sessionName}`,
1030
+ prompt: params.prompt,
1031
+ result: content,
1032
+ });
1033
+ }
807
1034
  const parsed = parseResultXml(content);
808
1035
  const durationMs = Date.now() - startedAt;
809
- const { toolUses, turns } = countToolUses(sessionDir);
1036
+ const { toolUses, turns } = countToolUses(sessionDir, sessionName);
810
1037
  return {
811
1038
  content: [
812
1039
  {
813
1040
  type: "text",
814
1041
  text: [
815
1042
  `${parsed.status || "done"}: ${parsed.summary || content.slice(0, 300)}`,
816
- toolUses > 0 ? `\n${turns || toolUses} toolcalls` : "",
1043
+ toolUses > 0 ? `\n${toolUses} toolcalls` : "",
817
1044
  durationMs >= 1000 ? `\n${formatMs(durationMs)}` : "",
818
1045
  ]
819
1046
  .filter(Boolean)
@@ -834,12 +1061,13 @@ export default function (pi) {
834
1061
  tool_uses: toolUses,
835
1062
  turn_count: turns,
836
1063
  background: false,
1064
+ conversation_id: conversationId,
837
1065
  },
838
1066
  };
839
1067
  }
840
1068
  // ── BACKGROUND MODE (default): add to tracker, return immediately ─────
841
1069
  const bgtask = {
842
- dir: artifactDir,
1070
+ dir: artifactsDir,
843
1071
  agentType: agent.name,
844
1072
  sessionName,
845
1073
  paneId,
@@ -848,6 +1076,7 @@ export default function (pi) {
848
1076
  startedAt: Date.now(),
849
1077
  toolUses: 0,
850
1078
  turns: 0,
1079
+ conversationId,
851
1080
  recentCalls: [],
852
1081
  };
853
1082
  backgroundTasks.set(id, bgtask);
@@ -857,15 +1086,21 @@ export default function (pi) {
857
1086
  agentType: agent.name,
858
1087
  description: descText,
859
1088
  sessionName,
860
- startedAt: Date.now(),
1089
+ startedAt: bgtask.startedAt,
861
1090
  paneId,
862
1091
  piDir,
863
- dir: artifactDir,
1092
+ dir: artifactsDir,
1093
+ conversationId,
864
1094
  };
865
1095
  // Write to JSON registry for on-load restore
866
1096
  const entries = readRegistry(piDir);
867
1097
  entries.push(entry);
868
1098
  writeRegistry(piDir, entries);
1099
+ upsertTaskSessionHistory(piDir, {
1100
+ ...entry,
1101
+ status: "running",
1102
+ background: true,
1103
+ });
869
1104
  // Also persist to session store via appendEntry (audit trail)
870
1105
  pi.appendEntry("task-registry", entry);
871
1106
  // ── Abort signal handling ──────────────────────────────────────────
@@ -896,7 +1131,7 @@ export default function (pi) {
896
1131
  taskId: id,
897
1132
  agentType: agent.name,
898
1133
  tmuxSession: sessionName,
899
- artifactDir,
1134
+ artifactDir: artifactsDir,
900
1135
  }),
901
1136
  },
902
1137
  ],
@@ -923,7 +1158,17 @@ export default function (pi) {
923
1158
  if (!d)
924
1159
  return new Text("", 0, 0);
925
1160
  if (d.background) {
926
- return new Text("", 0, 0);
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);
927
1172
  }
928
1173
  if (d.phase === "timeout" ||
929
1174
  d.phase === "aborted" ||
@@ -938,8 +1183,7 @@ export default function (pi) {
938
1183
  d.status === "failed";
939
1184
  const durationMs = d.duration_ms || 0;
940
1185
  const toolUses = d.tool_uses || 0;
941
- const turns = d.turn_count || 0;
942
- const useStr = toolUses > 0 ? `${turns || toolUses} toolcalls` : "";
1186
+ const useStr = toolUses > 0 ? `${toolUses} toolcalls` : "";
943
1187
  const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
944
1188
  const statsParts = [useStr, durStr].filter(Boolean);
945
1189
  const statsStr = statsParts.length
@@ -974,4 +1218,12 @@ export default function (pi) {
974
1218
  return new Text(line, 0, 0);
975
1219
  },
976
1220
  });
1221
+ pi.registerCommand("task-sessions", {
1222
+ description: "List durable pi-task conversations",
1223
+ handler: async (_args, ctx) => {
1224
+ const cwd = ctx.sessionManager?.getCwd?.() ?? process.cwd();
1225
+ const { piDir } = discoverAgents(cwd);
1226
+ ctx.ui.notify(renderConversationSessions(piDir), "info");
1227
+ },
1228
+ });
977
1229
  }