@junghanacs/entwurf 0.50.0 → 0.52.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 (39) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +140 -0
  3. package/ENTWURF.json +10 -4
  4. package/README.md +16 -8
  5. package/VERIFY.md +16 -9
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +1 -1
  8. package/docs/external-mcp-host.md +2 -2
  9. package/docs/setup-clean-host.md +92 -35
  10. package/mcp/entwurf-bridge/dist/ENTWURF.json +10 -4
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +4 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +4 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +1 -1
  14. package/package.json +11 -18
  15. package/pi/pi-durable/carrier/harness-setup.js +1 -1
  16. package/pi/pi-durable/carrier/runtime.js +221 -50
  17. package/pi/pi-durable/carrier/subagent.js +6 -3
  18. package/pi/pi-durable/carrier/tui.js +60 -22
  19. package/pi/pi-durable/overlay/upstream-pin.json +19 -18
  20. package/pi-extensions/lib/entwurf-control-rpc.ts +5 -1
  21. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +73 -69
  22. package/pi-extensions/lib/entwurf-v2-contract.ts +4 -4
  23. package/pi-extensions/lib/meta-mailbox-body.ts +1 -1
  24. package/pi-extensions/meta-bridge-pi-durable.ts +1 -1
  25. package/run.sh +148 -46
  26. package/scripts/check-entwurf-v2-contract.ts +4 -4
  27. package/scripts/check-entwurf-v2-surface.ts +7 -21
  28. package/scripts/check-gate-qualification.ts +3 -3
  29. package/scripts/check-pi-durable-send.ts +4 -1
  30. package/scripts/lib/codex-fresh-source-receipts.ts +12 -6
  31. package/scripts/mutants/agent-guidance.json +2 -2
  32. package/scripts/mutants/codex-native.json +2 -2
  33. package/scripts/mutants/control-send-receipt.json +47 -7
  34. package/scripts/mutants/pack-install.json +4 -4
  35. package/scripts/mutants/pi-durable.json +189 -3
  36. package/scripts/mutants/pi-floor.json +20 -5
  37. package/scripts/mutants/v2-surface.json +4 -6
  38. package/scripts/smoke-acp-cortex-live.ts +1 -1
  39. package/scripts/smoke-acp-v2-send-live.ts +1 -1
@@ -1,4 +1,3 @@
1
-
2
1
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
3
2
  import {
4
3
  clampThinkingLevel,
@@ -11,15 +10,22 @@ import {
11
10
 
12
11
 
13
12
 
13
+
14
14
 
15
+
15
16
 
16
17
 
17
18
  Harness,
18
19
 
20
+ LiveDoc,
19
21
 
22
+
20
23
  ROOT_CONVERSATION_ID,
21
24
 
22
25
 
26
+
27
+
28
+
23
29
  } from "@earendil-works/pi-durable";
24
30
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
25
31
  import { ModelRuntime } from "entwurf-pi-dist:core/model-runtime.js";
@@ -58,8 +64,19 @@ const context = BACKGROUND_CONTEXT;
58
64
  /** Everything the TUI renders. Plain values; no Harness objects cross this boundary. */
59
65
 
60
66
 
61
-
67
+
68
+
69
+
70
+
71
+
62
72
 
73
+
74
+
75
+
76
+
77
+
78
+
79
+
63
80
 
64
81
 
65
82
 
@@ -72,6 +89,11 @@ const context = BACKGROUND_CONTEXT;
72
89
 
73
90
 
74
91
 
92
+
93
+
94
+
95
+
96
+
75
97
 
76
98
 
77
99
 
@@ -156,6 +178,32 @@ function causeOf(error ) {
156
178
  return error instanceof Error ? error.message : String(error);
157
179
  }
158
180
 
181
+ /** The shown conversation's active transcript and the newest entry ID in it. */
182
+
183
+
184
+
185
+
186
+
187
+ /** A read's active transcript and the watch attached right after the read. */
188
+
189
+
190
+
191
+
192
+
193
+ /** The newest entry ID of `entries`; a head marker comes first, so the last entry need not be the newest. */
194
+ function newestOf(entries ) {
195
+ return entries.reduce((newest, entry) => Math.max(newest, entry.id), Number.NEGATIVE_INFINITY);
196
+ }
197
+
198
+ /** What the TUI renders: the watched documents with the active transcript as items. */
199
+ function transcriptOf(view , entries ) {
200
+ return {
201
+ conversation: view.conversation,
202
+ docs: view.docs,
203
+ items: entries.map((entry) => ({ kind: "entry" , entry })),
204
+ };
205
+ }
206
+
159
207
  export async function openDurable(options ) {
160
208
  // The type requires it; a JS caller is refused here, before the session root is created.
161
209
  if (options.fence === undefined) {
@@ -187,8 +235,9 @@ export async function openDurable(options )
187
235
  registry.install(Subagent);
188
236
  for (const extension of options.extensions ?? []) registry.install(extension);
189
237
 
190
- const pendingReports = [];
191
- let report = (error) => pendingReports.push(error);
238
+ // SDK 1.5 hands one tagged `Report` (what happened by `kind`, plus its `error`), not pi 1.4's (error, info).
239
+ const pendingReports = [];
240
+ let report = (reported) => pendingReports.push(reported);
192
241
  // Recorded BEFORE the call: once the open is attempted, no later outcome proves that nothing was opened.
193
242
  ticket.storageOpenAttempted = true;
194
243
  const backend = await openNodeSqliteStorage(location.database);
@@ -200,7 +249,7 @@ export async function openDurable(options )
200
249
  registry,
201
250
  settings,
202
251
  env: envs.env,
203
- onReport: (error) => report(error),
252
+ onReport: (reported) => report(reported),
204
253
  },
205
254
  context,
206
255
  );
@@ -245,7 +294,23 @@ export async function openDurable(options )
245
294
  cursor = page.next;
246
295
  } while (cursor !== undefined);
247
296
  let current = root;
248
- let conversation = await root.viewState(context);
297
+ // The shown conversation's transcript is pi 1.4's active one (#155 D1). One official read gives the active range
298
+ // (`Conversation.context().entries`); the watch attached right after it starts at the next entry ID, so what is
299
+ // committed between the two is its first value. Nothing here derives an active range: when one changes, it is
300
+ // read again.
301
+ const seed = async (conversation ) => {
302
+ const read = await conversation.context(context);
303
+ const newest = newestOf(read.entries);
304
+ const attached = await conversation.watch(
305
+ context,
306
+ Number.isFinite(newest) ? { from: (newest + 1) } : undefined,
307
+ );
308
+ return { watch: attached, shown: { entries: read.entries, newest } };
309
+ };
310
+ const first = await seed(root);
311
+ // The shown watch: the first one until `install` makes it so, then each one a switch or a new read installs.
312
+ let watch ;
313
+ let shown = first.shown;
249
314
  const models = () =>
250
315
  modelRuntime.getAvailableSnapshot().map((model) => ({
251
316
  provider: model.provider,
@@ -256,7 +321,8 @@ export async function openDurable(options )
256
321
 
257
322
  let state = {
258
323
  session: { id: location.id, directory: location.directory, cwd: location.cwd },
259
- conversation: conversation.value,
324
+ conversation: first.watch.value,
325
+ transcript: transcriptOf(first.watch.value, shown.entries),
260
326
  conversations: summaries,
261
327
  models: models(),
262
328
  notices: [],
@@ -279,13 +345,16 @@ export async function openDurable(options )
279
345
  update({ notices: [...state.notices, { id: nextNotice++, level, message }].slice(-20) });
280
346
  };
281
347
  const fail = (error ) => notice("error", error instanceof Error ? error.message : String(error));
282
- report = (error) => {
283
- const message = error instanceof Error ? error.message : String(error);
284
- // A failing Session runs its close listeners first and reports the cause right after: that report names it.
285
- if (state.failure !== undefined && state.failure.error === undefined) update({ failure: { error: message } });
286
- else notice("warning", message);
348
+ report = (reported) => {
349
+ // The error the SDK reported, never the Report itself: that would read `[object Object]`.
350
+ const message = causeOf(reported.error);
351
+ // A failing Session runs its close listeners first and reports the cause right after, as `storage_failed`:
352
+ // that report names the failure the close listener recorded.
353
+ if (reported.kind === "storage_failed" && state.failure !== undefined && state.failure.error === undefined) {
354
+ update({ failure: { error: message } });
355
+ } else notice(reported.kind === "storage_failed" ? "error" : "warning", `${reported.kind}: ${message}`);
287
356
  };
288
- for (const error of pendingReports) report(error);
357
+ for (const reported of pendingReports) report(reported);
289
358
  const failedListeners = new Set ();
290
359
  // The Session closes itself when its Storage fails. Its `closed` settles only after every running task returned,
291
360
  // which a task ignoring its abort can delay without end, so the failure is shown from the close listener itself.
@@ -300,29 +369,124 @@ export async function openDurable(options )
300
369
  }
301
370
  }
302
371
  });
303
- let unsubscribe = conversation.subscribe((value) => update({ conversation: value }));
304
- // Subagents appear as their conversations are created. A commit listener only records; it calls no Session API.
305
- const unsubscribeCommits = harness.subscribeCommits((publication) => {
306
- let conversations = state.conversations;
307
- for (const change of publication.changes) {
308
- if (change.type === "conversation") {
309
- conversations = [...conversations, { id: change.value.id, label: label(change.value.id) }];
310
- } else if (change.type === "entry" && change.value.kind === "pi.user") {
311
- const id = change.value.conversationId;
312
- conversations = conversations.map((summary) =>
313
- summary.id === id && summary.title === undefined ? { ...summary, ...titleOf(change.value) } : summary,
314
- );
372
+ let closing ;
373
+ // A watch that ends by itself while the Session runs leaves its part of the screen frozen: say so rather than look
374
+ // healthy (#155). Ending at close, at a failure the Session already shows, or by being replaced, is no news.
375
+ const ended = (part , end ) => {
376
+ if (closing !== undefined || state.failure !== undefined) return;
377
+ if (end.kind !== "failed" && end.kind !== "listener_error") return;
378
+ update({ stalled: { ...state.stalled, [part]: causeOf(end.error) } });
379
+ };
380
+ // The watch whose entries wait: its conversation is being read again, or that read failed. A failed read holds
381
+ // the transcript where it was, said on screen, until a switch installs a new watch; there is no retry.
382
+ let held ;
383
+ // The shown conversation read again, one read at a time. It is installed only while `trigger` is still the shown
384
+ // watch: a switch made meanwhile wins, and the late read is dropped (#155 D1).
385
+ const reseed = (trigger ) => {
386
+ held = trigger;
387
+ const conversation = current;
388
+ void seed(conversation).then(
389
+ (next) => {
390
+ if (watch !== trigger) {
391
+ void next.watch.stop();
392
+ return;
393
+ }
394
+ install(conversation, next);
395
+ },
396
+ (error) => {
397
+ // A read refused by the close or the Session's own failure is no news, as `ended` has it.
398
+ if (watch !== trigger || closing !== undefined || state.failure !== undefined) return;
399
+ update({ stalled: { ...state.stalled, conversation: causeOf(error) } });
400
+ },
401
+ );
402
+ };
403
+ // One value of the shown watch. Entries past the newest one shown join the transcript in ID order. A head marker
404
+ // among them (a compaction or a reset) or a window starting past the newest one shown (it dropped entries the
405
+ // transcript lacks) is a new active range: read again, nothing appended until it lands.
406
+ const advance = (view ) => {
407
+ if (held !== undefined && held === watch) {
408
+ update({ conversation: view });
409
+ return;
410
+ }
411
+ const fresh = view.entries.filter((entry) => entry.id > shown.newest);
412
+ const restarts = fresh.some((entry) => entry.head !== undefined);
413
+ const skipped = view.before !== undefined && view.before > shown.newest;
414
+ if (restarts || skipped) {
415
+ update({ conversation: view });
416
+ reseed(watch);
417
+ return;
418
+ }
419
+ // A value without new entries (a document change, such as a streaming answer's progress) leaves the
420
+ // transcript as it is.
421
+ if (fresh.length === 0) {
422
+ update({ conversation: view });
423
+ return;
424
+ }
425
+ shown = { entries: [...shown.entries, ...fresh], newest: newestOf(fresh) };
426
+ update({ conversation: view, transcript: transcriptOf(view, shown.entries) });
427
+ };
428
+ // Every later value of the shown watch; a replaced watch's late frames are dropped.
429
+ const follow = (handle ) => {
430
+ handle.start(async (call) => {
431
+ if (handle !== watch) return;
432
+ if (call.kind === "failed") {
433
+ notice("warning", `The conversation view could not update: ${causeOf(call.error)}`);
434
+ return;
315
435
  }
436
+ advance(call.value);
437
+ });
438
+ void handle.closed.then((end) => {
439
+ if (handle === watch) ended("conversation", end);
440
+ });
441
+ };
442
+ // Show `conversation` from a read: its active entries, then the watch's first value, which holds what was
443
+ // committed between the read and the attach and is taken exactly once here, then the watch's later values.
444
+ const install = (conversation , next ) => {
445
+ // The close and the Session's own failure end what is shown, as a switch does: a read landing after either
446
+ // is let go, its watch stopped (the Session may already have ended it), and nothing is patched or started.
447
+ if (closing !== undefined || state.failure !== undefined) {
448
+ void next.watch.stop();
449
+ return;
316
450
  }
317
- if (conversations !== state.conversations) update({ conversations });
451
+ const previous = watch;
452
+ current = conversation;
453
+ watch = next.watch;
454
+ shown = next.shown;
455
+ void previous?.stop();
456
+ // A new watch replaces a stalled one; the task graph's state is its own.
457
+ const stalled = state.stalled?.tasks === undefined ? undefined : { tasks: state.stalled.tasks };
458
+ update({ conversation: next.watch.value, transcript: transcriptOf(next.watch.value, shown.entries), stalled });
459
+ advance(next.watch.value);
460
+ follow(next.watch);
461
+ };
462
+ install(root, first);
463
+ // Subagents appear as their conversations are created. A commit observer only records; it calls no Session API.
464
+ // Its end decides nothing: SDK 1.5 ends it `failed` ("session closed") at a normal close as well (#155).
465
+ const commits = harness.observeCommits({
466
+ advance: (publication) => {
467
+ let conversations = state.conversations;
468
+ for (const change of publication.changes) {
469
+ if (change.type === "conversation") {
470
+ conversations = [...conversations, { id: change.value.id, label: label(change.value.id) }];
471
+ } else if (change.type === "entry" && change.value.kind === "pi.user") {
472
+ const id = change.value.conversationId;
473
+ conversations = conversations.map((summary) =>
474
+ summary.id === id && summary.title === undefined
475
+ ? { ...summary, ...titleOf(change.value) }
476
+ : summary,
477
+ );
478
+ }
479
+ }
480
+ if (conversations !== state.conversations) update({ conversations });
481
+ },
482
+ end: () => {},
318
483
  });
319
484
 
320
- let tasks ;
321
- let unsubscribeTasks = () => {};
485
+ let tasks ;
322
486
  const closeTasks = () => {
323
- unsubscribeTasks();
324
- tasks?.dispose();
487
+ const graph = tasks;
325
488
  tasks = undefined;
489
+ void graph?.stop();
326
490
  };
327
491
 
328
492
  let queue = Promise.resolve();
@@ -331,15 +495,21 @@ export async function openDurable(options )
331
495
  queue = queue.then(operation).catch(fail);
332
496
  return queue;
333
497
  };
334
- const watchAnswer = (submission ) => {
335
- void submission.wait(context).then((settled) => {
336
- if (settled.status === "unanswered" && settled.reason !== "aborted") {
498
+ // A run that ended without an answer stays on screen as its conversation's `pi.live.runFailure` (SDK 1.5), so an
499
+ // input that run settled adds no notice of its own; one settled without such a run (stale, say) still does.
500
+ const watchAnswer = (submission , conversation ) => {
501
+ void submission
502
+ .wait(context)
503
+ .then(async (settled) => {
504
+ if (settled.status !== "unanswered" || settled.reason === "aborted") return;
505
+ const live = await opened.snapshot(LiveDoc, conversation.id, context);
506
+ if (live?.runFailure?.inputs.includes(submission.id)) return;
337
507
  notice(
338
508
  "error",
339
509
  `No answer: ${settled.reason}${settled.detail === undefined ? "" : ` ${JSON.stringify(settled.detail)}`}`,
340
510
  );
341
- }
342
- }, fail);
511
+ })
512
+ .catch(fail);
343
513
  };
344
514
  const agentModel = () => {
345
515
  const ref = agentOf(state.conversation).model;
@@ -354,7 +524,8 @@ export async function openDurable(options )
354
524
  if (state.failure !== undefined) {
355
525
  throw new Error("The session failed and takes no input. Close it, then reopen it with --continue.");
356
526
  }
357
- watchAnswer(await current.submit({ type: "input", content: text, whenBusy }, context));
527
+ const target = current;
528
+ watchAnswer(await target.submit({ type: "input", content: text, whenBusy }, context), target);
358
529
  }),
359
530
  compact: (instructions) =>
360
531
  command(async () => {
@@ -408,12 +579,8 @@ export async function openDurable(options )
408
579
  command(async () => {
409
580
  const next = await opened.conversation(id, context);
410
581
  if (next === undefined) throw new Error(`Conversation ${id} does not exist`);
411
- const nextState = await next.viewState(context);
412
- unsubscribe();
413
- conversation.dispose();
414
- current = next;
415
- conversation = nextState;
416
- unsubscribe = nextState.subscribe((value) => update({ conversation: value }));
582
+ // The same read as the open: the whole active transcript, whatever this process showed of it before.
583
+ install(next, await seed(next));
417
584
  }),
418
585
  };
419
586
 
@@ -424,14 +591,17 @@ export async function openDurable(options )
424
591
  }
425
592
  if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
426
593
  // The task graph is observed until close; its panel starts open and /tasks hides it.
427
- const graph = await opened.taskGraph(context);
594
+ const graph = await opened.watchTaskGraph(context);
428
595
  tasks = graph;
429
596
  update({ tasks: graph.value });
430
- unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
597
+ graph.start(async (call) => {
598
+ if (call.kind === "value") update({ tasks: call.value });
599
+ else notice("warning", `The task graph could not update: ${causeOf(call.error)}`);
600
+ });
601
+ void graph.closed.then((end) => ended("tasks", end));
431
602
  // Recovered work from an interrupted turn continues now.
432
603
  harness.resume();
433
604
 
434
- let closing ;
435
605
  return {
436
606
  view: {
437
607
  current: () => state,
@@ -460,9 +630,8 @@ export async function openDurable(options )
460
630
  closing ??= (async () => {
461
631
  // First: the Session runs its close listeners inside its own close call.
462
632
  unsubscribeClose();
463
- unsubscribe();
464
- unsubscribeCommits();
465
- conversation.dispose();
633
+ void watch?.stop();
634
+ commits.detach();
466
635
  closeTasks();
467
636
  // The resource end the fence records (#157 B3). Storage is `closed` only when the first actual close
468
637
  // resolved, no failure was seen before it, and the Session's own terminal result is not `failed`.
@@ -473,8 +642,10 @@ export async function openDurable(options )
473
642
  try {
474
643
  // Close writes no outcome: a running turn resumes with --continue.
475
644
  await opened.close(context);
476
- const ended = await opened.closed;
477
- if (state.failure === undefined && ended.reason !== "failed") storage = "closed";
645
+ // SDK 1.5 tells the end by `kind`; pi 1.4's `reason` is absent there, and a type-stripped carrier would
646
+ // read it as undefined and call every end closed (#155).
647
+ const end = await opened.closed;
648
+ if (state.failure === undefined && end.kind !== "failed") storage = "closed";
478
649
  try {
479
650
  await envs.cleanup(context);
480
651
  } catch (error) {
@@ -1,5 +1,5 @@
1
-
2
- import { Type } from "@earendil-works/pi-ai";
1
+
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];