@junghanacs/entwurf 0.45.0 → 0.51.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 (116) hide show
  1. package/AGENTS.md +5 -3
  2. package/BASELINE.md +110 -1
  3. package/CHANGELOG.md +249 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/ENTWURF-CLI.json +1229 -0
  6. package/ENTWURF.json +63 -15
  7. package/FAQ.md +4 -4
  8. package/README.md +132 -188
  9. package/VERIFY.md +18 -11
  10. package/demo/README.md +1 -1
  11. package/docs/acp-backend-rail.md +1 -1
  12. package/docs/external-mcp-host.md +4 -4
  13. package/docs/setup-clean-host.md +94 -22
  14. package/mcp/entwurf-bridge/dist/ENTWURF.json +63 -15
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +19 -8
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +4 -4
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +483 -18
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +50 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
  28. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +30 -15
  29. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
  30. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
  31. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
  32. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  33. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
  34. package/mcp/entwurf-bridge/src/index.ts +28 -8
  35. package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
  36. package/native/win32-x64/manifest.json +1 -1
  37. package/package.json +16 -20
  38. package/pi/pi-durable/bootstrap.mjs +179 -6
  39. package/pi/pi-durable/carrier/runtime.js +327 -57
  40. package/pi/pi-durable/carrier/session-name.js +11 -0
  41. package/pi/pi-durable/carrier/sessions.js +96 -20
  42. package/pi/pi-durable/carrier/subagent.js +5 -2
  43. package/pi/pi-durable/carrier/tui.js +60 -22
  44. package/pi/pi-durable/overlay/upstream-pin.json +26 -19
  45. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +73 -69
  46. package/pi-extensions/lib/entwurf-v2-contract.ts +4 -4
  47. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +710 -38
  48. package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
  49. package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
  50. package/pi-extensions/lib/herdr-resume-call.ts +810 -0
  51. package/pi-extensions/lib/meta-session.ts +13 -8
  52. package/pi-extensions/lib/mux-resume-call.ts +77 -4
  53. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
  54. package/pi-extensions/lib/pi-durable-location.ts +446 -0
  55. package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
  56. package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
  57. package/pi-extensions/lib/resume-launch-identity.ts +31 -12
  58. package/pi-extensions/meta-bridge-pi-durable.ts +30 -16
  59. package/run.sh +552 -699
  60. package/scripts/check-claude-launch.ts +341 -0
  61. package/scripts/check-entwurf-v2-contract.ts +4 -4
  62. package/scripts/check-entwurf-v2-surface.ts +2 -2
  63. package/scripts/check-fresh-call-dispatch.ts +70 -9
  64. package/scripts/check-fresh-cut-gate.sh +23 -9
  65. package/scripts/check-gate-qualification.ts +19 -14
  66. package/scripts/check-herdr-activation.ts +301 -23
  67. package/scripts/check-herdr-fresh-call.ts +161 -25
  68. package/scripts/check-herdr-plugin-build.ts +173 -1
  69. package/scripts/check-herdr-plugin.ts +28 -2
  70. package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
  71. package/scripts/check-install-surface.ts +1236 -6
  72. package/scripts/check-meta-doctor-oracle.sh +1 -1
  73. package/scripts/check-meta-facts.ts +20 -1
  74. package/scripts/check-mux-resume-call.ts +28 -0
  75. package/scripts/check-peer-facts.ts +39 -2
  76. package/scripts/cli-help.mjs +222 -0
  77. package/scripts/dev-bin.sh +39 -2
  78. package/scripts/herdr-activation.mjs +28 -6
  79. package/scripts/herdr-operator-link.mjs +389 -0
  80. package/scripts/herdr-plugin-activate.mjs +5 -0
  81. package/scripts/herdr-plugin-deactivate.mjs +18 -5
  82. package/scripts/herdr-rails.mjs +43 -7
  83. package/scripts/herdr-runtime.mjs +27 -0
  84. package/scripts/meta-bridge-fresh-cut.ts +1 -27
  85. package/scripts/meta-bridge-prune.ts +1 -1
  86. package/scripts/meta-facts.ts +1 -1
  87. package/scripts/mutants/agent-guidance.json +4 -4
  88. package/scripts/mutants/claude-launch.json +142 -0
  89. package/scripts/mutants/cli-help-source.json +420 -0
  90. package/scripts/mutants/control-send-receipt.json +1 -1
  91. package/scripts/mutants/dev-gate-dispatch.json +109 -0
  92. package/scripts/mutants/fresh-call-dispatch.json +38 -2
  93. package/scripts/mutants/fresh-cut.json +15 -0
  94. package/scripts/mutants/herdr-activation.json +128 -0
  95. package/scripts/mutants/herdr-fresh-call.json +278 -5
  96. package/scripts/mutants/herdr-operator-link.json +318 -0
  97. package/scripts/mutants/herdr-plugin-build.json +55 -0
  98. package/scripts/mutants/herdr-plugin.json +17 -0
  99. package/scripts/mutants/herdr-resume-call.json +288 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
  101. package/scripts/mutants/meta-facts.json +15 -0
  102. package/scripts/mutants/mux-resume-call.json +63 -2
  103. package/scripts/mutants/pack-install.json +4 -4
  104. package/scripts/mutants/peer-facts.json +15 -0
  105. package/scripts/mutants/pi-durable.json +1167 -3
  106. package/scripts/mutants/pi-floor.json +22 -5
  107. package/scripts/mutants/source-install.json +35 -0
  108. package/scripts/mutants/v2-surface.json +2 -2
  109. package/scripts/mutants/v2-visible-resume.json +255 -6
  110. package/scripts/peer-facts.ts +1 -1
  111. package/scripts/smoke-agy-install-state.sh +66 -0
  112. package/scripts/smoke-meta-prune.sh +23 -0
  113. package/scripts/smoke-omp-fresh-live.ts +12 -3
  114. package/scripts/smoke-omp-receive-live.ts +7 -1
  115. package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
  116. package/team.json.example +39 -0
@@ -4,6 +4,55 @@ import { mkdir, readdir, realpath, stat } from "node:fs/promises";
4
4
  import { basename, join, resolve } from "node:path";
5
5
  import lockfile from "proper-lockfile";
6
6
  import { getAgentDir } from "entwurf-pi-dist:config.js";
7
+ import { isSessionName } from "./session-name.js";
8
+
9
+ /**
10
+ * An owner's end as the opener observed it (#157 B3): the storage outcome of the first actual close, and the
11
+ * lease outcome of its release.
12
+ */
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+ /** One admitted owner of a session directory, from before its native lease through its end. */
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+ /** What an owned open hands the fence once its directory is chosen, before its native lease. */
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+ /**
50
+ * The owner fence every owned open takes before its native lease (#157 B3). Supplied by the host, never imported
51
+ * here; it admits this opener or refuses, and a refusal opens nothing.
52
+ */
53
+
54
+
55
+
7
56
 
8
57
  /** One session directory holding `session.sqlite`, locked by this process. */
9
58
 
@@ -13,6 +62,8 @@ import { getAgentDir } from "entwurf-pi-dist:config.js";
13
62
 
14
63
 
15
64
 
65
+
66
+
16
67
 
17
68
 
18
69
  /** One session of a directory as its files show it (#143): nothing in it is opened. */
@@ -27,8 +78,6 @@ import { getAgentDir } from "entwurf-pi-dist:config.js";
27
78
 
28
79
 
29
80
 
30
- const SESSION_NAME = /^\d{13}-[0-9a-f-]{36}$/u;
31
-
32
81
  /** A path's stat, or undefined when nothing is there; any other failure is thrown, never read as absence. */
33
82
  async function statIfPresent(path ) {
34
83
  try {
@@ -39,15 +88,17 @@ async function statIfPresent(path ) {
39
88
  }
40
89
  }
41
90
 
42
- async function sessionRoot(cwdInput ) {
91
+ async function sessionRoot(cwdInput ) {
43
92
  const cwd = await realpath(resolve(cwdInput));
93
+ // Read once: the root is joined from this value, and the fence is handed this same value as it came (#157 B3).
94
+ const agentDir = getAgentDir();
44
95
  const root = join(
45
- getAgentDir(),
96
+ agentDir,
46
97
  "experimental",
47
98
  "durable-sessions",
48
99
  createHash("sha256").update(cwd).digest("hex").slice(0, 24),
49
100
  );
50
- return { cwd, root };
101
+ return { cwd, root, profile: agentDir };
51
102
  }
52
103
 
53
104
  /** The sessions of `cwd`, most recently written first. Reads names, file times and locks; creates nothing. */
@@ -62,7 +113,7 @@ export async function listSessions(cwdInput ) {
62
113
  }
63
114
  const sessions = [];
64
115
  for (const entry of entries) {
65
- if (!entry.isDirectory() || !SESSION_NAME.test(entry.name)) continue;
116
+ if (!entry.isDirectory() || !isSessionName(entry.name)) continue;
66
117
  const directory = join(root, entry.name);
67
118
  let lastWriteAt ;
68
119
  for (const file of ["session.sqlite", "session.sqlite-wal"]) {
@@ -80,19 +131,23 @@ export async function listSessions(cwdInput ) {
80
131
  return sessions.sort((a, b) => (b.lastWriteAt ?? b.createdAt) - (a.lastWriteAt ?? a.createdAt));
81
132
  }
82
133
 
83
- /** A new session for `cwd`, its newest one with `continueSession`, or the existing one named by `sessionId`. */
134
+ /**
135
+ * A new session for `cwd`, its newest one with `continueSession`, or the existing one named by `sessionId`, admitted
136
+ * by `fence` before its native lease is taken.
137
+ */
84
138
  export async function selectSession(
139
+ fence ,
85
140
  cwdInput ,
86
141
  continueSession ,
87
142
  sessionId ,
88
143
  ) {
89
- const { cwd, root } = await sessionRoot(cwdInput);
144
+ const { cwd, root, profile } = await sessionRoot(cwdInput);
90
145
  await mkdir(root, { recursive: true });
91
146
 
92
147
  let directory ;
93
148
  let created = false;
94
149
  if (sessionId !== undefined) {
95
- const chosen = SESSION_NAME.test(sessionId) ? join(root, sessionId) : undefined;
150
+ const chosen = isSessionName(sessionId) ? join(root, sessionId) : undefined;
96
151
  if (chosen === undefined || !(await statIfPresent(chosen))?.isDirectory()) {
97
152
  throw new Error(`No durable session ${sessionId} exists for ${cwd}`);
98
153
  }
@@ -100,7 +155,7 @@ export async function selectSession(
100
155
  } else if (continueSession) {
101
156
  const entries = await readdir(root, { withFileTypes: true });
102
157
  const newest = entries
103
- .filter((entry) => entry.isDirectory() && SESSION_NAME.test(entry.name))
158
+ .filter((entry) => entry.isDirectory() && isSessionName(entry.name))
104
159
  .map((entry) => entry.name)
105
160
  .sort()
106
161
  .at(-1);
@@ -112,15 +167,36 @@ export async function selectSession(
112
167
  created = true;
113
168
  }
114
169
 
115
- let release ;
116
- try {
117
- // A lock left by a crashed process goes stale after 10 s; wait that long before giving up.
118
- release = await lockfile.lock(directory, {
119
- realpath: false,
120
- retries: { retries: 12, minTimeout: 1000, maxTimeout: 1000 },
170
+ // The owner fence admits this opener BEFORE the native lease, or refuses with nothing taken (#157 B3).
171
+ const ticket = await fence.admit({ directory, selectedN: basename(directory), created, profile });
172
+ // A lock left by a crashed process goes stale after 10 s; wait that long before giving up.
173
+ const lease = lockfile.lock(directory, {
174
+ realpath: false,
175
+ retries: { retries: 12, minTimeout: 1000, maxTimeout: 1000 },
176
+ });
177
+ const release = await lease.catch(async (error) => {
178
+ const refused = new Error(`Session is already open in another process: ${directory}`, { cause: error });
179
+ // No acquisition was observed: this owner ends with nothing opened and no lease held.
180
+ await ticket.endUnleased().catch((fenceError) => {
181
+ throw new AggregateError([refused, fenceError], `${refused.message}; its owner end was not recorded`);
121
182
  });
122
- } catch (error) {
123
- throw new Error(`Session is already open in another process: ${directory}`, { cause: error });
124
- }
125
- return { id: basename(directory), directory, database: join(directory, "session.sqlite"), cwd, created, release };
183
+ throw refused;
184
+ });
185
+ // Apart from the lease's own failure: the lease IS held here, so a failed checkpoint is never recorded as an
186
+ // acquire failure. The open fails and releases the lease; the claim stays fenced under this live owner.
187
+ await ticket.leased().catch(async (error) => {
188
+ await release().catch((releaseError) => {
189
+ throw new AggregateError([error, releaseError], `lease checkpoint and release both failed: ${directory}`);
190
+ });
191
+ throw error;
192
+ });
193
+ return {
194
+ id: basename(directory),
195
+ directory,
196
+ database: join(directory, "session.sqlite"),
197
+ cwd,
198
+ created,
199
+ release,
200
+ ticket,
201
+ };
126
202
  }
@@ -1,5 +1,5 @@
1
1
 
2
- import { Type } from "@earendil-works/pi-ai";
2
+
3
3
  import {
4
4
  AssistantEntry,
5
5
  configure,
@@ -10,6 +10,7 @@ import {
10
10
 
11
11
 
12
12
  } from "@earendil-works/pi-durable";
13
+ import { obj, str } from "@earendil-works/pi-schema";
13
14
 
14
15
  async function answerText(api , answer , context ) {
15
16
  const entry = await api.commit((tx) => tx.entry(AssistantEntry, answer), context);
@@ -29,7 +30,9 @@ export const Subagent = defineExtension({
29
30
  name: "subagent",
30
31
  description:
31
32
  "Delegate a self-contained task to a subagent with the same tools and get its answer back. Give it everything it needs to know; it does not see this conversation.",
32
- parameters: Type.Object({ task: Type.String({ description: "What the subagent should do" }) }),
33
+ // pi-ai 1.5 no longer exports TypeBox's `Type`; pi-schema builds the same JSON Schema. `open` keeps the input
34
+ // as open as the TypeBox object was, since `obj()` alone closes it (SDK 1.5's own tools do the same, #155).
35
+ parameters: obj({ task: str({ description: "What the subagent should do" }) }, { open: true }),
33
36
  // A rerun after a crash finds the child it created and the submission it made.
34
37
  replay: "safe",
35
38
  execute: async (args, api, context) => {
@@ -6,6 +6,7 @@
6
6
 
7
7
 
8
8
 
9
+
9
10
 
10
11
 
11
12
  import {
@@ -176,8 +177,9 @@ class CompactionComponent extends Box {
176
177
 
177
178
 
178
179
  /**
179
- * The lines above the editor: first what stays true for as long as it is true, the session's failure and the
180
- * compaction failure a conversation keeps until a compaction produces a summary, then the latest notices.
180
+ * The lines above the editor: first what stays true for as long as it is true, the session's failure, the
181
+ * compaction failure a conversation keeps until a compaction produces a summary, the run that ended without an
182
+ * answer until another run starts, and a view that stopped updating; then the latest notices.
181
183
  */
182
184
  export function durableNotices(view ) {
183
185
  const lines = [];
@@ -188,9 +190,22 @@ export function durableNotices(view )
188
190
  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.`,
189
191
  });
190
192
  }
191
- const compaction = ((view.conversation.docs["pi.live"] ?? {}) ).compactionFailure;
193
+ const live = (view.conversation.docs["pi.live"] ?? {}) ;
194
+ const compaction = live.compactionFailure;
192
195
  if (compaction !== undefined) {
193
- lines.push({ level: "error", message: `Compaction failed (${compaction.reason}): ${compaction.error}` });
196
+ lines.push({ level: "error", message: `Compaction failed (${compaction.trigger}): ${compaction.error}` });
197
+ }
198
+ // SDK 1.5 keeps why the newest run ended without an answer. An aborted run is the user's own stop, as before:
199
+ // the answer it cut off already says so.
200
+ const run = live.runFailure;
201
+ if (run !== undefined && run.reason !== "aborted") {
202
+ const detail = run.detail === undefined ? "" : ` (${run.detail})`;
203
+ lines.push({ level: "error", message: `No answer: ${run.reason}${detail}` });
204
+ }
205
+ for (const [part, error] of Object.entries(view.stalled ?? {})) {
206
+ if (error === undefined) continue;
207
+ const what = part === "tasks" ? "The task panel" : "The conversation";
208
+ lines.push({ level: "error", message: `${what} stopped updating: ${error}` });
194
209
  }
195
210
  return [...lines, ...view.notices.slice(-4)];
196
211
  }
@@ -289,7 +304,8 @@ class DurableTui {
289
304
  #summaries = [];
290
305
  /** Tool output and summaries shown in full; toggled like pi. */
291
306
  #expanded = false;
292
- #renderedEntryIds = [];
307
+ /** One key per transcript item on screen, in order (`keyOf`). */
308
+ #renderedKeys = [];
293
309
  #streaming ;
294
310
  #indicator ;
295
311
  #statusText = "";
@@ -391,10 +407,11 @@ class DurableTui {
391
407
 
392
408
  apply(view ) {
393
409
  const live = (view.conversation.docs["pi.live"] ?? {}) ;
394
- this.#syncTranscript(view.conversation.entries);
410
+ // The active transcript, not the window: every entry of the active range, head marker first (#155 D1).
411
+ this.#syncTranscript(view.transcript.items);
395
412
  const message = live.generation?.message ;
396
413
  // A partial without its entry was dropped, for example by a retry: render the transcript again.
397
- if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.conversation.entries);
414
+ if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.transcript.items);
398
415
  if (message !== undefined) this.#syncStreaming(message);
399
416
  for (const slot of live.tools ?? []) {
400
417
  if (slot.status === "pending") continue;
@@ -476,8 +493,8 @@ class DurableTui {
476
493
  } else if (generation?.deferred !== undefined) text = "Waiting for deferred response...";
477
494
  else if (compaction !== undefined) {
478
495
  text = compaction.retry
479
- ? `Retrying ${compaction.reason} compaction (attempt ${compaction.attempt + 1})...`
480
- : `Compacting (${compaction.reason})...`;
496
+ ? `Retrying ${compaction.trigger} compaction (attempt ${compaction.attempt + 1})...`
497
+ : `Compacting (${compaction.trigger})...`;
481
498
  } else if (runningTool !== undefined) text = `Running ${runningTool.name}... (esc to abort)`;
482
499
  else if (live.run !== undefined) text = "Working... (esc to abort)";
483
500
  if (text === this.#statusText) return;
@@ -502,7 +519,7 @@ class DurableTui {
502
519
  view.models.find((model) => model.provider === agent.model?.provider && model.modelId === agent.model.modelId)
503
520
  ?.contextWindow ?? 0;
504
521
  if (contextWindow > 0) {
505
- const tokens = contextTokens(view.conversation.entries);
522
+ const tokens = contextTokens(entriesOf(view.transcript.items));
506
523
  const percent = tokens === undefined ? undefined : (tokens / contextWindow) * 100;
507
524
  const text = `${percent === undefined ? "?" : percent.toFixed(1)}%/${formatTokens(contextWindow)}`;
508
525
  stats.push(percent !== undefined && percent > 90 ? theme.fg("error", text) : text);
@@ -519,28 +536,38 @@ class DurableTui {
519
536
  );
520
537
  }
521
538
 
522
- #syncTranscript(entries ) {
523
- // Compaction and resets replace the head of the active transcript.
524
- if (this.#renderedEntryIds.some((id, index) => entries[index]?.id !== id)) this.#rebuild(entries);
525
- for (const entry of entries.slice(this.#renderedEntryIds.length)) {
526
- this.#addEntry(entry);
527
- this.#renderedEntryIds.push(entry.id);
539
+ #syncTranscript(items ) {
540
+ // The transcript grows at its end. A compaction or reset starts a new active range, its head marker first,
541
+ // which replaces what came before (#155 D1).
542
+ const keys = items.map(keyOf);
543
+ if (this.#renderedKeys.some((key, index) => keys[index] !== key)) this.#rebuild(items);
544
+ for (const item of items.slice(this.#renderedKeys.length)) {
545
+ this.#addItem(item);
546
+ this.#renderedKeys.push(keyOf(item));
528
547
  }
529
548
  }
530
549
 
531
- #rebuild(entries ) {
550
+ #rebuild(items ) {
532
551
  this.#chat.clear();
533
552
  this.#discardTools();
534
553
  this.#summaries.length = 0;
535
554
  this.#rebuilt = true;
536
- this.#renderedEntryIds = [];
555
+ this.#renderedKeys = [];
537
556
  this.#streaming = undefined;
538
- for (const entry of entries) {
539
- this.#addEntry(entry);
540
- this.#renderedEntryIds.push(entry.id);
557
+ for (const item of items) {
558
+ this.#addItem(item);
559
+ this.#renderedKeys.push(keyOf(item));
541
560
  }
542
561
  }
543
562
 
563
+ #addItem(item ) {
564
+ if (item.kind === "entry") this.#addEntry(item.entry);
565
+ // The runtime's active transcript holds entries only; these two placeholders are the SDK item type's other kinds
566
+ // (#156 owns any load-more input).
567
+ else if (item.kind === "earlier") this.#addText("[earlier entries not shown]");
568
+ else this.#addText("[missing entries, loading...]");
569
+ }
570
+
544
571
  #addEntry(entry ) {
545
572
  const message = entry.model?.[0];
546
573
  if (entry.kind === "pi.user" && message?.role === "user") {
@@ -676,9 +703,20 @@ function totalUsage(state ) {
676
703
  return total;
677
704
  }
678
705
 
706
+ /** The entries of transcript items, in ID order. */
707
+ function entriesOf(items ) {
708
+ return items.flatMap((item) => (item.kind === "entry" ? [item.entry] : []));
709
+ }
710
+
711
+ /** What was rendered for an item stays valid while the same key stays in the same place. */
712
+ function keyOf(item ) {
713
+ if (item.kind === "entry") return `entry:${item.entry.id}`;
714
+ return item.kind === "gap" ? `gap:${item.after}:${item.upTo}` : "earlier";
715
+ }
716
+
679
717
  /** Context size from the newest successful answer after the newest compaction; unknown before one. */
680
718
  function contextTokens(entries ) {
681
- // Kept entries follow the summary in the view but are older than it; only later answers measure the new context.
719
+ // Entries a summary keeps and those it replaced are older than it; only later answers measure the new context.
682
720
  const compacted = Math.max(0, ...entries.filter((entry) => entry.kind === "pi.compaction").map((entry) => entry.id));
683
721
  for (const entry of [...entries].reverse()) {
684
722
  const message = entry.model?.[0];
@@ -55,7 +55,7 @@
55
55
  }
56
56
  },
57
57
  "carrier": {
58
- "comment": "files: the six app files bootstrap.mjs needs (main.ts is replaced by the bootstrap), each emitted from its owned `source`. distTargets: every pi-coding-agent internal the stripped files import, as paths under the published dist/; the emitter refuses a set that differs and the resolver answers no other.",
58
+ "comment": "files: the six app files bootstrap.mjs needs (main.ts is replaced by the bootstrap) and the import-free session-name grammar Entwurf owns beside them (#157 G3 B-L1, no upstream origin), each emitted from its owned `source`. distTargets: every pi-coding-agent internal the stripped files import, as paths under the published dist/; the emitter refuses a set that differs and the resolver answers no other.",
59
59
  "emitter": {
60
60
  "tool": "node:module.stripTypeScriptTypes",
61
61
  "mode": "strip",
@@ -70,14 +70,14 @@
70
70
  {
71
71
  "name": "runtime.js",
72
72
  "source": "pi/pi-durable/src/experimental/durable/runtime.ts",
73
- "blob": "75d7bf2b4fdc36aa775f73f9d7f3de8659f19187",
74
- "sha256": "725757156580ad2a2d170c790440763371bb8035052a0c6caae6af5159d4718f"
73
+ "blob": "00646b1b1fbd7e935fb3e66b7578a2befc01f3c8",
74
+ "sha256": "b47113364b7525abd3bf9286ecb0069f78ac96688b8a271bdbca3a54b9d864c5"
75
75
  },
76
76
  {
77
77
  "name": "tui.js",
78
78
  "source": "pi/pi-durable/src/experimental/durable/tui.ts",
79
- "blob": "405600f6ba1558bf6bf22d144afdcf3f1295c282",
80
- "sha256": "d4bf4e2861bde917e1dcdea6f063bdc3e101ab500107a9c5fab0e3ce97fb2c4a"
79
+ "blob": "4b5dd1876ceac8f51910bb11ed5f0024003f4f34",
80
+ "sha256": "7fefe490d9d462e6a91621901b82b767546926f4a9f4e88d585fe74b727a02c1"
81
81
  },
82
82
  {
83
83
  "name": "harness-setup.js",
@@ -94,14 +94,20 @@
94
94
  {
95
95
  "name": "sessions.js",
96
96
  "source": "pi/pi-durable/src/experimental/durable/sessions.ts",
97
- "blob": "eed4b3ca51c6f5a3bab8106adaf0062f3f7d5b56",
98
- "sha256": "668373feadabcf13bd4dd7431e5429b7d7376c47c367e4b15353c57cb8711892"
97
+ "blob": "f107238fefb63c95b29add5aa76fcbd9153c94db",
98
+ "sha256": "0ac7c919f2bf8a3b0e5dbd307045108a803b161f87298cd9fe440f239784c7ac"
99
99
  },
100
100
  {
101
101
  "name": "subagent.js",
102
102
  "source": "pi/pi-durable/src/experimental/durable/subagent.ts",
103
- "blob": "3fa9aebbe93e82cfff436e7b9d4c9877e0ed0e27",
104
- "sha256": "1cb2390b673a0d914117bc07708c39b004fb10e48b31436bf9d9d2b953a64220"
103
+ "blob": "e842d8a6b8dc55825d0381d803b6d6496b205c19",
104
+ "sha256": "c9931fabd60f5fbf75220e896a57e5c8b131b4936887bf6f9660d50d01709f3a"
105
+ },
106
+ {
107
+ "name": "session-name.js",
108
+ "source": "pi/pi-durable/src/experimental/durable/session-name.ts",
109
+ "blob": "c57c760ca3c14363f195ac9d9c2add984e4118d5",
110
+ "sha256": "9eadecfe9a48bba0d221370f4a236286e0d6f10d663b15ff4cb5975adee67018"
105
111
  }
106
112
  ],
107
113
  "distTargets": [
@@ -132,17 +138,18 @@
132
138
  ]
133
139
  },
134
140
  "sdk": {
135
- "comment": "The published library set the carrier runs against, derived from the 1.4.0 declarations. members: the @earendil-works packages the carrier imports and every @earendil-works package those declare. direct: a third-party package a carrier file imports itself (sessions.ts: proper-lockfile, at the version pi-coding-agent 1.4.0 pins). Each is an exact production dependency in package.json. Since 1.3.0 the members' own edges are exact too (upstream #10865), and the installed physical edges are still what must equal these versions.",
141
+ "comment": "The published library set the carrier runs against, derived from the 1.5.0 declarations. members: the @earendil-works packages the carrier imports and every @earendil-works package those declare (1.5.0 adds pi-schema, declared by chord, pi-agent-core, pi-ai and pi-durable). direct: a third-party package a carrier file imports itself (sessions.ts: proper-lockfile, at the version pi-coding-agent 1.5.0 pins). Each is an exact production dependency in package.json. Since 1.3.0 the members' own edges are exact too (upstream #10865), and the installed physical edges are still what must equal these versions.",
136
142
  "members": {
137
- "@earendil-works/chord": "1.4.0",
138
- "@earendil-works/pi-agent-core": "1.4.0",
139
- "@earendil-works/pi-ai": "1.4.0",
140
- "@earendil-works/pi-codemode": "1.4.0",
141
- "@earendil-works/pi-coding-agent": "1.4.0",
142
- "@earendil-works/pi-durable": "1.4.0",
143
- "@earendil-works/pi-mcp": "1.4.0",
144
- "@earendil-works/pi-telemetry": "1.4.0",
145
- "@earendil-works/pi-tui": "1.4.0"
143
+ "@earendil-works/chord": "1.5.0",
144
+ "@earendil-works/pi-agent-core": "1.5.0",
145
+ "@earendil-works/pi-ai": "1.5.0",
146
+ "@earendil-works/pi-codemode": "1.5.0",
147
+ "@earendil-works/pi-coding-agent": "1.5.0",
148
+ "@earendil-works/pi-durable": "1.5.0",
149
+ "@earendil-works/pi-mcp": "1.5.0",
150
+ "@earendil-works/pi-schema": "1.5.0",
151
+ "@earendil-works/pi-telemetry": "1.5.0",
152
+ "@earendil-works/pi-tui": "1.5.0"
146
153
  },
147
154
  "direct": {
148
155
  "proper-lockfile": "4.1.2"
@@ -1,7 +1,7 @@
1
1
  /**
2
- * entwurf-v2-contract-schema — the pi-side TypeBox REPRESENTATION of the frozen
3
- * `entwurf_v2` contract. This module exists ONLY to carry the pi-ai TypeBox
4
- * schema builders (`StringEnum`, `Type`) out of the pi-free core
2
+ * entwurf-v2-contract-schema — the pi-side JSON Schema REPRESENTATION of the frozen
3
+ * `entwurf_v2` contract. This module exists ONLY to carry the pi schema builders
4
+ * (pi-ai's `StringEnum`, pi-schema's `obj`/`str`/…) out of the pi-free core
5
5
  * (`entwurf-v2-contract.ts`); the constants/types/decision logic live there.
6
6
  *
7
7
  * ⚠️ pi LANE ONLY — the MCP bridge (`mcp/entwurf-bridge/src/index.ts`) MUST NOT
@@ -11,18 +11,21 @@
11
11
  * the contract CONSTANTS + `resolveDispatch` (pi-free core), never these schemas.
12
12
  * Consumer: `check-entwurf-v2-contract` ONLY. This is a gate-side REPRESENTATION of the
13
13
  * frozen contract, not a shipped tool schema — measured 2026-07-27, nothing else in the
14
- * import graph reaches it. The two surfaces a model actually reads build their own:
15
- * `pi-extensions/entwurf-control.ts` (TypeBox `entwurfV2Parameters` + tool description) and
16
- * `mcp/entwurf-bridge/src/index.ts` (Zod). Change a model-facing description THERE; changing
17
- * it here reaches no model, and `check-entwurf-v2-surface` is what pins those two.
14
+ * import graph reaches it. The surface a model actually reads builds its own:
15
+ * `mcp/entwurf-bridge/src/index.ts` (Zod, descriptions from ENTWURF.json), which a pi citizen
16
+ * reaches through Pi's built-in MCP since the native pi tools were retired (2f7c883, Pi 0.99).
17
+ * Change a model-facing description THERE; changing it here reaches no model.
18
18
  *
19
- * StringEnum (typebox 1.x) inside Type.Object (typebox 0.34) — the same mix the
20
- * existing entwurf tools use. The logic types in the core are hand-written unions,
21
- * NOT `Static<>` inferences, so the 0.34/1.x widening caveat does not touch them;
22
- * `check-entwurf-v2-contract` keeps these schemas and the core types in lockstep.
19
+ * Pi SDK 1.5 (#155) removed pi-ai's TypeBox re-export (`Type`); its builders are now
20
+ * `@earendil-works/pi-schema`, which emits plain JSON Schema (obj() closed by default,
21
+ * opt() leaves a property out of `required`, lit() is `{const}`), and pi-ai's
22
+ * `StringEnum` still emits `{type:"string", enum}`. The logic types in the core are
23
+ * hand-written unions, NOT `Static<>` inferences; `check-entwurf-v2-contract` keeps
24
+ * these schemas and the core types in lockstep.
23
25
  */
24
26
 
25
- import { StringEnum, Type } from "@earendil-works/pi-ai";
27
+ import { StringEnum } from "@earendil-works/pi-ai";
28
+ import { bool, lit, nul, obj, opt, type Properties, type Schema, str, union } from "@earendil-works/pi-schema";
26
29
  import {
27
30
  ENTWURF_INTENTS,
28
31
  ENTWURF_V2_ACTIONS,
@@ -34,36 +37,43 @@ import {
34
37
  } from "./entwurf-v2-contract.ts";
35
38
  import { SESSION_ID_RE } from "./session-id.js";
36
39
 
37
- export const EntwurfV2InputSchema = Type.Object(
38
- {
39
- // R2/F6 executable: the garden-id shape is enforced by pattern, not prose —
40
- // a malformed/typo gid fails the schema (→ bad-target) and can never reach a
41
- // spawn. SSOT regex = SESSION_ID_RE (pi-extensions/lib/session-id.js).
42
- target: Type.String({
43
- pattern: SESSION_ID_RE.source,
40
+ /**
41
+ * The face the drift guard reads. pi-schema types a schema as `{[keyword]: unknown}`; obj() always emits
42
+ * `properties`, so this names that one keyword without asserting anything else about the shape.
43
+ */
44
+ type ObjectSchema = Schema & { readonly properties: Readonly<Record<string, Schema>> };
45
+
46
+ /** obj() — closed, so it emits `additionalProperties: false` — under the face above. */
47
+ const closedObject = (properties: Properties): ObjectSchema => obj(properties) as ObjectSchema;
48
+
49
+ export const EntwurfV2InputSchema = closedObject({
50
+ // R2/F6 executable: the garden-id shape is enforced by pattern, not prose —
51
+ // a malformed/typo gid fails the schema (→ bad-target) and can never reach a
52
+ // spawn. SSOT regex = SESSION_ID_RE (pi-extensions/lib/session-id.js).
53
+ target: str({
54
+ pattern: SESSION_ID_RE.source,
55
+ description:
56
+ "garden-id of an EXISTING citizen (pattern-enforced). Fresh sibling creation is out of scope on every current surface — there is no fallback verb that still spawns. A malformed/typo gid is bad-target, never a new spawn.",
57
+ }),
58
+ intent: StringEnum(ENTWURF_INTENTS, {
59
+ description:
60
+ "caller's declared outcome contract (F1). ONE member since the visible-first cut: fire-and-forget = ack only. The second, owned-outcome (caller owns completion), resumed a dormant citizen by launching a hidden background child and was withdrawn — a dormant citizen is currently unreachable by this verb.",
61
+ }),
62
+ mode: opt(
63
+ StringEnum(ENTWURF_V2_MODES, {
44
64
  description:
45
- "garden-id of an EXISTING citizen (pattern-enforced). Fresh sibling creation is out of scope on every current surface — there is no fallback verb that still spawns. A malformed/typo gid is bad-target, never a new spawn.",
65
+ "delivery mode — NOT the ownership axis (F1) nor liveness routing. steer (ask the receiver's steering queue) or follow_up (ask its follow-up queue). NEITHER is an interrupt: a busy receiver drains steering after each turn and follow-ups only when its inner loop ends, so a later steer overtakes every earlier follow_up and there is no order between the two. Both queues are volatile process memory — an abort drops what is in them. The receipt names which queue accepted the message (`queued-steer` / `queued-follow-up`); an idle receiver answers `sent`, meaning the turn was TRIGGERED, not that the model has seen the text. MEANINGLESS on the meta-mailbox transport (F-mailbox): a mailbox ack is enqueue+doorbell, not a turn injection, so steer/follow_up does not apply when the verdict transport is meta-mailbox.",
46
66
  }),
47
- intent: StringEnum(ENTWURF_INTENTS, {
48
- description:
49
- "caller's declared outcome contract (F1). ONE member since the visible-first cut: fire-and-forget = ack only. The second, owned-outcome (caller owns completion), resumed a dormant citizen by launching a hidden background child and was withdrawn — a dormant citizen is currently unreachable by this verb.",
67
+ ),
68
+ wantsReply: opt(
69
+ bool({
70
+ description: "conversation etiquette only — NOT ownership; never triggers an auto-send (Q2).",
50
71
  }),
51
- mode: Type.Optional(
52
- StringEnum(ENTWURF_V2_MODES, {
53
- description:
54
- "delivery mode — NOT the ownership axis (F1) nor liveness routing. steer (ask the receiver's steering queue) or follow_up (ask its follow-up queue). NEITHER is an interrupt: a busy receiver drains steering after each turn and follow-ups only when its inner loop ends, so a later steer overtakes every earlier follow_up and there is no order between the two. Both queues are volatile process memory — an abort drops what is in them. The receipt names which queue accepted the message (`queued-steer` / `queued-follow-up`); an idle receiver answers `sent`, meaning the turn was TRIGGERED, not that the model has seen the text. MEANINGLESS on the meta-mailbox transport (F-mailbox): a mailbox ack is enqueue+doorbell, not a turn injection, so steer/follow_up does not apply when the verdict transport is meta-mailbox.",
55
- }),
56
- ),
57
- wantsReply: Type.Optional(
58
- Type.Boolean({
59
- description: "conversation etiquette only — NOT ownership; never triggers an auto-send (Q2).",
60
- }),
61
- ),
62
- // `additionalProperties: false` — a frozen contract input is exact; an unknown
63
- // key is a caller error, not silently ignored.
64
- },
65
- { additionalProperties: false },
66
- );
72
+ ),
73
+ // `additionalProperties: false` — a frozen contract input is exact; an unknown
74
+ // key is a caller error, not silently ignored. obj() is closed by default (it
75
+ // emits `additionalProperties: false` unless `open: true`).
76
+ });
67
77
 
68
78
  // Receipt = a DISCRIMINATED union on `ok` (R3/F6) — NOT one flat object with
69
79
  // optionals. Each branch is EXACT (`additionalProperties: false`): without it,
@@ -72,35 +82,29 @@ export const EntwurfV2InputSchema = Type.Object(
72
82
  // With it, success carries action/transport/ownership and rejects a stray reason;
73
83
  // reject carries reason and rejects any allow facet — the branches are mutually
74
84
  // exclusive at the schema level, not merely by declared-property convention.
75
- export const EntwurfV2ReceiptSuccessSchema = Type.Object(
76
- {
77
- ok: Type.Literal(true),
78
- action: StringEnum(ENTWURF_V2_ACTIONS),
79
- transport: StringEnum(ENTWURF_V2_TRANSPORTS),
80
- ownership: StringEnum(ENTWURF_V2_OWNERSHIPS),
81
- observedLiveness: StringEnum(FACT_LIVENESSES, {
82
- description: "the 4-value fact liveness the verdict was computed from (R1/R3).",
83
- }),
84
- },
85
- { additionalProperties: false },
86
- );
85
+ export const EntwurfV2ReceiptSuccessSchema = closedObject({
86
+ ok: lit(true),
87
+ action: StringEnum(ENTWURF_V2_ACTIONS),
88
+ transport: StringEnum(ENTWURF_V2_TRANSPORTS),
89
+ ownership: StringEnum(ENTWURF_V2_OWNERSHIPS),
90
+ observedLiveness: StringEnum(FACT_LIVENESSES, {
91
+ description: "the 4-value fact liveness the verdict was computed from (R1/R3).",
92
+ }),
93
+ });
87
94
 
88
- export const EntwurfV2ReceiptRejectSchema = Type.Object(
89
- {
90
- ok: Type.Literal(false),
91
- reason: StringEnum(ENTWURF_V2_REJECT_REASONS),
92
- // ?6: required-nullable, NOT optional — a reject branch ALWAYS carries the
93
- // key, and it is `null` for the pre-probe rejects (PRE_PROBE_REJECT_REASONS)
94
- // and a real FactLiveness otherwise. Optional would lose the "key always
95
- // present, value may be null" shape and weaken the discriminated union; the
96
- // reason-dependent null/non-null rule is enforced semantically (the gate's
97
- // rejectObservedLivenessWellFormed fixture), not by this blanket union.
98
- observedLiveness: Type.Union([StringEnum(FACT_LIVENESSES), Type.Null()], {
99
- description:
100
- "the 4-value fact liveness the reject was computed from (R1/R3); null for the pre-probe rejects (bad-target / target-locked / target-address-conflict) where no probe ran.",
101
- }),
102
- },
103
- { additionalProperties: false },
104
- );
95
+ export const EntwurfV2ReceiptRejectSchema = closedObject({
96
+ ok: lit(false),
97
+ reason: StringEnum(ENTWURF_V2_REJECT_REASONS),
98
+ // ?6: required-nullable, NOT optional — a reject branch ALWAYS carries the
99
+ // key, and it is `null` for the pre-probe rejects (PRE_PROBE_REJECT_REASONS)
100
+ // and a real FactLiveness otherwise. Optional would lose the "key always
101
+ // present, value may be null" shape and weaken the discriminated union; the
102
+ // reason-dependent null/non-null rule is enforced semantically (the gate's
103
+ // rejectObservedLivenessWellFormed fixture), not by this blanket union.
104
+ observedLiveness: union([StringEnum(FACT_LIVENESSES), nul()], {
105
+ description:
106
+ "the 4-value fact liveness the reject was computed from (R1/R3); null for the pre-probe rejects (bad-target / target-locked / target-address-conflict) where no probe ran.",
107
+ }),
108
+ });
105
109
 
106
- export const EntwurfV2ReceiptSchema = Type.Union([EntwurfV2ReceiptSuccessSchema, EntwurfV2ReceiptRejectSchema]);
110
+ export const EntwurfV2ReceiptSchema = union([EntwurfV2ReceiptSuccessSchema, EntwurfV2ReceiptRejectSchema]);