@junghanacs/entwurf 0.35.1 → 0.36.1

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 (55) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +403 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/DELIVERY.md +17 -0
  5. package/FAQ.md +3 -3
  6. package/README.md +30 -16
  7. package/VERIFY.md +12 -10
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +69 -5
  10. package/docs/setup-clean-host.md +30 -22
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +59 -14
  15. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  16. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  17. package/package.json +21 -16
  18. package/pi/pi-durable/bootstrap.mjs +12 -8
  19. package/pi/pi-durable/carrier/runtime.js +52 -2
  20. package/pi/pi-durable/carrier/subagent.js +6 -1
  21. package/pi/pi-durable/carrier/tui.js +31 -4
  22. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  23. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  24. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  25. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  26. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  27. package/pi-extensions/meta-bridge-pi-durable.ts +77 -21
  28. package/run.sh +116 -39
  29. package/scripts/check-acp-prompt-lifecycle.ts +4 -2
  30. package/scripts/check-acp-session-reuse.ts +4 -2
  31. package/scripts/check-acp-stop-reason.ts +4 -2
  32. package/scripts/check-acp-stream-hooks.ts +4 -2
  33. package/scripts/check-acp-usage-accounting.ts +4 -2
  34. package/scripts/check-codex-app-server-launch.ts +55 -12
  35. package/scripts/check-codex-native-push.ts +231 -12
  36. package/scripts/check-gate-qualification.ts +4 -4
  37. package/scripts/check-herdr-supply.ts +32 -7
  38. package/scripts/codex-app-server-launch.sh +54 -29
  39. package/scripts/codex-socket-classify.ts +46 -0
  40. package/scripts/emit-pi-durable-carrier.ts +159 -168
  41. package/scripts/fixtures/herdr-supply.json +10 -9
  42. package/scripts/lib/acp-gate-home.ts +75 -0
  43. package/scripts/lib/codex-socket-listener.ts +429 -0
  44. package/scripts/mutants/acp-stop-reason.json +11 -0
  45. package/scripts/mutants/codex-app-server-launch.json +39 -12
  46. package/scripts/mutants/codex-native.json +67 -0
  47. package/scripts/mutants/control-send-receipt.json +1 -1
  48. package/scripts/mutants/herdr-supply.json +6 -6
  49. package/scripts/mutants/pack-install.json +3 -3
  50. package/scripts/mutants/pi-durable.json +165 -16
  51. package/scripts/mutants/pi-floor.json +5 -5
  52. package/scripts/pi-durable-contact-host.mjs +1 -1
  53. package/scripts/smoke-codex-fresh-live.ts +31 -7
  54. package/scripts/smoke-pi-durable-fresh-live.ts +22 -3
  55. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * WHAT THIS IS. The long-running durable app (`@earendil-works/pi-coding-agent`
5
5
  * `experimental/durable`, which upstream omits from its npm distribution — Entwurf's package
6
- * supplies it as a pinned carrier, #130, not as an operator prerequisite) owns its runtime, TUI,
7
- * models/auth, storage, prompt and recovery. This module is the whole Entwurf side of its contact:
6
+ * supplies it as a carrier emitted from its owned source, #130/#141, not as an operator
7
+ * prerequisite) owns its runtime, TUI, models/auth, storage, prompt and recovery. This module is
8
+ * the whole Entwurf side of its contact:
8
9
  *
9
10
  * createPiDurableContact({ bridgeEntry })
10
11
  * .extension tools-only durable Extension, handed to the overlay's
@@ -49,14 +50,16 @@
49
50
  * bridge whose list differs: the callable bridge schema is the truth, the copy only has to agree.
50
51
  *
51
52
  * RECEIVE (self-fetch, announce-only). When the bootstrap hands `attach` the overlay's
52
- * `submitToRoot`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
53
+ * `submitToRoot` and `onFailed`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
53
54
  * then the watch, then a check that the record and this process's sender marker still name the
54
55
  * session, and only then the receiver marker. A fresh `.msg` is stamped `.msg.delivered` and
55
56
  * announced to the ROOT conversation as input; the body stays in the mailbox until the model
56
57
  * calls `entwurf_inbox_read`, which is the read receipt. The watch and the sender marker share
57
58
  * this process's pid, so the #101 owner join applies (`pi-durable-host`). A watch error, a
58
- * vanished signal, a failed ring, a rejected admission or close retires the doorbell — watch
59
- * closed, our own marker removed, the reason kept by name — and nothing retries it.
59
+ * vanished signal, a failed ring, a rejected admission, the session failing (at once, from the
60
+ * overlay's `onFailed`, not at the next ring — and before `attach` returns when it had already
61
+ * failed, with no receiver marker left behind) or close retires the doorbell — watch closed, our own
62
+ * marker removed, the reason kept by name — and nothing retries it.
60
63
  *
61
64
  * WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
62
65
  * unread body. So after a read the notices still queued for that mail would each cost a turn and an
@@ -484,7 +487,7 @@ export function createPiDurableContact(options) {
484
487
  const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
485
488
  if (result.isError)
486
489
  throw new Error(result.text || `${name} failed`);
487
- return { content: [{ type: "text", text: result.text }] };
490
+ return { output: [{ type: "text", text: result.text }] };
488
491
  },
489
492
  });
490
493
  const extension = {
@@ -534,6 +537,7 @@ export function createPiDurableContact(options) {
534
537
  if (armed === null)
535
538
  return [];
536
539
  doorbell = null;
540
+ armed.unsubscribeFailed();
537
541
  const problems = [];
538
542
  try {
539
543
  armed.watcher.close();
@@ -686,9 +690,10 @@ export function createPiDurableContact(options) {
686
690
  * Mailbox, signal, watch — then the check that the record and this process's sender marker
687
691
  * still name the session — and only then the receiver marker, the one fact a sender reads.
688
692
  * Synchronous, so nothing can close the contact between the check and the publish. Throws,
689
- * after closing whatever it opened, when any step fails.
693
+ * after closing whatever it opened, when any step fails. Returns null when the session had
694
+ * already failed: the doorbell was retired as `session-failed` before this returned.
690
695
  */
691
- const arm = (gardenId, nativeSessionId, ownerPid, roots, submitToRoot) => {
696
+ const arm = (gardenId, nativeSessionId, ownerPid, roots, root) => {
692
697
  if (ownerPid !== process.pid) {
693
698
  throw new Error(`the doorbell's watch lives in this process (${process.pid}); a receiver marker naming owner ${ownerPid} would advertise a watch that pid does not hold`);
694
699
  }
@@ -698,6 +703,7 @@ export function createPiDurableContact(options) {
698
703
  if (!fs.existsSync(signal))
699
704
  fs.writeFileSync(signal, "", { mode: 0o600 });
700
705
  const watcher = watch(signal, () => void ring("signal"));
706
+ let unsubscribeFailed = () => { };
701
707
  try {
702
708
  watcher.on("error", (error) => retire("watch-error", describeError(error)));
703
709
  const identity = readAddressableMetaIdentity(gardenId, roots.sessionsDir);
@@ -710,6 +716,22 @@ export function createPiDurableContact(options) {
710
716
  ? `no live sender marker for owner ${ownerPid}`
711
717
  : `the sender marker for owner ${ownerPid} names ${sender.gardenId}/${sender.nativeSessionId}, not ${gardenId}/${nativeSessionId}`);
712
718
  }
719
+ // A failed session admits nothing: retire at once, so no sender reads a marker for a host that cannot wake.
720
+ // Subscribed before the marker is written, so a registration that throws leaves no marker. A session that
721
+ // failed before this arm says so during the subscription, before the doorbell exists: that is noted, and
722
+ // the retirement runs once the doorbell is published below. It is final; nothing re-arms.
723
+ let published = false;
724
+ let failedBeforePublished = false;
725
+ const sessionFailed = () => {
726
+ if (!published) {
727
+ failedBeforePublished = true;
728
+ return;
729
+ }
730
+ const problems = retire("session-failed", "the durable session failed and closed itself");
731
+ if (problems.length > 0)
732
+ throw new Error(`the doorbell did not retire cleanly: ${problems.join("; ")}`);
733
+ };
734
+ unsubscribeFailed = root.onFailed(sessionFailed);
713
735
  const markerPath = writeMetaReceiverMarker({
714
736
  gardenId,
715
737
  backend: PI_DURABLE_BACKEND,
@@ -719,17 +741,37 @@ export function createPiDurableContact(options) {
719
741
  armProvenance: "session-start",
720
742
  receiversDir: roots.receiversDir,
721
743
  });
722
- doorbell = { gardenId, ownerPid, mailbox, signal, markerPath, watcher, submitToRoot };
744
+ doorbell = {
745
+ gardenId,
746
+ ownerPid,
747
+ mailbox,
748
+ signal,
749
+ markerPath,
750
+ watcher,
751
+ submitToRoot: root.submitToRoot,
752
+ unsubscribeFailed,
753
+ };
723
754
  inbox = mailbox;
724
755
  report = { status: "armed", gardenId, markerPath, admissions };
756
+ published = true;
757
+ if (failedBeforePublished) {
758
+ sessionFailed();
759
+ return null;
760
+ }
725
761
  return markerPath;
726
762
  }
727
763
  catch (error) {
728
- try {
729
- watcher.close();
764
+ const cleanup = [];
765
+ for (const step of [unsubscribeFailed, () => watcher.close()]) {
766
+ try {
767
+ step();
768
+ }
769
+ catch (cleanupError) {
770
+ cleanup.push(cleanupError);
771
+ }
730
772
  }
731
- catch (closeError) {
732
- throw new AggregateError([error, closeError], `${describeError(error)} (cleanup also failed: ${describeError(closeError)})`);
773
+ if (cleanup.length > 0) {
774
+ throw new AggregateError([error, ...cleanup], `${describeError(error)} (cleanup also failed: ${cleanup.map(describeError).join("; ")})`);
733
775
  }
734
776
  throw error;
735
777
  }
@@ -757,6 +799,9 @@ export function createPiDurableContact(options) {
757
799
  if (root !== undefined && typeof root.submitToRoot !== "function") {
758
800
  throw new Error("the root admission handed to attach has no submitToRoot function");
759
801
  }
802
+ if (root !== undefined && typeof root.onFailed !== "function") {
803
+ throw new Error("the root admission handed to attach has no onFailed function");
804
+ }
760
805
  const ownerPid = options.ownerPid ?? process.pid;
761
806
  const sendersDir = path.resolve(options.sendersDir ?? defaultMetaSendersDir());
762
807
  const upsert = upsertMetaSession({
@@ -806,7 +851,7 @@ export function createPiDurableContact(options) {
806
851
  let receiverMarkerPath = null;
807
852
  if (root !== undefined) {
808
853
  try {
809
- receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root.submitToRoot);
854
+ receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root);
810
855
  }
811
856
  catch (error) {
812
857
  return await failAfterCleanup(new Error(`the doorbell did not arm: ${describeError(error)}`), () => client.close());
@@ -0,0 +1,44 @@
1
+ /**
2
+ * codex-socket-classify — classify the Codex app-server default control socket for THIS
3
+ * environment, through the one product classifier, and print the answer for a shell.
4
+ *
5
+ * WHY A LEAF. `entwurf codex-app-server` is a bash launcher, and it has to decide whether a
6
+ * launch would clobber something. From Codex 0.162.1 the advertised socket path is a
7
+ * rendezvous alias into a vendor-owned directory, and telling the vendor's exact alias from any
8
+ * other symlink takes a hash, an exact readlink comparison and ownership/mode checks. Doing that
9
+ * in the launcher's Python would be a second implementation of a security classification, and
10
+ * `codex-socket-path.ts` records what a second spelling of this socket did once (it diverged).
11
+ * So the launcher asks `checkCodexSocketFile`, the classifier delivery and preflight use, and
12
+ * only connects to what it was told.
13
+ *
14
+ * HOW IT IS REACHED. The launcher runs this file's COMPILED twin,
15
+ * `mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js`, directly with `node` — in a checkout
16
+ * and in an installed package alike. There is no `run.sh` verb and no `run_ts` crossing for it:
17
+ * `build-bridge` emits it (`pnpm install` runs that through `prepare`, packing through `prepack`),
18
+ * and a missing twin is an incomplete installation the launcher refuses by name.
19
+ *
20
+ * One line on stdout, a word then a tab-separated detail:
21
+ * socket <connectPath> a verified socket; the launcher connects there to tell live from stale
22
+ * absent nothing at the advertised path
23
+ * dangling <reason> Codex's own alias whose socket is gone; the vendor removes it on start
24
+ * indeterminate <reason> anything else; the launcher refuses
25
+ *
26
+ * Reads `process.env` and takes no argument, for the reason `codex-socket-path.ts` gives: an argv
27
+ * seam would be a way to redirect the address. It has no daemon-root input either; the gate's
28
+ * exact-alias cells drive `checkCodexSocketFile` directly with a sandbox root.
29
+ */
30
+ import { checkCodexSocketFile, resolveCodexDefaultSocketPath, } from "../pi-extensions/lib/native-push/codex-ws-client.js";
31
+ if (process.argv.length > 2) {
32
+ console.error("usage: codex-socket-classify (no arguments; the environment is the input)");
33
+ process.exit(2);
34
+ }
35
+ const oneLine = (text) => text.replace(/[\t\r\n]+/g, " ");
36
+ const check = checkCodexSocketFile(resolveCodexDefaultSocketPath(process.env));
37
+ if (check.ok)
38
+ process.stdout.write(`socket\t${check.connectPath}`);
39
+ else if (check.status === "dead" && check.layout === "absent")
40
+ process.stdout.write("absent");
41
+ else if (check.status === "dead")
42
+ process.stdout.write(`dangling\t${oneLine(check.reason)}`);
43
+ else
44
+ process.stdout.write(`indeterminate\t${oneLine(check.reason)}`);
@@ -122,6 +122,10 @@
122
122
  // twin would make the one-command launch die before its first line. Its only repo import is
123
123
  // native-push/codex-ws-client.ts, ALREADY emitted for meta-bridge-hook-codex, so this adds ONE
124
124
  // leaf JS and ZERO new deps.
125
+ // WHY codex-socket-classify rides THIS build (#141): the same launcher asks it whether a launch
126
+ // would clobber something, and runs THIS compiled twin directly with `node` in a checkout and an
127
+ // installed package alike (no run.sh verb, no run_ts crossing), so the emitted file is the only
128
+ // route. Its only repo import is that same codex-ws-client.ts: ONE leaf JS, ZERO new deps.
125
129
  // WHY meta-bridge-pi-durable rides THIS build (#129 W, L4): the packaged durable bootstrap
126
130
  // (pi/pi-durable/bootstrap.mjs) imports the contact from THIS closure — the same closure whose
127
131
  // bridge entry the contact spawns and whose meta-bridge-omp.js decodes the first-input payload —
@@ -151,6 +155,7 @@
151
155
  "../../scripts/peer-facts.ts",
152
156
  "../../scripts/omp-receive-facts.ts",
153
157
  "../../scripts/codex-socket-path.ts",
158
+ "../../scripts/codex-socket-classify.ts",
154
159
  "../../pi-extensions/meta-bridge-hook.ts",
155
160
  "../../pi-extensions/meta-bridge-hook-copilot.ts",
156
161
  "../../pi-extensions/meta-bridge-hook-codex.ts",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.35.1",
4
- "description": "Garden-citizen dispatch substrate and meta-bridge for pi, Claude Code, Copilot CLI, OMP, Antigravity, and Codex CLI; Claude and Snowflake Cortex Code ACP backends.",
3
+ "version": "0.36.1",
4
+ "description": "Visible sibling sessions across independent agent harnesses: launch in Herdr or tmux, reopen pi in tmux, and deliver messages by garden id; native bridges and Claude/Cortex ACP adapters.",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "Junghan Kim",
@@ -30,7 +30,12 @@
30
30
  "copilot",
31
31
  "antigravity",
32
32
  "pi-coding-agent",
33
- "pi-extension"
33
+ "pi-extension",
34
+ "herdr",
35
+ "tmux",
36
+ "visible-sessions",
37
+ "session-lifecycle",
38
+ "multi-agent"
34
39
  ],
35
40
  "type": "module",
36
41
  "files": [
@@ -59,7 +64,6 @@
59
64
  "pi/pi-durable/carrier-resolver.mjs",
60
65
  "pi/pi-durable/carrier/",
61
66
  "pi/pi-durable/overlay/upstream-pin.json",
62
- "pi/pi-durable/overlay/runtime-contacts.patch",
63
67
  "pi/meta-bridge/.claude-plugin/marketplace.json",
64
68
  "pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json",
65
69
  "pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json",
@@ -92,18 +96,18 @@
92
96
  "claudeCodeFloor": ">=2.1.217"
93
97
  },
94
98
  "dependencies": {
95
- "@agentclientprotocol/claude-agent-acp": "0.85.1",
96
- "@agentclientprotocol/sdk": "1.6.0",
99
+ "@agentclientprotocol/claude-agent-acp": "0.90.0",
100
+ "@agentclientprotocol/sdk": "1.8.0",
97
101
  "@anthropic-ai/sdk": "0.100.1",
98
- "@earendil-works/chord": "1.1.0",
99
- "@earendil-works/pi-agent-core": "1.1.0",
100
- "@earendil-works/pi-ai": "1.1.0",
101
- "@earendil-works/pi-codemode": "1.1.0",
102
- "@earendil-works/pi-coding-agent": "1.1.0",
103
- "@earendil-works/pi-durable": "1.1.0",
104
- "@earendil-works/pi-mcp": "1.1.0",
105
- "@earendil-works/pi-telemetry": "1.1.0",
106
- "@earendil-works/pi-tui": "1.1.0",
102
+ "@earendil-works/chord": "1.2.0",
103
+ "@earendil-works/pi-agent-core": "1.2.0",
104
+ "@earendil-works/pi-ai": "1.2.0",
105
+ "@earendil-works/pi-codemode": "1.2.0",
106
+ "@earendil-works/pi-coding-agent": "1.2.0",
107
+ "@earendil-works/pi-durable": "1.2.0",
108
+ "@earendil-works/pi-mcp": "1.2.0",
109
+ "@earendil-works/pi-telemetry": "1.2.0",
110
+ "@earendil-works/pi-tui": "1.2.0",
107
111
  "@modelcontextprotocol/sdk": "^1.12.1",
108
112
  "proper-lockfile": "4.1.2",
109
113
  "smol-toml": "^1.8.0",
@@ -115,6 +119,7 @@
115
119
  "devDependencies": {
116
120
  "@biomejs/biome": "^2.4.13",
117
121
  "@types/node": "^24.3.0",
122
+ "@types/proper-lockfile": "4.1.4",
118
123
  "husky": "^9.1.7",
119
124
  "rregex": "1.13.1",
120
125
  "typescript": "^6.0.2",
@@ -178,7 +183,7 @@
178
183
  "test": "vitest run",
179
184
  "test:watch": "vitest",
180
185
  "test:pack": "pnpm run check-pack && pnpm run check-pack-install",
181
- "typecheck": "tsc --noEmit && tsc -p mcp/tsconfig.json && tsc -p scripts/tsconfig.json",
186
+ "typecheck": "tsc --noEmit && tsc -p mcp/tsconfig.json && tsc -p scripts/tsconfig.json && tsc -p pi/pi-durable/tsconfig.json",
182
187
  "lint": "biome check .",
183
188
  "format": "biome check --write .",
184
189
  "check-meta-manifest-schema": "./run.sh check-meta-manifest-schema",
@@ -9,15 +9,16 @@
9
9
  // with these differences:
10
10
  // - Entwurf's tools-only extension is handed to `openDurable`;
11
11
  // - a NEW session is opened with an explicit model and an explicit width, never a default (below);
12
- // - the citizen is born once `openDurable` returns, with the overlay's `submitToRoot` so its
13
- // doorbell can announce mail to the root conversation;
12
+ // - the citizen is born once `openDurable` returns, with the owned runtime's `submitToRoot` so its
13
+ // doorbell can announce mail to the root conversation, and its `onFailed` so a session that
14
+ // fails and closes itself retires that doorbell at once;
14
15
  // - a launcher's first input, when given, is admitted to the root ONCE, after that birth;
15
16
  // - the TUI is handed the native view with the garden id on every conversation label
16
17
  // (`withGardenIdentity`).
17
18
  // Nothing of the native registry, controller or TUI is rebuilt here.
18
19
  //
19
- // The durable app itself is this package's CARRIER (./carrier/, the pinned upstream files emitted by
20
- // the maintainer — upstream publishes no experimental app), and it runs under the carrier's resolver,
20
+ // The durable app itself is this package's CARRIER (./carrier/, emitted from Entwurf's owned source
21
+ // pi/pi-durable/src — upstream publishes no experimental app), and it runs under the carrier's resolver,
21
22
  // which maps the app's pi-coding-agent internals to the published package the carrier resolves.
22
23
  // `entwurf pi-durable` (run.sh) verifies the carrier and its SDK set (pi-durable-runtime.ts) and
23
24
  // execs:
@@ -29,11 +30,11 @@
29
30
  //
30
31
  // Explicit means the native API, not a vendor argv: upstream's durable main takes only
31
32
  // `--continue`, so `--provider`/`--model` reach pi's own `findInitialAgentModel` through the
32
- // overlay's `model` option, for a new session only, and an unresolvable pair fails the open
33
+ // owned runtime's `model` option, for a new session only, and an unresolvable pair fails the open
33
34
  // before any citizen is born (the native session directory may already exist by then). The model
34
35
  // must be one registered catalog model of that provider, named by its canonical id or by
35
36
  // `id:<level>` as pi's own parser reads a thinking level: a typo, a partial or case-variant match, a
36
- // provider-prefixed id or an unregistered custom-id fallback is refused by the overlay as
37
+ // provider-prefixed id or an unregistered custom-id fallback is refused by the owned runtime as
37
38
  // `explicit-model-not-exact`, before the birth and the first input.
38
39
  // `--width task-wide` is the one width: no tool filter, so the model is offered exactly the tools
39
40
  // the installed extensions register. It is a statement of what is OFFERED, not an approval or
@@ -50,7 +51,7 @@
50
51
  // its initialization, and its default export is a native durable Extension, handed to `openDurable`
51
52
  // by reference after the contact. Initialization runs before the TUI or the runtime is imported —
52
53
  // the runtime creates the provider runtime before it installs any extension (carrier/runtime.js
53
- // 142-148), so an extension object cannot carry an environment phase. Entwurf checks only what the
54
+ // 153-159), so an extension object cannot carry an environment phase. Entwurf checks only what the
54
55
  // ingress needs: a default export object with a name, no extension name the app installs itself (a
55
56
  // same-name install REPLACES), and no tool name of the contact. Every other field is the native
56
57
  // registry's to validate. The module is trusted operator code: the identity carriers and the
@@ -341,7 +342,10 @@ export async function openDurableCitizen({
341
342
  }
342
343
  let attachment;
343
344
  try {
344
- attachment = await contact.attach(durable.view.current().session, { submitToRoot: durable.submitToRoot });
345
+ attachment = await contact.attach(durable.view.current().session, {
346
+ submitToRoot: durable.submitToRoot,
347
+ onFailed: durable.onFailed,
348
+ });
345
349
  } catch (error) {
346
350
  return failAfterCleanup(
347
351
  error,
@@ -62,6 +62,11 @@ const context = BACKGROUND_CONTEXT;
62
62
 
63
63
 
64
64
 
65
+
66
+
67
+
68
+
69
+
65
70
 
66
71
 
67
72
 
@@ -98,6 +103,12 @@ const context = BACKGROUND_CONTEXT;
98
103
 
99
104
 
100
105
 
106
+
107
+
108
+
109
+
110
+
111
+
101
112
 
102
113
 
103
114
 
@@ -234,8 +245,27 @@ export async function openDurable(options = {})
234
245
  update({ notices: [...state.notices, { id: nextNotice++, level, message }].slice(-20) });
235
246
  };
236
247
  const fail = (error ) => notice("error", error instanceof Error ? error.message : String(error));
237
- report = (error) => notice("warning", error instanceof Error ? error.message : String(error));
248
+ report = (error) => {
249
+ const message = error instanceof Error ? error.message : String(error);
250
+ // A failing Session runs its close listeners first and reports the cause right after: that report names it.
251
+ if (state.failure !== undefined && state.failure.error === undefined) update({ failure: { error: message } });
252
+ else notice("warning", message);
253
+ };
238
254
  for (const error of pendingReports) report(error);
255
+ const failedListeners = new Set ();
256
+ // The Session closes itself when its Storage fails. Its `closed` settles only after every running task returned,
257
+ // which a task ignoring its abort can delay without end, so the failure is shown from the close listener itself.
258
+ // `close()` unsubscribes before it closes the Session, so only a Session closing itself gets here.
259
+ const unsubscribeClose = harness.subscribeClose(() => {
260
+ update({ failure: {} });
261
+ for (const listener of failedListeners) {
262
+ try {
263
+ listener();
264
+ } catch (error) {
265
+ fail(error);
266
+ }
267
+ }
268
+ });
239
269
  let unsubscribe = conversation.subscribe((value) => update({ conversation: value }));
240
270
  // Subagents appear as their conversations are created. A commit listener only records; it calls no Session API.
241
271
  const unsubscribeCommits = harness.subscribeCommits((publication) => {
@@ -286,7 +316,12 @@ export async function openDurable(options = {})
286
316
  };
287
317
  const controller = {
288
318
  submit: (text, whenBusy) =>
289
- command(async () => watchAnswer(await current.submit({ type: "input", content: text, whenBusy }, context))),
319
+ command(async () => {
320
+ if (state.failure !== undefined) {
321
+ throw new Error("The session failed and takes no input. Close it, then reopen it with --continue.");
322
+ }
323
+ watchAnswer(await current.submit({ type: "input", content: text, whenBusy }, context));
324
+ }),
290
325
  compact: (instructions) =>
291
326
  command(async () => {
292
327
  const id = await current.compact(instructions, context);
@@ -374,8 +409,23 @@ export async function openDurable(options = {})
374
409
  controller,
375
410
  settings: settingsManager,
376
411
  submitToRoot: (draft) => root.submit(draft, context),
412
+ onFailed(listener) {
413
+ // The failure is kept in the view, so a listener that comes after it is told at once, and still only once.
414
+ if (state.failure !== undefined) {
415
+ try {
416
+ listener();
417
+ } catch (error) {
418
+ fail(error);
419
+ }
420
+ return () => {};
421
+ }
422
+ failedListeners.add(listener);
423
+ return () => failedListeners.delete(listener);
424
+ },
377
425
  close() {
378
426
  closing ??= (async () => {
427
+ // First: the Session runs its close listeners inside its own close call.
428
+ unsubscribeClose();
379
429
  unsubscribe();
380
430
  unsubscribeCommits();
381
431
  conversation.dispose();
@@ -8,6 +8,7 @@ import {
8
8
 
9
9
 
10
10
 
11
+
11
12
  } from "@earendil-works/pi-durable";
12
13
 
13
14
  async function answerText(api , answer , context ) {
@@ -48,7 +49,11 @@ export const Subagent = defineExtension({
48
49
  throw new Error(`Subagent ${child} failed: ${settled.status}`);
49
50
  }
50
51
  const text = await answerText(api, settled.answer, context);
51
- return { content: [{ type: "text", text }], details: { conversationId: child } };
52
+ // `output` is what the model reads; spelled out so the result shape is checked, not just accepted.
53
+ return {
54
+ output: [{ type: "text", text }],
55
+ details: { conversationId: child },
56
+ } ;
52
57
  },
53
58
  }),
54
59
  ],
@@ -46,7 +46,7 @@ import { ToolExecutionComponent, } from "entwurf-pi-dist:mode
46
46
  import { UserMessageComponent } from "entwurf-pi-dist:modes/interactive/components/user-message.js";
47
47
  import { getEditorTheme, getMarkdownTheme, initTheme, theme } from "entwurf-pi-dist:modes/interactive/theme/theme.js";
48
48
  import { InteractiveThemeController } from "entwurf-pi-dist:modes/interactive/theme/theme-controller.js";
49
- import { agentOf, } from "./runtime.js";
49
+ import { agentOf, } from "./runtime.js";
50
50
 
51
51
  const SELECT_THEME = {
52
52
  selectedPrefix: (text) => theme.fg("accent", text),
@@ -152,6 +152,26 @@ class CompactionComponent extends Box {
152
152
 
153
153
 
154
154
 
155
+ /**
156
+ * The lines above the editor: first what stays true for as long as it is true, the session's failure and the
157
+ * compaction failure a conversation keeps until a compaction produces a summary, then the latest notices.
158
+ */
159
+ export function durableNotices(view ) {
160
+ const lines = [];
161
+ if (view.failure !== undefined) {
162
+ const cause = view.failure.error === undefined ? "" : `: ${view.failure.error}`;
163
+ lines.push({
164
+ level: "error",
165
+ message: `Session failed${cause}. It takes no more input, and a tool still running may take a while to stop. Close this session, then reopen it with --continue.`,
166
+ });
167
+ }
168
+ const compaction = ((view.conversation.docs["pi.live"] ?? {}) ).compactionFailure;
169
+ if (compaction !== undefined) {
170
+ lines.push({ level: "error", message: `Compaction failed (${compaction.reason}): ${compaction.error}` });
171
+ }
172
+ return [...lines, ...view.notices.slice(-4)];
173
+ }
174
+
155
175
  /**
156
176
  * The live background-task badge: the dock's first row, never shrunk, so live background work stays on
157
177
  * screen however small the terminal gets. It counts the graph's background tasks whatever their kind and
@@ -212,7 +232,13 @@ export function durableLayout(transcript , badge , dock
212
232
  minSize: 1,
213
233
  visible: (viewport) => badge.count === 0 || viewport.height > 1,
214
234
  },
215
- { component: new VStack([{ component: badge, shrink: 0 }, ...dock]), basis: "auto", grow: 0, shrink: 1, minSize: 1 },
235
+ {
236
+ component: new VStack([{ component: badge, shrink: 0 }, ...dock]),
237
+ basis: "auto",
238
+ grow: 0,
239
+ shrink: 1,
240
+ minSize: 1,
241
+ },
216
242
  ]);
217
243
  }
218
244
 
@@ -368,7 +394,8 @@ class DurableTui {
368
394
  this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) );
369
395
  this.#syncNotices(view);
370
396
  this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");
371
- this.#syncStatus(live);
397
+ // A failed session's live state is its last one; nothing is working any more.
398
+ this.#syncStatus(view.failure === undefined ? live : {});
372
399
  this.#syncFooter(view);
373
400
  if (this.#rebuilt) this.#transcript.scrollToEnd();
374
401
  this.#ui.requestRender(this.#rebuilt);
@@ -409,7 +436,7 @@ class DurableTui {
409
436
 
410
437
  #syncNotices(view ) {
411
438
  this.#notices.clear();
412
- for (const item of view.notices.slice(-4)) {
439
+ for (const item of durableNotices(view)) {
413
440
  const color = item.level === "error" ? "error" : item.level === "warning" ? "warning" : "muted";
414
441
  this.#notices.addChild(new TruncatedText(theme.fg(color, item.message), 1, 0));
415
442
  }
@@ -3,8 +3,8 @@
3
3
  // rewrite the emitter performs and the resolution the runtime accepts cannot drift apart.
4
4
  //
5
5
  // The durable app is source-only upstream: published `@earendil-works/pi-coding-agent` ships no
6
- // `dist/experimental`, and its exports map exposes no deep `dist/` path. The carrier is the pinned
7
- // upstream app files, type-stripped, with exactly two rewrites:
6
+ // `dist/experimental`, and its exports map exposes no deep `dist/` path. The carrier is Entwurf's
7
+ // owned app source (pi/pi-durable/src, #141), type-stripped, with exactly two rewrites:
8
8
  //
9
9
  // "./<sibling>.ts" → "./<sibling>.js" a carrier file importing another carrier file
10
10
  // "../../<path>.ts" → entwurf-pi-dist:<path>.js the app importing a pi-coding-agent internal