pi-async 0.0.0-stage → 0.2.0
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 +54 -0
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/docs/CONTRACT.md +308 -0
- package/extensions/index.ts +138 -0
- package/package.json +55 -4
- package/src/core/bus.ts +36 -0
- package/src/core/duration.ts +16 -0
- package/src/core/env.ts +23 -0
- package/src/core/notify.ts +57 -0
- package/src/core/output.ts +52 -0
- package/src/core/registry.ts +543 -0
- package/src/core/spawn.ts +138 -0
- package/src/core/task.ts +102 -0
- package/src/core/watch.ts +308 -0
- package/src/guard/settle-guard.ts +75 -0
- package/src/tools/index.ts +131 -0
- package/src/tui/host.ts +30 -0
- package/src/tui/panel.ts +279 -0
- package/src/tui/rail.ts +206 -0
- package/src/tui/status.ts +63 -0
- package/src/tui/width.ts +53 -0
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-async",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Pi extension for async shell tasks: background/delayed/sync modes, output-matching watches, event-driven waiting, settle guard for legion children, and a TUI task rail — completion notifications wake your session.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "zzjcool",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/zzjcool/pi-async.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/zzjcool/pi-async#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/zzjcool/pi-async/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"pi",
|
|
20
|
+
"coding-agent",
|
|
21
|
+
"async",
|
|
22
|
+
"background-tasks",
|
|
23
|
+
"task-manager",
|
|
24
|
+
"shell",
|
|
25
|
+
"watch",
|
|
26
|
+
"delayed",
|
|
27
|
+
"pi-legion"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.19.0"
|
|
31
|
+
},
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./extensions/index.ts"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"extensions/",
|
|
39
|
+
"src/",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"docs/CONTRACT.md",
|
|
43
|
+
"CHANGELOG.md"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@earendil-works/pi-coding-agent": "^1.2.0",
|
|
51
|
+
"@earendil-works/pi-tui": "^1.2.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.19.19",
|
|
55
|
+
"typescript": "^5.9.3"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/core/bus.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PiCtx } from "./notify.ts";
|
|
2
|
+
|
|
3
|
+
/** Aggregate activity source id (frozen §7). */
|
|
4
|
+
const AGGREGATE_ACTIVITY_ID = "pi-async";
|
|
5
|
+
|
|
6
|
+
/** Per-task activity id — legion #156 / osc-busy-plugin: `plugin/<name>/…`. */
|
|
7
|
+
export function taskActivityId(taskId: string): string {
|
|
8
|
+
return `plugin/pi-async/${taskId}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Report aggregate async work through Pi's activity API when the host exposes it. */
|
|
12
|
+
export function reportActivity(pi: PiCtx, active: { running: number; pending: number }): void {
|
|
13
|
+
// SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
|
|
14
|
+
const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
|
|
15
|
+
if (typeof ui?.setActivity !== "function") return;
|
|
16
|
+
const count = active.running + active.pending;
|
|
17
|
+
ui.setActivity(AGGREGATE_ACTIVITY_ID, count === 0 ? undefined : { message: `${count} async tasks` });
|
|
18
|
+
// Activity uses the host API; never emit terminal OSC/printf escapes. In particular,
|
|
19
|
+
// leave PI_PROGRAM_STATUS untouched for hosts which own that status channel.
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Raise task-scoped activity when the host supports Pi's optional activity API. */
|
|
23
|
+
export function raiseTaskActivity(pi: PiCtx, taskId: string, message = "Async task running"): void {
|
|
24
|
+
// SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
|
|
25
|
+
const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
|
|
26
|
+
if (typeof ui?.setActivity !== "function") return;
|
|
27
|
+
ui.setActivity(taskActivityId(taskId), { message });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Clear task-scoped activity when the host supports Pi's optional activity API. */
|
|
31
|
+
export function clearTaskActivity(pi: PiCtx, taskId: string): void {
|
|
32
|
+
// SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
|
|
33
|
+
const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
|
|
34
|
+
if (typeof ui?.setActivity !== "function") return;
|
|
35
|
+
ui.setActivity(taskActivityId(taskId), undefined);
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TaskSnapshot } from "./task.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format a task's elapsed duration for TUI surfaces.
|
|
5
|
+
* - pending → em dash (not started)
|
|
6
|
+
* - end base = finishedAt ?? now (terminal states freeze; live tasks tick)
|
|
7
|
+
* - three tiers: Ns / Mm Ss / Hh Mm
|
|
8
|
+
*/
|
|
9
|
+
export function formatDuration(task: TaskSnapshot, now: number): string {
|
|
10
|
+
if (task.status === "pending") return "—";
|
|
11
|
+
const end = task.finishedAt ?? now;
|
|
12
|
+
const seconds = Math.floor(Math.max(0, end - (task.startedAt ?? task.createdAt)) / 1000);
|
|
13
|
+
if (seconds < 60) return `${seconds}s`;
|
|
14
|
+
const minutes = Math.floor(seconds / 60);
|
|
15
|
+
return minutes < 60 ? `${minutes}m ${seconds % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
16
|
+
}
|
package/src/core/env.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface EnvNumberOpts {
|
|
2
|
+
min?: number;
|
|
3
|
+
max?: number;
|
|
4
|
+
/** When true, non-integers fall back (avoids Number("")===0 and 2.7→3 surprises). */
|
|
5
|
+
integer?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a numeric env var with empty-string → fallback (not Number("")===0).
|
|
10
|
+
* Non-finite / out-of-range / non-integer (when requested) also fall back.
|
|
11
|
+
*/
|
|
12
|
+
export function envNumber(name: string, fallback: number, opts?: EnvNumberOpts): number {
|
|
13
|
+
const raw = process.env[name];
|
|
14
|
+
if (raw === undefined) return fallback;
|
|
15
|
+
const trimmed = raw.trim();
|
|
16
|
+
if (trimmed === "") return fallback;
|
|
17
|
+
const parsed = Number(trimmed);
|
|
18
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
19
|
+
if (opts?.integer && !Number.isInteger(parsed)) return fallback;
|
|
20
|
+
if (opts?.min !== undefined && parsed < opts.min) return fallback;
|
|
21
|
+
if (opts?.max !== undefined && parsed > opts.max) return fallback;
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TaskRegistry } from "./registry.ts";
|
|
3
|
+
import type { FailureKind, KillKind, TaskStatus } from "./task.ts";
|
|
4
|
+
|
|
5
|
+
export type PiCtx = ExtensionAPI;
|
|
6
|
+
export interface NotifyPayload {
|
|
7
|
+
taskId: string; name: string; status: TaskStatus; exitCode?: number;
|
|
8
|
+
outputFile: string; summary?: string; guidance?: string;
|
|
9
|
+
killKind?: KillKind;
|
|
10
|
+
failureKind?: FailureKind;
|
|
11
|
+
summaryTruncated?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** XML 1.0 Char excludes C0 (except TAB/LF/CR), lone surrogates, and U+FFFE/FFFF (#19). */
|
|
15
|
+
const ILLEGAL_XML = /[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]/g;
|
|
16
|
+
|
|
17
|
+
const escapeXml = (value: unknown): string =>
|
|
18
|
+
String(value)
|
|
19
|
+
.replace(ILLEGAL_XML, "")
|
|
20
|
+
.replace(/[<&>\"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" })[char]!);
|
|
21
|
+
|
|
22
|
+
export function buildNotificationXml(p: NotifyPayload): string {
|
|
23
|
+
const fields: Array<[string, unknown]> = [["task-id", p.taskId], ["name", p.name], ["status", p.status]];
|
|
24
|
+
if (p.exitCode !== undefined) fields.push(["exit-code", p.exitCode]);
|
|
25
|
+
if (p.killKind) fields.push(["kill-kind", p.killKind]);
|
|
26
|
+
if (p.failureKind) fields.push(["failure-kind", p.failureKind]);
|
|
27
|
+
fields.push(["output-file", p.outputFile]);
|
|
28
|
+
if (p.summary) fields.push(["summary", p.summary]);
|
|
29
|
+
if (p.summaryTruncated) fields.push(["summary-truncated", "true"]);
|
|
30
|
+
if (p.status === "failed" && p.guidance) fields.push(["guidance", p.guidance]);
|
|
31
|
+
return `<async-task-notification>${fields.map(([tag, value]) => `<${tag}>${escapeXml(value)}</${tag}>`).join("")}</async-task-notification>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function sendCompletionNotification(
|
|
35
|
+
pi: PiCtx,
|
|
36
|
+
registry: TaskRegistry,
|
|
37
|
+
p: NotifyPayload,
|
|
38
|
+
options: { triggerTurn?: boolean } = {},
|
|
39
|
+
): Promise<boolean> {
|
|
40
|
+
if (registry.get(p.taskId)?.outputConsumed || !registry.claimNotification(p.taskId)) return false;
|
|
41
|
+
// SAFETY: The pi extension API provides sendMessage in supported hosts; detect it at runtime for other contexts.
|
|
42
|
+
const target = pi as unknown as { sendMessage?: (message: unknown, options: unknown) => unknown };
|
|
43
|
+
if (!target.sendMessage) { registry.rollbackNotification(p.taskId); return false; }
|
|
44
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
45
|
+
try {
|
|
46
|
+
await target.sendMessage(
|
|
47
|
+
{ customType: "async-task-notification", content: buildNotificationXml(p) },
|
|
48
|
+
{ deliverAs: "followUp", ...(options.triggerTurn === false ? {} : { triggerTurn: true }) },
|
|
49
|
+
);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
if (attempt < 2) await Promise.resolve();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
registry.rollbackNotification(p.taskId);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const ANSI_ESCAPE =
|
|
2
|
+
/\x1B(?:\][\s\S]*?(?:\x07|\x1B\\|\x9C)|\][\s\S]*$|\[[0-?]*[ -/]*[@-~]|\[[0-?]*[ -/]*$|[PX^_][\s\S]*?\x1B\\|[ -/]*[0-~])|\x9B[0-?]*[ -/]*[@-~]|\x9D[\s\S]*?(?:\x07|\x1B\\|\x9C)/g;
|
|
3
|
+
|
|
4
|
+
/** Keep tab/LF/CR; drop other C0 controls that must not reach model/TUI/XML. */
|
|
5
|
+
const C0_CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
|
|
6
|
+
|
|
7
|
+
/** Remove ANSI escape, CSI, OSC, and string-control sequences from text.
|
|
8
|
+
* Unterminated OSC/CSI are dropped through end-of-string so truncation mid-sequence
|
|
9
|
+
* does not leave the payload as visible garbage (#32). */
|
|
10
|
+
export function stripAnsi(text: string): string {
|
|
11
|
+
return text.replace(ANSI_ESCAPE, "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Strip bare C0 control bytes (except TAB/LF/CR which are folded to spaces)
|
|
16
|
+
* and collapse runs of whitespace. Use after stripAnsi for TUI-visible text.
|
|
17
|
+
* Keeps panel and rail sanitization aligned (CONTRACT §9 / issue #3).
|
|
18
|
+
*/
|
|
19
|
+
export function stripControl(text: string): string {
|
|
20
|
+
return text
|
|
21
|
+
.replace(C0_CONTROLS, "")
|
|
22
|
+
.replace(/[\r\n\t]+/g, " ");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** ANSI + C0 sanitizer for TUI labels (task names, panel rows). */
|
|
26
|
+
export function visibleText(value: unknown): string {
|
|
27
|
+
return stripControl(stripAnsi(String(value ?? "")));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Model/logs path: strip ANSI then bare C0, preserve TAB/LF/CR (#18). */
|
|
31
|
+
export function stripLogControls(text: string): string {
|
|
32
|
+
return stripAnsi(text).replace(C0_CONTROLS, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Truncate a UTF-8 buffer to at most `maxBytes`, cutting on a leading-byte boundary
|
|
37
|
+
* so multi-byte characters are never split into U+FFFD (#17).
|
|
38
|
+
* When `tail` is true, keep the end of the buffer (advance past continuation bytes).
|
|
39
|
+
*/
|
|
40
|
+
export function truncateUtf8Bytes(bytes: Buffer, maxBytes: number, tail = true): string {
|
|
41
|
+
if (maxBytes <= 0) return "";
|
|
42
|
+
if (bytes.byteLength <= maxBytes) return bytes.toString("utf8");
|
|
43
|
+
if (!tail) {
|
|
44
|
+
let end = maxBytes;
|
|
45
|
+
// Walk back while on a UTF-8 continuation byte so we don't cut mid-sequence.
|
|
46
|
+
while (end > 0 && (bytes[end]! & 0xc0) === 0x80) end--;
|
|
47
|
+
return bytes.subarray(0, end).toString("utf8");
|
|
48
|
+
}
|
|
49
|
+
let start = bytes.byteLength - maxBytes;
|
|
50
|
+
while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) start++;
|
|
51
|
+
return bytes.subarray(start).toString("utf8");
|
|
52
|
+
}
|