@junghanacs/entwurf 0.32.0 → 0.34.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.
@@ -28,8 +28,8 @@ evidence split, and its three states must not collapse into "macOS is supported"
28
28
  | npm/pnpm | npm is bundled with Node; pnpm is required for source setup | package or source installation |
29
29
  | Python 3 | required by `setup`/`install` (project-path normalization + settings writers); `--help`/`check-bridge` stay Python-free | pi/Claude/agy/Copilot/Codex wiring writers |
30
30
  | entwurf | global/project-local `@junghanacs/entwurf`, or a source checkout | operator command and garden capability |
31
- | pi | optional-by-presence, `>=1.0.4 <1.1` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
32
- | Native durable (`pi-durable`) | supplied by the Entwurf package (#130): a pinned upstream v1.0.4 app carrier plus the exact 1.0.4 pi SDK set as production dependencies — setup PASS when they verify, a named FAIL otherwise (never SKIP) | the `entwurf pi-durable` contact; native birth/LIVE on this supply surface is a separate release proof (§2b) |
31
+ | pi | optional-by-presence, `>=1.1.0 <1.2` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
32
+ | Native durable (`pi-durable`) | supplied by the Entwurf package (#130): a pinned upstream v1.1.0 app carrier plus the exact 1.1.0 pi SDK set as production dependencies — setup PASS when they verify, a named FAIL otherwise (never SKIP) | the `entwurf pi-durable` contact; native birth/LIVE on this supply surface is a separate release proof (§2b) |
33
33
  | Claude Code | optional, **`>=2.1.217`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
34
34
  | GitHub Copilot CLI | optional-by-presence, operator-installed and authenticated — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/receiver/footer) | self-fetch citizen and visible fresh |
35
35
  | OMP (`omp`) | optional-by-presence, operator-installed — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/`tools.xdev` setting/receiver) | self-fetch citizen and visible fresh (accepted on one host — see §4b) |
@@ -191,7 +191,7 @@ on Darwin; that is wiring, not a rail receipt. If `pi` is on PATH,
191
191
  Install the exact release floor, then wire the project:
192
192
 
193
193
  ```bash
194
- npm install -g @earendil-works/pi-coding-agent@1.0.4
194
+ npm install -g @earendil-works/pi-coding-agent@1.1.0
195
195
  pi --version
196
196
 
197
197
  cd ~/entwurf-smoke
@@ -199,7 +199,7 @@ entwurf install .
199
199
  pi -e "$(npm root -g)/@junghanacs/entwurf" --list-models entwurf
200
200
  ```
201
201
 
202
- The supported range is `>=1.0.4 <1.1`. It is a hard minimum: installing this
202
+ The supported range is `>=1.1.0 <1.2`. It is a hard minimum: installing this
203
203
  release onto a 0.83.x pi host upgrades the runtime rather than keeping the older
204
204
  minor. A host using only the external MCP bridge can skip pi until it needs a
205
205
  control socket; no delivery rail launches a pi process.
@@ -295,22 +295,22 @@ by `entwurf pi` or by registering ordinary Pi's adapter.
295
295
  What the Entwurf package supplies:
296
296
 
297
297
  1. **The pi SDK set** — `@earendil-works/{pi-coding-agent, pi-ai, pi-tui, pi-agent-core,
298
- pi-durable, chord, pi-mcp, pi-codemode, pi-telemetry}` at exactly `1.0.4`, plus
298
+ pi-durable, chord, pi-mcp, pi-codemode, pi-telemetry}` at exactly `1.1.0`, plus
299
299
  `proper-lockfile`, as Entwurf's own production dependencies (declared once in
300
300
  `pi/pi-durable/overlay/upstream-pin.json` `sdk`). Model data rides the published
301
301
  `pi-ai`; there is no catalog copy.
302
302
  2. **The native app/TUI carrier** — upstream does not publish the experimental durable
303
303
  app (`pi-coding-agent` excludes `dist/experimental`; `pi-durable` on npm is the
304
- library). Entwurf ships the six pinned app files from upstream tag `v1.0.4`
305
- (`7c10bd4337495ee613f2224843ecdf349b80d1df`), its narrow runtime-contact overlay
304
+ library). Entwurf ships the six pinned app files from upstream tag `v1.1.0`
305
+ (`abe508e1b89912adde45528136c3221eb69acdd7`), its narrow runtime-contact overlay
306
306
  applied to `runtime.ts`, type-stripped and relocated, in `pi/pi-durable/carrier/`
307
307
  with upstream's MIT license. Each file's source blob and emitted digest are in the pin.
308
308
  The carrier reaches pi-coding-agent's internals only through its resolver
309
309
  (`pi/pi-durable/carrier-resolver.mjs`), which maps them to the published package's
310
310
  own `dist/` as installed — never through a source checkout.
311
311
 
312
- The upstream [library guide](https://github.com/earendil-works/pi/blob/v1.0.4/packages/durable/README.md)
313
- and [experimental app guide](https://github.com/earendil-works/pi/blob/v1.0.4/packages/coding-agent/src/experimental/durable/README.md)
312
+ The upstream [library guide](https://github.com/earendil-works/pi/blob/v1.1.0/packages/durable/README.md)
313
+ and [experimental app guide](https://github.com/earendil-works/pi/blob/v1.1.0/packages/coding-agent/src/experimental/durable/README.md)
314
314
  describe what the carrier is. Running the bare upstream app does not attach Entwurf's
315
315
  garden contact. Entwurf does not rebuild the controller/model/auth/storage system or
316
316
  proxy a subscription; native execution, models, auth, queues, SQLite and recovery stay
@@ -327,7 +327,7 @@ and from every package copy it reaches, at any depth, to its own `@earendil-work
327
327
  lands on a package of the declared name at exactly the pinned version (`pi-durable-sdk-mismatch`). The members declare one another with caret
328
328
  ranges, so semver satisfaction is not the contract; the bytes an import binds are. Sharing
329
329
  one copy with an aligned host install of the same version is an ordinary layout. A mixed
330
- tree (for example an override that binds pi-durable's `pi-ai` one patch behind) still
330
+ tree (for example an override that binds pi-durable's `pi-ai` to an older published version) still
331
331
  *imports* — which is why the edge check exists — and is refused by name.
332
332
 
333
333
  - `entwurf setup` reports a `pi-durable` row from that check and writes no durable wiring:
@@ -358,12 +358,33 @@ tree (for example an override that binds pi-durable's `pi-ai` one patch behind)
358
358
  (`native-module-path-*`), a module that throws (`native-module-import-failed`), a function or
359
359
  nameless default (`native-module-export-invalid`), an extension named like one the app installs
360
360
  itself (`entwurf`, `coding-tools`, `pi-prompt`, `subagent`) or a tool named like one of Entwurf's
361
- contact tools (`native-module-name-reserved`; any other duplicate is the native registry's to
362
- judge), and a module whose initialization changes
361
+ contact tools (`native-module-name-reserved`; within one module, any other duplicate is the native
362
+ registry's to judge), and a module whose initialization changes
363
363
  `PI_SESSION_ID`, `PI_CODING_AGENT_DIR`, `HOME` or any `ENTWURF_*` (`native-module-env-identity-changed`)
364
364
  or the process directory (`native-module-cwd-changed`). Your module is trusted code; those
365
- checks catch mistakes, they do not sandbox it. Nothing else names a module — no directory,
366
- setting or profile — and a sibling opened by `entwurf_fresh_call` starts without one.
365
+ checks catch mistakes, they do not sandbox it. Nothing else names a module — no default
366
+ directory, setting or profile — and a sibling opened by `entwurf_fresh_call` starts without one.
367
+
368
+ - **A module directory instead (#134, after 0.33.0).** `--native-module-dir
369
+ <absolute directory>` replaces `--native-module` (naming both is `native-module-ingress-exclusive`).
370
+ Entwurf reads that one directory once, at its top level: every `*.extension.mjs` that is not a
371
+ dotfile is a module; helpers such as `env.mjs` or `index.mjs`, other suffixes, subdirectories and
372
+ directory links are never opened. The modules initialize one by one in byte-wise name order
373
+ (`00-env.extension.mjs` before `10-tools.extension.mjs`), each under the same checks as above,
374
+ and install in that order after the contact. Also refused by name: a directory without a module
375
+ (`native-module-dir-empty`), a matching entry that is not a regular file
376
+ (`native-module-path-invalid`), two names for one file (`native-module-dir-duplicate-target`), and
377
+ a module repeating an earlier module's extension or tool name (`native-module-name-duplicate` —
378
+ the native registry would replace that extension or keep both tools). A refusal at a later module
379
+ leaves the earlier ones already evaluated; nothing is rolled back.
380
+
381
+ - **Background-task badge (#134, after 0.33.0).** While a native background task is live, the durable
382
+ TUI shows `⏳ n tasks` as the first line under the transcript, whatever kind of task it is, with
383
+ the ones not running named (`aborting`, `completing`, `waiting`, `pending`). It never shrinks: a
384
+ short terminal loses the editor and footer before it, a one-row terminal shows only the badge, and
385
+ a narrow one shortens it down to `*`. `/tasks` only shows or hides the task panel; the badge stays.
386
+ With no background task the screen is unchanged. A task that has already finished leaves the
387
+ count — its outcome is reported by the task's own extension.
367
388
 
368
389
  - **Known warning.** Ordinary Pi may print `Host-provided extension packages must be declared in
369
390
  peerDependencies with a "*" range, not dependencies: @earendil-works/…` for Entwurf's
@@ -372,7 +393,7 @@ tree (for example an override that binds pi-durable's `pi-ai` one patch behind)
372
393
  manifest field; it is a known constraint of supplying both from one package, accepted for 0.32,
373
394
  not a claim that every load path is harmless.
374
395
 
375
- The ordinary Pi CLI on PATH is a separate subject (§2, range `>=1.0.4 <1.1`): a host Pi is
396
+ The ordinary Pi CLI on PATH is a separate subject (§2, range `>=1.1.0 <1.2`): a host Pi is
376
397
  not needed to run pi-durable, and a host inside the range need not be the same copy as the
377
398
  carrier's. Native Pi owns credentials, model settings and durable SQLite sessions; keep them
378
399
  in place rather than copying credentials or migrating ordinary Pi transcripts. Native
@@ -562,7 +583,7 @@ as a note rather than a fault.
562
583
 
563
584
  **entwurf sets no OMP version floor in code, and will not grow one on schedule.** Detection is
564
585
  presence-only (`command -v "${OMP_BIN:-omp}"`); there is no `entwurf.ompFloor`, no coherence
565
- gate, and no exact pin — unlike Node (`engines.node`), pi (`>=1.0.4 <1.1`) and Claude Code
586
+ gate, and no exact pin — unlike Node (`engines.node`), pi (`>=1.1.0 <1.2`) and Claude Code
566
587
  (`entwurf.claudeCodeFloor`), each of which has an enforcement point. That asymmetry is a
567
588
  decision, not an omission. A floor is the answer to a vendor that fails SILENTLY — Claude Code
568
589
  earned one because an older binary validates the exec manifest, drops `args` at runtime, and
@@ -16,7 +16,7 @@
16
16
  * The bootstrap that composes the two lives with the overlay (`pi/pi-durable/bootstrap.mjs`), not
17
17
  * here: this module never imports or initializes the durable app runtime and carries no path into
18
18
  * a vendor checkout. That is this module's import boundary, not the package's dependency list: the
19
- * package does declare the exact `@earendil-works/pi-durable` (1.0.4) for its carrier (#130).
19
+ * package does declare the exact `@earendil-works/pi-durable` (per the pin's SDK set) for its carrier (#130).
20
20
  *
21
21
  * WHY BIRTH IS AFTER OPEN, BEHIND A GATE. `[read @cd32f77]` The native session id is chosen
22
22
  * inside `openDurable` (`runtime.ts:125`, `sessions.ts:18-55`), and `openDurable` calls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for pi, Claude Code, Copilot CLI, OMP, Antigravity, and Codex CLI; Claude and Snowflake Cortex Code ACP backends.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -93,15 +93,15 @@
93
93
  "@agentclientprotocol/claude-agent-acp": "0.85.1",
94
94
  "@agentclientprotocol/sdk": "1.6.0",
95
95
  "@anthropic-ai/sdk": "0.100.1",
96
- "@earendil-works/chord": "1.0.4",
97
- "@earendil-works/pi-agent-core": "1.0.4",
98
- "@earendil-works/pi-ai": "1.0.4",
99
- "@earendil-works/pi-codemode": "1.0.4",
100
- "@earendil-works/pi-coding-agent": "1.0.4",
101
- "@earendil-works/pi-durable": "1.0.4",
102
- "@earendil-works/pi-mcp": "1.0.4",
103
- "@earendil-works/pi-telemetry": "1.0.4",
104
- "@earendil-works/pi-tui": "1.0.4",
96
+ "@earendil-works/chord": "1.1.0",
97
+ "@earendil-works/pi-agent-core": "1.1.0",
98
+ "@earendil-works/pi-ai": "1.1.0",
99
+ "@earendil-works/pi-codemode": "1.1.0",
100
+ "@earendil-works/pi-coding-agent": "1.1.0",
101
+ "@earendil-works/pi-durable": "1.1.0",
102
+ "@earendil-works/pi-mcp": "1.1.0",
103
+ "@earendil-works/pi-telemetry": "1.1.0",
104
+ "@earendil-works/pi-tui": "1.1.0",
105
105
  "@modelcontextprotocol/sdk": "^1.12.1",
106
106
  "proper-lockfile": "4.1.2",
107
107
  "smol-toml": "^1.8.0",
@@ -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");