@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/CHANGELOG.md +52 -4
- package/README.md +33 -1
- package/dist/conversation.d.ts +86 -0
- package/dist/conversation.js +238 -0
- package/dist/helpers.d.ts +6 -6
- package/dist/helpers.js +30 -6
- package/dist/index.d.ts +17 -0
- package/dist/index.js +427 -175
- package/dist/session-text.d.ts +2 -2
- package/dist/session-text.js +28 -2
- package/dist/subagent/buildArgv.d.ts +1 -0
- package/dist/subagent/buildArgv.js +1 -1
- package/dist/subagent/waitCompletion.d.ts +1 -0
- package/dist/subagent/waitCompletion.js +27 -20
- package/dist/task-widget.d.ts +21 -0
- package/dist/task-widget.js +122 -0
- package/package.json +1 -1
package/dist/session-text.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/session-text.js
CHANGED
|
@@ -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
|
|
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;
|
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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",
|