@heyhuynhgiabuu/pi-task 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -45,7 +45,7 @@ Foreground task:
45
45
 
46
46
  Background task:
47
47
 
48
- ```json
48
+ ```
49
49
  {
50
50
  "agent_type": "scout",
51
51
  "description": "Research SDK docs",
@@ -54,6 +54,33 @@ Background task:
54
54
  }
55
55
  ```
56
56
 
57
+ Durable specialist conversation:
58
+
59
+ ```
60
+ {
61
+ "agent_type": "scout",
62
+ "conversation_id": "research-ai",
63
+ "description": "Ask research assistant",
64
+ "background": false,
65
+ "prompt": "Continue our prior research thread. What did we conclude about retrieval evaluation?"
66
+ }
67
+ ```
68
+
69
+ `conversation_id` maps to one existing `task-<id>` artifact under `.pi/artifacts/` and reuses its `sessions/` directory on later calls. This is for scoped specialist memory, e.g. a reusable research assistant. Use `/task-sessions` to list known durable conversations.
70
+
71
+ Stored files:
72
+
73
+ ```
74
+ .pi/artifacts/task-registry.json
75
+ .pi/artifacts/task-<id>/CONTEXT.md
76
+ .pi/artifacts/task-<id>/RESULT.md
77
+ .pi/artifacts/task-<id>/SESSION.md
78
+ .pi/artifacts/task-<id>/metadata.json
79
+ .pi/artifacts/task-<id>/sessions/
80
+ ```
81
+
82
+ Note: true conversation resume requires the tmux/CLI backend so Pi can reopen the saved subagent session. SDK fallback can run one-shot tasks, but it cannot resume a prior Pi session.
83
+
57
84
  ## Agent precedence
58
85
 
59
86
  When two agents have the same name, later sources override earlier ones:
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Conversational subagent helpers.
3
+ *
4
+ * Durable subagent conversations reuse the existing
5
+ * `.pi/artifacts/task-<id>/` artifact convention and add a small
6
+ * `conversation_id` -> `task-<id>` registry under the same artifacts dir.
7
+ */
8
+ export interface ConversationMetadata {
9
+ conversation_id: string;
10
+ task_id: string;
11
+ artifact: string;
12
+ agent_type: string;
13
+ session_dir: string;
14
+ session_name: string;
15
+ created_at: string;
16
+ last_used_at: string;
17
+ last_prompt?: string;
18
+ }
19
+ export type ConversationRegistry = Record<string, string>;
20
+ export declare const CONVERSATION_REGISTRY_FILE = "task-conversations.json";
21
+ export declare function getArtifactsDir(piDir: string): string;
22
+ export declare function getConversationRegistryPath(piDir: string): string;
23
+ export declare function taskArtifactName(taskId: string): string;
24
+ export declare function taskIdFromArtifactName(artifactName: string): string;
25
+ export declare function normalizeConversationId(value: unknown): string | undefined;
26
+ export declare function readConversationRegistry(piDir: string): ConversationRegistry;
27
+ export declare function writeConversationRegistry(piDir: string, registry: ConversationRegistry): void;
28
+ export declare function readConversationMetadata(metadataPath: string): ConversationMetadata | undefined;
29
+ export declare function buildSessionCard(metadata: ConversationMetadata): string;
30
+ export declare function writeConversationArtifacts(options: {
31
+ taskDir: string;
32
+ taskId: string;
33
+ conversationId: string;
34
+ agentType: string;
35
+ sessionDir: string;
36
+ sessionName: string;
37
+ prompt: string;
38
+ }): ConversationMetadata;
39
+ export declare function renderConversationSessions(piDir: string): string;
@@ -0,0 +1,123 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const CONVERSATION_REGISTRY_FILE = "task-conversations.json";
4
+ export function getArtifactsDir(piDir) {
5
+ return join(piDir, "artifacts");
6
+ }
7
+ export function getConversationRegistryPath(piDir) {
8
+ return join(getArtifactsDir(piDir), CONVERSATION_REGISTRY_FILE);
9
+ }
10
+ export function taskArtifactName(taskId) {
11
+ return taskId.startsWith("task-") ? taskId : `task-${taskId}`;
12
+ }
13
+ export function taskIdFromArtifactName(artifactName) {
14
+ return artifactName.startsWith("task-")
15
+ ? artifactName.slice("task-".length)
16
+ : artifactName;
17
+ }
18
+ export function normalizeConversationId(value) {
19
+ if (typeof value !== "string")
20
+ return undefined;
21
+ const conversationId = value.trim();
22
+ if (!conversationId)
23
+ return undefined;
24
+ if (!/^[A-Za-z0-9._-]{1,80}$/.test(conversationId)) {
25
+ throw new Error("conversation_id must be 1-80 chars and contain only letters, numbers, '.', '_' or '-'");
26
+ }
27
+ return conversationId;
28
+ }
29
+ export function readConversationRegistry(piDir) {
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(getConversationRegistryPath(piDir), "utf-8"));
32
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
33
+ return {};
34
+ }
35
+ const registry = {};
36
+ for (const [key, value] of Object.entries(parsed)) {
37
+ if (typeof value === "string")
38
+ registry[key] = value;
39
+ }
40
+ return registry;
41
+ }
42
+ catch {
43
+ return {};
44
+ }
45
+ }
46
+ export function writeConversationRegistry(piDir, registry) {
47
+ const artifactsDir = getArtifactsDir(piDir);
48
+ mkdirSync(artifactsDir, { recursive: true });
49
+ writeFileSync(getConversationRegistryPath(piDir), `${JSON.stringify(registry, null, 2)}\n`, "utf-8");
50
+ }
51
+ export function readConversationMetadata(metadataPath) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(metadataPath, "utf-8"));
54
+ if (!parsed.conversation_id || !parsed.task_id)
55
+ return undefined;
56
+ return parsed;
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ export function buildSessionCard(metadata) {
63
+ return [
64
+ `# ${metadata.conversation_id}`,
65
+ "",
66
+ `Agent: ${metadata.agent_type}`,
67
+ `Task: ${taskArtifactName(metadata.task_id)}`,
68
+ `Last used: ${metadata.last_used_at}`,
69
+ `Session dir: ${metadata.session_dir}`,
70
+ "",
71
+ "## Resume",
72
+ "",
73
+ "```json",
74
+ JSON.stringify({
75
+ agent_type: metadata.agent_type,
76
+ conversation_id: metadata.conversation_id,
77
+ prompt: "Continue from the prior specialist conversation.",
78
+ }, null, 2),
79
+ "```",
80
+ "",
81
+ "## Last prompt",
82
+ "",
83
+ metadata.last_prompt ?? "",
84
+ "",
85
+ ].join("\n");
86
+ }
87
+ export function writeConversationArtifacts(options) {
88
+ const now = new Date().toISOString();
89
+ const metadataPath = join(options.taskDir, "metadata.json");
90
+ const existing = readConversationMetadata(metadataPath);
91
+ const metadata = {
92
+ conversation_id: options.conversationId,
93
+ task_id: options.taskId,
94
+ artifact: taskArtifactName(options.taskId),
95
+ agent_type: options.agentType,
96
+ session_dir: options.sessionDir,
97
+ session_name: options.sessionName,
98
+ created_at: existing?.created_at ?? now,
99
+ last_used_at: now,
100
+ last_prompt: options.prompt,
101
+ };
102
+ mkdirSync(options.taskDir, { recursive: true });
103
+ writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf-8");
104
+ writeFileSync(join(options.taskDir, "SESSION.md"), buildSessionCard(metadata), "utf-8");
105
+ return metadata;
106
+ }
107
+ export function renderConversationSessions(piDir) {
108
+ const registry = readConversationRegistry(piDir);
109
+ const entries = Object.entries(registry).sort(([left], [right]) => left.localeCompare(right));
110
+ if (entries.length === 0) {
111
+ return 'No durable task conversations found. Start one with task({ conversation_id: "research-ai", ... }).';
112
+ }
113
+ const lines = ["Durable task conversations:"];
114
+ for (const [conversationId, artifactName] of entries) {
115
+ const taskId = taskIdFromArtifactName(artifactName);
116
+ const metadata = readConversationMetadata(join(getArtifactsDir(piDir), taskArtifactName(taskId), "metadata.json"));
117
+ const suffix = metadata
118
+ ? ` — ${metadata.agent_type}, last used ${metadata.last_used_at}`
119
+ : "";
120
+ lines.push(`${conversationId} -> ${taskArtifactName(taskId)}${suffix}`);
121
+ }
122
+ return lines.join("\n");
123
+ }
package/dist/index.d.ts CHANGED
@@ -15,4 +15,21 @@
15
15
  * detection, 30-minute timeout.
16
16
  */
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ export /** Details attached to tool result for rendering. */ interface TaskDetails {
19
+ task_id: string;
20
+ agent_type: string;
21
+ description: string;
22
+ conversation_id?: string;
23
+ phase: "done" | "timeout" | "aborted" | "failed";
24
+ status?: string;
25
+ summary?: string;
26
+ findings?: string;
27
+ evidence?: string;
28
+ confidence?: string;
29
+ duration_ms?: number;
30
+ turn_count?: number;
31
+ tool_uses?: number;
32
+ background?: boolean;
33
+ tmux_session?: string;
34
+ }
18
35
  export default function (pi: ExtensionAPI): void;
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { randomUUID } from "node:crypto";
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";
24
25
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
25
26
  import { TASK_BACKGROUND_DEFAULT, TASK_RESULT_XML_INSTRUCTIONS, TASK_TOOL_DESCRIPTION, buildTmuxSplitWindowArgs, chooseTmuxSplitDirection, formatBackgroundReceipt, buildPiArgs, parseResultXml, formatMs, shellQuote, discoverAgents, formatAgentList, countToolUses, readRecentToolCalls, } from "./helpers.js";
26
27
  import { runSdkSubagent } from "./subagent/runSdk.js";
@@ -31,7 +32,7 @@ const BUNDLED_AGENT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "a
31
32
  const BACKGROUND_CHECK_MS = 10_000; // poll every 10 sec
32
33
  const COUNT_POLL_MS = 3_000; // update toolcall counts every 3 sec
33
34
  const TASK_TIMEOUT_MS = 30 * 60 * 1_000; // 30 minutes
34
- // ─── Registry helpers (durable JSON) ─────────────────────────────────────────
35
+ // Conversation helpers live in ./conversation.js.
35
36
  function readRegistry(piDir) {
36
37
  const path = join(piDir, "task-registry.json");
37
38
  try {
@@ -190,6 +191,7 @@ export default function (pi) {
190
191
  startedAt: entry.startedAt,
191
192
  toolUses: 0,
192
193
  turns: 0,
194
+ conversationId: entry.conversationId,
193
195
  recentCalls: [],
194
196
  };
195
197
  backgroundTasks.set(entry.id, bgtask);
@@ -478,7 +480,10 @@ export default function (pi) {
478
480
  description: "A short (3-5 word) summary of the task",
479
481
  }),
480
482
  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)",
483
+ description: "Resume an existing background task by id instead of starting a new task.",
484
+ })),
485
+ conversation_id: Type.Optional(Type.String({
486
+ description: "Durable specialist conversation id. Reuses .pi/artifacts/task-<id>/sessions when called again.",
482
487
  })),
483
488
  background: Type.Optional(Type.Boolean({
484
489
  description: "Run in background (async). You will be notified when it completes. DO NOT sleep, poll, ask the task for status, or duplicate its work while it runs in background.",
@@ -508,13 +513,115 @@ export default function (pi) {
508
513
  isError: true,
509
514
  };
510
515
  }
511
- // ── Resolve task identity: new or resume ───────────────────────────
516
+ // ── Resolve task identity: new, task resume, or conversation resume ──
517
+ const conversationId = normalizeConversationId(params.conversation_id);
518
+ const conversationRegistry = conversationId
519
+ ? readConversationRegistry(piDir)
520
+ : {};
521
+ const registeredArtifact = conversationId
522
+ ? conversationRegistry[conversationId]
523
+ : undefined;
524
+ const registeredTaskId = registeredArtifact
525
+ ? taskIdFromArtifactName(registeredArtifact)
526
+ : undefined;
527
+ if (params.task_id &&
528
+ registeredTaskId &&
529
+ params.task_id !== registeredTaskId) {
530
+ return {
531
+ content: [
532
+ {
533
+ 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.`,
535
+ },
536
+ ],
537
+ details: {
538
+ phase: "failed",
539
+ error: "conversation_id/task_id mismatch",
540
+ },
541
+ isError: true,
542
+ };
543
+ }
512
544
  let id;
513
545
  let sessionName;
514
546
  let artifactDir;
515
547
  let resultPath;
516
548
  let resume = false;
517
- if (params.task_id) {
549
+ if (registeredTaskId) {
550
+ id = registeredTaskId;
551
+ sessionName = taskArtifactName(id);
552
+ artifactDir = join(getArtifactsDir(piDir), sessionName);
553
+ resultPath = join(artifactDir, "RESULT.md");
554
+ if (!existsSync(artifactDir)) {
555
+ return {
556
+ content: [
557
+ {
558
+ type: "text",
559
+ text: `conversation_id "${conversationId}" points to missing artifact directory: ${artifactDir}`,
560
+ },
561
+ ],
562
+ details: {
563
+ phase: "failed",
564
+ error: "Conversation artifact dir missing",
565
+ conversation_id: conversationId,
566
+ },
567
+ isError: true,
568
+ };
569
+ }
570
+ const metadata = readConversationMetadata(join(artifactDir, "metadata.json"));
571
+ if (metadata?.agent_type && metadata.agent_type !== agent.name) {
572
+ return {
573
+ content: [
574
+ {
575
+ 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.`,
577
+ },
578
+ ],
579
+ details: {
580
+ phase: "failed",
581
+ error: "conversation_id agent_type mismatch",
582
+ conversation_id: conversationId,
583
+ },
584
+ isError: true,
585
+ };
586
+ }
587
+ resume = true;
588
+ const entry = readRegistry(piDir).find((candidate) => candidate.id === id);
589
+ if (params.background !== false &&
590
+ entry?.paneId &&
591
+ paneExists(entry.paneId)) {
592
+ const bgtask = {
593
+ dir: artifactDir,
594
+ agentType: entry.agentType,
595
+ sessionName,
596
+ paneId: entry.paneId,
597
+ originalPane: null,
598
+ description: params.description || entry.description,
599
+ startedAt: entry.startedAt,
600
+ toolUses: 0,
601
+ turns: 0,
602
+ conversationId,
603
+ recentCalls: [],
604
+ };
605
+ backgroundTasks.set(id, bgtask);
606
+ return {
607
+ content: [
608
+ {
609
+ type: "text",
610
+ text: `Resumed conversation "${conversationId}" via ${taskArtifactName(id)}. The subagent is running in background and will notify on completion.`,
611
+ },
612
+ ],
613
+ details: {
614
+ task_id: id,
615
+ agent_type: agent.name,
616
+ description: params.description,
617
+ conversation_id: conversationId,
618
+ tmux_session: sessionName,
619
+ background: true,
620
+ },
621
+ };
622
+ }
623
+ }
624
+ else if (params.task_id) {
518
625
  // Look up the task in the persistent registry
519
626
  const entries = readRegistry(piDir);
520
627
  const entry = entries.find((e) => e.id === params.task_id);
@@ -568,6 +675,7 @@ export default function (pi) {
568
675
  startedAt: entry.startedAt,
569
676
  toolUses: 0,
570
677
  turns: 0,
678
+ conversationId: entry.conversationId,
571
679
  recentCalls: [],
572
680
  };
573
681
  backgroundTasks.set(id, bgtask);
@@ -582,6 +690,7 @@ export default function (pi) {
582
690
  task_id: id,
583
691
  agent_type: agent.name,
584
692
  description: params.description,
693
+ conversation_id: entry.conversationId ?? conversationId,
585
694
  tmux_session: sessionName,
586
695
  background: true,
587
696
  },
@@ -590,11 +699,41 @@ export default function (pi) {
590
699
  }
591
700
  else {
592
701
  id = `${Date.now().toString(36)}-${randomUUID().slice(0, 4)}`;
593
- sessionName = `task-${id}`;
594
- artifactDir = join(piDir, "artifacts", sessionName);
702
+ sessionName = taskArtifactName(id);
703
+ artifactDir = join(getArtifactsDir(piDir), sessionName);
595
704
  await mkdir(artifactDir, { recursive: true });
596
705
  resultPath = join(artifactDir, "RESULT.md");
597
706
  }
707
+ if (conversationId && !hasTmux()) {
708
+ return {
709
+ content: [
710
+ {
711
+ type: "text",
712
+ 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.",
713
+ },
714
+ ],
715
+ details: {
716
+ phase: "failed",
717
+ error: "tmux required for durable conversation",
718
+ conversation_id: conversationId,
719
+ },
720
+ isError: true,
721
+ };
722
+ }
723
+ 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
+ });
736
+ }
598
737
  const descText = params.description || "";
599
738
  const isBackground = params.background ?? TASK_BACKGROUND_DEFAULT;
600
739
  // default true
@@ -661,6 +800,7 @@ export default function (pi) {
661
800
  startedAt: Date.now(),
662
801
  toolUses: 0,
663
802
  turns: 0,
803
+ conversationId,
664
804
  recentCalls: [],
665
805
  };
666
806
  if (foregroundTask) {
@@ -679,6 +819,7 @@ export default function (pi) {
679
819
  startedAt: Date.now(),
680
820
  toolUses: 0,
681
821
  turns: 0,
822
+ conversationId,
682
823
  recentCalls: [],
683
824
  };
684
825
  backgroundTasks.set(id, bgtask);
@@ -690,6 +831,7 @@ export default function (pi) {
690
831
  startedAt: bgtask.startedAt,
691
832
  piDir,
692
833
  dir: artifactDir,
834
+ conversationId,
693
835
  };
694
836
  const entries = readRegistry(piDir);
695
837
  entries.push(entry);
@@ -722,6 +864,7 @@ export default function (pi) {
722
864
  background: true,
723
865
  backend: "sdk",
724
866
  result_path: resultPath,
867
+ conversation_id: conversationId,
725
868
  },
726
869
  };
727
870
  }
@@ -736,6 +879,7 @@ export default function (pi) {
736
879
  backend: "sdk",
737
880
  session_path: sessionPath,
738
881
  result_path: resultPath,
882
+ conversation_id: conversationId,
739
883
  },
740
884
  };
741
885
  }
@@ -834,6 +978,7 @@ export default function (pi) {
834
978
  tool_uses: toolUses,
835
979
  turn_count: turns,
836
980
  background: false,
981
+ conversation_id: conversationId,
837
982
  },
838
983
  };
839
984
  }
@@ -848,6 +993,7 @@ export default function (pi) {
848
993
  startedAt: Date.now(),
849
994
  toolUses: 0,
850
995
  turns: 0,
996
+ conversationId,
851
997
  recentCalls: [],
852
998
  };
853
999
  backgroundTasks.set(id, bgtask);
@@ -861,6 +1007,7 @@ export default function (pi) {
861
1007
  paneId,
862
1008
  piDir,
863
1009
  dir: artifactDir,
1010
+ conversationId,
864
1011
  };
865
1012
  // Write to JSON registry for on-load restore
866
1013
  const entries = readRegistry(piDir);
@@ -974,4 +1121,12 @@ export default function (pi) {
974
1121
  return new Text(line, 0, 0);
975
1122
  },
976
1123
  });
1124
+ pi.registerCommand("task-sessions", {
1125
+ description: "List durable pi-task conversations",
1126
+ handler: async (_args, ctx) => {
1127
+ const cwd = ctx.sessionManager?.getCwd?.() ?? process.cwd();
1128
+ const { piDir } = discoverAgents(cwd);
1129
+ ctx.ui.notify(renderConversationSessions(piDir), "info");
1130
+ },
1131
+ });
977
1132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyhuynhgiabuu/pi-task",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Delegating task/subagent extension for Pi: foreground/background subagents, widgets, tmux observability, SDK fallback.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",