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.
@@ -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 { disposeStatus, initStatus } from "../src/tui/status.ts";
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
- pid?: number;
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
- function registryDataDir(pi: PiCtx): string {
28
- const context = pi as RuntimeContext;
29
- const pid = Number.isInteger(context.pid) && (context.pid ?? 0) > 0 ? context.pid ?? process.pid : process.pid;
30
- const sessionId = context.sessionManager?.getSessionId?.() ?? context.getSessionId?.() ?? context.sessionId;
31
- const safeSessionId = (sessionId || String(process.pid)).replace(/[^\w.-]/g, "_");
32
- const cwd = typeof context.cwd === "string" && context.cwd.length > 0 ? context.cwd : process.cwd();
33
- return join(cwd, ".pi", "async", `${safeSessionId}-${pid}`);
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
- const registry = new TaskRegistry({ dataDir: registryDataDir(pi) });
39
- // NOTE: pi's factory-time ExtensionAPI exposes no `mode`/`ui` — those live on
40
- // event-handler contexts (pi.on("session_start", (event, ctx) => ...)). TUI
41
- // registration must therefore bind lazily from the first event context.
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
- const outputTail = registry.logs(snapshot.taskId, { maxBytes: 8192, tail: true }).text;
59
- const guidance = snapshot.status === "failed" ? `Inspect task output with async_logs (taskId: ${snapshot.taskId}).` : undefined;
60
- void sendCompletionNotification(pi, registry, {
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
- }, { triggerTurn: snapshot.metadata.triggerOnCompletion !== false })
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
- try {
81
- await registry.reloadFromDisk();
82
- } catch (error) {
83
- console.error("pi-async: failed to reload task state; continuing with an empty registry", error);
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) => bindTui(ctx));
91
- context.on("message_start", (_event: unknown, ctx: unknown) => bindTui(ctx));
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) shutdownPromise = (async () => {
99
- if (tui) disposeStatus();
100
- await registry.shutdown();
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.1.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("pi-async", count === 0 ? undefined : { message: `${count} async tasks` });
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(`pi-async/${taskId}`, { message });
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(`pi-async/${taskId}`, undefined);
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
+ }
@@ -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
+ }