@junghanacs/entwurf 0.36.0 → 0.37.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +290 -0
  2. package/DELIVERY.md +2 -0
  3. package/README.md +5 -0
  4. package/VERIFY.md +11 -4
  5. package/docs/acp-backend-rail.md +69 -5
  6. package/docs/setup-clean-host.md +1 -0
  7. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +16 -1
  8. package/package.json +5 -4
  9. package/pi/pi-durable/bootstrap.mjs +113 -11
  10. package/pi/pi-durable/carrier/harness-setup.js +4 -1
  11. package/pi/pi-durable/carrier/prompt.js +31 -2
  12. package/pi/pi-durable/carrier/runtime.js +11 -2
  13. package/pi/pi-durable/carrier/sessions.js +75 -5
  14. package/pi/pi-durable/carrier/tui.js +23 -0
  15. package/pi/pi-durable/overlay/upstream-pin.json +10 -10
  16. package/pi-extensions/meta-bridge-pi-durable.ts +20 -0
  17. package/run.sh +5 -2
  18. package/scripts/check-acp-prompt-lifecycle.ts +4 -2
  19. package/scripts/check-acp-session-reuse.ts +4 -2
  20. package/scripts/check-acp-stop-reason.ts +4 -2
  21. package/scripts/check-acp-stream-hooks.ts +4 -2
  22. package/scripts/check-acp-usage-accounting.ts +4 -2
  23. package/scripts/check-gate-qualification.ts +3 -3
  24. package/scripts/check-herdr-supply.ts +32 -7
  25. package/scripts/fixtures/herdr-supply.json +12 -10
  26. package/scripts/lib/acp-gate-home.ts +75 -0
  27. package/scripts/lib/codex-socket-listener.ts +429 -0
  28. package/scripts/mutants/acp-stop-reason.json +11 -0
  29. package/scripts/mutants/codex-native.json +52 -0
  30. package/scripts/mutants/herdr-supply.json +8 -8
  31. package/scripts/mutants/pi-durable.json +80 -8
  32. package/scripts/smoke-codex-fresh-live.ts +31 -7
  33. package/scripts/smoke-pi-durable-fresh-live.ts +22 -3
@@ -80,19 +80,21 @@ import {
80
80
  failAfterCleanup,
81
81
  PI_DURABLE_EXTENSION_NAME,
82
82
  PI_DURABLE_TOOL_NAMES,
83
+ piDurableGardenIds,
83
84
  withGardenIdentity,
84
85
  } from "../../mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js";
85
86
 
86
87
  const RUNTIME = "./carrier/runtime.js";
87
88
  const TUI = "./carrier/tui.js";
89
+ const SESSIONS = "./carrier/sessions.js";
88
90
 
89
91
  /** The entwurf-bridge of THIS package closure: the one the contact spawns as its MCP hand. */
90
92
  export const PACKAGED_BRIDGE_ENTRY = fileURLToPath(
91
93
  new URL("../../mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js", import.meta.url),
92
94
  );
93
95
 
94
- /** Flags that take a value. Anything else but `--continue`/`-c` is an unknown argument — a bridge
95
- * entry included: the launch argv cannot point the contact at another bridge. */
96
+ /** Flags that take a value. Anything else but `--continue`/`-c` and `--resume` is an unknown argument — a
97
+ * bridge entry included: the launch argv cannot point the contact at another bridge. */
96
98
  const VALUE_FLAGS = [
97
99
  "--provider",
98
100
  "--model",
@@ -293,12 +295,13 @@ async function loadNativeModules(files) {
293
295
  * @param {{
294
296
  * bridgeEntry?: string,
295
297
  * continueSession?: boolean,
298
+ * session?: string,
296
299
  * cwd?: string,
297
300
  * model?: { provider: string, model: string },
298
301
  * firstInput?: string,
299
302
  * extensions?: readonly object[],
300
303
  * contact?: Record<string, unknown>,
301
- * open?: (options: { cwd: string, continueSession: boolean, extensions: readonly object[], model?: { provider: string, model: string } }) => Promise<any>,
304
+ * open?: (options: { cwd: string, continueSession: boolean, session?: string, extensions: readonly object[], model?: { provider: string, model: string } }) => Promise<any>,
302
305
  * }} options
303
306
  * @returns {Promise<{ durable: any, contact: any, attachment: { gardenId: string } & Record<string, any>, firstInput?: any, close(): Promise<void> }>}
304
307
  * (stated, because the compiled closure carries no declarations: a cleanup path that rethrows
@@ -307,6 +310,7 @@ async function loadNativeModules(files) {
307
310
  export async function openDurableCitizen({
308
311
  bridgeEntry = PACKAGED_BRIDGE_ENTRY,
309
312
  continueSession = false,
313
+ session,
310
314
  cwd = process.cwd(),
311
315
  model,
312
316
  firstInput,
@@ -320,6 +324,12 @@ export async function openDurableCitizen({
320
324
  "an explicit model and a first input apply only to a new session; a continued session is reopened as it was saved",
321
325
  );
322
326
  }
327
+ if (session !== undefined && (continueSession || model !== undefined || firstInput !== undefined)) {
328
+ throw refuse(
329
+ "fresh-only-argument-with-resume",
330
+ "a chosen session is reopened as it was saved: no --continue, explicit model or first input",
331
+ );
332
+ }
323
333
  if (model !== undefined && !(nonEmpty(model.provider) && nonEmpty(model.model))) {
324
334
  throw refuse("explicit-model-incomplete", "an explicit model names both its provider and its model id");
325
335
  }
@@ -333,6 +343,7 @@ export async function openDurableCitizen({
333
343
  durable = await openDurable({
334
344
  cwd,
335
345
  continueSession,
346
+ ...(session === undefined ? {} : { session }),
336
347
  extensions: [contact.extension, ...extensions],
337
348
  ...(model === undefined ? {} : { model }),
338
349
  });
@@ -389,6 +400,7 @@ export async function openDurableCitizen({
389
400
 
390
401
  function parseArgs(argv) {
391
402
  let continueSession = false;
403
+ let resume = false;
392
404
  const values = new Map();
393
405
  for (let i = 0; i < argv.length; i++) {
394
406
  const arg = argv[i];
@@ -396,6 +408,10 @@ function parseArgs(argv) {
396
408
  continueSession = true;
397
409
  continue;
398
410
  }
411
+ if (arg === "--resume") {
412
+ resume = true;
413
+ continue;
414
+ }
399
415
  if (!VALUE_FLAGS.includes(arg)) throw new Error(`Unknown argument: ${arg}`);
400
416
  if (values.has(arg)) throw refuse("argument-repeated", `${arg} is given more than once`);
401
417
  const value = argv[++i];
@@ -404,7 +420,19 @@ function parseArgs(argv) {
404
420
  }
405
421
  values.set(arg, value);
406
422
  }
423
+ if (resume && continueSession) {
424
+ throw refuse(
425
+ "resume-continue-exclusive",
426
+ "--resume chooses a session and --continue opens the newest one; give one of them",
427
+ );
428
+ }
407
429
  const fresh = FRESH_ONLY_FLAGS.filter((flag) => values.has(flag));
430
+ if (resume && fresh.length > 0) {
431
+ throw refuse(
432
+ "fresh-only-argument-with-resume",
433
+ `${fresh.join(", ")} apply only to a new session; --resume reopens the chosen session as it was saved`,
434
+ );
435
+ }
408
436
  if (continueSession && fresh.length > 0) {
409
437
  throw refuse(
410
438
  "fresh-only-argument-with-continue",
@@ -426,6 +454,7 @@ function parseArgs(argv) {
426
454
  ? { nativeModules: nativeModuleDirFiles(dirArg) }
427
455
  : {};
428
456
  if (continueSession) return { continueSession, ...nativeModule };
457
+ if (resume) return { resume, ...nativeModule };
429
458
  const provider = values.get("--provider");
430
459
  if (provider === undefined) {
431
460
  throw refuse(
@@ -457,29 +486,102 @@ function parseArgs(argv) {
457
486
  };
458
487
  }
459
488
 
489
+ /** A local `YYYY-MM-DD HH:MM` for a list line. */
490
+ function localTime(ms) {
491
+ const date = new Date(ms);
492
+ const two = (n) => String(n).padStart(2, "0");
493
+ return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}`;
494
+ }
495
+
460
496
  /**
461
- * Parse, initialize the operator's module, load the TUI, open, run, close. `loadTui`, `open` and
462
- * `contact` are test seams; `bridgeEntry` is the checkout-only gates' private bundle, as in
463
- * `openDurableCitizen` — never the launch argv.
497
+ * `--resume` (#143): this directory's durable sessions, most recently written first, each by what its files and
498
+ * the record store already say — creation time, garden id, last write, whether another process holds it. No
499
+ * session database is opened to list them, and nothing is named that the store does not hold. Resolves the
500
+ * chosen session's id, or undefined when the choice was cancelled; never a fallback to another session.
501
+ */
502
+ async function chooseSession(cwd, sessionsDir, pick) {
503
+ const { listSessions } = await import(SESSIONS);
504
+ const sessions = await listSessions(cwd);
505
+ if (sessions.length === 0) throw refuse("resume-no-session", `no durable session exists for ${cwd}`);
506
+ const { gardens, unreadable } = piDurableGardenIds(
507
+ sessions.map((session) => session.id),
508
+ sessionsDir,
509
+ );
510
+ const items = sessions.map((session) => ({
511
+ value: session.id,
512
+ label: `${localTime(session.createdAt)} ${gardens.get(session.id) ?? "no record"}`,
513
+ description: [
514
+ session.lastWriteAt === undefined ? "never written" : `last write ${localTime(session.lastWriteAt)}`,
515
+ session.id,
516
+ ...(session.openElsewhere ? ["open elsewhere"] : []),
517
+ ].join(" · "),
518
+ }));
519
+ const title =
520
+ unreadable === 0 ? "Resume durable session:" : `Resume durable session (${unreadable} unreadable record(s)):`;
521
+ const id = await pick(title, items);
522
+ if (id === undefined) return undefined;
523
+ const chosen = sessions.find((session) => session.id === id);
524
+ if (chosen === undefined) throw refuse("session-not-found", `${id} is not one of the listed sessions`);
525
+ if (chosen.openElsewhere) {
526
+ throw refuse("session-open-elsewhere", `${id} is open in another process; close it there first`);
527
+ }
528
+ return chosen.id;
529
+ }
530
+
531
+ /**
532
+ * Parse, initialize the operator's module, load the TUI, open, run, close. `loadTui`, `open`,
533
+ * `contact` and `pick` (the `--resume` list's choice) are test seams; `bridgeEntry` is the
534
+ * checkout-only gates' private bundle, as in `openDurableCitizen` — never the launch argv.
464
535
  *
465
536
  * @param {readonly string[]} argv
466
537
  * @param {{
467
- * loadTui?: () => Promise<{ runDurableTui: (...args: any[]) => Promise<void> }>,
538
+ * loadTui?: () => Promise<{ runDurableTui: (...args: any[]) => Promise<void>, pickDurableSession?: (title: string, items: object[]) => Promise<string | undefined> }>,
468
539
  * open?: (options: any) => Promise<any>,
469
540
  * contact?: Record<string, unknown>,
470
541
  * bridgeEntry?: string,
542
+ * pick?: (title: string, items: { value: string, label: string, description: string }[]) => Promise<string | undefined>,
471
543
  * }} [seams]
472
544
  */
473
- export async function main(argv, { loadTui = () => import(TUI), open, contact, bridgeEntry } = {}) {
545
+ export async function main(argv, { loadTui = () => import(TUI), open, contact, bridgeEntry, pick } = {}) {
474
546
  // Every argument refusal is decided here, before any module, the TUI or the app loads.
475
- const { nativeModules = [], ...args } = parseArgs(argv);
547
+ const { nativeModules = [], resume = false, ...args } = parseArgs(argv);
476
548
  // The caller's directory, fixed before any operator code runs and handed to the app explicitly.
477
549
  const cwd = process.cwd();
478
550
  const extensions = await loadNativeModules(nativeModules);
479
551
  // Loaded before anything opens, as upstream main.ts imports it statically: a TUI that cannot
480
552
  // load opens no session, mints no citizen and leaves nothing to clean up.
481
- const { runDurableTui } = await loadTui();
482
- const citizen = await openDurableCitizen({ ...args, cwd, extensions, bridgeEntry, open, contact });
553
+ const tui = await loadTui();
554
+ const { runDurableTui } = tui;
555
+ let session;
556
+ if (resume) {
557
+ session = await chooseSession(
558
+ cwd,
559
+ contact?.sessionsDir,
560
+ pick ??
561
+ ((title, items) => {
562
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
563
+ throw refuse(
564
+ "resume-needs-terminal",
565
+ "--resume lists the sessions to choose from in an interactive terminal",
566
+ );
567
+ }
568
+ return tui.pickDurableSession(title, items);
569
+ }),
570
+ );
571
+ if (session === undefined) {
572
+ process.stderr.write("[pi-durable] resume-cancelled: nothing was opened\n");
573
+ return;
574
+ }
575
+ }
576
+ const citizen = await openDurableCitizen({
577
+ ...args,
578
+ ...(session === undefined ? {} : { session }),
579
+ cwd,
580
+ extensions,
581
+ bridgeEntry,
582
+ open,
583
+ contact,
584
+ });
483
585
  try {
484
586
  await runDurableTui(
485
587
  withGardenIdentity(citizen.durable.view, citizen.attachment.gardenId),
@@ -28,13 +28,16 @@ export function createHarnessSettings(settingsManager )
28
28
  const provider = settingsManager.getProviderRetrySettings();
29
29
  const idle = settingsManager.getHttpIdleTimeoutMs();
30
30
  return {
31
+ transport: settingsManager.getTransport(),
31
32
  timeoutMs: provider.timeoutMs ?? (idle === 0 ? 2147483647 : idle),
32
33
  maxRetryDelayMs: provider.maxRetryDelayMs,
33
34
  ...(provider.maxRetries === undefined ? {} : { maxRetries: provider.maxRetries }),
34
35
  };
35
36
  },
37
+ // The reference app waits for a compaction instead of summarizing beside the running turn (#143): no background
38
+ // compaction. Threshold, manual and overflow compaction stay the SDK's, on pi's own compaction settings.
36
39
  get compaction() {
37
- return settingsManager.getCompactionSettings();
40
+ return { ...settingsManager.getCompactionSettings(), backgroundTokens: 0 };
38
41
  },
39
42
  get retry() {
40
43
  return settingsManager.getRetrySettings();
@@ -1,5 +1,6 @@
1
+ import { isAbsolute, join, resolve } from "node:path";
1
2
  import { defineExtension, section } from "@earendil-works/pi-durable";
2
- import { getAgentDir } from "entwurf-pi-dist:config.js";
3
+ import { CONFIG_DIR_NAME, getAgentDir } from "entwurf-pi-dist:config.js";
3
4
  import { loadProjectContextFiles } from "entwurf-pi-dist:core/resource-loader.js";
4
5
 
5
6
  import { loadSkills, } from "entwurf-pi-dist:core/skills.js";
@@ -19,6 +20,29 @@ const CONTRIBUTIONS = {
19
20
  /** pi's section order; `buildSystemPromptSections()` omits the ones without content. */
20
21
  const KEYS = ["preamble", "tools", "rules", "docs", "project_context", "skills", "cwd"] ;
21
22
 
23
+ /** An entry pi reads as a pattern, not a path: an override or a glob. */
24
+ const PATTERN = /^[!+-]|[*?]/u;
25
+
26
+ /**
27
+ * The `skills` setting with each scope's relative entries read against that scope's own directory, as pi resolves
28
+ * them: the project's against the session directory's `.pi`, the global ones against the agent directory, the
29
+ * project's first. Read against the conversation's directory, a relative entry names another place. Absolute, `~`
30
+ * and pattern entries go on unchanged.
31
+ */
32
+ function scopedSkillPaths(settings , agentDir , projectCwd ) {
33
+ const against = (entries , base ) =>
34
+ (entries ?? []).map((entry) => {
35
+ const path = entry.trim();
36
+ return path === "" || isAbsolute(path) || path === "~" || path.startsWith("~/") || PATTERN.test(path)
37
+ ? entry
38
+ : resolve(base, path);
39
+ });
40
+ return [
41
+ ...against(settings.getProjectSettings().skills, join(projectCwd, CONFIG_DIR_NAME)),
42
+ ...against(settings.getGlobalSettings().skills, agentDir),
43
+ ];
44
+ }
45
+
22
46
  /**
23
47
  * pi's system prompt as one extension: the sections of `buildSystemPromptSections()` for the request's tools and the
24
48
  * conversation's directory. Context files and skills load once per directory, like pi at startup.
@@ -31,7 +55,12 @@ export function createPiPrompt(settings , fallbackCwd ) {
31
55
  const agentDir = getAgentDir();
32
56
  found = {
33
57
  contextFiles: loadProjectContextFiles({ cwd, agentDir }),
34
- skills: loadSkills({ cwd, agentDir, skillPaths: settings.getSkillPaths(), includeDefaults: true }).skills,
58
+ skills: loadSkills({
59
+ cwd,
60
+ agentDir,
61
+ skillPaths: scopedSkillPaths(settings, agentDir, fallbackCwd),
62
+ includeDefaults: true,
63
+ }).skills,
35
64
  };
36
65
  resources.set(cwd, found);
37
66
  }
@@ -90,6 +90,8 @@ const context = BACKGROUND_CONTEXT;
90
90
 
91
91
 
92
92
 
93
+
94
+
93
95
 
94
96
 
95
97
 
@@ -143,10 +145,17 @@ function titleOf(entry ) {
143
145
  }
144
146
 
145
147
  export async function openDurable(options = {}) {
146
- if (options.model !== undefined && options.continueSession) {
148
+ if (options.model !== undefined && (options.continueSession || options.session !== undefined)) {
147
149
  throw new Error("An explicit model applies only to a new session, not to a continued one");
148
150
  }
149
- const location = await selectSession(options.cwd ?? process.cwd(), options.continueSession ?? false);
151
+ if (options.continueSession && options.session !== undefined) {
152
+ throw new Error("A chosen session and the newest one are two different sessions; name one");
153
+ }
154
+ const location = await selectSession(
155
+ options.cwd ?? process.cwd(),
156
+ options.continueSession ?? false,
157
+ options.session,
158
+ );
150
159
  const envs = new ExecutionEnvs(location.cwd);
151
160
  let harness ;
152
161
  try {
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, readdir, realpath } from "node:fs/promises";
2
+
3
+ import { mkdir, readdir, realpath, stat } from "node:fs/promises";
3
4
  import { basename, join, resolve } from "node:path";
4
5
  import lockfile from "proper-lockfile";
5
6
  import { getAgentDir } from "entwurf-pi-dist:config.js";
@@ -14,8 +15,31 @@ import { getAgentDir } from "entwurf-pi-dist:config.js";
14
15
 
15
16
 
16
17
 
17
- /** A new session for `cwd`, or its newest one with `continueSession`. */
18
- export async function selectSession(cwdInput , continueSession ) {
18
+ /** One session of a directory as its files show it (#143): nothing in it is opened. */
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+ const SESSION_NAME = /^\d{13}-[0-9a-f-]{36}$/u;
31
+
32
+ /** A path's stat, or undefined when nothing is there; any other failure is thrown, never read as absence. */
33
+ async function statIfPresent(path ) {
34
+ try {
35
+ return await stat(path);
36
+ } catch (error) {
37
+ if ((error ).code === "ENOENT") return undefined;
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ async function sessionRoot(cwdInput ) {
19
43
  const cwd = await realpath(resolve(cwdInput));
20
44
  const root = join(
21
45
  getAgentDir(),
@@ -23,14 +47,60 @@ export async function selectSession(cwdInput , continueSession )
23
47
  "durable-sessions",
24
48
  createHash("sha256").update(cwd).digest("hex").slice(0, 24),
25
49
  );
50
+ return { cwd, root };
51
+ }
52
+
53
+ /** The sessions of `cwd`, most recently written first. Reads names, file times and locks; creates nothing. */
54
+ export async function listSessions(cwdInput ) {
55
+ const { root } = await sessionRoot(cwdInput);
56
+ let entries ;
57
+ try {
58
+ entries = await readdir(root, { withFileTypes: true });
59
+ } catch (error) {
60
+ if ((error ).code === "ENOENT") return [];
61
+ throw error;
62
+ }
63
+ const sessions = [];
64
+ for (const entry of entries) {
65
+ if (!entry.isDirectory() || !SESSION_NAME.test(entry.name)) continue;
66
+ const directory = join(root, entry.name);
67
+ let lastWriteAt ;
68
+ for (const file of ["session.sqlite", "session.sqlite-wal"]) {
69
+ const mtime = (await statIfPresent(join(directory, file)))?.mtimeMs;
70
+ if (mtime !== undefined && (lastWriteAt === undefined || mtime > lastWriteAt)) lastWriteAt = mtime;
71
+ }
72
+ sessions.push({
73
+ id: entry.name,
74
+ directory,
75
+ createdAt: Number(entry.name.slice(0, 13)),
76
+ ...(lastWriteAt === undefined ? {} : { lastWriteAt }),
77
+ openElsewhere: await lockfile.check(directory, { realpath: false }),
78
+ });
79
+ }
80
+ return sessions.sort((a, b) => (b.lastWriteAt ?? b.createdAt) - (a.lastWriteAt ?? a.createdAt));
81
+ }
82
+
83
+ /** A new session for `cwd`, its newest one with `continueSession`, or the existing one named by `sessionId`. */
84
+ export async function selectSession(
85
+ cwdInput ,
86
+ continueSession ,
87
+ sessionId ,
88
+ ) {
89
+ const { cwd, root } = await sessionRoot(cwdInput);
26
90
  await mkdir(root, { recursive: true });
27
91
 
28
92
  let directory ;
29
93
  let created = false;
30
- if (continueSession) {
94
+ if (sessionId !== undefined) {
95
+ const chosen = SESSION_NAME.test(sessionId) ? join(root, sessionId) : undefined;
96
+ if (chosen === undefined || !(await statIfPresent(chosen))?.isDirectory()) {
97
+ throw new Error(`No durable session ${sessionId} exists for ${cwd}`);
98
+ }
99
+ directory = chosen;
100
+ } else if (continueSession) {
31
101
  const entries = await readdir(root, { withFileTypes: true });
32
102
  const newest = entries
33
- .filter((entry) => entry.isDirectory() && /^\d{13}-[0-9a-f-]{36}$/u.test(entry.name))
103
+ .filter((entry) => entry.isDirectory() && SESSION_NAME.test(entry.name))
34
104
  .map((entry) => entry.name)
35
105
  .sort()
36
106
  .at(-1);
@@ -113,6 +113,29 @@ class ListSelector extends Container {
113
113
  }
114
114
  }
115
115
 
116
+ /**
117
+ * Choose one of the sessions `--resume` lists (#143) before any of them opens: the /agents list on a screen of its
118
+ * own, stopped before the session's TUI starts. Resolves the chosen value, or undefined when cancelled.
119
+ */
120
+ export function pickDurableSession(title , items ) {
121
+ initTheme();
122
+ const ui = new TuiAltScreen(new ProcessTerminal(), false, getAgentDir());
123
+ setKeybindings(KeybindingsManager.create());
124
+ return new Promise((resolve) => {
125
+ let done = false;
126
+ const finish = (value ) => {
127
+ if (done) return;
128
+ done = true;
129
+ ui.stop();
130
+ resolve(value);
131
+ };
132
+ const selector = new ListSelector(title, items, finish, () => finish(undefined));
133
+ ui.addChild(selector);
134
+ ui.setFocus(selector);
135
+ ui.start();
136
+ });
137
+ }
138
+
116
139
  /** The summary that replaced earlier context: collapsed to one line until expanded. */
117
140
  class CompactionComponent extends Box {
118
141
  #summary ;
@@ -70,32 +70,32 @@
70
70
  {
71
71
  "name": "runtime.js",
72
72
  "source": "pi/pi-durable/src/experimental/durable/runtime.ts",
73
- "blob": "36c352291620ab86184172a1b4a0df4562ab9581",
74
- "sha256": "60c2965eadbdbbe78fce1b7eeb89d97eb3104a30b7d58ebca87009d2e9118403"
73
+ "blob": "75d7bf2b4fdc36aa775f73f9d7f3de8659f19187",
74
+ "sha256": "725757156580ad2a2d170c790440763371bb8035052a0c6caae6af5159d4718f"
75
75
  },
76
76
  {
77
77
  "name": "tui.js",
78
78
  "source": "pi/pi-durable/src/experimental/durable/tui.ts",
79
- "blob": "dab99cd4778ae1db7f062e64db2d0c068f7e342d",
80
- "sha256": "0f9ebaf8fbb2a532ccd1a7198e1a25533a99720b5fedbf03f42d5c381db3abc5"
79
+ "blob": "eb5e2ec4e98919cd3ff4f81f166eb52afe6555af",
80
+ "sha256": "a1b0d9d9358c5b54ac0974de84068b7792669171917ebd7b7351374ed625c9c5"
81
81
  },
82
82
  {
83
83
  "name": "harness-setup.js",
84
84
  "source": "pi/pi-durable/src/experimental/durable/harness-setup.ts",
85
- "blob": "4d6a4be5333ebb8e0f17eac1987f18ea35a91fc7",
86
- "sha256": "712b630145dc6f5c655a85b01b1e20299c962542e63c5494d2202bb7bf414a15"
85
+ "blob": "36a622ec6d5dccda828ffaceaf74ce8ec241e39d",
86
+ "sha256": "efb06ae25258fbbab5b28f8a9bbb50956c7ebfd77a5ed23b065706f662e70ba3"
87
87
  },
88
88
  {
89
89
  "name": "prompt.js",
90
90
  "source": "pi/pi-durable/src/experimental/durable/prompt.ts",
91
- "blob": "8e2f5d5273fba58da8b314766956ed62951c3814",
92
- "sha256": "ff1ff765fa313e5cb1c3d7537c901c23adb0562ee433bd050fd35eb3d892134c"
91
+ "blob": "0e99ff64214c44986c1c2259490e401898659fd5",
92
+ "sha256": "15870ea4c9cb465395d70189be4f1480c48544d724bd9fc3b0e6650f17226c0f"
93
93
  },
94
94
  {
95
95
  "name": "sessions.js",
96
96
  "source": "pi/pi-durable/src/experimental/durable/sessions.ts",
97
- "blob": "34f9ceeeaeaf874f7478d56115da2e4f726c6a85",
98
- "sha256": "1bd1a04ece66f5ab3ca22860c64dba9abc848334d11a27a9afe543f1365c7254"
97
+ "blob": "eed4b3ca51c6f5a3bab8106adaf0062f3f7d5b56",
98
+ "sha256": "668373feadabcf13bd4dd7431e5429b7d7376c47c367e4b15353c57cb8711892"
99
99
  },
100
100
  {
101
101
  "name": "subagent.js",
@@ -113,6 +113,7 @@ import {
113
113
  defaultMetaReceiversDir,
114
114
  defaultMetaSendersDir,
115
115
  defaultMetaSessionsDir,
116
+ listAllMetaIdentitiesDir,
116
117
  readAddressableMetaIdentity,
117
118
  readMetaReceiverMarker,
118
119
  readMetaSenderMarker,
@@ -125,6 +126,25 @@ import {
125
126
  /** The backend id this contact mints under. */
126
127
  export const PI_DURABLE_BACKEND = "pi-durable" as const;
127
128
 
129
+ /**
130
+ * The garden ids the record store holds for these durable sessions (#143 `--resume`), read only: a session no record
131
+ * names is absent, and an unreadable record is counted, never guessed.
132
+ */
133
+ export function piDurableGardenIds(
134
+ nativeSessionIds: readonly string[],
135
+ sessionsDir: string = defaultMetaSessionsDir(),
136
+ ): { gardens: Map<string, string>; unreadable: number } {
137
+ const wanted = new Set(nativeSessionIds);
138
+ const { identities, errors } = listAllMetaIdentitiesDir(sessionsDir);
139
+ const gardens = new Map<string, string>();
140
+ for (const identity of identities) {
141
+ if (identity.backend === PI_DURABLE_BACKEND && wanted.has(identity.nativeSessionId)) {
142
+ gardens.set(identity.nativeSessionId, identity.gardenId);
143
+ }
144
+ }
145
+ return { gardens, unreadable: errors.length };
146
+ }
147
+
128
148
  /** The durable Extension's name, owned exactly. */
129
149
  export const PI_DURABLE_EXTENSION_NAME = "entwurf";
130
150
 
package/run.sh CHANGED
@@ -273,7 +273,7 @@ Usage:
273
273
  ./run.sh uninstall-meta-bridge # 1.0.0 meta-bridge Phase 2: stateful GLOBAL uninstall (restore only keys/items captured in install-state)
274
274
  ./run.sh doctor-meta-bridge # THE RELEASE ORACLE (#51, Linux-certified repair axis). exit 0 = every required layer was MEASURED on this Linux host: toolchain + state + plugin/MCP + resolved-artifact launch-form classification (all 3 owner hooks + doorbell static contract) + synthetic owner join + store scan + hook errors + SessionStart evidence + REQUIRED live MCP↔marker join + writer-version parity. Missing live evidence is NOT CERTIFIED (open a Claude session and re-run), never a pass; Darwin is NOT CERTIFIED and stays nonzero, on ONE narrowed reason — start-key and ppid are portable there (`ps` fallback, same string the TS core mints), so what holds the lane closed is per-process environment DISCOVERY alone, pending physical-host evidence. Detection power is held by check-meta-doctor-oracle
275
275
  ./run.sh copilot [args...] # #82 RAIL 7: the MANAGED Copilot launch. exec()s the vendor CLI in THIS terminal (cwd/pid/exit preserved) with COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS set for that one invocation — entwurf owns no part of your shell and writes nothing to it, but it owns the process it is about to become. Without that flag Copilot skips the extension scan SILENTLY. Refuses to launch unless the receiver unit is actually installed. Injects `--model auto` when no --model was given and `--yolo` when no explicit permission/surface policy flag was given, both BEFORE any `--`; every argument you pass is forwarded byte-identical. RUNNING THIS IS YOUR CONSENT to that profile — use plain `copilot` for stock vendor behaviour. Not tmux, not fresh-call, mints no citizen (birth is still the first prompt)
276
- ./run.sh pi-durable [args...] # #129/#130: the MANAGED durable launch. Verifies this package's pi-durable CARRIER (pi/pi-durable/carrier/, emitted deterministically from Entwurf's owned source pi/pi-durable/src) and the pi SDK set it resolves — every declared member/direct edge at exactly the pinned version — refusing by name (package-incomplete / carrier-drift / sdk-absent / sdk-mismatch, exit 3), then exec()s node under the carrier resolver with the packaged bootstrap (pi/pi-durable/bootstrap.mjs) in this terminal. A new session needs --provider <p> --model <exact catalog id> --width task-wide [--entwurf-bootstrap <{v:2,task}>]; --continue takes none of them. Nothing outside the package is located, installed or repaired
276
+ ./run.sh pi-durable [args...] # #129/#130: the MANAGED durable launch. Verifies this package's pi-durable CARRIER (pi/pi-durable/carrier/, emitted deterministically from Entwurf's owned source pi/pi-durable/src) and the pi SDK set it resolves — every declared member/direct edge at exactly the pinned version — refusing by name (package-incomplete / carrier-drift / sdk-absent / sdk-mismatch, exit 3), then exec()s node under the carrier resolver with the packaged bootstrap (pi/pi-durable/bootstrap.mjs) in this terminal. A new session needs --provider <p> --model <exact catalog id> --width task-wide [--entwurf-bootstrap <{v:2,task}>]; --continue (this directory's newest session) and --resume (choose one of its sessions) take none of them. Nothing outside the package is located, installed or repaired
277
277
  ./run.sh install-copilot-bridge # #82: GLOBAL install of the Copilot BIRTH plugin (own marketplace root; node+entry baked into the no-argv exec string). MCP wiring and the RECEIVER extension are separate install surfaces (install-copilot-mcp, install-copilot-receive). Also retires the stale Claude unit (--keep-stale-claude-unit opts out)
278
278
  ./run.sh uninstall-copilot-bridge # #86 C3a: honest inverse of install-copilot-bridge from the package-owned install-state (exact qualified id + exact marketplace name/path + recorded assembly only; state deleted LAST; a failing vendor list is UNKNOWN and refuses; never --force, never the stale Claude unit)
279
279
  ./run.sh doctor-copilot-bridge # #82/#86: fail-loud surface for that unit — runtime axis (red = a hook that RAN and failed, or a broken/unbaked artifact; "installed with zero records" is NOT red and is reported as NOT-YET: a Copilot session is born on its FIRST PROMPT, not when the window opens, measured) + ownership axis (install-state shape/binding, marketplace path drift, UNKNOWN vendor lists; legacy no-state install = named non-green, repair = install-copilot-bridge adoption). Either axis red = red
@@ -1596,7 +1596,10 @@ check_mux_fresh_call() {
1596
1596
  # pi-durable-fresh-live-receipts rides here for tmux-coordinate-row's reason: it is the
1597
1597
  # deterministic half of a LIVE sibling (smoke-pi-durable-fresh-live, #129) — the oracles that judge
1598
1598
  # that gate's receipts, proven on inline rows, so no model turn and no durable host is needed.
1599
- run_vitest test/mux-fresh-call.test.ts test/fresh-call-composition.contract.test.ts test/copilot-fresh-preflight.test.ts test/codex-fresh-preflight.test.ts test/fresh-call-surfaces.contract.test.ts test/fresh-call-provider.contract.test.ts test/omp-fresh-bootstrap.contract.test.ts test/tmux-coordinate-row.test.ts test/codex-fresh-live-protocol.test.ts test/codex-caller-seat.test.ts test/pi-durable-fresh-live-receipts.test.ts
1599
+ # codex-socket-listener rides here for the same reason (#128): it is the ownership precondition
1600
+ # of smoke-codex-fresh-live, judged on synthetic wrapper/self shapes and observed on a throwaway
1601
+ # wrapper → listener pair of its own, so no Codex, no app-server and no model turn is needed.
1602
+ run_vitest test/mux-fresh-call.test.ts test/fresh-call-composition.contract.test.ts test/copilot-fresh-preflight.test.ts test/codex-fresh-preflight.test.ts test/fresh-call-surfaces.contract.test.ts test/fresh-call-provider.contract.test.ts test/omp-fresh-bootstrap.contract.test.ts test/tmux-coordinate-row.test.ts test/codex-fresh-live-protocol.test.ts test/codex-caller-seat.test.ts test/pi-durable-fresh-live-receipts.test.ts test/codex-socket-listener.test.ts
1600
1603
  }
1601
1604
 
1602
1605
  check_pi_durable_beside() {
@@ -39,6 +39,7 @@ import { pathToFileURL } from "node:url";
39
39
  import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
40
40
  import { normalizeContext } from "@earendil-works/pi-ai";
41
41
  import { isRetryableAssistantError } from "@earendil-works/pi-ai/compat";
42
+ import { assertOverlayInSandbox, sandboxAcpGateHome } from "./lib/acp-gate-home.ts";
42
43
 
43
44
  const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
44
45
 
@@ -196,8 +197,6 @@ function makeHarness(recordDir: string) {
196
197
  await notifier?.({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
197
198
  },
198
199
  deps: {
199
- resolveLaunch: () => ({ command: "node", args: ["fake"] }),
200
- ensureOverlay: () => {},
201
200
  spawnChild: () => {
202
201
  const c = makeFakeChild();
203
202
  children.push(c);
@@ -279,6 +278,7 @@ function reuseCtx(prior: string, latest: string): TranscriptContext {
279
278
  const TMP_EMIT = ".tmp-verify/acp-prompt-lifecycle";
280
279
  rmSync(TMP_EMIT, { recursive: true, force: true });
281
280
  const recordDir = mkdtempSync(resolve(tmpdir(), "acp-prompt-life-"));
281
+ const gateHome = sandboxAcpGateHome("acp-prompt-lifecycle-home-");
282
282
 
283
283
  try {
284
284
  execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
@@ -288,6 +288,7 @@ try {
288
288
  mkdirSync(promptsOut, { recursive: true });
289
289
  copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
290
290
  const backend = (await import(pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href)) as any;
291
+ await assertOverlayInSandbox(TMP_EMIT, gateHome);
291
292
 
292
293
  // ----------------------------------------------------------------------
293
294
  // CELL 1 — a long / quiet turn is NOT killed.
@@ -918,6 +919,7 @@ try {
918
919
  // non-empty or already gone — fine either way
919
920
  }
920
921
  rmSync(recordDir, { recursive: true, force: true });
922
+ gateHome.restore();
921
923
  }
922
924
 
923
925
  console.log(
@@ -32,6 +32,7 @@ import { resolve } from "node:path";
32
32
  import { pathToFileURL } from "node:url";
33
33
  import type { Api, AssistantMessageEvent, Context, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
34
34
  import { normalizeContext } from "@earendil-works/pi-ai";
35
+ import { assertOverlayInSandbox, sandboxAcpGateHome } from "./lib/acp-gate-home.ts";
35
36
 
36
37
  // The pi built-ins the Claude child also exposes natively. pi declares its tool
37
38
  // surface on every turn; since 0.86 that declaration reaches a provider as the
@@ -175,8 +176,6 @@ function makeHarness(recordDir: string, config: any = DEFAULT_RESOLVED_CONFIG, e
175
176
  });
176
177
 
177
178
  const deps = {
178
- resolveLaunch: () => ({ command: "node", args: ["fake"] }),
179
- ensureOverlay: () => {},
180
179
  spawnChild: () => {
181
180
  const c = makeFakeChild();
182
181
  children.push(c);
@@ -268,6 +267,7 @@ function reuseCtx(prior: string, latest: string): TranscriptContext {
268
267
  const TMP_EMIT = ".tmp-verify/acp-session-reuse";
269
268
  rmSync(TMP_EMIT, { recursive: true, force: true });
270
269
  const recordDir = mkdtempSync(resolve(tmpdir(), "acp-reuse-rec-"));
270
+ const gateHome = sandboxAcpGateHome("acp-session-reuse-home-");
271
271
 
272
272
  try {
273
273
  execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
@@ -283,6 +283,7 @@ try {
283
283
  const backendUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href;
284
284
  const storeUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/session-store.js")).href;
285
285
  const backend = (await import(backendUrl)) as any;
286
+ await assertOverlayInSandbox(TMP_EMIT, gateHome);
286
287
  const store = (await import(storeUrl)) as any;
287
288
  const configUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/config.js")).href;
288
289
  const configMod = (await import(configUrl)) as any;
@@ -814,6 +815,7 @@ try {
814
815
  // non-empty or already gone — fine either way
815
816
  }
816
817
  rmSync(recordDir, { recursive: true, force: true });
818
+ gateHome.restore();
817
819
  }
818
820
 
819
821
  console.log(