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.
@@ -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 { 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);
34
44
  }
35
45
 
36
- /** Previous init generation; /reload re-runs init without session_shutdown (#44). */
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
- let activeDisposeTui: (() => void) | 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(" · ");
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
- // Reload replaces the extension runtime; retire the previous generation first so
43
- // its processes, watch loops, and timers are released (pi docs: cleanup must be
44
- // idempotent across reload / session replacement / exit).
45
- if (activeRegistry) {
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
- activeDisposeTui?.();
48
- } catch { /* best effort */ }
49
- activeDisposeTui = undefined;
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 activeRegistry.shutdown();
130
+ await previousRegistry.suspend();
52
131
  } catch (error) {
53
- console.error("pi-async: previous registry shutdown during reload failed", error);
132
+ console.error("pi-async: previous registry suspension during reload failed", error);
54
133
  }
55
- activeRegistry = undefined;
56
134
  }
57
- const registry = new TaskRegistry({ dataDir: registryDataDir(pi) });
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) => ...)). TUI
61
- // registration must therefore bind lazily from the first event context.
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
- const logSlice = registry.logs(snapshot.taskId, { maxBytes: 8192, tail: true });
79
- const outputTail = logSlice.text;
80
- const guidance = snapshot.status === "failed" ? `Inspect task output with async_logs (taskId: ${snapshot.taskId}).` : undefined;
81
- const summaryTruncated = snapshot.metadata.capped === true || logSlice.truncated;
82
- 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 = {
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
- ...(summaryTruncated ? { summaryTruncated: true } : {}),
93
- }, { triggerTurn: snapshot.metadata.triggerOnCompletion !== false })
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
- try {
106
- await registry.reloadFromDisk();
107
- } catch (error) {
108
- 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
+ }));
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) => bindTui(ctx));
116
- context.on("message_start", (_event: unknown, ctx: unknown) => bindTui(ctx));
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
- activeDisposeTui = () => {
121
- if (tui) disposeStatus();
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) shutdownPromise = (async () => {
128
- activeDisposeTui?.();
129
- activeDisposeTui = undefined;
130
- if (activeRegistry === registry) activeRegistry = undefined;
131
- await registry.shutdown();
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.2.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",
@@ -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 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);
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
+ }
@@ -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<boolean> {
40
- if (registry.get(p.taskId)?.outputConsumed || !registry.claimNotification(p.taskId)) return false;
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) { 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
- }
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.rollbackNotification(p.taskId);
56
- return false;
103
+ registry.markNotified(p.taskId);
104
+ return "queued";
57
105
  }