@heyhuynhgiabuu/pi-task 0.1.5 → 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,19 +14,20 @@
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
- import { getArtifactsDir, normalizeConversationId, readConversationMetadata, readConversationRegistry, renderConversationSessions, taskArtifactName, taskIdFromArtifactName, writeConversationArtifacts, writeConversationRegistry, } from "./conversation.js";
25
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";
26
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";
27
28
  import { runSdkSubagent } from "./subagent/runSdk.js";
28
29
  import { checkTaskCompletion, waitForTaskCompletion as waitForSessionTaskCompletion, } from "./subagent/waitCompletion.js";
29
- import { buildAgentToolSelection } from "./agent-tools.js";
30
+ import { renderTaskWidget, TASK_WIDGET_RENDER_MS, } from "./task-widget.js";
30
31
  // ─── Constants ───────────────────────────────────────────────────────────────
31
32
  const BUNDLED_AGENT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "agents");
32
33
  const BACKGROUND_CHECK_MS = 10_000; // poll every 10 sec
@@ -46,6 +47,87 @@ function writeRegistry(piDir, entries) {
46
47
  const path = join(piDir, "task-registry.json");
47
48
  writeFileSync(path, JSON.stringify(entries, null, 2), "utf-8");
48
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
+ }
49
131
  // ─── Tmux Helpers ────────────────────────────────────────────────────────────
50
132
  function tmuxCmd(args) {
51
133
  return execFileSync("tmux", args, {
@@ -129,6 +211,22 @@ function completeTask(pi, id, task, content, phase, piDir) {
129
211
  killAgentPane(task.paneId, task.originalPane);
130
212
  const parsed = parseResultXml(content);
131
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
+ });
132
230
  // Send completion notification
133
231
  pi.sendMessage({
134
232
  customType: "task-complete",
@@ -214,62 +312,27 @@ export default function (pi) {
214
312
  ]) {
215
313
  const sessionDir = join(task.dir, "sessions");
216
314
  // Single walk: counts + recent tool-call history with status
217
- const { toolUses, turns, recent } = readRecentToolCalls(sessionDir, 12);
315
+ const { toolUses, turns, recent } = readRecentToolCalls(sessionDir, 12, task.sessionName);
218
316
  task.toolUses = toolUses;
219
317
  task.turns = turns;
220
318
  task.recentCalls = recent;
221
319
  }
222
320
  }, 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
321
  // 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
322
  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
323
  function renderWidget(width) {
268
324
  // Defensive: never let a render exception kill the TUI. If anything
269
325
  // throws (theme lookup miss, malformed session JSONL, etc.), fall
270
326
  // back to a minimal single-line summary so the TUI stays alive.
271
327
  try {
272
- 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
+ });
273
336
  }
274
337
  catch (err) {
275
338
  const msg = err instanceof Error ? err.message : String(err);
@@ -281,7 +344,7 @@ export default function (pi) {
281
344
  return [];
282
345
  const [, task] = active[0];
283
346
  return [
284
- 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)),
285
348
  ];
286
349
  }
287
350
  }
@@ -291,7 +354,7 @@ export default function (pi) {
291
354
  widgetCtx = targetCtx;
292
355
  targetCtx.ui.setWidget("task", (tui, theme) => {
293
356
  widgetTheme = theme ?? null;
294
- widgetTimer = setInterval(() => tui.requestRender(), WIDGET_RENDER_MS);
357
+ widgetTimer = setInterval(() => tui.requestRender(), TASK_WIDGET_RENDER_MS);
295
358
  // Don't keep the process alive just for the widget refresh.
296
359
  widgetTimer.unref?.();
297
360
  return {
@@ -313,62 +376,6 @@ export default function (pi) {
313
376
  widgetCtx = null;
314
377
  }
315
378
  }
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
- }
372
379
  // ── Polling loop (background task completion, pane death, timeout) ──────
373
380
  const checkInterval = setInterval(async () => {
374
381
  if (backgroundTasks.size === 0) {
@@ -381,24 +388,27 @@ export default function (pi) {
381
388
  const task = backgroundTasks.get(id);
382
389
  if (!task)
383
390
  continue;
384
- backgroundTasks.delete(id); // Remove atomically
385
391
  // ── Check timeout ────────────────────────────────────────────
386
392
  if (now - task.startedAt > TASK_TIMEOUT_MS) {
387
393
  killAgentPane(task.paneId, task.originalPane);
394
+ backgroundTasks.delete(id);
395
+ clearTaskWidgetIfIdle();
388
396
  completeTask(pi, id, task, "Task timed out after 30 minutes", "timeout", piDir);
389
397
  continue;
390
398
  }
391
399
  const snapshot = await checkTaskCompletion({
392
400
  resultPath: join(task.dir, "RESULT.md"),
393
- sessionDir: task.dir,
401
+ sessionDir: join(task.dir, "sessions"),
394
402
  sessionName: task.sessionName,
395
403
  paneId: task.paneId,
404
+ sinceMs: task.startedAt,
396
405
  });
397
406
  if (snapshot.status === "running") {
398
- backgroundTasks.set(id, task);
399
407
  continue;
400
408
  }
401
409
  const phase = snapshot.status === "completed" ? "done" : "failed";
410
+ backgroundTasks.delete(id);
411
+ clearTaskWidgetIfIdle();
402
412
  completeTask(pi, id, task, snapshot.content, phase, piDir);
403
413
  }
404
414
  }, BACKGROUND_CHECK_MS);
@@ -424,34 +434,36 @@ export default function (pi) {
424
434
  const confidence = d.confidence || "";
425
435
  const durationMs = d.duration_ms || 0;
426
436
  const toolUses = d.tool_uses || 0;
427
- const turns = d.turn_count || 0;
428
- let line = theme.fg("accent", agentType);
437
+ let line = " " + theme.fg("accent", agentType);
429
438
  if (desc)
430
439
  line += theme.fg("dim", ` - ${desc}`);
431
- const useStr = toolUses > 0 ? `${turns || toolUses} toolcalls` : "";
440
+ const useStr = toolUses > 0 ? `${toolUses} toolcalls` : "";
432
441
  const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
433
442
  const statsParts = [useStr, durStr].filter(Boolean);
434
- if (statsParts.length) {
435
- line += "\n" + theme.fg("dim", statsParts.join(" • "));
436
- }
443
+ const statsText = statsParts.join(" • ");
437
444
  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
+ 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);
445
456
  }
446
457
  if (expanded) {
447
458
  if (summary)
448
- line += "\n" + theme.fg("muted", summary);
459
+ line += "\n " + theme.fg("muted", summary);
449
460
  if (findings)
450
- line += "\n" + theme.fg("dim", findings);
461
+ line += "\n " + theme.fg("dim", findings);
451
462
  }
452
463
  if (!line.trim())
453
464
  return undefined;
454
- 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);
455
467
  });
456
468
  // ── Tool Registration ──────────────────────────────────────────────────
457
469
  pi.registerTool({
@@ -515,14 +527,11 @@ export default function (pi) {
515
527
  }
516
528
  // ── Resolve task identity: new, task resume, or conversation resume ──
517
529
  const conversationId = normalizeConversationId(params.conversation_id);
518
- const conversationRegistry = conversationId
519
- ? readConversationRegistry(piDir)
530
+ const taskSessionsRegistry = conversationId
531
+ ? readTaskSessionsRegistry(piDir)
520
532
  : {};
521
- const registeredArtifact = conversationId
522
- ? conversationRegistry[conversationId]
523
- : undefined;
524
- const registeredTaskId = registeredArtifact
525
- ? taskIdFromArtifactName(registeredArtifact)
533
+ const registeredTaskId = conversationId
534
+ ? taskSessionsRegistry[conversationId]?.task_id
526
535
  : undefined;
527
536
  if (params.task_id &&
528
537
  registeredTaskId &&
@@ -531,7 +540,7 @@ export default function (pi) {
531
540
  content: [
532
541
  {
533
542
  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.`,
543
+ text: `conversation_id "${conversationId}" maps to ${registeredTaskId}, not ${params.task_id}. Omit task_id or use the mapped task id.`,
535
544
  },
536
545
  ],
537
546
  details: {
@@ -543,37 +552,39 @@ export default function (pi) {
543
552
  }
544
553
  let id;
545
554
  let sessionName;
546
- let artifactDir;
547
555
  let resultPath;
548
556
  let resume = false;
557
+ let resumeSessionRef;
558
+ const artifactsDir = join(piDir, "artifacts");
549
559
  if (registeredTaskId) {
550
560
  id = registeredTaskId;
551
- sessionName = taskArtifactName(id);
552
- artifactDir = join(getArtifactsDir(piDir), sessionName);
553
- resultPath = join(artifactDir, "RESULT.md");
554
- if (!existsSync(artifactDir)) {
561
+ sessionName = conversationId ?? `task-${id}`;
562
+ resultPath = join(artifactsDir, `RESULT-${id}.md`);
563
+ if (!existsSync(resultPath)) {
555
564
  return {
556
565
  content: [
557
566
  {
558
567
  type: "text",
559
- text: `conversation_id "${conversationId}" points to missing artifact directory: ${artifactDir}`,
568
+ text: `conversation_id "${conversationId}" has no prior result file at ${resultPath}. Cannot resume.`,
560
569
  },
561
570
  ],
562
571
  details: {
563
572
  phase: "failed",
564
- error: "Conversation artifact dir missing",
573
+ error: "Conversation result missing",
565
574
  conversation_id: conversationId,
566
575
  },
567
576
  isError: true,
568
577
  };
569
578
  }
570
- const metadata = readConversationMetadata(join(artifactDir, "metadata.json"));
571
- if (metadata?.agent_type && metadata.agent_type !== agent.name) {
579
+ const block = readTaskBlock(piDir, id);
580
+ const previousMetadata = parseMetadataFromBody(block?.body);
581
+ const metadataAgent = previousMetadata?.agent_type;
582
+ if (metadataAgent && metadataAgent !== agent.name) {
572
583
  return {
573
584
  content: [
574
585
  {
575
586
  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.`,
587
+ text: `conversation_id "${conversationId}" belongs to agent "${metadataAgent}", not "${agent.name}". Use the original agent_type or start a different conversation_id.`,
577
588
  },
578
589
  ],
579
590
  details: {
@@ -590,7 +601,7 @@ export default function (pi) {
590
601
  entry?.paneId &&
591
602
  paneExists(entry.paneId)) {
592
603
  const bgtask = {
593
- dir: artifactDir,
604
+ dir: artifactsDir,
594
605
  agentType: entry.agentType,
595
606
  sessionName,
596
607
  paneId: entry.paneId,
@@ -607,7 +618,7 @@ export default function (pi) {
607
618
  content: [
608
619
  {
609
620
  type: "text",
610
- text: `Resumed conversation "${conversationId}" via ${taskArtifactName(id)}. The subagent is running in background and will notify on completion.`,
621
+ text: `Resumed conversation "${conversationId}" via ${sessionName}. The subagent is running in background and will notify on completion.`,
611
622
  },
612
623
  ],
613
624
  details: {
@@ -622,15 +633,31 @@ export default function (pi) {
622
633
  }
623
634
  }
624
635
  else if (params.task_id) {
625
- // Look up the task in the persistent registry
636
+ // Look up active tasks first, then durable completed-session history.
626
637
  const entries = readRegistry(piDir);
627
- 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
+ }
628
655
  if (!entry) {
629
656
  return {
630
657
  content: [
631
658
  {
632
659
  type: "text",
633
- 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.`,
634
661
  },
635
662
  ],
636
663
  details: {
@@ -655,23 +682,24 @@ export default function (pi) {
655
682
  isError: true,
656
683
  };
657
684
  }
658
- // 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.
659
687
  id = entry.id;
660
688
  sessionName = entry.sessionName;
661
- artifactDir = entry.dir;
662
- resultPath = join(artifactDir, "RESULT.md");
689
+ resultPath = join(artifactsDir, `RESULT-${id}.md`);
663
690
  resume = true;
691
+ resumeSessionRef = entry.sessionRef;
664
692
  // If background and pane still alive, reattach to tracker
665
693
  if (params.background !== false &&
666
694
  entry.paneId &&
667
695
  paneExists(entry.paneId)) {
668
696
  const bgtask = {
669
- dir: artifactDir,
670
- agentType: agent.name,
697
+ dir: artifactsDir,
698
+ agentType: entry.agentType,
671
699
  sessionName,
672
700
  paneId: entry.paneId,
673
701
  originalPane: null,
674
- description: params.description || entry.agentType,
702
+ description: params.description || entry.description,
675
703
  startedAt: entry.startedAt,
676
704
  toolUses: 0,
677
705
  turns: 0,
@@ -688,21 +716,34 @@ export default function (pi) {
688
716
  ],
689
717
  details: {
690
718
  task_id: id,
691
- agent_type: agent.name,
692
- description: params.description,
719
+ agent_type: entry.agentType,
720
+ description: params.description || entry.description,
693
721
  conversation_id: entry.conversationId ?? conversationId,
694
722
  tmux_session: sessionName,
695
723
  background: true,
696
724
  },
697
725
  };
698
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
+ }
699
742
  }
700
743
  else {
701
744
  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");
745
+ sessionName = conversationId ?? `task-${id}`;
746
+ resultPath = join(artifactsDir, `RESULT-${id}.md`);
706
747
  }
707
748
  if (conversationId && !hasTmux()) {
708
749
  return {
@@ -721,25 +762,19 @@ export default function (pi) {
721
762
  };
722
763
  }
723
764
  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
- });
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);
736
772
  }
737
773
  const descText = params.description || "";
738
774
  const isBackground = params.background ?? TASK_BACKGROUND_DEFAULT;
739
775
  // default true
740
- // ── Write durable task context ──────────────────────────────────────
741
- const contextPath = join(artifactDir, "CONTEXT.md");
742
- const contextContent = [
776
+ // ── Build the prompt (instructions are inlined; no CONTEXT.md file) ─
777
+ const promptContent = [
743
778
  `# Task: ${descText}`,
744
779
  "",
745
780
  `## Agent`,
@@ -752,26 +787,18 @@ export default function (pi) {
752
787
  ctx.cwd,
753
788
  "",
754
789
  `## Output`,
755
- `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.",
756
791
  "",
757
- "Use this format:",
792
+ "Use this format for the summary:",
758
793
  "",
759
794
  "```",
760
795
  TASK_RESULT_XML_INSTRUCTIONS,
761
796
  "```",
762
797
  ].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");
798
+ const sessionDir = join(artifactsDir, "sessions");
772
799
  await mkdir(sessionDir, { recursive: true });
773
800
  // ─── Build and run the sub-agent pi process ──────────────────────────
774
- const piArgs = buildPiArgs(agent, sessionName, sessionDir, promptContent, resume, parentToolNames);
801
+ const piArgs = buildPiArgs(agent, sessionName, sessionDir, promptContent, resume, parentToolNames, resumeSessionRef);
775
802
  const envPrefix = `PI_TASK_TOOL_DISABLED=1`;
776
803
  const toolSelection = buildAgentToolSelection({
777
804
  tools: agent.tools,
@@ -792,7 +819,7 @@ export default function (pi) {
792
819
  const foregroundTask = isBackground
793
820
  ? undefined
794
821
  : {
795
- dir: artifactDir,
822
+ dir: artifactsDir,
796
823
  agentType: agent.name,
797
824
  sessionName,
798
825
  originalPane: null,
@@ -811,7 +838,7 @@ export default function (pi) {
811
838
  if (!hasTmux()) {
812
839
  if (isBackground) {
813
840
  const bgtask = {
814
- dir: artifactDir,
841
+ dir: artifactsDir,
815
842
  agentType: agent.name,
816
843
  sessionName,
817
844
  originalPane: null,
@@ -830,18 +857,22 @@ export default function (pi) {
830
857
  sessionName,
831
858
  startedAt: bgtask.startedAt,
832
859
  piDir,
833
- dir: artifactDir,
860
+ dir: artifactsDir,
834
861
  conversationId,
835
862
  };
836
863
  const entries = readRegistry(piDir);
837
864
  entries.push(entry);
838
865
  writeRegistry(piDir, entries);
866
+ upsertTaskSessionHistory(piDir, {
867
+ ...entry,
868
+ status: "running",
869
+ background: true,
870
+ });
839
871
  pi.appendEntry("task-registry", entry);
840
872
  ensureTaskWidget(ctx);
841
873
  void runSdkFallback()
842
874
  .then(async ({ output }) => {
843
875
  const finalOutput = output || "SDK subagent completed without assistant text.";
844
- await writeFile(resultPath, finalOutput, "utf-8");
845
876
  backgroundTasks.delete(id);
846
877
  clearTaskWidgetIfIdle();
847
878
  completeTask(pi, id, bgtask, finalOutput, "done", piDir);
@@ -871,7 +902,17 @@ export default function (pi) {
871
902
  try {
872
903
  const { output, sessionPath } = await runSdkFallback();
873
904
  const finalOutput = output || "SDK subagent completed without assistant text.";
874
- 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
+ }
875
916
  return {
876
917
  content: [{ type: "text", text: finalOutput }],
877
918
  details: {
@@ -930,14 +971,29 @@ export default function (pi) {
930
971
  }
931
972
  // ── FOREGROUND MODE: block until result, return directly ────────────
932
973
  if (!isBackground) {
933
- 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
+ });
934
988
  const completion = await waitForSessionTaskCompletion({
935
989
  resultPath,
936
990
  sessionDir,
937
991
  sessionName,
938
992
  paneId,
939
993
  signal,
940
- timeoutMs: 30 * 60 * 1000,
994
+ timeoutMs: TASK_TIMEOUT_MS,
995
+ pollMs: 1000,
996
+ sinceMs: startedAt,
941
997
  });
942
998
  const content = completion.content;
943
999
  const phase = completion.status === "completed"
@@ -945,19 +1001,46 @@ export default function (pi) {
945
1001
  : completion.status === "cancelled"
946
1002
  ? "cancelled"
947
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
+ });
948
1020
  killAgentPane(paneId, originalPane);
949
1021
  foregroundTasks.delete(id);
950
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
+ }
951
1034
  const parsed = parseResultXml(content);
952
1035
  const durationMs = Date.now() - startedAt;
953
- const { toolUses, turns } = countToolUses(sessionDir);
1036
+ const { toolUses, turns } = countToolUses(sessionDir, sessionName);
954
1037
  return {
955
1038
  content: [
956
1039
  {
957
1040
  type: "text",
958
1041
  text: [
959
1042
  `${parsed.status || "done"}: ${parsed.summary || content.slice(0, 300)}`,
960
- toolUses > 0 ? `\n${turns || toolUses} toolcalls` : "",
1043
+ toolUses > 0 ? `\n${toolUses} toolcalls` : "",
961
1044
  durationMs >= 1000 ? `\n${formatMs(durationMs)}` : "",
962
1045
  ]
963
1046
  .filter(Boolean)
@@ -984,7 +1067,7 @@ export default function (pi) {
984
1067
  }
985
1068
  // ── BACKGROUND MODE (default): add to tracker, return immediately ─────
986
1069
  const bgtask = {
987
- dir: artifactDir,
1070
+ dir: artifactsDir,
988
1071
  agentType: agent.name,
989
1072
  sessionName,
990
1073
  paneId,
@@ -1003,16 +1086,21 @@ export default function (pi) {
1003
1086
  agentType: agent.name,
1004
1087
  description: descText,
1005
1088
  sessionName,
1006
- startedAt: Date.now(),
1089
+ startedAt: bgtask.startedAt,
1007
1090
  paneId,
1008
1091
  piDir,
1009
- dir: artifactDir,
1092
+ dir: artifactsDir,
1010
1093
  conversationId,
1011
1094
  };
1012
1095
  // Write to JSON registry for on-load restore
1013
1096
  const entries = readRegistry(piDir);
1014
1097
  entries.push(entry);
1015
1098
  writeRegistry(piDir, entries);
1099
+ upsertTaskSessionHistory(piDir, {
1100
+ ...entry,
1101
+ status: "running",
1102
+ background: true,
1103
+ });
1016
1104
  // Also persist to session store via appendEntry (audit trail)
1017
1105
  pi.appendEntry("task-registry", entry);
1018
1106
  // ── Abort signal handling ──────────────────────────────────────────
@@ -1043,7 +1131,7 @@ export default function (pi) {
1043
1131
  taskId: id,
1044
1132
  agentType: agent.name,
1045
1133
  tmuxSession: sessionName,
1046
- artifactDir,
1134
+ artifactDir: artifactsDir,
1047
1135
  }),
1048
1136
  },
1049
1137
  ],
@@ -1070,7 +1158,17 @@ export default function (pi) {
1070
1158
  if (!d)
1071
1159
  return new Text("", 0, 0);
1072
1160
  if (d.background) {
1073
- 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);
1074
1172
  }
1075
1173
  if (d.phase === "timeout" ||
1076
1174
  d.phase === "aborted" ||
@@ -1085,8 +1183,7 @@ export default function (pi) {
1085
1183
  d.status === "failed";
1086
1184
  const durationMs = d.duration_ms || 0;
1087
1185
  const toolUses = d.tool_uses || 0;
1088
- const turns = d.turn_count || 0;
1089
- const useStr = toolUses > 0 ? `${turns || toolUses} toolcalls` : "";
1186
+ const useStr = toolUses > 0 ? `${toolUses} toolcalls` : "";
1090
1187
  const durStr = durationMs >= 1000 ? formatMs(durationMs) : "";
1091
1188
  const statsParts = [useStr, durStr].filter(Boolean);
1092
1189
  const statsStr = statsParts.length