pi-async 0.1.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 +188 -0
- package/README.md +93 -19
- package/docs/CONTRACT.md +301 -61
- package/extensions/index.ts +221 -38
- package/package.json +6 -3
- package/src/core/bus.ts +11 -3
- package/src/core/duration.ts +20 -0
- package/src/core/env.ts +134 -0
- package/src/core/notify.ts +82 -18
- package/src/core/output.ts +48 -2
- package/src/core/registry.ts +646 -95
- package/src/core/spawn.ts +95 -20
- package/src/core/task.ts +38 -8
- package/src/core/watch.ts +49 -13
- package/src/guard/settle-guard.ts +152 -46
- package/src/tools/index.ts +221 -27
- package/src/tui/panel.ts +375 -80
- package/src/tui/rail.ts +117 -33
- package/src/tui/status.ts +102 -25
- package/src/tui/width.ts +53 -0
package/extensions/index.ts
CHANGED
|
@@ -1,45 +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);
|
|
44
|
+
}
|
|
45
|
+
|
|
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
|
+
*/
|
|
58
|
+
let activeRegistry: TaskRegistry | undefined;
|
|
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(" · ");
|
|
34
110
|
}
|
|
35
111
|
|
|
36
112
|
/** Wire the task runtime to the host API, degrading optional Pi features gracefully. */
|
|
37
113
|
export async function init(pi: PiCtx): Promise<void> {
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
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) {
|
|
122
|
+
try {
|
|
123
|
+
previousGuard.dispose();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error("pi-async: previous settle guard disposal during reload failed", error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (previousRegistry) {
|
|
129
|
+
try {
|
|
130
|
+
await previousRegistry.suspend();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error("pi-async: previous registry suspension during reload failed", error);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
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 });
|
|
143
|
+
activeRegistry = registry;
|
|
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;
|
|
42
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
|
+
|
|
43
162
|
const bindTui = (ctx: unknown): void => {
|
|
44
163
|
if (!bindTuiHost(ctx)) return;
|
|
45
164
|
tui = true;
|
|
@@ -48,16 +167,53 @@ export async function init(pi: PiCtx): Promise<void> {
|
|
|
48
167
|
registerPanelCommands(pi, registry);
|
|
49
168
|
};
|
|
50
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
|
+
|
|
51
189
|
registry.setEvents({
|
|
52
190
|
onFinalize: (snapshot) => {
|
|
53
191
|
clearTaskActivity(pi, snapshot.taskId);
|
|
54
192
|
reportActivity(pi, registry.activeCount());
|
|
55
193
|
if (tui) initStatus(pi, registry);
|
|
56
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
|
+
}
|
|
57
205
|
const outputFile = String(snapshot.metadata.outputFile ?? "");
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 = {
|
|
61
217
|
taskId: snapshot.taskId,
|
|
62
218
|
name: snapshot.name,
|
|
63
219
|
status: snapshot.status,
|
|
@@ -65,7 +221,18 @@ export async function init(pi: PiCtx): Promise<void> {
|
|
|
65
221
|
outputFile,
|
|
66
222
|
...(outputTail ? { summary: outputTail } : {}),
|
|
67
223
|
...(guidance ? { guidance } : {}),
|
|
68
|
-
|
|
224
|
+
...(snapshot.killKind ? { killKind: snapshot.killKind } : {}),
|
|
225
|
+
...(snapshot.failureKind ? { failureKind: snapshot.failureKind } : {}),
|
|
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
|
+
})
|
|
69
236
|
.catch((error: unknown) => console.error(`pi-async: completion notification failed for ${snapshot.taskId}`, error));
|
|
70
237
|
},
|
|
71
238
|
onStateChange: (snapshot) => {
|
|
@@ -77,28 +244,44 @@ export async function init(pi: PiCtx): Promise<void> {
|
|
|
77
244
|
},
|
|
78
245
|
});
|
|
79
246
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}));
|
|
84
256
|
}
|
|
85
257
|
|
|
86
|
-
const context = pi as RuntimeContext;
|
|
87
|
-
if (typeof context.registerTool === "function") registerTools(pi, registry);
|
|
88
|
-
// Bind TUI surfaces from the first event context (factory API has no mode/ui).
|
|
89
258
|
if (typeof context.on === "function") {
|
|
90
|
-
context.on("session_start", (_event: unknown, ctx: unknown) =>
|
|
91
|
-
context.on("message_start", (_event: unknown, ctx: unknown) =>
|
|
259
|
+
context.on("session_start", (_event: unknown, ctx: unknown) => onLifecycle(ctx));
|
|
260
|
+
context.on("message_start", (_event: unknown, ctx: unknown) => onLifecycle(ctx));
|
|
261
|
+
|
|
262
|
+
// agent_settled / agent_before_settle are registered by SettleGuard itself.
|
|
263
|
+
activeGuard = new SettleGuard(pi, registry);
|
|
264
|
+
activeGuard.register();
|
|
92
265
|
}
|
|
93
|
-
if (typeof context.on === "function") new SettleGuard(pi, registry).register();
|
|
94
266
|
|
|
95
267
|
let shutdownPromise: Promise<void> | undefined;
|
|
96
268
|
if (typeof context.on === "function") {
|
|
97
|
-
context.on("session_shutdown", async () => {
|
|
98
|
-
if (!shutdownPromise)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|
|
102
285
|
await shutdownPromise;
|
|
103
286
|
});
|
|
104
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",
|
|
@@ -39,11 +39,14 @@
|
|
|
39
39
|
"src/",
|
|
40
40
|
"README.md",
|
|
41
41
|
"LICENSE",
|
|
42
|
-
"docs/CONTRACT.md"
|
|
42
|
+
"docs/CONTRACT.md",
|
|
43
|
+
"CHANGELOG.md"
|
|
43
44
|
],
|
|
44
45
|
"scripts": {
|
|
45
46
|
"typecheck": "tsc --noEmit",
|
|
46
|
-
"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"
|
|
47
50
|
},
|
|
48
51
|
"peerDependencies": {
|
|
49
52
|
"@earendil-works/pi-coding-agent": "^1.2.0",
|
package/src/core/bus.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import type { PiCtx } from "./notify.ts";
|
|
2
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
|
+
|
|
3
11
|
/** Report aggregate async work through Pi's activity API when the host exposes it. */
|
|
4
12
|
export function reportActivity(pi: PiCtx, active: { running: number; pending: number }): void {
|
|
5
13
|
// SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
|
|
6
14
|
const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
|
|
7
15
|
if (typeof ui?.setActivity !== "function") return;
|
|
8
16
|
const count = active.running + active.pending;
|
|
9
|
-
ui.setActivity(
|
|
17
|
+
ui.setActivity(AGGREGATE_ACTIVITY_ID, count === 0 ? undefined : { message: `${count} async tasks` });
|
|
10
18
|
// Activity uses the host API; never emit terminal OSC/printf escapes. In particular,
|
|
11
19
|
// leave PI_PROGRAM_STATUS untouched for hosts which own that status channel.
|
|
12
20
|
}
|
|
@@ -16,7 +24,7 @@ export function raiseTaskActivity(pi: PiCtx, taskId: string, message = "Async ta
|
|
|
16
24
|
// SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
|
|
17
25
|
const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
|
|
18
26
|
if (typeof ui?.setActivity !== "function") return;
|
|
19
|
-
ui.setActivity(
|
|
27
|
+
ui.setActivity(taskActivityId(taskId), { message });
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/** Clear task-scoped activity when the host supports Pi's optional activity API. */
|
|
@@ -24,5 +32,5 @@ export function clearTaskActivity(pi: PiCtx, taskId: string): void {
|
|
|
24
32
|
// SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
|
|
25
33
|
const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
|
|
26
34
|
if (typeof ui?.setActivity !== "function") return;
|
|
27
|
-
ui.setActivity(
|
|
35
|
+
ui.setActivity(taskActivityId(taskId), undefined);
|
|
28
36
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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 formatElapsedMs(ms: number): string {
|
|
10
|
+
const seconds = Math.floor(Math.max(0, ms) / 1000);
|
|
11
|
+
if (seconds < 60) return `${seconds}s`;
|
|
12
|
+
const minutes = Math.floor(seconds / 60);
|
|
13
|
+
return minutes < 60 ? `${minutes}m ${seconds % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
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
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
}
|
|
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
|
+
}
|