@junghanacs/entwurf 0.33.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.
package/AGENTS.md CHANGED
@@ -31,7 +31,7 @@ Ask: does this follow capability rather than a surface name; record backend asym
31
31
  3. **One identity contract, capability domains.** Branch on rail/liveness capability, not a privileged citizen class.
32
32
  4. **Live-fact dispatch.** Never store liveness or infer send/resume before resolving the record and probing its rail.
33
33
  5. **Rejects are real.** Wrong intent, dead/drifted identity, undeliverable mailbox, ambiguity, and indeterminate probe reject without mutation or fallback.
34
- 6. **MCP and native modules are explicit.** MCP uses only explicit `mcpServers`; native extension modules use operator-declared paths through the owning bootstrap. Neither discovers ambient configuration, scans extension directories, or retrieves modules automatically. Native modules do not emulate a foreign extension API.
34
+ 6. **MCP and native modules are explicit.** MCP uses only explicit `mcpServers`. Native extension modules enter only through the owning bootstrap, from an operator-named absolute file or an operator-named absolute directory read once at launch: top level only, exact suffix filter, deterministic order, every selected entry a regular file. Neither discovers ambient or default locations, takes module paths from settings or environment, recurses, watches, or retrieves modules automatically. Native modules do not emulate a foreign extension API.
35
35
  7. **One meta-store contract.** Record body and filename agree; active records are readable regular non-symlink V3 files; `nativeSessionId` is unique. Writers and doctor certify the active store before writing. Address-bearing reads use the addressable reader; `readStoreRecordFile`/`O_NOFOLLOW` and lstat policy both remain load-bearing. Quiesce and fresh-cut an unreadable generation; redeploy a stale unknown-backend reader. Store protocol and gates: `meta-session.ts`, `check-meta-*`, `check-fresh-cut-gate`.
36
36
  8. **GC reclaims processes, not memory.** Preserve or archive records and transcripts; do not casually delete them.
37
37
  9. **Not a second harness.** No prompt reconstruction, transcript hydration, tool-result ledger, credential mediation, or harness emulation. Backends own auth and transcript.
@@ -88,13 +88,13 @@ A new contract's test goes beside the behavior it certifies — `pi-extensions/*
88
88
 
89
89
  ```text
90
90
  implement → affected focused gates → independent review → one amendment bundle
91
- → qualification once if gate/mutant/matrix changed → full floor once on frozen candidate → commit
91
+ → affected qualification PARTIAL once if gate/mutant/matrix changed → full floor once on frozen candidate → commit
92
92
  ```
93
93
 
94
- - GLG-requested branch checkpoint commits keep completed subject/proof bundles small: scoped review, affected focused checks and normal hooks/shared scanner before each checkpoint, with known RED/unmeasured boundaries recorded. A checkpoint is not frozen-candidate acceptance. The sequence above owns final candidate acceptance: required changed qualification, frozen FULL and native/LIVE/release floors remain mandatory before landing/shipment, never inherited from an interim commit.
94
+ - GLG-requested branch checkpoint commits keep completed subject/proof bundles small: scoped review, affected focused checks and normal hooks/shared scanner before each checkpoint, with known RED/unmeasured boundaries recorded. A checkpoint is not frozen-candidate acceptance. The sequence above owns final candidate acceptance: the affected qualification PARTIAL, frozen FULL and native/LIVE/release floors remain mandatory before landing, and the prepared SHA's CI qualification body before shipment, never inherited from an interim commit.
95
95
  - Inner loop runs only affected gates; review and its corrections finish before the full floor.
96
96
  - A changed contract names a production subject and independent oracle; its focused assertion has a stable QK and exact-once mutant. Matrices change by their declared axes/cells, not appended anecdotes.
97
- - Qualification body is scheduled once when its surface changes; its manifest/head checks remain in the deterministic floor. A release requires the exact-SHA qualification evidence required by VERIFY, never a nearby green run.
97
+ - Development never runs the whole qualification body. A changed gate/mutant/matrix runs its affected exact-argv groups once, after review and amendment, as PARTIAL receipts (`./run.sh check-gate-qualification --group '<argv JSON>' --receipt <absolute path outside the repo>`); the manifest head stays in the deterministic floor. The whole body runs once per cut, in the prepared SHA's CI: a prepared state awaits it and is never presumed PASS, and a release requires the exact-SHA evidence VERIFY names, never a nearby green run or a PARTIAL.
98
98
  - Freeze the candidate while the full floor runs; any worktree/index movement voids that receipt. Pre-commit is not the full floor.
99
99
  - LIVE/release floors are not lowered by a shorter inner loop. Failed gate or evidence downgrade blocks the relevant commit/release decision.
100
100
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,63 @@ All notable changes to this project will be documented here. Format follows [Kee
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.34.0 - 2026-10-09
8
+
9
+ ### Release coordination
10
+
11
+ - **One cut for #134 and #133.** Coordinated by the pi-durable citizen `20261008T113104-801621` and
12
+ implemented by Claude Code Opus under GLG's grant for prepare and make; npm publication is
13
+ coordinated with GLG separately. This section is written at prepare time: the prepared SHA's CI,
14
+ the preserved artifact and the tag are recorded in the GitHub release, not claimed here.
15
+ - **Verified for this cut.** `check:full` passed on the prepare tree (719 s). The first
16
+ `release-gate --cut` ended RED, MUST 23/1/0: `smoke-codex-fresh-live` timed out because Codex
17
+ 0.153.4 refused the stated Codex-side model `gpt-6-luna` for a ChatGPT account (vendor 400); that
18
+ run is preserved as RED. The rerun with the Codex model `gpt-5.6-terra` (Pi side unchanged) passed
19
+ MUST 24/0/0 and BEHAVIOR 1/0/0 in 1115 s, its own `check:full` 723 s. Before the commit,
20
+ `check-pack-install` (with the module-directory cell) and the `check-pi-durable-contact`/`-send`/
21
+ `-receive` gates against a bridge bundle emitted from the candidate passed, and an installed-package
22
+ fixture drove agent-config's background-bash module through a faux model: badge with the panel
23
+ hidden and at narrow sizes, report once, an interrupted task never re-run after `--continue`.
24
+ Vendor model calls there were 0 by construction; its token counts are faux accounting. Affected
25
+ qualification partials: 58/58 for #134 (earlier tree), 52/52 and 22/22 for #133. The whole
26
+ qualification body runs once, in the prepared SHA's CI.
27
+ - **Not measured, by GLG's decision for this cut.** GLG waived the agy shipped-lane criterion: the
28
+ three agy doctors passed, the first native-push probe of a fresh conversation was indeterminate,
29
+ and agy native-push stays NOT CERTIFIED. GLG deferred the badge on GLG's own visible terminal and
30
+ the new post-install Claude session of the meta-bridge criterion to a later session; the host's
31
+ dev-linked doctor passed with five live joins, and the meta-bridge writer is unchanged since
32
+ 0.33.0. None of these is a pass, and no 0.33.0 exception carries over.
33
+
34
+ ### pi-durable
35
+
36
+ - **Background-task badge (#134).** The carrier runtime observes the native task graph from open to
37
+ close; `/tasks` now toggles only the panel. The carrier TUI draws a kind-agnostic badge of live
38
+ background tasks (`node.background`) as the dock's first, never-shrunk row, naming the ones not
39
+ running and shortening down to one ASCII cell; a one-row terminal with live background work shows
40
+ the badge, and with none the screen is upstream's. The overlay patch now carries one section per
41
+ patched carrier file (`runtime.js`, `tui.js`), and the emitter refuses any other shape.
42
+ - **Module directory ingress (#134).** `--native-module-dir <absolute directory>` is the paired,
43
+ exclusive form of `--native-module`: one top-level read, `*.extension.mjs` non-dotfiles only,
44
+ byte-wise order, per-module guards, and named refusals for an empty directory, a non-regular
45
+ match, two names for one file and a cross-module extension or tool name. Hard Rule 6 now admits an
46
+ operator-named directory read once; ambient, default, recursive, watched and setting/environment
47
+ discovery stay forbidden.
48
+ - **Deferred:** #106 no-turn retirement moved to ROADMAP as GLG's deferral, not implemented.
49
+ #132's unfinished Herdr plugin alignment (npm re-pin, source proof, pi-durable under Herdr) moved
50
+ to ROADMAP, closed not-planned, to reopen against this release's published bytes.
51
+
52
+ ### Verification
53
+
54
+ - **Development runs no whole qualification body (#133).** `run.sh check-gate-qualification` now
55
+ hands its arguments to the runner's parser: `--group '<argv JSON>' --receipt <absolute path>`
56
+ runs one affected exact-argv group as a PARTIAL receipt, and a malformed, missing or repeated flag
57
+ is refused by name before any body or snapshot. Until now the wrapper dropped them, so a requested
58
+ PARTIAL started the whole body. Bare, it stays the whole body, which runs once per cut at the
59
+ prepared SHA's CI; a prepared state awaits it and is never presumed PASS. AGENTS, VERIFY and the
60
+ release skill retire the development rule of a local whole run when a gate, mutant or matrix
61
+ changes. Native/LIVE, the deterministic and package floors, the four required CI jobs and the
62
+ artifact acceptance are unchanged; P4 FULL and its CI repetition are not reduced here.
63
+
7
64
  ## 0.33.0 - 2026-10-08
8
65
 
9
66
  ### Release coordination
package/README.md CHANGED
@@ -27,10 +27,10 @@ Claude and Cortex Code are ACP backends. Codex and Antigravity stay native so th
27
27
  retain their own tools and work context. Capabilities differ by rail: a shipped
28
28
  citizen is not a promise of every lifecycle operation.
29
29
 
30
- > **Current release: [0.32.0](https://github.com/junghan0611/entwurf/releases/tag/v0.32.0)**
31
- > ([`80d66f4`](https://github.com/junghan0611/entwurf/commit/80d66f4ca66f0be674b2d2bfbe20c49717c0340e)).
32
- > Includes the pinned Pi 1.0.4 native durable app/TUI and one explicit native-module ingress.
33
- > npm publication and registry byte equality are [verified](https://github.com/junghan0611/entwurf/issues/130#issuecomment-6049936030).
30
+ > **Current release: [0.33.0](https://github.com/junghan0611/entwurf/releases/tag/v0.33.0)**
31
+ > ([`fea9b3d`](https://github.com/junghan0611/entwurf/commit/fea9b3dc4779ac5deb34e239abc8e0b5e3e95382)).
32
+ > Includes the pinned Pi 1.1.0 native durable app/TUI and one explicit native-module ingress;
33
+ > `npm` `latest` is `0.33.0`. What that cut ran and did not run is in its CHANGELOG section.
34
34
  > Release history and detailed evidence belong in [CHANGELOG](./CHANGELOG.md).
35
35
 
36
36
  ## Install
@@ -88,8 +88,11 @@ models/auth, queues, SQLite, transcripts and recovery remain upstream-owned:
88
88
  [Hard Rule 17](./AGENTS.md#hard-rules), **supply the missing surface; do not become the harness**.
89
89
 
90
90
  Optional `--native-module <absolute file>` initializes trusted ESM before runtime/TUI
91
- imports, then installs its default native Extension after the contact. No automatic
92
- module discovery or classic Pi API shim; guards are not a sandbox or rollback.
91
+ imports, then installs its default native Extension after the contact. After 0.33.0 (#134):
92
+ `--native-module-dir <absolute directory>` loads the top-level `*.extension.mjs` files of that
93
+ one named directory in byte-wise order instead, and the durable TUI keeps a live
94
+ background-task badge on screen at any terminal size. No automatic module discovery or
95
+ classic Pi API shim; guards are not a sandbox or rollback.
93
96
  Ordinary Pi's known `peerDependencies` warning reflects the separate native SDK supply.
94
97
  [Native setup](./docs/setup-clean-host.md#2b-optional-native-durable-pi-durable)
95
98
  owns flags, refusals and the extension contract.
package/VERIFY.md CHANGED
@@ -86,7 +86,7 @@ that moved out of `scripts/` and into a file beside its subject would otherwise
86
86
  ### The canonical floor — two entry points
87
87
 
88
88
  - **Deterministic floor — two tiers (#70):** `pnpm check` is the everyday CORE — toolchain (lint + typecheck), the vitest lanes, and the pure-unit / behavioral-contract / source-topology gates plus the cheap static coherence checks. It prints its own total wall time; acceptance is ≤60s on the reference host `oracle` (an operator measurement, never a hard wall-clock gate on arbitrary hosts). `pnpm run check:full` is the FULL deterministic floor — the core plus the hermetic-integration and package/install tiers — and is what the frozen-candidate protocol, push CI, release-gate, and `prepublishOnly` run. Exact membership is the named `check:*` group scripts in `package.json` (the executable SSOT — this document records meaning and principles, not the command list); a gate changes tier by semantic-class decision, never by getting faster or slower. One core member assumes a build output: the beside test `pi-extensions/entwurf-control.test.ts` (reached through `check-tests-beside-behavior`) registers the COMPILED bridge and refuses by name when it is absent. `prepare` normally builds it at install; after an `--ignore-scripts` install or a bridge source change, run `pnpm run build-bridge` (in the sandbox candidate when verifying one) before the core. The FULL tier carries the qualification HEAD (`check-gate-manifests`, through `check:hermetic`); the everyday core does not, and neither tier carries the separately scheduled mutant-executing body (`check-gate-qualification`).
89
- - **Discriminating power of that floor:** `./run.sh check-gate-qualification` (scheduled, not ambient: standalone when a lane changes a gate/mutant/matrix, in the CI `check` job on a branch push that touched the qualification surface, and — for a cut — at the prepared SHA, where the exact-SHA release oracle requires that CI step (#132: `release-gate` no longer runs a second, local BODY; this moves only the release-mode owner — the development rule of one local run when a gate/mutant/matrix changes stays in force until #133 retires it, and the 0.33.0 cut skips it only by a one-cut exception) — no longer inside the default check chains; a semver TAG push triggers no CI at all, and the exact-SHA evidence a release quotes is whichever run at that commit CARRIES the body — the branch run when the push touched the qualification surface, else a dispatch run; a push that touched none of the qualification surface skips the body, and `workflow_dispatch -f qualify=true` plus a weekly `schedule` run it unconditionally — the exact-SHA release oracle refuses a SHA whose body step did not conclude success) re-plants committed defect mutants (`scripts/mutants/*.json`, one per closed defect class) in an isolated snapshot repo and requires each to turn its gate red **bounded and at its claimed `[QK:<claim>]` signature** — a wrong-reason red fails, a baseline-red control voids the whole group, and the runner is negative-controlled on every run (zero-match/multi-match/survived/wrong-reason/hang/control-red/impurity). This measures whether the deterministic gates still *block* what they claim to block; it is **not a new evidence level** (L0–L5 are untouched) and never substitutes for LIVE evidence. Per-cut records cite claim IDs + killed mutant IDs — "N checks passed" alone is not evidence. `check-agy-permission-matrix` complements it with the enumerated permission contract space (literal cells + stated exclusion rules, oracle independent of the SUT). The gate's HEAD is separable and IS in the floor: `check-gate-manifests` runs the runner self-test, validates the committed manifest set against the origin index, and asserts the declared lane inventory, while executing zero COMMITTED mutants (the self-test runs only its synthetic fixture mutants, in tmp fixture repos) and never snapshotting the repo — three of the five reds qualification has ever produced in CI died in that head, in under five seconds.
89
+ - **Discriminating power of that floor:** `./run.sh check-gate-qualification` (scheduled, not ambient: the whole body runs only as the bare call — in the CI `check` job on a branch push that touched the qualification surface, and — for a cut — at the prepared SHA, where the exact-SHA release oracle requires that CI step once and the prepared state awaits it, never presumed PASS; a cut therefore batches its commits into that one prepared push (#132 took the cut's body off the local `release-gate`). Development runs no whole body (#133): a lane that changes a gate/mutant/matrix runs its affected exact-argv groups once, after review, as PARTIAL receipts — `./run.sh check-gate-qualification --group '<argv JSON>' --receipt <absolute path outside the repo>`, whose arguments reach the runner's parser unchanged and are refused there by name, before any body or snapshot, when malformed, missing or repeated. A receipt is a measurement of one group, never a qualification pass. Neither form is inside the default check chains; a semver TAG push triggers no CI at all, and the exact-SHA evidence a release quotes is whichever run at that commit CARRIES the body — the branch run when the push touched the qualification surface, else a dispatch run; a push that touched none of the qualification surface skips the body, and `workflow_dispatch -f qualify=true` plus a weekly `schedule` run it unconditionally — the exact-SHA release oracle refuses a SHA whose body step did not conclude success) re-plants committed defect mutants (`scripts/mutants/*.json`, one per closed defect class) in an isolated snapshot repo and requires each to turn its gate red **bounded and at its claimed `[QK:<claim>]` signature** — a wrong-reason red fails, a baseline-red control voids the whole group, and the runner is negative-controlled on every run (zero-match/multi-match/survived/wrong-reason/hang/control-red/impurity). This measures whether the deterministic gates still *block* what they claim to block; it is **not a new evidence level** (L0–L5 are untouched) and never substitutes for LIVE evidence. Per-cut records cite claim IDs + killed mutant IDs — "N checks passed" alone is not evidence. `check-agy-permission-matrix` complements it with the enumerated permission contract space (literal cells + stated exclusion rules, oracle independent of the SUT). The gate's HEAD is separable and IS in the floor: `check-gate-manifests` runs the runner self-test, validates the committed manifest set against the origin index, and asserts the declared lane inventory, while executing zero COMMITTED mutants (the self-test runs only its synthetic fixture mutants, in tmp fixture repos) and never snapshotting the repo — three of the five reds qualification has ever produced in CI died in that head, in under five seconds.
90
90
  - **The snapshot is source-only (#125).** It replicates the git work surface and never copies an ignored artifact from the checkout. A gate that consumes an ignored build output — the compiled `entwurf-bridge` a pi citizen registers — declares it on each of its mutants (`"build": {"argv": [...], "output": "<dir>"}`, one declaration per gate group). For that group only, the runner builds the output from the snapshot's own bytes before the control, rebuilds it after every mutation and from the restored bytes before the post-control (which must reproduce the baseline digest), never counts a build red as a kill, voids a run whose gate writes into the output, and removes the output when the group ends. The runner self-test owns this as `[QK:QUALIFY-BUILD-*]`.
91
91
  - **Live floor:** `LIVE=1 ./run.sh release-gate <scratch-project-dir> --cut` — `pnpm run check:full` + the v2-native live gates + the ACP plugin acceptance floor. It reports a **two-tier summary**:
92
92
  - **MUST tier** (release-blocking — owns the exit code; "green" applies only here): `pnpm run check:full`, `smoke-entwurf-v2-matrix-live`, `check-bridge`, `doctor-pi-provider` (#81 — `check-bridge` proves the launcher this checkout SHIPS; this proves the invocation the operator's pi provider actually EXECS, by booting it and requiring the entwurf verb set back, because `command -v` answers yes for a command that exits 127; wired as a step on 2026-08-19 after a relocated pnpm cmd-shim cost a cut sixteen LIVE steps before the same fact surfaced at `smoke-acp-bundled-mcp-live`), the resident-garden-guard zero-token half (record birth / record-keyed socket / attach-on-reopen), the `smoke-acp-*-live` ACP plugin smokes (socket-citizen / raw-turn / overlay / provider / session-reuse / carrier-augment / memory-containment / rgg / mcp / skill / bundled-mcp / v2-send), the axis wired in on 2026-07-31 that the aggregate had simply never listed (`smoke-claude-native-resume-live`), `smoke-entwurf-chain-live` — the cross-harness delivery chain (native Claude Code → pi GPT → pi ACP Sonnet → mailbox terminus) proving sender identity and replyability at every hop plus a real read receipt at the end — the integrated `smoke-mux-lifecycle-live` (its own note below), and the two OMP steps 0.16.0 wired: `smoke-omp-receive-live` (the addressed roundtrip into a live omp citizen; it reads the capability registry and decides its own outcome rather than passing by default) and `smoke-omp-fresh-live` (the clause 7 receipt the release stop now requires). (`smoke-session-id-name` is gone — #50 C3: its `--session-id`/`--name` substrate has no entwurf consumer anymore.)
@@ -203,10 +203,21 @@ Do not collapse source, package, fixture, and native-host evidence into one “g
203
203
  **Native durable contact (#129, #130 carrier).** `check-pi-durable-beside` is the deterministic
204
204
  beside-test/mutant coordinate in `check:contracts`: the adapter, the installed carrier/SDK-set
205
205
  verifier, the preflight, the owner join, the carrier's relocation grammar, resolver and
206
- maintainer emitter (its source reads refuse replace refs), and the bootstrap's explicit
207
- `--native-module` ingress (#130 P2), driven through the packaged `main`'s own seams: initialization
208
- before the TUI and the open, named refusals, the identity and directory guards, the contact-first
209
- order by reference, and the bridge spawn *spec* (not the child's inheritance). `check-pi-runtime-version` asks the
206
+ maintainer emitter (its source reads refuse replace refs; its one patch carries exactly one
207
+ section per patched carrier file, #134), and the bootstrap's explicit `--native-module` ingress
208
+ (#130 P2) with its paired `--native-module-dir` (#134), driven through the packaged `main`'s own
209
+ seams: initialization before the TUI and the open, named refusals, the identity and directory
210
+ guards, the top-level `*.extension.mjs` filter and byte-wise order, cross-module name refusals, the
211
+ contact-first order by reference, and the bridge spawn *spec* (not the child's inheritance). The
212
+ carrier's task footer (#134) is observed in ONE sandboxed child that loads the committed carrier
213
+ through its own resolver: the real carrier runtime opened and closed with no model (graph observed
214
+ for the session, `/tasks` toggling only the panel), then continued after one native background task
215
+ was committed into that session through the SDK's public `Conversation.commit` (the carrier's graph
216
+ holds that node and the badge counts it, `pending` while recovered), and the carrier TUI's badge and
217
+ `durableLayout` drawn by the published pi-tui renderer on a fake terminal across rows 1–8 × cols
218
+ 1–12, count changes and resizes, against the pinned upstream screen for the zero-task case. Its
219
+ editor is a fixed stand-in: the real `CustomEditor`, overlays, emoji width and a consumer's own task
220
+ are the visible-terminal and consumer subjects. `check-pi-runtime-version` asks the
210
221
  same verifier whether this checkout resolves exactly the pinned SDK set (the native set; the PATH
211
222
  host is setup's subject), and `check-pi-import-surface` asserts the carrier fence. The separate
212
223
  `check-pi-durable-contact`, `check-pi-durable-send` and `check-pi-durable-receive` are
@@ -227,7 +238,9 @@ same-sandbox neighbour as the rejected control), every reachable physical SDK ed
227
238
  version and declared name, a mixed-version tree refused by name although it still imports, a
228
239
  constructed depth-3 mismatch refused by the verifier and seen by the oracle, the public launch reaching the
229
240
  app's own model refusal before any birth — also with one installed `--native-module` initialized
230
- first, and a reserved extension name refused before anything opens — and the HOST kept a separate subject — a real pi 1.0.4
241
+ first, a reserved extension name refused before anything opens, one installed `--native-module-dir`
242
+ initializing only its top-level `*.extension.mjs` (#134) and a directory without a module refused
243
+ before anything opens — and the HOST kept a separate subject — a real pi 1.0.4
231
244
  binary (the previous generation) beside the pinned native set is a named pi FAIL with zero Pi wiring while pi-durable still
232
245
  PASSes — not native admission. The maintainer's rebuild
233
246
  equality is `emit-pi-durable-carrier --check` against a supplied upstream clone; without one it is
@@ -358,12 +358,33 @@ tree (for example an override that binds pi-durable's `pi-ai` to an older publis
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.33.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": {
@@ -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");