@junghanacs/entwurf 0.33.0 → 0.34.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.
@@ -50,18 +50,27 @@
50
50
  // its initialization, and its default export is a native durable Extension, handed to `openDurable`
51
51
  // by reference after the contact. Initialization runs before the TUI or the runtime is imported —
52
52
  // the runtime creates the provider runtime before it installs any extension (carrier/runtime.js
53
- // 140-146), so an extension object cannot carry an environment phase. Entwurf checks only what the
53
+ // 142-148), so an extension object cannot carry an environment phase. Entwurf checks only what the
54
54
  // ingress needs: a default export object with a name, no extension name the app installs itself (a
55
55
  // same-name install REPLACES), and no tool name of the contact. Every other field is the native
56
56
  // registry's to validate. The module is trusted operator code: the identity carriers and the
57
57
  // process directory are compared across its initialization and a change is refused by name — a
58
- // mistake guard, not a sandbox, and nothing is rolled back. Nothing is discovered: no directory,
59
- // setting, environment variable or profile names a module.
58
+ // mistake guard, not a sandbox, and nothing is rolled back. Nothing is discovered: no ambient or
59
+ // default directory, setting, environment variable or profile names a module.
60
+ //
61
+ // `--native-module-dir <absolute directory>` (#134) is the paired form, exclusive with the file
62
+ // ingress: the operator-named directory is read ONCE, top level only, and a name is a module exactly
63
+ // when it matches `*.extension.mjs` and does not start with a dot. Every other name (helpers, tests,
64
+ // subdirectories, directory symlinks) is ignored unopened; a matching name must resolve to a regular
65
+ // file. The modules initialize one by one in byte-wise name order, each under the same guards; two
66
+ // modules naming one extension or one tool are refused by name, because the native registry would
67
+ // REPLACE the first extension or keep both tools. A refusal at module k leaves modules before it
68
+ // evaluated: nothing is rolled back. No watch, no recursion, nothing persisted.
60
69
  //
61
70
  // Without that resolver the carrier's first pi-coding-agent internal import fails by name (an
62
71
  // unsupported entwurf-pi-dist: URL scheme), and that failure is the honest one.
63
- import { realpathSync, statSync } from "node:fs";
64
- import { isAbsolute } from "node:path";
72
+ import { readdirSync, realpathSync, statSync } from "node:fs";
73
+ import { isAbsolute, join } from "node:path";
65
74
  import { fileURLToPath, pathToFileURL } from "node:url";
66
75
  import { decodeOmpBootstrapPayload } from "../../mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js";
67
76
  import {
@@ -83,7 +92,14 @@ export const PACKAGED_BRIDGE_ENTRY = fileURLToPath(
83
92
 
84
93
  /** Flags that take a value. Anything else but `--continue`/`-c` is an unknown argument — a bridge
85
94
  * entry included: the launch argv cannot point the contact at another bridge. */
86
- const VALUE_FLAGS = ["--provider", "--model", "--width", "--entwurf-bootstrap", "--native-module"];
95
+ const VALUE_FLAGS = [
96
+ "--provider",
97
+ "--model",
98
+ "--width",
99
+ "--entwurf-bootstrap",
100
+ "--native-module",
101
+ "--native-module-dir",
102
+ ];
87
103
  /** Flags only a NEW session can honour. */
88
104
  const FRESH_ONLY_FLAGS = ["--provider", "--model", "--width", "--entwurf-bootstrap"];
89
105
 
@@ -93,6 +109,8 @@ const FRESH_ONLY_FLAGS = ["--provider", "--model", "--width", "--entwurf-bootstr
93
109
  * carrier/subagent.js:25). The native registry REPLACES an installed extension of the same name.
94
110
  */
95
111
  const RESERVED_EXTENSION_NAMES = [PI_DURABLE_EXTENSION_NAME, "coding-tools", "pi-prompt", "subagent"];
112
+ /** A directory entry that is an operator module: `*.extension.mjs`, not a dotfile. */
113
+ const MODULE_ENTRY = /^[^.].*\.extension\.mjs$/;
96
114
  /** The identity carriers a module's initialization must leave as it found them, every ENTWURF_* included. */
97
115
  const IDENTITY_KEYS = ["PI_SESSION_ID", "PI_CODING_AGENT_DIR", "HOME"];
98
116
 
@@ -131,6 +149,51 @@ function nativeModuleFile(value) {
131
149
  return file;
132
150
  }
133
151
 
152
+ /**
153
+ * The operator's module directory: absolute, resolving to a directory, read once at the top level. The
154
+ * matching entries in byte-wise name order, each resolved to a regular file and none twice. Decided
155
+ * before anything loads.
156
+ */
157
+ function nativeModuleDirFiles(named) {
158
+ if (!isAbsolute(named))
159
+ throw refuse("native-module-path-not-absolute", `--native-module-dir takes an absolute path, got ${named}`);
160
+ let dir;
161
+ try {
162
+ dir = realpathSync(named);
163
+ } catch (error) {
164
+ throw Object.assign(refuse("native-module-path-invalid", `${named} does not resolve`), { cause: error });
165
+ }
166
+ if (!statSync(dir).isDirectory()) throw refuse("native-module-path-invalid", `${named} is not a directory`);
167
+ const names = readdirSync(dir)
168
+ .filter((name) => MODULE_ENTRY.test(name))
169
+ .sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
170
+ if (names.length === 0) {
171
+ throw refuse("native-module-dir-empty", `${named} holds no *.extension.mjs module at its top level`);
172
+ }
173
+ const seen = new Map();
174
+ return names.map((name) => {
175
+ let file;
176
+ try {
177
+ file = realpathSync(join(dir, name));
178
+ } catch (error) {
179
+ throw Object.assign(refuse("native-module-path-invalid", `${name} in ${named} does not resolve`), {
180
+ cause: error,
181
+ });
182
+ }
183
+ if (!statSync(file).isFile()) {
184
+ throw refuse(
185
+ "native-module-path-invalid",
186
+ `${name} in ${named} matches *.extension.mjs but is not a regular file`,
187
+ );
188
+ }
189
+ if (seen.has(file)) {
190
+ throw refuse("native-module-dir-duplicate-target", `${seen.get(file)} and ${name} in ${named} are the same file`);
191
+ }
192
+ seen.set(file, name);
193
+ return file;
194
+ });
195
+ }
196
+
134
197
  /** The module's default export, checked only for what the ingress needs and returned BY REFERENCE. */
135
198
  function nativeExtensionOf(extension, file) {
136
199
  if (typeof extension === "function") {
@@ -187,6 +250,31 @@ async function loadNativeModule(file) {
187
250
  return nativeExtensionOf(namespace.default, file);
188
251
  }
189
252
 
253
+ /**
254
+ * Initialize the operator's modules in order. A later module that names an extension or a tool an
255
+ * earlier one already did is refused by name: the native registry would replace that extension, or keep
256
+ * both tools, without a word.
257
+ */
258
+ async function loadNativeModules(files) {
259
+ const extensions = [];
260
+ const owners = new Map();
261
+ for (const file of files) {
262
+ const extension = await loadNativeModule(file);
263
+ const names = [
264
+ `extension ${extension.name}`,
265
+ ...(Array.isArray(extension.tools) ? extension.tools : []).map((tool) => `tool ${tool?.name}`),
266
+ ];
267
+ for (const name of names) {
268
+ if (owners.has(name)) {
269
+ throw refuse("native-module-name-duplicate", `${file} and ${owners.get(name)} both name ${name}`);
270
+ }
271
+ }
272
+ for (const name of names) owners.set(name, file);
273
+ extensions.push(extension);
274
+ }
275
+ return extensions;
276
+ }
277
+
190
278
  /**
191
279
  * Open the durable app with the contact installed, then birth the citizen and arm its doorbell on
192
280
  * the root conversation, then admit the first input (when given) to the root once. On any failure
@@ -198,8 +286,8 @@ async function loadNativeModule(file) {
198
286
  * roots; tests also pass a connector). `open` replaces the runtime's `openDurable` for tests.
199
287
  * `bridgeEntry` defaults to this package's own compiled bridge; only a programmatic caller (the
200
288
  * checkout-only gates, which drive a private bundle) passes another one — never the launch argv.
201
- * `extensions` are the operator's native extensions (`--native-module`), installed after the
202
- * contact, by reference.
289
+ * `extensions` are the operator's native extensions (`--native-module` or `--native-module-dir`),
290
+ * installed after the contact, by reference.
203
291
  *
204
292
  * @param {{
205
293
  * bridgeEntry?: string,
@@ -320,7 +408,19 @@ function parseArgs(argv) {
320
408
  );
321
409
  }
322
410
  const moduleArg = values.get("--native-module");
323
- const nativeModule = moduleArg === undefined ? {} : { nativeModule: nativeModuleFile(moduleArg) };
411
+ const dirArg = values.get("--native-module-dir");
412
+ if (moduleArg !== undefined && dirArg !== undefined) {
413
+ throw refuse(
414
+ "native-module-ingress-exclusive",
415
+ "--native-module and --native-module-dir are two forms of one ingress; name the file or the directory",
416
+ );
417
+ }
418
+ const nativeModule =
419
+ moduleArg !== undefined
420
+ ? { nativeModules: [nativeModuleFile(moduleArg)] }
421
+ : dirArg !== undefined
422
+ ? { nativeModules: nativeModuleDirFiles(dirArg) }
423
+ : {};
324
424
  if (continueSession) return { continueSession, ...nativeModule };
325
425
  const provider = values.get("--provider");
326
426
  if (provider === undefined) {
@@ -368,10 +468,10 @@ function parseArgs(argv) {
368
468
  */
369
469
  export async function main(argv, { loadTui = () => import(TUI), open, contact, bridgeEntry } = {}) {
370
470
  // Every argument refusal is decided here, before any module, the TUI or the app loads.
371
- const { nativeModule, ...args } = parseArgs(argv);
471
+ const { nativeModules = [], ...args } = parseArgs(argv);
372
472
  // The caller's directory, fixed before any operator code runs and handed to the app explicitly.
373
473
  const cwd = process.cwd();
374
- const extensions = nativeModule === undefined ? [] : [await loadNativeModule(nativeModule)];
474
+ const extensions = await loadNativeModules(nativeModules);
375
475
  // Loaded before anything opens, as upstream main.ts imports it statically: a TUI that cannot
376
476
  // load opens no session, mints no citizen and leaves nothing to clean up.
377
477
  const { runDurableTui } = await loadTui();
@@ -58,8 +58,10 @@ const context = BACKGROUND_CONTEXT;
58
58
 
59
59
 
60
60
 
61
-
61
+
62
62
 
63
+
64
+
63
65
 
64
66
 
65
67
 
@@ -213,6 +215,7 @@ export async function openDurable(options = {})
213
215
  conversations: summaries,
214
216
  models: models(),
215
217
  notices: [],
218
+ tasksPanel: true,
216
219
  };
217
220
  const listeners = new Set ();
218
221
  let notifying = false;
@@ -330,17 +333,8 @@ export async function openDurable(options = {})
330
333
  const thinking = agentOf(state.conversation).thinkingLevel ?? "off";
331
334
  await current.configure({ model: ref, thinkingLevel: clampThinkingLevel(model, thinking) }, context);
332
335
  }),
333
- toggleTasks: () =>
334
- command(async () => {
335
- if (tasks !== undefined) {
336
- closeTasks();
337
- update({ tasks: undefined });
338
- return;
339
- }
340
- const graph = await opened.taskGraph(context);
341
- tasks = graph;
342
- unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
343
- }),
336
+ // Only the panel: the graph stays observed, so live background work is never out of view.
337
+ toggleTasks: () => command(async () => update({ tasksPanel: !state.tasksPanel })),
344
338
  switchConversation: (id) =>
345
339
  command(async () => {
346
340
  const next = await opened.conversation(id, context);
@@ -360,8 +354,11 @@ export async function openDurable(options = {})
360
354
  notice("warning", `Saved model is unavailable: ${saved.provider}/${saved.modelId}`);
361
355
  }
362
356
  if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
363
- // The task panel starts open; /tasks hides it.
364
- await controller.toggleTasks();
357
+ // The task graph is observed until close; its panel starts open and /tasks hides it.
358
+ const graph = await opened.taskGraph(context);
359
+ tasks = graph;
360
+ update({ tasks: graph.value });
361
+ unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
365
362
  // Recovered work from an interrupted turn continues now.
366
363
  harness.resume();
367
364
 
@@ -23,12 +23,14 @@ import {
23
23
  SelectList,
24
24
 
25
25
  Spacer,
26
+
26
27
  setCapabilityOverrides,
27
28
  setKeybindings,
28
29
  Text,
29
30
  TruncatedText,
30
31
  TuiAltScreen,
31
32
  VStack,
33
+ visibleWidth,
32
34
  } from "@earendil-works/pi-tui";
33
35
  import { getAgentDir } from "entwurf-pi-dist:config.js";
34
36
  import { KeybindingsManager } from "entwurf-pi-dist:core/keybindings.js";
@@ -150,10 +152,75 @@ class CompactionComponent extends Box {
150
152
 
151
153
 
152
154
 
155
+ /**
156
+ * The live background-task badge: the dock's first row, never shrunk, so live background work stays on
157
+ * screen however small the terminal gets. It counts the graph's background tasks whatever their kind and
158
+ * names the ones that are not running; with none it renders nothing and the dock is upstream's.
159
+ */
160
+ export class TaskBadge {
161
+ #count = 0;
162
+ #detail = "";
163
+
164
+ get count() {
165
+ return this.#count;
166
+ }
167
+
168
+ set(graph ) {
169
+ const nodes = Object.values(graph?.tasks ?? {}).filter((node) => node.background);
170
+ const tally = (state , match ) => {
171
+ const n = nodes.filter(match).length;
172
+ return n === 0 ? [] : [`${n} ${state}`];
173
+ };
174
+ this.#count = nodes.length;
175
+ this.#detail = [
176
+ ...tally("aborting", (node) => node.abortRequested),
177
+ ...tally("completing", (node) => !node.abortRequested && node.state.status === "completing"),
178
+ ...tally("waiting", (node) => !node.abortRequested && node.state.status === "waiting"),
179
+ ...tally("pending", (node) => !node.abortRequested && node.state.status === "pending"),
180
+ ].join(", ");
181
+ }
182
+
183
+ /** The widest form that fits, padded when there is room; narrower terminals drop detail, then words. */
184
+ render(width ) {
185
+ const n = this.#count;
186
+ if (n === 0) return [];
187
+ const tasks = `⏳ ${n} ${n === 1 ? "task" : "tasks"}`;
188
+ const detailed = this.#detail ? [`${tasks} (${this.#detail})`] : [];
189
+ const forms = [...detailed, tasks, `⏳ ${n}`, `⏳${n}`, `*${n}`, "*+", "*"];
190
+ for (const form of forms) {
191
+ if (visibleWidth(form) < width) return [theme.fg("accent", ` ${form}`)];
192
+ if (visibleWidth(form) === width) return [theme.fg("accent", form)];
193
+ }
194
+ return [theme.fg("accent", "*")];
195
+ }
196
+
197
+ invalidate() {}
198
+ }
199
+
200
+ /**
201
+ * The screen: the transcript above the dock. The badge is the dock's first row and never shrinks, so the
202
+ * renderer, which clips from the bottom, drops the editor and the footer before it; a one-row terminal
203
+ * with live background work shows the badge instead of the transcript.
204
+ */
205
+ export function durableLayout(transcript , badge , dock ) {
206
+ return new VStack([
207
+ {
208
+ component: transcript,
209
+ basis: 0,
210
+ grow: 1,
211
+ shrink: 1,
212
+ minSize: 1,
213
+ visible: (viewport) => badge.count === 0 || viewport.height > 1,
214
+ },
215
+ { component: new VStack([{ component: badge, shrink: 0 }, ...dock]), basis: "auto", grow: 0, shrink: 1, minSize: 1 },
216
+ ]);
217
+ }
218
+
153
219
  class DurableTui {
154
220
  static #renderers = createAllToolRenderers();
155
221
  #ui ;
156
222
  #chat = new Container();
223
+ #taskBadge = new TaskBadge();
157
224
  #tasks = new Container();
158
225
  #queue = new Container();
159
226
  #notices = new Container();
@@ -216,15 +283,9 @@ class DurableTui {
216
283
  content.addChild(new Spacer(1));
217
284
  const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" });
218
285
  this.#transcript = transcript;
219
- const dock = new VStack([
220
- { component: this.#tasks, shrink: 1, minSize: 0 },
221
- { component: this.#queue, shrink: 1, minSize: 0 },
222
- { component: this.#notices, shrink: 1, minSize: 0 },
223
- { component: this.#editorContainer, shrink: 1, minSize: 3 },
224
- { component: this.#footer, shrink: 1, minSize: 0 },
225
- ]);
226
286
  for (const component of [
227
287
  this.#chat,
288
+ this.#taskBadge,
228
289
  this.#tasks,
229
290
  this.#queue,
230
291
  this.#notices,
@@ -234,9 +295,12 @@ class DurableTui {
234
295
  this.#ui.addChild(component);
235
296
  }
236
297
  this.#ui.setLayoutRoot(
237
- new VStack([
238
- { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
239
- { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
298
+ durableLayout(transcript, this.#taskBadge, [
299
+ { component: this.#tasks, shrink: 1, minSize: 0 },
300
+ { component: this.#queue, shrink: 1, minSize: 0 },
301
+ { component: this.#notices, shrink: 1, minSize: 0 },
302
+ { component: this.#editorContainer, shrink: 1, minSize: 3 },
303
+ { component: this.#footer, shrink: 1, minSize: 0 },
240
304
  ]),
241
305
  );
242
306
  this.#ui.setFocus(this.#editor);
@@ -299,7 +363,8 @@ class DurableTui {
299
363
  );
300
364
  }
301
365
  }
302
- this.#syncTasks(view.tasks);
366
+ this.#taskBadge.set(view.tasks);
367
+ this.#syncTasks(view.tasksPanel ? view.tasks : undefined);
303
368
  this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) );
304
369
  this.#syncNotices(view);
305
370
  this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");
@@ -1,5 +1,5 @@
1
1
  diff --git a/packages/coding-agent/src/experimental/durable/runtime.ts b/packages/coding-agent/src/experimental/durable/runtime.ts
2
- index fcd6e523..929dbc79 100644
2
+ index fcd6e523..7f00e27f 100644
3
3
  --- a/packages/coding-agent/src/experimental/durable/runtime.ts
4
4
  +++ b/packages/coding-agent/src/experimental/durable/runtime.ts
5
5
  @@ -8,7 +8,9 @@ import {
@@ -12,7 +12,19 @@ index fcd6e523..929dbc79 100644
12
12
  type ModelRef,
13
13
  ROOT_CONVERSATION_ID,
14
14
  type Submission,
15
- @@ -81,6 +83,10 @@ export interface DurableController {
15
+ @@ -56,8 +58,10 @@ export interface DurableView {
16
+ readonly conversations: readonly ConversationSummary[];
17
+ readonly models: readonly ModelSummary[];
18
+ readonly notices: readonly Notice[];
19
+ - /** The live task graph while the task panel is open. */
20
+ + /** The live task graph, observed for the session's lifetime whether or not its panel is shown. */
21
+ readonly tasks?: TaskGraph;
22
+ + /** Whether the task panel is shown; /tasks toggles only this. */
23
+ + readonly tasksPanel: boolean;
24
+ }
25
+
26
+ export interface DurableViewSource {
27
+ @@ -81,6 +85,10 @@ export interface DurableController {
16
28
  export interface OpenDurableOptions {
17
29
  readonly cwd?: string;
18
30
  readonly continueSession?: boolean;
@@ -23,7 +35,7 @@ index fcd6e523..929dbc79 100644
23
35
  }
24
36
 
25
37
  export interface OpenDurableResult {
26
- @@ -88,6 +94,8 @@ export interface OpenDurableResult {
38
+ @@ -88,6 +96,8 @@ export interface OpenDurableResult {
27
39
  readonly controller: DurableController;
28
40
  /** pi's settings, for the TUI's theme and terminal capabilities. */
29
41
  readonly settings: SettingsManager;
@@ -32,7 +44,7 @@ index fcd6e523..929dbc79 100644
32
44
  close(): Promise<void>;
33
45
  }
34
46
 
35
- @@ -122,6 +130,9 @@ function titleOf(entry: EntryRecord | undefined): { title?: string } {
47
+ @@ -122,6 +132,9 @@ function titleOf(entry: EntryRecord | undefined): { title?: string } {
36
48
  }
37
49
 
38
50
  export async function openDurable(options: OpenDurableOptions = {}): Promise<OpenDurableResult> {
@@ -42,7 +54,7 @@ index fcd6e523..929dbc79 100644
42
54
  const location = await selectSession(options.cwd ?? process.cwd(), options.continueSession ?? false);
43
55
  const envs = new ExecutionEnvs(location.cwd);
44
56
  let harness: Harness | undefined;
45
- @@ -132,6 +143,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
57
+ @@ -132,6 +145,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
46
58
  const settings = createHarnessSettings(settingsManager);
47
59
  const registry = createCodingRegistry(settingsManager, location.cwd);
48
60
  registry.install(Subagent);
@@ -50,7 +62,7 @@ index fcd6e523..929dbc79 100644
50
62
 
51
63
  const pendingReports: unknown[] = [];
52
64
  let report: (error: unknown) => void = (error) => pendingReports.push(error);
53
- @@ -146,7 +158,29 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
65
+ @@ -146,7 +160,29 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
54
66
  },
55
67
  context,
56
68
  );
@@ -81,7 +93,49 @@ index fcd6e523..929dbc79 100644
81
93
  const root = await harness.root(context, {
82
94
  agent: {
83
95
  cwd: location.cwd,
84
- @@ -342,6 +376,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
96
+ @@ -179,6 +215,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
97
+ conversations: summaries,
98
+ models: models(),
99
+ notices: [],
100
+ + tasksPanel: true,
101
+ };
102
+ const listeners = new Set<() => void>();
103
+ let notifying = false;
104
+ @@ -296,17 +333,8 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
105
+ const thinking: ModelThinkingLevel = agentOf(state.conversation).thinkingLevel ?? "off";
106
+ await current.configure({ model: ref, thinkingLevel: clampThinkingLevel(model, thinking) }, context);
107
+ }),
108
+ - toggleTasks: () =>
109
+ - command(async () => {
110
+ - if (tasks !== undefined) {
111
+ - closeTasks();
112
+ - update({ tasks: undefined });
113
+ - return;
114
+ - }
115
+ - const graph = await opened.taskGraph(context);
116
+ - tasks = graph;
117
+ - unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
118
+ - }),
119
+ + // Only the panel: the graph stays observed, so live background work is never out of view.
120
+ + toggleTasks: () => command(async () => update({ tasksPanel: !state.tasksPanel })),
121
+ switchConversation: (id) =>
122
+ command(async () => {
123
+ const next = await opened.conversation(id, context);
124
+ @@ -326,8 +354,11 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
125
+ notice("warning", `Saved model is unavailable: ${saved.provider}/${saved.modelId}`);
126
+ }
127
+ if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
128
+ - // The task panel starts open; /tasks hides it.
129
+ - await controller.toggleTasks();
130
+ + // The task graph is observed until close; its panel starts open and /tasks hides it.
131
+ + const graph = await opened.taskGraph(context);
132
+ + tasks = graph;
133
+ + update({ tasks: graph.value });
134
+ + unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
135
+ // Recovered work from an interrupted turn continues now.
136
+ harness.resume();
137
+
138
+ @@ -342,6 +373,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
85
139
  },
86
140
  controller,
87
141
  settings: settingsManager,
@@ -89,3 +143,141 @@ index fcd6e523..929dbc79 100644
89
143
  close() {
90
144
  closing ??= (async () => {
91
145
  unsubscribe();
146
+ diff --git a/packages/coding-agent/src/experimental/durable/tui.ts b/packages/coding-agent/src/experimental/durable/tui.ts
147
+ index 397b276c..6a8fce8f 100644
148
+ --- a/packages/coding-agent/src/experimental/durable/tui.ts
149
+ +++ b/packages/coding-agent/src/experimental/durable/tui.ts
150
+ @@ -23,12 +23,14 @@ import {
151
+ SelectList,
152
+ type SelectListTheme,
153
+ Spacer,
154
+ + type StackEntry,
155
+ setCapabilityOverrides,
156
+ setKeybindings,
157
+ Text,
158
+ TruncatedText,
159
+ TuiAltScreen,
160
+ VStack,
161
+ + visibleWidth,
162
+ } from "@earendil-works/pi-tui";
163
+ import { getAgentDir } from "../../config.ts";
164
+ import { KeybindingsManager } from "../../core/keybindings.ts";
165
+ @@ -150,10 +152,75 @@ interface Handlers {
166
+ cycleThinking(): void;
167
+ }
168
+
169
+ +/**
170
+ + * The live background-task badge: the dock's first row, never shrunk, so live background work stays on
171
+ + * screen however small the terminal gets. It counts the graph's background tasks whatever their kind and
172
+ + * names the ones that are not running; with none it renders nothing and the dock is upstream's.
173
+ + */
174
+ +export class TaskBadge implements Component {
175
+ + #count = 0;
176
+ + #detail = "";
177
+ +
178
+ + get count(): number {
179
+ + return this.#count;
180
+ + }
181
+ +
182
+ + set(graph: TaskGraph | undefined): void {
183
+ + const nodes = Object.values(graph?.tasks ?? {}).filter((node) => node.background);
184
+ + const tally = (state: string, match: (node: TaskGraphNode) => boolean): string[] => {
185
+ + const n = nodes.filter(match).length;
186
+ + return n === 0 ? [] : [`${n} ${state}`];
187
+ + };
188
+ + this.#count = nodes.length;
189
+ + this.#detail = [
190
+ + ...tally("aborting", (node) => node.abortRequested),
191
+ + ...tally("completing", (node) => !node.abortRequested && node.state.status === "completing"),
192
+ + ...tally("waiting", (node) => !node.abortRequested && node.state.status === "waiting"),
193
+ + ...tally("pending", (node) => !node.abortRequested && node.state.status === "pending"),
194
+ + ].join(", ");
195
+ + }
196
+ +
197
+ + /** The widest form that fits, padded when there is room; narrower terminals drop detail, then words. */
198
+ + render(width: number): string[] {
199
+ + const n = this.#count;
200
+ + if (n === 0) return [];
201
+ + const tasks = `⏳ ${n} ${n === 1 ? "task" : "tasks"}`;
202
+ + const detailed = this.#detail ? [`${tasks} (${this.#detail})`] : [];
203
+ + const forms = [...detailed, tasks, `⏳ ${n}`, `⏳${n}`, `*${n}`, "*+", "*"];
204
+ + for (const form of forms) {
205
+ + if (visibleWidth(form) < width) return [theme.fg("accent", ` ${form}`)];
206
+ + if (visibleWidth(form) === width) return [theme.fg("accent", form)];
207
+ + }
208
+ + return [theme.fg("accent", "*")];
209
+ + }
210
+ +
211
+ + invalidate(): void {}
212
+ +}
213
+ +
214
+ +/**
215
+ + * The screen: the transcript above the dock. The badge is the dock's first row and never shrinks, so the
216
+ + * renderer, which clips from the bottom, drops the editor and the footer before it; a one-row terminal
217
+ + * with live background work shows the badge instead of the transcript.
218
+ + */
219
+ +export function durableLayout(transcript: Component, badge: TaskBadge, dock: readonly StackEntry[]): VStack {
220
+ + return new VStack([
221
+ + {
222
+ + component: transcript,
223
+ + basis: 0,
224
+ + grow: 1,
225
+ + shrink: 1,
226
+ + minSize: 1,
227
+ + visible: (viewport) => badge.count === 0 || viewport.height > 1,
228
+ + },
229
+ + { component: new VStack([{ component: badge, shrink: 0 }, ...dock]), basis: "auto", grow: 0, shrink: 1, minSize: 1 },
230
+ + ]);
231
+ +}
232
+ +
233
+ class DurableTui {
234
+ static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
235
+ readonly #ui: TuiAltScreen;
236
+ readonly #chat = new Container();
237
+ + readonly #taskBadge = new TaskBadge();
238
+ readonly #tasks = new Container();
239
+ readonly #queue = new Container();
240
+ readonly #notices = new Container();
241
+ @@ -216,15 +283,9 @@ class DurableTui {
242
+ content.addChild(new Spacer(1));
243
+ const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" });
244
+ this.#transcript = transcript;
245
+ - const dock = new VStack([
246
+ - { component: this.#tasks, shrink: 1, minSize: 0 },
247
+ - { component: this.#queue, shrink: 1, minSize: 0 },
248
+ - { component: this.#notices, shrink: 1, minSize: 0 },
249
+ - { component: this.#editorContainer, shrink: 1, minSize: 3 },
250
+ - { component: this.#footer, shrink: 1, minSize: 0 },
251
+ - ]);
252
+ for (const component of [
253
+ this.#chat,
254
+ + this.#taskBadge,
255
+ this.#tasks,
256
+ this.#queue,
257
+ this.#notices,
258
+ @@ -234,9 +295,12 @@ class DurableTui {
259
+ this.#ui.addChild(component);
260
+ }
261
+ this.#ui.setLayoutRoot(
262
+ - new VStack([
263
+ - { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
264
+ - { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
265
+ + durableLayout(transcript, this.#taskBadge, [
266
+ + { component: this.#tasks, shrink: 1, minSize: 0 },
267
+ + { component: this.#queue, shrink: 1, minSize: 0 },
268
+ + { component: this.#notices, shrink: 1, minSize: 0 },
269
+ + { component: this.#editorContainer, shrink: 1, minSize: 3 },
270
+ + { component: this.#footer, shrink: 1, minSize: 0 },
271
+ ]),
272
+ );
273
+ this.#ui.setFocus(this.#editor);
274
+ @@ -299,7 +363,8 @@ class DurableTui {
275
+ );
276
+ }
277
+ }
278
+ - this.#syncTasks(view.tasks);
279
+ + this.#taskBadge.set(view.tasks);
280
+ + this.#syncTasks(view.tasksPanel ? view.tasks : undefined);
281
+ this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) as InboxState);
282
+ this.#syncNotices(view);
283
+ this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");
@@ -23,14 +23,14 @@
23
23
  "source": "packages/coding-agent/src/experimental/durable/runtime.ts",
24
24
  "patched": true,
25
25
  "blob": "fcd6e5239e999ebf17dea0335fb3dc9cd7f7ce45",
26
- "sha256": "ca0ee10dc904a57d46bb33b37fcc4efe48dfa08b2f0d10e79e7bc22136871f0a"
26
+ "sha256": "bfd88844fae61e8bd32d57b5b986f48056c64eebb5466d9826318a0783ac9005"
27
27
  },
28
28
  {
29
29
  "name": "tui.js",
30
30
  "source": "packages/coding-agent/src/experimental/durable/tui.ts",
31
- "patched": false,
31
+ "patched": true,
32
32
  "blob": "397b276cbeb1c90e1fbc96c3cc59e6a25f92dad7",
33
- "sha256": "bef1ea4bd6e8ab150273752a9b94552d8ce672b518ba3d1ed1a209b8405be1b3"
33
+ "sha256": "f61cc68e5d32552a8196785093ae9e2f4a946088fe71582bb05690517db8c5b4"
34
34
  },
35
35
  {
36
36
  "name": "harness-setup.js",