@fitchmultz/pi-subagents 0.52.4 → 0.52.6

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +5 -4
  3. package/dist/agent/completion-watches.js +61 -0
  4. package/dist/agent/contracts.js +9 -0
  5. package/dist/agent/host.js +5 -0
  6. package/dist/agent/index.js +20 -51
  7. package/dist/agent/legacy.js +41 -11
  8. package/dist/agent/notifications.js +2 -2
  9. package/dist/agent/parking.js +121 -13
  10. package/dist/agent/process.js +107 -63
  11. package/dist/agent/receipts.js +21 -4
  12. package/dist/agent/runner.js +19 -5
  13. package/dist/agent/shutdown.js +43 -0
  14. package/dist/agent/view.js +5 -1
  15. package/dist/build-info.js +1 -1
  16. package/dist/pi-intercom/broker/broker.js +7 -6
  17. package/dist/pi-intercom/broker/client-wake.d.ts +17 -0
  18. package/dist/pi-intercom/broker/client-wake.js +54 -0
  19. package/dist/pi-intercom/broker/client.d.ts +2 -0
  20. package/dist/pi-intercom/broker/client.js +23 -16
  21. package/dist/pi-intercom/broker/parked.js +122 -5
  22. package/dist/pi-intercom/broker/spawn.js +10 -6
  23. package/dist/pi-intercom/config.js +3 -2
  24. package/dist/pi-intercom/connection.js +72 -11
  25. package/dist/pi-intercom/inbound-delivery.js +10 -5
  26. package/dist/pi-intercom/index.js +41 -6
  27. package/dist/pi-intercom/outbound.js +24 -7
  28. package/dist/pi-intercom/reply-wait.js +6 -1
  29. package/dist/pi-intercom/tool-results.js +2 -0
  30. package/dist/pi-intercom/types.d.ts +6 -0
  31. package/dist/shared/background-reporting.js +72 -0
  32. package/dist/shared/parking.js +6 -0
  33. package/docs/development.md +15 -3
  34. package/docs/intercom.md +14 -0
  35. package/docs/performance.md +2 -2
  36. package/docs/reference.md +13 -5
  37. package/package.json +1 -1
  38. package/scripts/package-smoke.mjs +21 -3
  39. package/src/agent/completion-watches.ts +73 -0
  40. package/src/agent/contracts.ts +11 -0
  41. package/src/agent/host.ts +6 -0
  42. package/src/agent/index.ts +24 -54
  43. package/src/agent/legacy.ts +44 -12
  44. package/src/agent/notifications.ts +2 -3
  45. package/src/agent/parking.ts +148 -12
  46. package/src/agent/process.ts +130 -66
  47. package/src/agent/receipts.ts +25 -4
  48. package/src/agent/runner.ts +25 -5
  49. package/src/agent/shutdown.ts +50 -0
  50. package/src/agent/view.ts +9 -0
  51. package/src/pi-intercom/broker/broker.ts +8 -7
  52. package/src/pi-intercom/broker/client-wake.ts +61 -0
  53. package/src/pi-intercom/broker/client.ts +23 -17
  54. package/src/pi-intercom/broker/parked.ts +152 -10
  55. package/src/pi-intercom/broker/spawn.ts +10 -9
  56. package/src/pi-intercom/config.ts +5 -2
  57. package/src/pi-intercom/connection.ts +88 -12
  58. package/src/pi-intercom/inbound-delivery.ts +21 -6
  59. package/src/pi-intercom/index.ts +64 -21
  60. package/src/pi-intercom/outbound.ts +30 -6
  61. package/src/pi-intercom/reply-wait.ts +6 -1
  62. package/src/pi-intercom/tool-results.ts +1 -0
  63. package/src/pi-intercom/types.ts +4 -0
  64. package/src/shared/background-reporting.ts +78 -0
  65. package/src/shared/parking.ts +16 -0
@@ -2,16 +2,26 @@ import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { AgentSession, ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import { parkedSchema, type Launch, type Parked } from "./contracts.ts";
5
+ import {
6
+ exitSchema,
7
+ parkedSchema,
8
+ readySchema,
9
+ wakeFailureSchema,
10
+ type Launch,
11
+ type Parked,
12
+ } from "./contracts.ts";
6
13
  import { errorText, loadSDK, type WorkingState } from "./host.ts";
7
14
  import { conversation, latest, launchAt, list, read, runDirectory, write } from "./storage.ts";
8
- import { alive, shell, shells, wakeActor } from "./process.ts";
15
+ import { actor as claimedActor, alive, shell, shells, wakeActor } from "./process.ts";
16
+ import { DirectoryWatch, Wake } from "./wake.ts";
9
17
  import {
10
18
  isHistoryRequest,
19
+ isWakeRequest,
11
20
  PARK,
12
21
  PARKED_HISTORY,
13
22
  WAKE,
14
23
  type ParkRequest,
24
+ type WakeResult,
15
25
  } from "../shared/parking.ts";
16
26
  import { nativePeerId } from "../pi-intercom/session-targets.ts";
17
27
  import type { NativeHistory } from "../pi-intercom/last-message.ts";
@@ -147,6 +157,8 @@ export function parkedState(
147
157
  /** One actor generation's idle parking and exact restore; commands stay with the runner. */
148
158
  export class Parking {
149
159
  saved?: WorkingState;
160
+ /** Set before entering either native open, whose effects may outlive an exception. */
161
+ opened = false;
150
162
  private restored?: string;
151
163
  private since = 0;
152
164
  private leaf?: string | null;
@@ -172,6 +184,7 @@ export class Parking {
172
184
  const { sdk, launch } = this;
173
185
  const { openWorkingSession: restore } = sdk;
174
186
  const parked = parkedState(this.dir, launch, restore && sdk.readWorkingSession);
187
+ this.opened = true;
175
188
  if (!parked || !restore) {
176
189
  return sdk.SessionManager.open(launch.sessionFile, undefined, launch.cwd);
177
190
  }
@@ -328,6 +341,96 @@ async function history(runs: Runs | undefined, peer: string): Promise<NativeHist
328
341
  return state && { getBranch: () => branchOf(state) };
329
342
  }
330
343
 
344
+ function unavailableCwd(cwd: string): string | undefined {
345
+ try {
346
+ if (!fs.statSync(cwd).isDirectory()) {
347
+ return `Saved working directory ${cwd} is unavailable: not a directory.`;
348
+ }
349
+ fs.accessSync(cwd, fs.constants.X_OK);
350
+ } catch (error) {
351
+ return `Saved working directory ${cwd} is unavailable: ${errorText(error)}.`;
352
+ }
353
+ return undefined;
354
+ }
355
+
356
+ function exitedWake(
357
+ dir: string,
358
+ identity: NonNullable<ReturnType<typeof claimedActor>>,
359
+ legacy: boolean,
360
+ ): string | undefined {
361
+ const exit = read(path.join(dir, `exit-${identity.token}.json`), exitSchema);
362
+ if (!exit && alive(identity)) {
363
+ return undefined;
364
+ }
365
+ const failure = read(path.join(dir, `wake-failure-${identity.token}.json`), wakeFailureSchema);
366
+ return (
367
+ failure?.reason ??
368
+ `Actor exited before ${legacy ? "readiness" : "intercom registration"} (code ${exit?.code ?? "unknown"}, signal ${exit?.signal ?? "unknown"}). See ${path.join(dir, "runner.log")}.`
369
+ );
370
+ }
371
+
372
+ /** A spawned actor is only requested until its own readiness or exit is observed. */
373
+ async function awaitWake(
374
+ base: string,
375
+ launch: Launch,
376
+ signal: AbortSignal,
377
+ legacy: boolean,
378
+ ): Promise<void> {
379
+ const dir = conversation(base, launch);
380
+ const identity = claimedActor(base, launch);
381
+ if (!identity) {
382
+ throw new Error("The wake returned without an observable actor claim.");
383
+ }
384
+ const wake = new Wake();
385
+ const watch = new DirectoryWatch(dir, () => true, wake.raise);
386
+ try {
387
+ for (;;) {
388
+ signal.throwIfAborted();
389
+ wake.take();
390
+ watch.ensure();
391
+ if (
392
+ legacy &&
393
+ read(path.join(dir, "ready.json"), readySchema)?.token === identity.token &&
394
+ !fs.existsSync(path.join(dir, "parked.json"))
395
+ ) {
396
+ return;
397
+ }
398
+ const failure = exitedWake(dir, identity, legacy);
399
+ if (failure !== undefined) {
400
+ throw new Error(failure);
401
+ }
402
+ // DirectoryWatch reconciles dropped hints without imposing a deadline on native startup.
403
+ // oxlint-disable-next-line no-await-in-loop
404
+ await wake.sleep(200);
405
+ }
406
+ } finally {
407
+ watch.close();
408
+ }
409
+ }
410
+
411
+ function wakeFailed(
412
+ current: Runs | undefined,
413
+ launch: Launch | undefined,
414
+ error: unknown,
415
+ ): WakeResult {
416
+ let reason = errorText(error);
417
+ const unavailable = launch && unavailableCwd(launch.cwd);
418
+ if (unavailable !== undefined) {
419
+ reason += ` ${unavailable}`;
420
+ }
421
+ if (current && launch) {
422
+ try {
423
+ write(path.join(conversation(current.base, launch), "wake-failure.json"), {
424
+ at: Date.now(),
425
+ reason,
426
+ });
427
+ } catch (recordError) {
428
+ reason += ` Wake failure could not be recorded: ${errorText(recordError)}.`;
429
+ }
430
+ }
431
+ return { status: "failed", reason };
432
+ }
433
+
331
434
  /**
332
435
  * Wakes this owner's parked children for broker input and reads their saved history.
333
436
  * `runs` is undefined while this session owns no children.
@@ -337,17 +440,50 @@ export function serveParked(
337
440
  runs: () => Runs | undefined,
338
441
  signal: AbortSignal,
339
442
  ): void {
340
- pi.events.on(WAKE, (peer) => {
341
- // On failure, retained input stays queued at the broker until the next main registration.
342
- (async () => {
343
- const current = runs();
344
- const launch = ownedRun(current, peer);
345
- if (current && launch) {
346
- await wakeActor(current.base, launch, signal);
443
+ const pending = new Map<string, Promise<WakeResult>>();
444
+ const attempt = async (peer: string, registered?: AbortSignal): Promise<WakeResult> => {
445
+ let current: Runs | undefined;
446
+ let launch: Launch | undefined;
447
+ const cancelled = registered ? AbortSignal.any([signal, registered]) : signal;
448
+ try {
449
+ current = runs();
450
+ launch = ownedRun(current, peer);
451
+ if (!current || !launch) {
452
+ throw new Error("No active agent runtime owns this parked child.");
347
453
  }
348
- })().catch((error: unknown) => {
349
- console.error(`Agent wake: ${errorText(error)}`);
350
- });
454
+ await wakeActor(current.base, launch, cancelled);
455
+ await awaitWake(current.base, launch, cancelled, registered === undefined);
456
+ return { status: "requested" };
457
+ } catch (error) {
458
+ if (registered?.aborted === true) {
459
+ return { status: "requested" };
460
+ }
461
+ return wakeFailed(current, launch, error);
462
+ }
463
+ };
464
+ pi.events.on(WAKE, (request) => {
465
+ const peer = isWakeRequest(request) ? request.peer : request;
466
+ if (typeof peer !== "string") {
467
+ return;
468
+ }
469
+ // Serialize wake requests; a successful in-flight wake already serves queued requests.
470
+ const previous = pending.get(peer);
471
+ const registered = isWakeRequest(request) ? request.signal : undefined;
472
+ const result = previous
473
+ ? previous.then((outcome) =>
474
+ outcome.status === "requested" ? outcome : attempt(peer, registered),
475
+ )
476
+ : attempt(peer, registered);
477
+ pending.set(peer, result);
478
+ const clear = () => {
479
+ if (pending.get(peer) === result) {
480
+ pending.delete(peer);
481
+ }
482
+ };
483
+ void result.then(clear, clear);
484
+ if (isWakeRequest(request)) {
485
+ request.reply(result);
486
+ }
351
487
  });
352
488
  pi.events.on(PARKED_HISTORY, (request) => {
353
489
  if (isHistoryRequest(request)) {
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { spawn } from "node:child_process";
3
+ import { spawn, type ChildProcess } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { setTimeout as delay } from "node:timers/promises";
@@ -17,10 +17,11 @@ import {
17
17
  attachmentSchema,
18
18
  parkedSchema,
19
19
  processSchema,
20
+ wakeFailureSchema,
20
21
  type Launch,
21
22
  type ProcessIdentity,
22
23
  } from "./contracts.ts";
23
- import { conversation, enqueue, launchAt, list, read, write } from "./storage.ts";
24
+ import { conversation, enqueue, read, write } from "./storage.ts";
24
25
  import { errorText } from "./host.ts";
25
26
 
26
27
  /** Darwin's in-memory publication bound; Android records carry their own native clock. */
@@ -149,34 +150,74 @@ function childEnvironment(launch: Launch): NodeJS.ProcessEnv {
149
150
  PI_INTERCOM_PARENT_SESSION_ID: launch.owner,
150
151
  });
151
152
  }
152
- function spawnActor(base: string, launch: Launch, token: string): void {
153
+ interface LaunchClaim {
154
+ readonly spawned: () => void;
155
+ readonly release: () => void;
156
+ }
157
+ function saveSpawnFailure(dir: string, error: Error): Error {
158
+ try {
159
+ write(path.join(dir, "spawn-error.json"), { error: error.message });
160
+ return error;
161
+ } catch (failure) {
162
+ return Object.assign(
163
+ new Error(`${error.message}; could not save the spawn diagnostic: ${errorText(failure)}`, {
164
+ cause: error,
165
+ }),
166
+ "code" in error ? { code: error.code } : {},
167
+ );
168
+ }
169
+ }
170
+ async function spawnActor(
171
+ base: string,
172
+ launch: Launch,
173
+ token: string,
174
+ claim: LaunchClaim,
175
+ ): Promise<void> {
153
176
  const dir = conversation(base, launch);
154
- const log = fs.openSync(path.join(dir, "runner.log"), "a", 0o600);
155
- const runner = fileURLToPath(
156
- new URL(import.meta.url.endsWith(".ts") ? "./runner.ts" : "./runner.js", import.meta.url),
157
- );
158
- let child;
177
+ let child: ChildProcess;
178
+ let spawnError: Promise<Error> | undefined;
159
179
  try {
160
- child = spawn(process.execPath, [runner, base, launch.rootId, token, launch.id], {
161
- cwd: launch.cwd,
162
- env: childEnvironment(launch),
163
- detached: true,
164
- stdio: ["ignore", log, log],
165
- });
166
- } finally {
167
- fs.closeSync(log);
180
+ const log = fs.openSync(path.join(dir, "runner.log"), "a", 0o600);
181
+ try {
182
+ const runner = fileURLToPath(
183
+ new URL(import.meta.url.endsWith(".ts") ? "./runner.ts" : "./runner.js", import.meta.url),
184
+ );
185
+ child = spawn(process.execPath, [runner, base, launch.rootId, token, launch.id], {
186
+ cwd: launch.cwd,
187
+ env: childEnvironment(launch),
188
+ detached: true,
189
+ stdio: ["ignore", log, log],
190
+ });
191
+ if (child.pid === undefined) {
192
+ spawnError = new Promise((resolve) => {
193
+ child.once("error", (error) => resolve(saveSpawnFailure(dir, error)));
194
+ });
195
+ claim.release();
196
+ } else {
197
+ claim.spawned();
198
+ child.on("error", (error) => {
199
+ write(path.join(dir, "spawn-error.json"), { error: error.message });
200
+ });
201
+ }
202
+ } finally {
203
+ fs.closeSync(log);
204
+ }
205
+ } catch (error) {
206
+ claim.release();
207
+ throw error;
208
+ }
209
+ if (child.pid === undefined && spawnError) {
210
+ throw await spawnError;
211
+ }
212
+ if (child.pid === undefined) {
213
+ claim.spawned();
214
+ throw new Error("Actor spawn outcome is ambiguous.");
168
215
  }
169
- child.on("error", (error) => {
170
- write(path.join(dir, "spawn-error.json"), { error: error.message });
171
- });
172
216
  child.on("exit", (code, signal) => {
173
217
  write(path.join(dir, `exit-${token}.json`), { pid: child.pid, code, signal, at: Date.now() });
174
218
  trySignalChildTree(child, "SIGKILL");
175
219
  });
176
220
  child.unref();
177
- if (child.pid === undefined) {
178
- throw new Error("Actor was not spawned; retained launch claim requires inspection.");
179
- }
180
221
  write(path.join(dir, "actor.lock", "process.json"), {
181
222
  pid: child.pid,
182
223
  identity: readChildProcessIdentity(child.pid) ?? "unverified",
@@ -205,6 +246,50 @@ async function awaitStopping(
205
246
  }
206
247
  }
207
248
  }
249
+ function releaseClaim(
250
+ lock: string,
251
+ identity: fs.Stats | undefined,
252
+ token: string | undefined,
253
+ ): void {
254
+ try {
255
+ const current = fs.lstatSync(lock, { throwIfNoEntry: false });
256
+ if (current && identity && current.dev === identity.dev && current.ino === identity.ino) {
257
+ const file = path.join(lock, "claim.json");
258
+ const claim = read(file, processSchema);
259
+ if (claim === undefined || claim.token === token) {
260
+ fs.rmSync(file, { force: true });
261
+ fs.rmdirSync(lock);
262
+ }
263
+ }
264
+ } catch {
265
+ // Keep the original failure; an unremovable claim remains safely ambiguous.
266
+ }
267
+ }
268
+ async function launchClaimedActor(base: string, launch: Launch, lock: string): Promise<void> {
269
+ let identity: fs.Stats | undefined;
270
+ let token: string | undefined;
271
+ let state: "claimed" | "spawned" | "released" = "claimed";
272
+ const claim: LaunchClaim = {
273
+ spawned: () => {
274
+ state = "spawned";
275
+ },
276
+ release: () => {
277
+ if (state === "claimed") {
278
+ state = "released";
279
+ releaseClaim(lock, identity, token);
280
+ }
281
+ },
282
+ };
283
+ try {
284
+ identity = fs.lstatSync(lock);
285
+ token = randomUUID();
286
+ write(path.join(lock, "claim.json"), { ...ownIdentity(), token });
287
+ await spawnActor(base, launch, token, claim);
288
+ } catch (error) {
289
+ claim.release();
290
+ throw error;
291
+ }
292
+ }
208
293
  export async function ensureActor(
209
294
  base: string,
210
295
  launch: Launch,
@@ -237,16 +322,34 @@ export async function ensureActor(
237
322
  }
238
323
  throw error;
239
324
  }
240
- const token = randomUUID();
241
- write(path.join(lock, "claim.json"), { ...ownIdentity(), token });
242
- spawnActor(base, launch, token);
325
+ await launchClaimedActor(base, launch, lock);
326
+ }
327
+ /** A failed restore can hand the old cut forward only before native open and after death. */
328
+ function failedBeforeOpenToken(
329
+ dir: string,
330
+ exited: OwnedProcess,
331
+ current: OwnedProcess | undefined,
332
+ ): string | undefined {
333
+ const failure = read(path.join(dir, `wake-failure-${exited.token}.json`), wakeFailureSchema);
334
+ if (
335
+ current?.token === exited.token &&
336
+ !alive(current) &&
337
+ failure?.beforeOpen?.actorToken === exited.token
338
+ ) {
339
+ return failure.beforeOpen.parkedToken;
340
+ }
341
+ return undefined;
243
342
  }
244
343
  /** A failed park keeps its input queued; exact state is lost, so nothing restarts it implicitly. */
245
344
  export function parkFailure(base: string, launch: Launch, exited?: OwnedProcess): void {
246
345
  const dir = conversation(base, launch);
247
346
  const parked = read(path.join(dir, "parked.json"), parkedSchema);
248
- if (exited && parked && parked.token !== exited.token) {
249
- // Only the exited claim that saved this state may hand it to the next generation.
347
+ if (
348
+ exited &&
349
+ parked &&
350
+ parked.token !== exited.token &&
351
+ failedBeforeOpenToken(dir, exited, actor(base, launch)) !== parked.token
352
+ ) {
250
353
  throw new Error(
251
354
  "Parked working state belongs to another actor generation; nothing was reopened.",
252
355
  );
@@ -354,45 +457,6 @@ export function requestStop(
354
457
  });
355
458
  return { targets: live, command };
356
459
  }
357
- export async function stopChildren(base: string, owner: string): Promise<void> {
358
- const roots = new Set<string>();
359
- const targets: OwnedProcess[] = [];
360
- for (const id of list(base)) {
361
- try {
362
- const launch = launchAt(base, id, owner);
363
- if (!roots.has(launch.rootId)) {
364
- roots.add(launch.rootId);
365
- targets.push(
366
- ...requestStop(base, launch, (error) => {
367
- console.error(`Child shutdown ${id}: ${errorText(error)}`);
368
- }).targets,
369
- );
370
- }
371
- } catch (error) {
372
- console.error(`Child shutdown ${id}: ${errorText(error)}`);
373
- }
374
- }
375
- await waitForExit(targets, 3000);
376
- for (const target of targets) {
377
- try {
378
- signalProcess(target, "SIGKILL");
379
- } catch (error) {
380
- console.error(`Child shutdown ${target.pid}: ${errorText(error)}`);
381
- }
382
- }
383
- await waitForExit(targets, 1000);
384
- if (targets.some(alive)) {
385
- console.error("Child shutdown could not confirm every owned process exit.");
386
- }
387
- }
388
- async function waitForExit(targets: readonly OwnedProcess[], timeout: number): Promise<void> {
389
- const deadline = Date.now() + timeout;
390
- while (Date.now() < deadline && targets.some(alive)) {
391
- // Join actual process exit while native abort persists the interrupted turn.
392
- // oxlint-disable-next-line no-await-in-loop
393
- await delay(25);
394
- }
395
- }
396
460
  export function shell(file: string, token: string): OwnedProcess | undefined {
397
461
  const identity = published(file, read(file, processSchema));
398
462
  if (identity && identity.token !== token) {
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { scanJournal } from "../shared/journal-frames.ts";
3
4
  import { journalStamp, nativeAppend } from "../shared/native-journal.ts";
4
5
  import type { Projection } from "../shared/json-projection.ts";
@@ -157,6 +158,24 @@ function owned(file: string | undefined, owner: string): ReceiptIndex | undefine
157
158
  const header = journal.entries.at(0);
158
159
  return header?.type === "session" && header.id === owner ? journal : undefined;
159
160
  }
161
+ /** Without a session file (`--no-session`), Pi's append-only memory is the whole native transcript. */
162
+ function remembered(
163
+ manager: ExtensionContext["sessionManager"],
164
+ owner: string,
165
+ ): Prefix | undefined {
166
+ const header = manager.getHeader();
167
+ if (header?.id !== owner) {
168
+ return;
169
+ }
170
+ const journal: Prefix = { end: 0, entries: [], completions: new Map() };
171
+ // Index the same record frames the physical journal scan accepts.
172
+ for (const entry of [header, ...manager.getEntries()]) {
173
+ if (isRecord(entry)) {
174
+ addEntry(journal, entry);
175
+ }
176
+ }
177
+ return journal;
178
+ }
160
179
  export function receiptId(
161
180
  file: string | undefined,
162
181
  owner: string,
@@ -167,12 +186,14 @@ export function receiptId(
167
186
  );
168
187
  return typeof found?.id === "string" ? found.id : undefined;
169
188
  }
170
- /** Live read-only completion IDs by run; later appends extend this view.
171
- * A matching entry without an ID is unpublished. Invalidations replace the view.
189
+ /** Completion IDs by run from the native transcript: the session file when Pi persists one,
190
+ * otherwise Pi's in-memory entries. A matching entry without an ID is unpublished.
172
191
  */
173
192
  export function completionReceipts(
174
- file: string | undefined,
193
+ manager: ExtensionContext["sessionManager"],
175
194
  owner: string,
176
195
  ): ReadonlyMap<string, string | undefined> {
177
- return owned(file, owner)?.completions ?? new Map<string, string>();
196
+ const file = manager.getSessionFile();
197
+ const journal = file === undefined ? remembered(manager, owner) : owned(file, owner);
198
+ return journal?.completions ?? new Map<string, string>();
178
199
  }
@@ -2,7 +2,13 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { AssistantMessage } from "@earendil-works/pi-ai";
5
- import { commandSchema, type Command, type Launch, type Result } from "./contracts.ts";
5
+ import {
6
+ commandSchema,
7
+ parkedSchema,
8
+ type Command,
9
+ type Launch,
10
+ type Result,
11
+ } from "./contracts.ts";
6
12
  import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
7
13
  import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
8
14
  import { errorText, loadSDK } from "./host.ts";
@@ -374,7 +380,7 @@ class Actor {
374
380
  }
375
381
  return this.parking;
376
382
  }
377
- private failure(error: unknown): void {
383
+ private failure(error: unknown, unopened?: string): void {
378
384
  const launch = this.current ?? this.initial;
379
385
  const result: Result = {
380
386
  id: launch.id,
@@ -384,6 +390,16 @@ class Actor {
384
390
  error: errorText(error),
385
391
  };
386
392
  const directory = runDirectory(this.base, launch.id);
393
+ if (fs.existsSync(path.join(this.dir, "parked.json"))) {
394
+ const parked = read(path.join(this.dir, "parked.json"), parkedSchema);
395
+ write(path.join(this.dir, `wake-failure-${this.token}.json`), {
396
+ at: Date.now(),
397
+ reason: errorText(error),
398
+ ...(unopened !== undefined && parked?.token === unopened
399
+ ? { beforeOpen: { actorToken: this.token, parkedToken: unopened } }
400
+ : {}),
401
+ });
402
+ }
387
403
  if (!fs.existsSync(path.join(directory, "result.json"))) {
388
404
  fs.writeFileSync(path.join(directory, "output.txt"), "", { mode: 0o600 });
389
405
  write(path.join(directory, "result.json"), result);
@@ -449,17 +465,21 @@ class Actor {
449
465
  parked.adopt();
450
466
  }
451
467
  async serve(): Promise<void> {
452
- const sdk = await loadSDK(this.initial.sdk);
453
- const parked = new Parking(this.base, this.initial, this.token, sdk);
454
468
  let runtime: Runtime | undefined;
469
+ let parked: Parking | undefined;
470
+ let parkedToken: string | undefined;
455
471
  try {
472
+ // Capture the old generation before SDK import or any native working-state operation.
473
+ parkedToken = read(path.join(this.dir, "parked.json"), parkedSchema)?.token;
474
+ const sdk = await loadSDK(this.initial.sdk);
475
+ parked = new Parking(this.base, this.initial, this.token, sdk);
456
476
  runtime = await this.start(sdk, parked);
457
477
  process.on("SIGTERM", this.stop);
458
478
  process.on("SIGINT", this.stop);
459
479
  await this.admitCommands(runtime, parked);
460
480
  await this.active;
461
481
  } catch (error) {
462
- this.failure(error);
482
+ this.failure(error, parked?.opened === true ? undefined : parkedToken);
463
483
  } finally {
464
484
  await this.finish(runtime);
465
485
  }
@@ -0,0 +1,50 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import type { ProcessIdentity } from "./contracts.ts";
3
+ import { errorText } from "./host.ts";
4
+ import { alive, requestStop, signalProcess } from "./process.ts";
5
+ import { launchAt, list } from "./storage.ts";
6
+
7
+ export async function stopChildren(
8
+ base: string,
9
+ owner: string,
10
+ report: (message: string) => void,
11
+ ): Promise<void> {
12
+ const roots = new Set<string>();
13
+ const targets: ProcessIdentity[] = [];
14
+ for (const id of list(base)) {
15
+ try {
16
+ const launch = launchAt(base, id, owner);
17
+ if (!roots.has(launch.rootId)) {
18
+ roots.add(launch.rootId);
19
+ targets.push(
20
+ ...requestStop(base, launch, (error) => {
21
+ report(`Child shutdown ${id}: ${errorText(error)}`);
22
+ }).targets,
23
+ );
24
+ }
25
+ } catch (error) {
26
+ report(`Child shutdown ${id}: ${errorText(error)}`);
27
+ }
28
+ }
29
+ await waitForExit(targets, 3000);
30
+ for (const target of targets) {
31
+ try {
32
+ signalProcess(target, "SIGKILL");
33
+ } catch (error) {
34
+ report(`Child shutdown ${target.pid}: ${errorText(error)}`);
35
+ }
36
+ }
37
+ await waitForExit(targets, 1000);
38
+ if (targets.some(alive)) {
39
+ report("Child shutdown could not confirm every owned process exit.");
40
+ }
41
+ }
42
+
43
+ async function waitForExit(targets: readonly ProcessIdentity[], timeout: number): Promise<void> {
44
+ const deadline = Date.now() + timeout;
45
+ while (Date.now() < deadline && targets.some(alive)) {
46
+ // Join actual process exit while native abort persists the interrupted turn.
47
+ // oxlint-disable-next-line no-await-in-loop
48
+ await delay(25);
49
+ }
50
+ }
package/src/agent/view.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  commandSchema,
9
9
  exitSchema,
10
10
  successorSchema,
11
+ wakeFailureSchema,
11
12
  type Launch,
12
13
  type Result,
13
14
  } from "./contracts.ts";
@@ -47,6 +48,7 @@ interface RunSummary {
47
48
  result?: Pick<Result, "status" | "endedAt" | "error">;
48
49
  nativeReceipt: string | null;
49
50
  process: ReturnType<typeof processView>;
51
+ lastWakeFailure?: { at: number; reason: string };
50
52
  sessionId: string;
51
53
  sessionFile: string;
52
54
  outputFile: string;
@@ -175,6 +177,10 @@ function metadata(
175
177
  result,
176
178
  nativeReceipt: options.ctx ? (published(options.ctx, launch) ?? null) : null,
177
179
  process,
180
+ lastWakeFailure: read(
181
+ path.join(conversation(base, launch), "wake-failure.json"),
182
+ wakeFailureSchema,
183
+ ),
178
184
  sessionId: launch.sessionId,
179
185
  sessionFile: launch.sessionFile,
180
186
  outputFile,
@@ -275,6 +281,9 @@ function resultText(details: RunView): string {
275
281
  `Agent run ${details.id}: ${details.status}; admission ${details.admission}; process ${details.process.live ? "live" : "not observed live"}.`,
276
282
  receiptText(details),
277
283
  exitText(details),
284
+ details.lastWakeFailure
285
+ ? `Last wake failure at ${new Date(details.lastWakeFailure.at).toISOString()}: ${details.lastWakeFailure.reason} Retained intercom messages may still arrive after a later wake.`
286
+ : "",
278
287
  `Guidance (latest 20):\n${guidance.length > 0 ? guidance : " none"}`,
279
288
  "Guidance states: pending = not yet admitted; accepted = native queue admission, not journal publication or model consumption; stale = rejected, no new work was started (retry the entire prompt as a continuation).",
280
289
  details.result?.error ?? "",