@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.
- package/AGENTS.md +4 -4
- package/CHANGELOG.md +103 -0
- package/README.md +57 -119
- package/VERIFY.md +25 -12
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/setup-clean-host.md +37 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +1 -1
- package/package.json +10 -10
- package/pi/pi-durable/bootstrap.mjs +111 -11
- package/pi/pi-durable/carrier/runtime.js +11 -14
- package/pi/pi-durable/carrier/tui.js +76 -11
- package/pi/pi-durable/overlay/runtime-contacts.patch +199 -7
- package/pi/pi-durable/overlay/upstream-pin.json +15 -15
- package/pi-extensions/meta-bridge-pi-durable.ts +4 -2
- package/run.sh +83 -53
- package/scripts/check-gate-qualification.ts +60 -3
- package/scripts/check-release-gate-outcomes.ts +22 -18
- package/scripts/emit-pi-durable-carrier.ts +61 -21
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/gate-qualification.json +24 -0
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +178 -11
- package/scripts/mutants/pi-floor.json +6 -6
- package/scripts/mutants/release-gate.json +5 -3
package/docs/setup-clean-host.md
CHANGED
|
@@ -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
|
|
32
|
-
| Native durable (`pi-durable`) | supplied by the Entwurf package (#130): a pinned upstream v1.0
|
|
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
|
|
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
|
|
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
|
|
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
|
|
305
|
-
(`
|
|
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
|
|
313
|
-
and [experimental app guide](https://github.com/earendil-works/pi/blob/v1.0
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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` (
|
|
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.
|
|
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
|
|
97
|
-
"@earendil-works/pi-agent-core": "1.0
|
|
98
|
-
"@earendil-works/pi-ai": "1.0
|
|
99
|
-
"@earendil-works/pi-codemode": "1.0
|
|
100
|
-
"@earendil-works/pi-coding-agent": "1.0
|
|
101
|
-
"@earendil-works/pi-durable": "1.0
|
|
102
|
-
"@earendil-works/pi-mcp": "1.0
|
|
103
|
-
"@earendil-works/pi-telemetry": "1.0
|
|
104
|
-
"@earendil-works/pi-tui": "1.0
|
|
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
|
-
//
|
|
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
|
|
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 = [
|
|
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`),
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
334
|
-
|
|
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
|
|
364
|
-
await
|
|
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
|
-
|
|
238
|
-
{ component:
|
|
239
|
-
{ component:
|
|
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.#
|
|
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");
|