@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.
@@ -2,6 +2,6 @@
2
2
  * Read assistant text from pi JSONL session directories (task / harness).
3
3
  */
4
4
  /**
5
- * Last non-empty assistant message across all .jsonl files in sessionDir.
5
+ * Last non-empty assistant message from matching .jsonl files in sessionDir.
6
6
  */
7
- export declare function getLastAssistantTextFromSessionDir(sessionDir: string): string;
7
+ export declare function getLastAssistantTextFromSessionDir(sessionDir: string, sessionName?: string, sinceMs?: number): string;
@@ -14,10 +14,29 @@ function extractText(content) {
14
14
  .join("\n")
15
15
  .trim();
16
16
  }
17
+ function matchesSessionName(content, sessionName) {
18
+ if (!sessionName)
19
+ return true;
20
+ for (const rawLine of content.split("\n")) {
21
+ const line = rawLine.trim();
22
+ if (!line)
23
+ continue;
24
+ try {
25
+ const entry = JSON.parse(line);
26
+ if (entry.type === "session_info") {
27
+ return (entry.name ?? entry.session_info?.name) === sessionName;
28
+ }
29
+ }
30
+ catch {
31
+ /* skip malformed JSONL rows */
32
+ }
33
+ }
34
+ return false;
35
+ }
17
36
  /**
18
- * Last non-empty assistant message across all .jsonl files in sessionDir.
37
+ * Last non-empty assistant message from matching .jsonl files in sessionDir.
19
38
  */
20
- export function getLastAssistantTextFromSessionDir(sessionDir) {
39
+ export function getLastAssistantTextFromSessionDir(sessionDir, sessionName, sinceMs) {
21
40
  if (!existsSync(sessionDir))
22
41
  return "";
23
42
  const files = readdirSync(sessionDir)
@@ -26,6 +45,8 @@ export function getLastAssistantTextFromSessionDir(sessionDir) {
26
45
  let last = "";
27
46
  for (const file of files) {
28
47
  const content = readFileSync(join(sessionDir, file), "utf-8");
48
+ if (!matchesSessionName(content, sessionName))
49
+ continue;
29
50
  for (const rawLine of content.split("\n")) {
30
51
  const line = rawLine.trim();
31
52
  if (!line)
@@ -34,6 +55,11 @@ export function getLastAssistantTextFromSessionDir(sessionDir) {
34
55
  const entry = JSON.parse(line);
35
56
  if (entry.type !== "message")
36
57
  continue;
58
+ if (sinceMs !== undefined && entry.timestamp) {
59
+ const timestampMs = Date.parse(entry.timestamp);
60
+ if (Number.isFinite(timestampMs) && timestampMs < sinceMs)
61
+ continue;
62
+ }
37
63
  const msg = entry.message;
38
64
  if (!msg || msg.role !== "assistant")
39
65
  continue;
@@ -8,6 +8,7 @@ export interface BuildPiArgvOptions {
8
8
  sessionDir: string;
9
9
  promptContent: string;
10
10
  resume?: boolean;
11
+ resumeSessionRef?: string;
11
12
  parentToolNames?: string[];
12
13
  }
13
14
  export declare function buildPiArgv(opts: BuildPiArgvOptions): string[];
@@ -18,7 +18,7 @@ export function buildPiArgv(opts) {
18
18
  args.push("--name", sessionName);
19
19
  args.push("--session-dir", sessionDir);
20
20
  if (resume)
21
- args.push("--session", sessionName);
21
+ args.push("--session", opts.resumeSessionRef ?? sessionName);
22
22
  args.push("--append-system-prompt", agent.body);
23
23
  args.push(promptContent);
24
24
  return args;
@@ -12,6 +12,7 @@ export interface TaskCompletionOptions {
12
12
  signal?: AbortSignal;
13
13
  timeoutMs?: number;
14
14
  pollMs?: number;
15
+ sinceMs?: number;
15
16
  }
16
17
  export declare function checkTaskCompletion(options: TaskCompletionOptions): Promise<TaskCompletionSnapshot>;
17
18
  export declare function waitForTaskCompletion(options: TaskCompletionOptions): Promise<TaskCompletionSnapshot>;
@@ -1,42 +1,49 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
- import { join } from "node:path";
4
3
  import { getLastAssistantTextFromSessionDir } from "../session-text.js";
5
4
  import { paneExists } from "./tmux.js";
5
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
6
  async function readResultFile(resultPath) {
7
7
  if (!existsSync(resultPath))
8
8
  return null;
9
9
  const text = (await readFile(resultPath, "utf-8")).trim();
10
10
  return text.length > 0 ? text : null;
11
11
  }
12
- function readSessionText(sessionDir, sessionName) {
13
- const sessionPath = join(sessionDir, "sessions", sessionName);
14
- const text = getLastAssistantTextFromSessionDir(sessionPath).trim();
12
+ function readSessionText(sessionDir, sessionName, sinceMs) {
13
+ // Session files are written by pi directly into `sessionDir`
14
+ // (flat). Filter by session_info.name so a new task never
15
+ // completes from an older task's JSONL.
16
+ const text = getLastAssistantTextFromSessionDir(sessionDir, sessionName, sinceMs).trim();
15
17
  return text.length > 0 ? text : null;
16
18
  }
17
19
  export async function checkTaskCompletion(options) {
20
+ // When the pane has exited, give pi a brief moment to flush the
21
+ // session file. Without this, the read can catch a partial
22
+ // file (e.g. the last `agent_end` / `message_end` events not
23
+ // yet written) and report "failed" even though the subagent
24
+ // completed successfully.
25
+ if (options.paneId && !paneExists(options.paneId)) {
26
+ await sleep(500);
27
+ }
18
28
  const result = await readResultFile(options.resultPath);
19
29
  if (result) {
20
30
  return { status: "completed", content: result, source: "result-file" };
21
31
  }
22
- if (options.paneId && paneExists(options.paneId)) {
23
- return { status: "running", content: "", source: "pane" };
24
- }
25
- const sessionText = readSessionText(options.sessionDir, options.sessionName);
26
- if (sessionText) {
27
- return {
28
- status: "completed",
29
- content: sessionText,
30
- source: "session-jsonl",
31
- };
32
+ // Check session text FIRST. If the subagent's session file has
33
+ // its final assistant message, the subagent is done — kill the
34
+ // pane and return, regardless of whether the pane shell is
35
+ // still open (e.g. remain-on-exit on, or the command exited but
36
+ // tmux kept the shell alive).
37
+ const sessionResult = readSessionText(options.sessionDir, options.sessionName, options.sinceMs);
38
+ if (sessionResult) {
39
+ return { status: "completed", content: sessionResult, source: "session-jsonl" };
32
40
  }
33
- if (options.paneId) {
34
- return {
35
- status: "failed",
36
- content: "Task pane exited before producing a result or assistant response.",
37
- source: "pane",
38
- };
41
+ // No session text yet. If the pane is gone and we never got
42
+ // session text, the subagent failed.
43
+ if (options.paneId && !paneExists(options.paneId)) {
44
+ return { status: "failed", content: "Subagent pane exited without producing a result." };
39
45
  }
46
+ // Pane still exists and no session text yet — keep polling.
40
47
  return { status: "running", content: "", source: "pane" };
41
48
  }
42
49
  export async function waitForTaskCompletion(options) {
@@ -0,0 +1,21 @@
1
+ import type { ToolCallRecord } from "./helpers.js";
2
+ export interface WidgetTask {
3
+ agentType: string;
4
+ description?: string;
5
+ startedAt: number;
6
+ toolUses: number;
7
+ recentCalls?: ToolCallRecord[];
8
+ }
9
+ export interface ThemeLike {
10
+ fg(color: string, text: string): string;
11
+ }
12
+ export declare const TASK_WIDGET_RENDER_MS = 80;
13
+ export declare function renderTaskWidget(params: {
14
+ foregroundTasks: Iterable<[string, WidgetTask]>;
15
+ backgroundTasks: Iterable<[string, WidgetTask]>;
16
+ foregroundCount: number;
17
+ backgroundCount: number;
18
+ width: number;
19
+ theme?: ThemeLike | null;
20
+ now?: number;
21
+ }): string[];
@@ -0,0 +1,122 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { formatMs } from "./helpers.js";
3
+ export const TASK_WIDGET_RENDER_MS = 80;
4
+ const SPINNER_FRAMES = [
5
+ "\u280B",
6
+ "\u2819",
7
+ "\u2838",
8
+ "\u2834",
9
+ "\u2826",
10
+ "\u2827",
11
+ "\u2807",
12
+ "\u280F",
13
+ ];
14
+ const MAX_TOOL_LINES = 12;
15
+ const MAX_BACKGROUND_LINES = 8;
16
+ const MAX_WIDTH = 120;
17
+ const TREE_MIDDLE = "\u251C\u2500"; // ├─
18
+ const TREE_LAST = "\u2514\u2500"; // └─
19
+ function color(theme, token, text) {
20
+ return theme?.fg ? theme.fg(token, text) : text;
21
+ }
22
+ function toolStatusMark(theme, status, spinner) {
23
+ switch (status) {
24
+ case "done":
25
+ return color(theme, "success", "\u2713");
26
+ case "error":
27
+ return color(theme, "error", "\u2717");
28
+ case "in_progress":
29
+ default:
30
+ return color(theme, "accent", spinner);
31
+ }
32
+ }
33
+ function formatToolCount(count) {
34
+ return `${count} ${count === 1 ? "tool" : "tools"}`;
35
+ }
36
+ function formatLatestTool(task, spinner, theme) {
37
+ const latest = task.recentCalls?.at(-1);
38
+ if (!latest) {
39
+ return `${toolStatusMark(theme, "in_progress", spinner)} ${color(theme, "dim", "waiting")}`;
40
+ }
41
+ const detail = latest.detail ? ` ${latest.detail}` : "";
42
+ return (`${toolStatusMark(theme, latest.status, spinner)} ` +
43
+ color(theme, "text", latest.name) +
44
+ color(theme, "dim", detail));
45
+ }
46
+ function renderForegroundTask(task, now, maxWidth, spinner, theme) {
47
+ const agentName = task.agentType.charAt(0).toUpperCase() + task.agentType.slice(1);
48
+ const elapsed = formatMs(now - task.startedAt);
49
+ const description = task.description ? ` — ${task.description}` : "";
50
+ const lines = [];
51
+ const header = color(theme, "accent", spinner) +
52
+ " " +
53
+ color(theme, "toolTitle", agentName) +
54
+ color(theme, "dim", description) +
55
+ color(theme, "dim", " \u2022 ") +
56
+ color(theme, "warning", elapsed) +
57
+ (task.toolUses > 0
58
+ ? color(theme, "dim", " \u2022 ") +
59
+ color(theme, "success", formatToolCount(task.toolUses))
60
+ : "");
61
+ lines.push(truncateToWidth(header, maxWidth));
62
+ const recent = task.recentCalls ?? [];
63
+ const slice = recent.slice(-MAX_TOOL_LINES);
64
+ slice.forEach((tc, idx) => {
65
+ const connector = idx === slice.length - 1 ? TREE_LAST : TREE_MIDDLE;
66
+ const detail = tc.detail ? ` ${tc.detail}` : "";
67
+ const line = " " +
68
+ color(theme, "dim", connector) +
69
+ " " +
70
+ toolStatusMark(theme, tc.status, spinner) +
71
+ " " +
72
+ color(theme, "text", tc.name) +
73
+ color(theme, "dim", detail);
74
+ lines.push(truncateToWidth(line, maxWidth));
75
+ });
76
+ return lines;
77
+ }
78
+ function renderBackgroundLine(id, task, now, maxWidth, spinner, theme) {
79
+ const elapsed = formatMs(now - task.startedAt);
80
+ const latest = formatLatestTool(task, spinner, theme);
81
+ const line = color(theme, "dim", "- ") +
82
+ color(theme, "toolTitle", task.agentType) +
83
+ color(theme, "dim", " \u00b7 ") +
84
+ color(theme, "accent", id) +
85
+ color(theme, "dim", " \u00b7 ") +
86
+ color(theme, "warning", elapsed) +
87
+ color(theme, "dim", " \u00b7 ") +
88
+ color(theme, "success", formatToolCount(task.toolUses)) +
89
+ color(theme, "dim", " \u00b7 ") +
90
+ latest;
91
+ return truncateToWidth(line, maxWidth);
92
+ }
93
+ export function renderTaskWidget(params) {
94
+ const { foregroundTasks, backgroundTasks, foregroundCount, backgroundCount, width, theme, } = params;
95
+ if (foregroundCount === 0 && backgroundCount === 0)
96
+ return [];
97
+ const now = params.now ?? Date.now();
98
+ const maxWidth = Math.min(width, MAX_WIDTH);
99
+ const tick = Math.floor(now / TASK_WIDGET_RENDER_MS);
100
+ const spinner = SPINNER_FRAMES[tick % SPINNER_FRAMES.length];
101
+ const lines = [];
102
+ for (const [, task] of foregroundTasks) {
103
+ lines.push(...renderForegroundTask(task, now, maxWidth, spinner, theme));
104
+ lines.push("");
105
+ }
106
+ const renderedBackground = [];
107
+ for (const entry of backgroundTasks) {
108
+ if (renderedBackground.length >= MAX_BACKGROUND_LINES)
109
+ break;
110
+ renderedBackground.push(entry);
111
+ }
112
+ for (const [id, task] of renderedBackground) {
113
+ lines.push(renderBackgroundLine(id, task, now, maxWidth, spinner, theme));
114
+ }
115
+ const hidden = backgroundCount - renderedBackground.length;
116
+ if (hidden > 0) {
117
+ lines.push(truncateToWidth(color(theme, "dim", `+ ${hidden} more background tasks`), maxWidth));
118
+ }
119
+ // Keep a little breathing room above the editor.
120
+ lines.push("");
121
+ return lines;
122
+ }
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.6",
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",