pi-async 0.2.0 → 0.3.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 +135 -1
- package/README.md +73 -19
- package/docs/CONTRACT.md +242 -53
- package/extensions/index.ts +211 -59
- package/package.json +4 -2
- package/src/core/duration.ts +8 -4
- package/src/core/env.ts +111 -0
- package/src/core/notify.ts +63 -15
- package/src/core/registry.ts +465 -41
- package/src/core/spawn.ts +46 -11
- package/src/core/task.ts +13 -0
- package/src/guard/settle-guard.ts +152 -43
- package/src/tools/index.ts +185 -30
- package/src/tui/panel.ts +308 -74
- package/src/tui/rail.ts +76 -45
- package/src/tui/status.ts +86 -30
package/extensions/index.ts
CHANGED
|
@@ -1,65 +1,164 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
1
2
|
import { join } from "node:path";
|
|
2
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { clearTaskActivity, raiseTaskActivity, reportActivity } from "../src/core/bus.ts";
|
|
4
5
|
import { sendCompletionNotification } from "../src/core/notify.ts";
|
|
5
6
|
import { TaskRegistry } from "../src/core/registry.ts";
|
|
7
|
+
import { isTerminal, type TaskSnapshot } from "../src/core/task.ts";
|
|
6
8
|
import { SettleGuard } from "../src/guard/settle-guard.ts";
|
|
7
|
-
import { registerTools } from "../src/tools/index.ts";
|
|
9
|
+
import { registerTools, type ToolContext } from "../src/tools/index.ts";
|
|
8
10
|
import { registerRail } from "../src/tui/rail.ts";
|
|
9
|
-
import {
|
|
11
|
+
import { initStatus } from "../src/tui/status.ts";
|
|
10
12
|
import { registerPanelCommands } from "../src/tui/panel.ts";
|
|
11
13
|
import { bindTuiHost } from "../src/tui/host.ts";
|
|
12
|
-
import { isTerminal } from "../src/core/task.ts";
|
|
13
14
|
|
|
14
15
|
export type PiCtx = ExtensionAPI;
|
|
15
16
|
|
|
17
|
+
/** Structural view of pi's session manager: 0.3.0 only reads dir + id (validated at runtime). */
|
|
18
|
+
type SessionManagerLike = {
|
|
19
|
+
getSessionDir?: () => string | undefined;
|
|
20
|
+
getSessionId?: () => string | undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
16
23
|
type RuntimeContext = PiCtx & {
|
|
17
24
|
cwd?: string;
|
|
18
|
-
|
|
19
|
-
sessionId?: string;
|
|
20
|
-
getSessionId?: () => string | undefined;
|
|
21
|
-
sessionManager?: { getSessionId?: () => string | undefined };
|
|
22
|
-
mode?: string;
|
|
25
|
+
sessionManager?: SessionManagerLike;
|
|
23
26
|
on?: (event: string, handler: (...args: unknown[]) => unknown) => unknown;
|
|
24
27
|
registerTool?: (...args: unknown[]) => unknown;
|
|
28
|
+
registerMessageRenderer?: (...args: unknown[]) => unknown;
|
|
25
29
|
};
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Session-scoped data directory (D-16 / PLAN §2.8):
|
|
33
|
+
* `<sessionDir>/pi-async/<sessionId>`, or undefined when the host exposes no
|
|
34
|
+
* session directory (degraded hosts, factory-time calls) so the caller can
|
|
35
|
+
* fall back to the library default. Nothing is created here — the registry
|
|
36
|
+
* creates directories lazily on first persist/configure (D-04).
|
|
37
|
+
*/
|
|
38
|
+
function sessionDataDir(ctx?: { sessionManager?: SessionManagerLike }): string | undefined {
|
|
39
|
+
const dir = ctx?.sessionManager?.getSessionDir?.();
|
|
40
|
+
const sessionId = ctx?.sessionManager?.getSessionId?.();
|
|
41
|
+
if (typeof dir !== "string" || dir.length === 0) return undefined;
|
|
42
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return undefined;
|
|
43
|
+
return join(dir, "pi-async", sessionId);
|
|
34
44
|
}
|
|
35
45
|
|
|
36
|
-
/**
|
|
46
|
+
/** Library fallback when no session directory is available: per-process `~/.pi/async/<pid>`. */
|
|
47
|
+
function fallbackDataDir(): string {
|
|
48
|
+
return join(homedir(), ".pi", "async", String(process.pid));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Previous init generation. Retired (guard disposed, registry suspended — never
|
|
53
|
+
* killed) at the start of the next init: `/reload` must be able to adopt the
|
|
54
|
+
* running tasks, and a timer that outlives the invalidated ctx would crash the
|
|
55
|
+
* whole process (#44 / crash 2026-10-21). Killing is reserved for
|
|
56
|
+
* `session_shutdown` with quit/new/fork.
|
|
57
|
+
*/
|
|
37
58
|
let activeRegistry: TaskRegistry | undefined;
|
|
38
|
-
|
|
59
|
+
|
|
60
|
+
/** Previous generation's settle guard; disposed up front instead of trusting a teardown event. */
|
|
61
|
+
let activeGuard: SettleGuard | undefined;
|
|
62
|
+
|
|
63
|
+
/** One-line card for completion notifications (PLAN §2.8), rendered from message.details. */
|
|
64
|
+
function formatDuration(ms: number): string {
|
|
65
|
+
return `${(Math.max(0, ms) / 1000).toFixed(1)}s`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function firstContentLine(content: unknown): string {
|
|
69
|
+
if (typeof content === "string") {
|
|
70
|
+
return content.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() ?? "";
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(content)) {
|
|
73
|
+
for (const block of content) {
|
|
74
|
+
if (!block || typeof block !== "object") continue;
|
|
75
|
+
const text = (block as { text?: unknown }).text;
|
|
76
|
+
if (typeof text !== "string") continue;
|
|
77
|
+
const line = text.split(/\r?\n/).find((candidate) => candidate.trim().length > 0);
|
|
78
|
+
if (line) return line.trim();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `✓ name (completed, 1.2s) · exit 0` / `✗ name (failed, 1.2s) · exit 7 · kill timeout`.
|
|
86
|
+
* Duration comes from the live snapshot (details does not carry timings); without
|
|
87
|
+
* a snapshot the duration is omitted. Falls back to the message content's first
|
|
88
|
+
* line when the custom message carries no details.
|
|
89
|
+
*/
|
|
90
|
+
function notificationCard(message: unknown, registry: TaskRegistry): string {
|
|
91
|
+
const details = (message as { details?: unknown })?.details;
|
|
92
|
+
if (!details || typeof details !== "object") {
|
|
93
|
+
return firstContentLine((message as { content?: unknown })?.content) || "async task notification";
|
|
94
|
+
}
|
|
95
|
+
const d = details as { taskId?: unknown; name?: unknown; status?: unknown; exitCode?: unknown; killKind?: unknown; failureKind?: unknown };
|
|
96
|
+
const taskId = typeof d.taskId === "string" ? d.taskId : undefined;
|
|
97
|
+
const snapshot: TaskSnapshot | undefined = taskId ? registry.get(taskId) : undefined;
|
|
98
|
+
const status = typeof d.status === "string" ? d.status : snapshot?.status ?? "unknown";
|
|
99
|
+
const name = typeof d.name === "string" ? d.name : snapshot?.name ?? "async task";
|
|
100
|
+
const finishedAt = snapshot?.finishedAt;
|
|
101
|
+
const duration = finishedAt === undefined ? "" : `, ${formatDuration(finishedAt - (snapshot?.startedAt ?? snapshot?.createdAt ?? finishedAt))}`;
|
|
102
|
+
const parts = [`${status === "completed" ? "✓" : "✗"} ${name} (${status}${duration})`];
|
|
103
|
+
const exitCode = typeof d.exitCode === "number" ? d.exitCode : snapshot?.exitCode;
|
|
104
|
+
if (exitCode !== undefined) parts.push(`exit ${exitCode}`);
|
|
105
|
+
const killKind = typeof d.killKind === "string" ? d.killKind : undefined;
|
|
106
|
+
const failureKind = typeof d.failureKind === "string" ? d.failureKind : undefined;
|
|
107
|
+
if (killKind) parts.push(`kill ${killKind}`);
|
|
108
|
+
else if (failureKind) parts.push(`failure ${failureKind}`);
|
|
109
|
+
return parts.join(" · ");
|
|
110
|
+
}
|
|
39
111
|
|
|
40
112
|
/** Wire the task runtime to the host API, degrading optional Pi features gracefully. */
|
|
41
113
|
export async function init(pi: PiCtx): Promise<void> {
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
114
|
+
// Retire the previous generation first: dispose its guard and SUSPEND its registry
|
|
115
|
+
// (timers/watches stopped, processes left running) so a /reload can adopt the work
|
|
116
|
+
// from disk. Never kill here — killing is quit/new/fork's job (PLAN §2.8).
|
|
117
|
+
const previousGuard = activeGuard;
|
|
118
|
+
const previousRegistry = activeRegistry;
|
|
119
|
+
activeGuard = undefined;
|
|
120
|
+
activeRegistry = undefined;
|
|
121
|
+
if (previousGuard) {
|
|
46
122
|
try {
|
|
47
|
-
|
|
48
|
-
} catch {
|
|
49
|
-
|
|
123
|
+
previousGuard.dispose();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error("pi-async: previous settle guard disposal during reload failed", error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (previousRegistry) {
|
|
50
129
|
try {
|
|
51
|
-
await
|
|
130
|
+
await previousRegistry.suspend();
|
|
52
131
|
} catch (error) {
|
|
53
|
-
console.error("pi-async: previous registry
|
|
132
|
+
console.error("pi-async: previous registry suspension during reload failed", error);
|
|
54
133
|
}
|
|
55
|
-
activeRegistry = undefined;
|
|
56
134
|
}
|
|
57
|
-
|
|
135
|
+
|
|
136
|
+
const context = pi as RuntimeContext;
|
|
137
|
+
// The factory creates the registry only — no mkdir, no reloadFromDisk, no timers.
|
|
138
|
+
// Data dir and cwd land in `configure()` on the first session/tool context (D-04).
|
|
139
|
+
// Real pi exposes no sessionManager at factory time, so this is usually the library
|
|
140
|
+
// fallback until the first session ctx arrives (PLAN §2.8: "no sessionManager → fallback").
|
|
141
|
+
const factoryDataDir = sessionDataDir(context) ?? fallbackDataDir();
|
|
142
|
+
const registry = new TaskRegistry({ dataDir: factoryDataDir });
|
|
58
143
|
activeRegistry = registry;
|
|
59
|
-
// NOTE: pi's factory-time ExtensionAPI exposes no `mode`/`ui` — those live on
|
|
60
|
-
// event-handler contexts (pi.on("session_start", (event, ctx) => ...))
|
|
61
|
-
//
|
|
144
|
+
// NOTE: pi's factory-time ExtensionAPI exposes no `mode`/`ui`/`sessionManager` — those live on
|
|
145
|
+
// event-handler contexts (pi.on("session_start", (event, ctx) => ...)), so data dir, cwd and TUI
|
|
146
|
+
// binding are resolved lazily from the first ctx.
|
|
147
|
+
let configured = false;
|
|
62
148
|
let tui = false;
|
|
149
|
+
|
|
150
|
+
/** One-time `configure({dataDir, cwd})` from whatever ctx is available (session event or tool call). */
|
|
151
|
+
const ensureConfigured = (ctx?: { cwd?: string; sessionManager?: SessionManagerLike }): void => {
|
|
152
|
+
if (configured) return;
|
|
153
|
+
configured = true;
|
|
154
|
+
try {
|
|
155
|
+
registry.configure({ dataDir: sessionDataDir(ctx) ?? factoryDataDir, cwd: typeof ctx?.cwd === "string" && ctx.cwd.length > 0 ? ctx.cwd : undefined });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
// configure() is contractually non-throwing (D-05); never let a wiring hiccup break a tool call.
|
|
158
|
+
console.error("pi-async: registry configure failed", error);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
63
162
|
const bindTui = (ctx: unknown): void => {
|
|
64
163
|
if (!bindTuiHost(ctx)) return;
|
|
65
164
|
tui = true;
|
|
@@ -68,18 +167,53 @@ export async function init(pi: PiCtx): Promise<void> {
|
|
|
68
167
|
registerPanelCommands(pi, registry);
|
|
69
168
|
};
|
|
70
169
|
|
|
170
|
+
// First session_start / message_start: configure → resend missed notifications, and bind
|
|
171
|
+
// the TUI synchronously on the first TUI-capable ctx (pi exposes ui only on event ctxs).
|
|
172
|
+
// Idempotent: repeated events neither re-configure nor re-send.
|
|
173
|
+
let startup: Promise<void> | undefined;
|
|
174
|
+
const onLifecycle = (ctx: unknown): Promise<void> => {
|
|
175
|
+
if (!startup) {
|
|
176
|
+
ensureConfigured(ctx as RuntimeContext);
|
|
177
|
+
startup = (async () => {
|
|
178
|
+
try {
|
|
179
|
+
await registry.resendUnnotified();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.error("pi-async: re-sending missed notifications failed", error);
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
184
|
+
}
|
|
185
|
+
bindTui(ctx);
|
|
186
|
+
return startup;
|
|
187
|
+
};
|
|
188
|
+
|
|
71
189
|
registry.setEvents({
|
|
72
190
|
onFinalize: (snapshot) => {
|
|
73
191
|
clearTaskActivity(pi, snapshot.taskId);
|
|
74
192
|
reportActivity(pi, registry.activeCount());
|
|
75
193
|
if (tui) initStatus(pi, registry);
|
|
76
194
|
if (snapshot.metadata.notifyOnCompletion === false) return;
|
|
195
|
+
let outputTail = "";
|
|
196
|
+
let truncated = false;
|
|
197
|
+
try {
|
|
198
|
+
const logSlice = registry.logs(snapshot.taskId, { maxBytes: 8192, tail: true });
|
|
199
|
+
outputTail = logSlice.text;
|
|
200
|
+
truncated = logSlice.truncated;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
// Out-of-dataDir outputFile (D-11) must not crash finalization or the notification path.
|
|
203
|
+
console.error(`pi-async: could not read output for ${snapshot.taskId}`, error);
|
|
204
|
+
}
|
|
77
205
|
const outputFile = String(snapshot.metadata.outputFile ?? "");
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
206
|
+
// `lost` = adopted after /reload but the exit status was never observed; saying only "failed"
|
|
207
|
+
// would misreport a normal reload ending. `startError` (W1a) explains a failed spawn/delayed start.
|
|
208
|
+
const startError = typeof snapshot.metadata.startError === "string" && snapshot.metadata.startError.length > 0
|
|
209
|
+
? snapshot.metadata.startError
|
|
210
|
+
: undefined;
|
|
211
|
+
const failureNotes: string[] = [];
|
|
212
|
+
if (snapshot.failureKind === "lost") failureNotes.push("Exit status unknown after session reload; output may be incomplete.");
|
|
213
|
+
if (startError !== undefined) failureNotes.push(`Start error: ${startError}`);
|
|
214
|
+
if (snapshot.status === "failed") failureNotes.push(`Inspect task output with async_logs (taskId: ${snapshot.taskId}).`);
|
|
215
|
+
const guidance = failureNotes.length > 0 ? failureNotes.join(" ") : undefined;
|
|
216
|
+
const payload = {
|
|
83
217
|
taskId: snapshot.taskId,
|
|
84
218
|
name: snapshot.name,
|
|
85
219
|
status: snapshot.status,
|
|
@@ -89,8 +223,16 @@ export async function init(pi: PiCtx): Promise<void> {
|
|
|
89
223
|
...(guidance ? { guidance } : {}),
|
|
90
224
|
...(snapshot.killKind ? { killKind: snapshot.killKind } : {}),
|
|
91
225
|
...(snapshot.failureKind ? { failureKind: snapshot.failureKind } : {}),
|
|
92
|
-
...(
|
|
93
|
-
}
|
|
226
|
+
...(truncated || snapshot.metadata.capped === true ? { summaryTruncated: true } : {}),
|
|
227
|
+
};
|
|
228
|
+
// Honest notification (PLAN §2.4/§2.8): no fake retries here. A failed send keeps
|
|
229
|
+
// notified=false / notifyAttempts>0 in the registry and is re-sent at the next session_start.
|
|
230
|
+
void sendCompletionNotification(pi, registry, payload, { triggerTurn: snapshot.metadata.triggerOnCompletion !== false })
|
|
231
|
+
.then((outcome) => {
|
|
232
|
+
if (outcome === "failed") {
|
|
233
|
+
console.error(`pi-async: completion notification failed for ${snapshot.taskId}; it will be re-sent on the next session_start`);
|
|
234
|
+
}
|
|
235
|
+
})
|
|
94
236
|
.catch((error: unknown) => console.error(`pi-async: completion notification failed for ${snapshot.taskId}`, error));
|
|
95
237
|
},
|
|
96
238
|
onStateChange: (snapshot) => {
|
|
@@ -102,34 +244,44 @@ export async function init(pi: PiCtx): Promise<void> {
|
|
|
102
244
|
},
|
|
103
245
|
});
|
|
104
246
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
247
|
+
if (typeof context.registerTool === "function") registerTools(pi, registry, ensureConfigured);
|
|
248
|
+
|
|
249
|
+
// Completion notification card (PLAN §2.8). Local component implementation: the TUI lane owns
|
|
250
|
+
// src/tui/*, so this renderer must not import from there.
|
|
251
|
+
if (typeof context.registerMessageRenderer === "function") {
|
|
252
|
+
context.registerMessageRenderer("async-task-notification", (message: unknown) => ({
|
|
253
|
+
render: () => [notificationCard(message, registry)],
|
|
254
|
+
invalidate: () => undefined,
|
|
255
|
+
}));
|
|
109
256
|
}
|
|
110
257
|
|
|
111
|
-
const context = pi as RuntimeContext;
|
|
112
|
-
if (typeof context.registerTool === "function") registerTools(pi, registry);
|
|
113
|
-
// Bind TUI surfaces from the first event context (factory API has no mode/ui).
|
|
114
258
|
if (typeof context.on === "function") {
|
|
115
|
-
context.on("session_start", (_event: unknown, ctx: unknown) =>
|
|
116
|
-
context.on("message_start", (_event: unknown, ctx: unknown) =>
|
|
117
|
-
}
|
|
118
|
-
if (typeof context.on === "function") new SettleGuard(pi, registry).register();
|
|
259
|
+
context.on("session_start", (_event: unknown, ctx: unknown) => onLifecycle(ctx));
|
|
260
|
+
context.on("message_start", (_event: unknown, ctx: unknown) => onLifecycle(ctx));
|
|
119
261
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
262
|
+
// agent_settled / agent_before_settle are registered by SettleGuard itself.
|
|
263
|
+
activeGuard = new SettleGuard(pi, registry);
|
|
264
|
+
activeGuard.register();
|
|
265
|
+
}
|
|
123
266
|
|
|
124
267
|
let shutdownPromise: Promise<void> | undefined;
|
|
125
268
|
if (typeof context.on === "function") {
|
|
126
|
-
context.on("session_shutdown", async () => {
|
|
127
|
-
if (!shutdownPromise)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
269
|
+
context.on("session_shutdown", async (event: unknown) => {
|
|
270
|
+
if (!shutdownPromise) {
|
|
271
|
+
shutdownPromise = (async () => {
|
|
272
|
+
// D-16 / PLAN §2.8: reload + resume suspend (next generation adopts from disk);
|
|
273
|
+
// quit/new/fork (and an unknown/undefined reason) shut down and kill.
|
|
274
|
+
const reason = (event as { reason?: string } | undefined)?.reason;
|
|
275
|
+
const suspendOnly = reason === "reload" || reason === "resume";
|
|
276
|
+
if (activeRegistry === registry) activeRegistry = undefined;
|
|
277
|
+
try {
|
|
278
|
+
if (suspendOnly) await registry.suspend();
|
|
279
|
+
else await registry.shutdown();
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error(`pi-async: session shutdown (${String(reason ?? "unknown")}) failed`, error);
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
284
|
+
}
|
|
133
285
|
await shutdownPromise;
|
|
134
286
|
});
|
|
135
287
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-async",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,7 +44,9 @@
|
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
46
|
"typecheck": "tsc --noEmit",
|
|
47
|
-
"test": "node --experimental-strip-types --test tests/*.test.ts"
|
|
47
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
48
|
+
"verify": "npm run typecheck && npm test",
|
|
49
|
+
"prepublishOnly": "npm run verify"
|
|
48
50
|
},
|
|
49
51
|
"peerDependencies": {
|
|
50
52
|
"@earendil-works/pi-coding-agent": "^1.2.0",
|
package/src/core/duration.ts
CHANGED
|
@@ -6,11 +6,15 @@ import type { TaskSnapshot } from "./task.ts";
|
|
|
6
6
|
* - end base = finishedAt ?? now (terminal states freeze; live tasks tick)
|
|
7
7
|
* - three tiers: Ns / Mm Ss / Hh Mm
|
|
8
8
|
*/
|
|
9
|
-
export function
|
|
10
|
-
|
|
11
|
-
const end = task.finishedAt ?? now;
|
|
12
|
-
const seconds = Math.floor(Math.max(0, end - (task.startedAt ?? task.createdAt)) / 1000);
|
|
9
|
+
export function formatElapsedMs(ms: number): string {
|
|
10
|
+
const seconds = Math.floor(Math.max(0, ms) / 1000);
|
|
13
11
|
if (seconds < 60) return `${seconds}s`;
|
|
14
12
|
const minutes = Math.floor(seconds / 60);
|
|
15
13
|
return minutes < 60 ? `${minutes}m ${seconds % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
16
14
|
}
|
|
15
|
+
|
|
16
|
+
export function formatDuration(task: TaskSnapshot, now: number): string {
|
|
17
|
+
if (task.status === "pending") return "—";
|
|
18
|
+
const end = task.finishedAt ?? now;
|
|
19
|
+
return formatElapsedMs(end - (task.startedAt ?? task.createdAt));
|
|
20
|
+
}
|
package/src/core/env.ts
CHANGED
|
@@ -21,3 +21,114 @@ export function envNumber(name: string, fallback: number, opts?: EnvNumberOpts):
|
|
|
21
21
|
if (opts?.max !== undefined && parsed > opts.max) return fallback;
|
|
22
22
|
return parsed;
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
/** Child environment inheritance policy (REV-16). `safe` is the default. */
|
|
26
|
+
export type EnvPolicy = "safe" | "all" | "none";
|
|
27
|
+
|
|
28
|
+
/** Keys whose inheritance would silently change how the child resolves code (REV-16). */
|
|
29
|
+
const DANGEROUS_KEYS: ReadonlySet<string> = new Set([
|
|
30
|
+
"PATH",
|
|
31
|
+
"LD_PRELOAD",
|
|
32
|
+
"LD_LIBRARY_PATH",
|
|
33
|
+
"NODE_OPTIONS",
|
|
34
|
+
"BASH_ENV",
|
|
35
|
+
"ENV",
|
|
36
|
+
"SHELLOPTS",
|
|
37
|
+
"PYTHONPATH",
|
|
38
|
+
"GIT_SSH_COMMAND",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Suffix families treated as credentials under `safe` inheritance. */
|
|
42
|
+
const SENSITIVE_SUFFIX = /_(API_KEY|TOKEN|SECRET|PASSWORD)$/i;
|
|
43
|
+
const SENSITIVE_SUBSTRING = /CREDENTIAL/i;
|
|
44
|
+
const SENSITIVE_EXPLICIT: ReadonlySet<string> = new Set([
|
|
45
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
46
|
+
"AWS_ACCESS_KEY_ID",
|
|
47
|
+
"AWS_SESSION_TOKEN",
|
|
48
|
+
"GITHUB_TOKEN",
|
|
49
|
+
"GH_TOKEN",
|
|
50
|
+
"NPM_TOKEN",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** `none` keeps only what a POSIX shell needs to start at all. */
|
|
54
|
+
const NONE_ALLOWED: ReadonlySet<string> = new Set(["PATH", "HOME", "SHELL", "TERM", "LANG", "TMPDIR", "USER"]);
|
|
55
|
+
|
|
56
|
+
/** Host-only tuning knob: never leaks into a child, under any policy. */
|
|
57
|
+
export const INTERNAL_MAX_OUTPUT_ENV = "PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES";
|
|
58
|
+
|
|
59
|
+
function isSensitiveKey(key: string): boolean {
|
|
60
|
+
if (SENSITIVE_EXPLICIT.has(key) || SENSITIVE_SUFFIX.test(key) || SENSITIVE_SUBSTRING.test(key)) return true;
|
|
61
|
+
// npm reads credentials from lower-case `npm_config_*` keys (e.g. npm_config__authToken).
|
|
62
|
+
return key.toLowerCase().startsWith("npm_config_") && /(auth|token|password)/i.test(key);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function noneAllows(key: string): boolean {
|
|
66
|
+
return NONE_ALLOWED.has(key) || key.startsWith("LC_");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assignDefined(target: Record<string, string>, source: NodeJS.ProcessEnv | Record<string, string>): void {
|
|
70
|
+
for (const [key, value] of Object.entries(source)) {
|
|
71
|
+
if (value === undefined) continue;
|
|
72
|
+
target[key] = String(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reject explicit overrides of process-resolution keys (`PATH`, `NODE_OPTIONS`, …).
|
|
78
|
+
*
|
|
79
|
+
* Exported so task-submission paths (e.g. delayed tasks, which spawn later) can fail fast
|
|
80
|
+
* instead of surfacing the error only when the process is finally started. Applied by every
|
|
81
|
+
* inheritance policy.
|
|
82
|
+
*/
|
|
83
|
+
export function assertSafeEnvKeys(extra: Record<string, string> | undefined, policyName?: EnvPolicy): void {
|
|
84
|
+
if (!extra) return;
|
|
85
|
+
const policyContext = policyName ? ` under env policy "${policyName}"` : "";
|
|
86
|
+
for (const key of Object.keys(extra)) {
|
|
87
|
+
if (!DANGEROUS_KEYS.has(key)) continue;
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Refusing to set dangerous environment variable ${key} for an async task: ` +
|
|
90
|
+
`explicit overrides of process-resolution keys are rejected${policyContext}. ` +
|
|
91
|
+
`The inherited ${key} is used instead.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the child environment for a task (pure: never mutates `base` or `extra`).
|
|
98
|
+
*
|
|
99
|
+
* - `safe` (default): inherit `base` minus credential-ish keys; extra ordinary keys override.
|
|
100
|
+
* - `all` : legacy `{...base, ...extra}`.
|
|
101
|
+
* - `none` : only PATH/HOME/SHELL/TERM/LANG/LC_-prefixed/TMPDIR/USER from `base`, plus extra ordinary keys.
|
|
102
|
+
*
|
|
103
|
+
* In every policy an explicit dangerous key (`PATH`, `NODE_OPTIONS`, …) in `extra` throws,
|
|
104
|
+
* and the internal output-cap knob is always stripped.
|
|
105
|
+
*/
|
|
106
|
+
export function buildChildEnv(
|
|
107
|
+
base: NodeJS.ProcessEnv,
|
|
108
|
+
extra: Record<string, string> | undefined,
|
|
109
|
+
policy: EnvPolicy,
|
|
110
|
+
): Record<string, string> {
|
|
111
|
+
assertSafeEnvKeys(extra, policy);
|
|
112
|
+
const env: Record<string, string> = {};
|
|
113
|
+
switch (policy) {
|
|
114
|
+
case "all":
|
|
115
|
+
assignDefined(env, base);
|
|
116
|
+
break;
|
|
117
|
+
case "none":
|
|
118
|
+
for (const [key, value] of Object.entries(base)) {
|
|
119
|
+
if (value === undefined || !noneAllows(key)) continue;
|
|
120
|
+
env[key] = String(value);
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
case "safe":
|
|
124
|
+
default:
|
|
125
|
+
for (const [key, value] of Object.entries(base)) {
|
|
126
|
+
if (value === undefined || isSensitiveKey(key)) continue;
|
|
127
|
+
env[key] = String(value);
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
assignDefined(env, extra ?? {});
|
|
132
|
+
delete env[INTERNAL_MAX_OUTPUT_ENV];
|
|
133
|
+
return env;
|
|
134
|
+
}
|
package/src/core/notify.ts
CHANGED
|
@@ -11,6 +11,12 @@ export interface NotifyPayload {
|
|
|
11
11
|
summaryTruncated?: boolean;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Honest notification outcome (PLAN §2.3): `queued` only means the host accepted the message,
|
|
16
|
+
* not that the model read it. There is no ack, so there is no "delivered" state and no retry loop.
|
|
17
|
+
*/
|
|
18
|
+
export type NotifyOutcome = "queued" | "skipped" | "failed";
|
|
19
|
+
|
|
14
20
|
/** XML 1.0 Char excludes C0 (except TAB/LF/CR), lone surrogates, and U+FFFE/FFFF (#19). */
|
|
15
21
|
const ILLEGAL_XML = /[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]/g;
|
|
16
22
|
|
|
@@ -31,27 +37,69 @@ export function buildNotificationXml(p: NotifyPayload): string {
|
|
|
31
37
|
return `<async-task-notification>${fields.map(([tag, value]) => `<${tag}>${escapeXml(value)}</${tag}>`).join("")}</async-task-notification>`;
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
/** Structured twin of the XML body, consumed by the TUI message renderer (PLAN §2.3). */
|
|
41
|
+
function buildNotificationDetails(p: NotifyPayload): Record<string, unknown> {
|
|
42
|
+
return {
|
|
43
|
+
taskId: p.taskId,
|
|
44
|
+
name: p.name,
|
|
45
|
+
status: p.status,
|
|
46
|
+
...(p.exitCode === undefined ? {} : { exitCode: p.exitCode }),
|
|
47
|
+
...(p.killKind === undefined ? {} : { killKind: p.killKind }),
|
|
48
|
+
...(p.failureKind === undefined ? {} : { failureKind: p.failureKind }),
|
|
49
|
+
...(p.summary === undefined ? {} : { summary: p.summary }),
|
|
50
|
+
...(p.summaryTruncated === undefined ? {} : { summaryTruncated: p.summaryTruncated }),
|
|
51
|
+
outputFile: p.outputFile,
|
|
52
|
+
...(p.guidance === undefined ? {} : { guidance: p.guidance }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `sendMessage` is `void` in older pi hosts and `Promise<void>` in newer ones (both shapes ship). */
|
|
57
|
+
const isThenable = (value: unknown): value is PromiseLike<unknown> =>
|
|
58
|
+
(typeof value === "object" ? value !== null : typeof value === "function") &&
|
|
59
|
+
typeof (value as PromiseLike<unknown>).then === "function";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Send a completion notification exactly once per undelivered task.
|
|
63
|
+
*
|
|
64
|
+
* Order (PLAN §2.3 / D-01): skip checks → in-memory claim (concurrency dedupe only) →
|
|
65
|
+
* persist the attempt → send `sendMessage` (awaited only when the host returns a promise) →
|
|
66
|
+
* latch `notified` on success or drop the claim on failure (`notified` stays false so
|
|
67
|
+
* `resendUnnotified` can retry later).
|
|
68
|
+
*/
|
|
34
69
|
export async function sendCompletionNotification(
|
|
35
70
|
pi: PiCtx,
|
|
36
71
|
registry: TaskRegistry,
|
|
37
72
|
p: NotifyPayload,
|
|
38
73
|
options: { triggerTurn?: boolean } = {},
|
|
39
|
-
): Promise<
|
|
40
|
-
|
|
74
|
+
): Promise<NotifyOutcome> {
|
|
75
|
+
const task = registry.get(p.taskId);
|
|
76
|
+
// skipped: nothing to notify about (unknown/consumed task) or the caller opted out.
|
|
77
|
+
if (!task || task.outputConsumed || task.metadata.notifyOnCompletion === false) return "skipped";
|
|
78
|
+
// The claim is memory-only (D-01): losing it to a concurrent sender means skipped, not failed.
|
|
79
|
+
if (!registry.claimNotification(p.taskId)) return "skipped";
|
|
80
|
+
// Persist the attempt before the send so a failed host call stays visible after reload/restart.
|
|
81
|
+
registry.noteNotifyAttempt(p.taskId);
|
|
41
82
|
// SAFETY: The pi extension API provides sendMessage in supported hosts; detect it at runtime for other contexts.
|
|
83
|
+
// The result is typed `unknown` on purpose: pi 1.2/1.3 declare `sendMessage(...): void` while 1.5+ declares
|
|
84
|
+
// `Promise<void>`. A `void` return means "handed to the host"; a promise still has to settle before we
|
|
85
|
+
// can honestly call it queued — and awaiting it keeps a rejection from escaping as unhandledRejection.
|
|
42
86
|
const target = pi as unknown as { sendMessage?: (message: unknown, options: unknown) => unknown };
|
|
43
|
-
if (!target.sendMessage) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
87
|
+
if (!target.sendMessage) {
|
|
88
|
+
registry.releaseNotification(p.taskId);
|
|
89
|
+
return "failed";
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
// No retry loop and no rollback — the host offers no ack, so retrying only duplicates messages.
|
|
93
|
+
const result = target.sendMessage(
|
|
94
|
+
{ customType: "async-task-notification", content: buildNotificationXml(p), details: buildNotificationDetails(p) },
|
|
95
|
+
{ deliverAs: "followUp", ...(options.triggerTurn === false ? {} : { triggerTurn: true }) },
|
|
96
|
+
);
|
|
97
|
+
if (isThenable(result)) await result;
|
|
98
|
+
} catch {
|
|
99
|
+
// Sync throw or rejected promise: same honest outcome, `notified` stays false.
|
|
100
|
+
registry.releaseNotification(p.taskId);
|
|
101
|
+
return "failed";
|
|
54
102
|
}
|
|
55
|
-
registry.
|
|
56
|
-
return
|
|
103
|
+
registry.markNotified(p.taskId);
|
|
104
|
+
return "queued";
|
|
57
105
|
}
|