@junghanacs/entwurf 0.40.0 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -2
- package/BASELINE.md +55 -2
- package/CHANGELOG.md +562 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +18 -2
- package/FAQ.md +8 -4
- package/README.md +60 -45
- package/VERIFY.md +14 -11
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +6 -2
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +76 -20
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/src/index.ts +93 -19
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
- package/package.json +15 -14
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi/pi-durable/carrier/tui.js +18 -3
- package/pi/pi-durable/overlay/upstream-pin.json +12 -12
- package/pi-extensions/entwurf-control.ts +55 -20
- package/pi-extensions/lib/acp/backend-adapter.ts +5 -3
- package/pi-extensions/lib/acp/backend.ts +12 -9
- package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/pi-extensions/lib/cc-retire-request.ts +553 -0
- package/pi-extensions/lib/control-retire.ts +195 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
- package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
- package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
- package/pi-extensions/lib/fresh-call-composition.ts +15 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/meta-session.ts +140 -16
- package/pi-extensions/lib/mux-fresh-call.ts +7 -27
- package/pi-extensions/lib/mux-launch.ts +29 -0
- package/pi-extensions/lib/mux-resume-call.ts +33 -12
- package/pi-extensions/lib/pi-retire-request.ts +111 -0
- package/pi-extensions/lib/resume-launch-identity.ts +104 -18
- package/pi-extensions/lib/retirement-event.ts +253 -0
- package/pi-extensions/meta-bridge-hook.ts +142 -13
- package/pi-extensions/meta-bridge-pi-durable.ts +57 -3
- package/run.sh +123 -34
- package/scripts/agy-bridge-config.py +4 -1
- package/scripts/build-bridge.sh +14 -0
- package/scripts/check-capability-bundle-reach.ts +135 -2
- package/scripts/check-entwurf-bridge-boot.ts +2 -1
- package/scripts/check-entwurf-fact-provider.ts +1 -0
- package/scripts/check-entwurf-v2-production.ts +2 -0
- package/scripts/check-entwurf-v2-surface.ts +7 -0
- package/scripts/check-gate-qualification.ts +14 -8
- package/scripts/check-herdr-activation.ts +2 -2
- package/scripts/check-herdr-placement.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +6 -15
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-meta-doctor-oracle.sh +27 -1
- package/scripts/check-meta-facts.ts +8 -3
- package/scripts/check-meta-hook-session-switch.ts +230 -13
- package/scripts/check-meta-identity-consumers.ts +1 -0
- package/scripts/check-meta-listing.ts +1 -0
- package/scripts/check-meta-manifest-schema.py +1 -1
- package/scripts/check-meta-session.ts +31 -0
- package/scripts/check-meta-v3-record.ts +55 -1
- package/scripts/check-mux-resume-call.ts +35 -9
- package/scripts/check-peer-facts.ts +1 -0
- package/scripts/check-pi-durable-contact.ts +3 -1
- package/scripts/check-pi-durable-send.ts +4 -2
- package/scripts/check-pi-mcp-bridge.ts +1 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/codex-fresh-live-protocol.ts +3 -2
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/meta-bridge-claude-floor.sh +13 -9
- package/scripts/meta-bridge-doctor.sh +17 -0
- package/scripts/meta-bridge-install.sh +6 -0
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/bridge-boot-resume.json +5 -2
- package/scripts/mutants/cc-retire-pidfd.json +129 -0
- package/scripts/mutants/cc-retire-request.json +171 -0
- package/scripts/mutants/control-retire.json +105 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/copilot-receive.json +2 -6
- package/scripts/mutants/entwurf-v2-retire.json +476 -0
- package/scripts/mutants/fresh-call-dispatch.json +37 -0
- package/scripts/mutants/meta-hook-session-switch.json +88 -0
- package/scripts/mutants/meta-identity.json +57 -0
- package/scripts/mutants/mux-fresh-call.json +1 -1
- package/scripts/mutants/mux-resume-call.json +40 -4
- package/scripts/mutants/pack-install.json +4 -4
- package/scripts/mutants/pi-durable.json +139 -0
- package/scripts/mutants/pi-floor.json +5 -5
- package/scripts/mutants/pi-retire-request.json +72 -0
- package/scripts/mutants/resume-launch-identity.json +35 -8
- package/scripts/mutants/retirement-event.json +195 -0
- package/scripts/mutants/v2-surface.json +16 -0
- package/scripts/mutants/v2-visible-resume.json +283 -13
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-codex-fresh-live.ts +2 -1
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +15 -7
- package/scripts/smoke-meta-install-state.sh +4 -4
- package/scripts/smoke-mux-lifecycle-live.ts +10 -10
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
- package/scripts/smoke-pi-provider-state.sh +3 -3
package/docs/setup-clean-host.md
CHANGED
|
@@ -23,22 +23,26 @@ evidence split, and its three states must not collapse into "macOS is supported"
|
|
|
23
23
|
|
|
24
24
|
## Requirements
|
|
25
25
|
|
|
26
|
+
This table is this source candidate's, which moves the Pi SDK set to 1.4.0 and is **not published
|
|
27
|
+
to npm yet**. The newest published package, `@junghanacs/entwurf@0.41.0`, ships the Pi 1.3.0 SDK
|
|
28
|
+
set and requires Pi 1.3.x; the setup guide inside that tarball is its contract.
|
|
29
|
+
|
|
26
30
|
| Component | Requirement | Needed for |
|
|
27
31
|
|---|---|---|
|
|
28
32
|
| Node | **`>=24.0.0`** | package and bridge runtime |
|
|
29
33
|
| npm/pnpm | npm is bundled with Node; pnpm is required for source setup | package or source installation |
|
|
30
34
|
| Python 3 | required by `setup`/`install` (project-path normalization + settings writers); `--help`/`check-bridge` stay Python-free | pi/Claude/agy/Copilot/Codex wiring writers |
|
|
31
35
|
| entwurf | global/project-local `@junghanacs/entwurf`, or a source checkout | operator command and garden capability |
|
|
32
|
-
| pi | optional-by-presence, `>=1.
|
|
33
|
-
| Native durable (`pi-durable`) | supplied by the Entwurf package (#130): Entwurf's owned app carrier plus the exact 1.
|
|
34
|
-
| Claude Code | optional, **`>=2.1.
|
|
36
|
+
| pi | optional-by-presence, `>=1.4.0 <1.5` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
|
|
37
|
+
| Native durable (`pi-durable`) | supplied by the Entwurf package (#130): Entwurf's owned app carrier plus the exact 1.4.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) |
|
|
38
|
+
| Claude Code | optional, **`>=2.1.306`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
|
|
35
39
|
| 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 |
|
|
36
40
|
| 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) |
|
|
37
41
|
| Antigravity `agy` | optional, operator-installed and authenticated | native-push citizen |
|
|
38
42
|
| OpenAI Codex CLI | optional-by-presence, operator-installed and authenticated; detected composes all four operator-owned units (birth/MCP/status-line/terminal-title) and stays non-green until the operator trusts the birth declaration once in a visible Codex; visible fresh also requires the operator-owned app-server, started with `entwurf codex-app-server` in a tmux session of the operator's choosing (#95 D1 retired the fixed `codex` home) | native-push and visible fresh, supported in 0.21.0 |
|
|
39
43
|
| Cortex Code | optional, operator-installed and authenticated | Cortex ACP backend |
|
|
40
44
|
|
|
41
|
-
Claude Code >=2.1.
|
|
45
|
+
Claude Code >=2.1.306 is required for the managed exec-hook lifecycle and same-id resume. The package
|
|
42
46
|
never supplies or proxies backend credentials.
|
|
43
47
|
|
|
44
48
|
On a physical Darwin host, `python3` may be the CommandLineTools stub (a
|
|
@@ -79,11 +83,15 @@ node --version
|
|
|
79
83
|
npm --version
|
|
80
84
|
```
|
|
81
85
|
|
|
86
|
+
The two npm forms below install the **published** package, `0.41.0` (Pi 1.3.x, the Pi 1.3.0 SDK
|
|
87
|
+
set), not this candidate. This candidate (Pi 1.4) installs only from a source checkout of its
|
|
88
|
+
commit, further down this section; it has no npm install command until it is released.
|
|
89
|
+
|
|
82
90
|
Global install is simplest when native harnesses should find stable bins from every
|
|
83
91
|
working directory:
|
|
84
92
|
|
|
85
93
|
```bash
|
|
86
|
-
npm install -g @junghanacs/entwurf
|
|
94
|
+
npm install -g @junghanacs/entwurf@0.41.0
|
|
87
95
|
entwurf --help
|
|
88
96
|
entwurf setup /path/to/your-project
|
|
89
97
|
entwurf check-bridge
|
|
@@ -94,7 +102,7 @@ A project-local installation is also supported:
|
|
|
94
102
|
```bash
|
|
95
103
|
mkdir -p ~/entwurf-smoke && cd ~/entwurf-smoke
|
|
96
104
|
npm init -y
|
|
97
|
-
npm install --save-dev @junghanacs/entwurf
|
|
105
|
+
npm install --save-dev @junghanacs/entwurf@0.41.0
|
|
98
106
|
npx entwurf setup .
|
|
99
107
|
npx entwurf check-bridge
|
|
100
108
|
```
|
|
@@ -104,7 +112,7 @@ project-local install, or `./run.sh <command>` from a source checkout. Keep that
|
|
|
104
112
|
same scope when upgrading and checking; native process restarts are separate (§7).
|
|
105
113
|
|
|
106
114
|
`check-bridge` is auth-free. It proves the installed prebuilt MCP server boots and
|
|
107
|
-
lists the
|
|
115
|
+
lists the nine garden tools (0.41.0); it does not prove a backend model turn, native hook or retirement permission.
|
|
108
116
|
|
|
109
117
|
Neither npm form installs a harness runtime. `pi`, Claude Code, Copilot CLI, Codex CLI, agy,
|
|
110
118
|
Cortex, and their authentication remain operator-owned optional prerequisites for the
|
|
@@ -119,9 +127,15 @@ with a nonzero setup result):
|
|
|
119
127
|
```bash
|
|
120
128
|
git clone https://github.com/junghan0611/entwurf ~/repos/gh/entwurf
|
|
121
129
|
cd ~/repos/gh/entwurf
|
|
130
|
+
git checkout "$CANDIDATE_REF" # set it first to the commit you mean to run; a bare clone gives the default branch
|
|
122
131
|
./run.sh setup /path/to/consumer-project
|
|
123
132
|
```
|
|
124
133
|
|
|
134
|
+
Set `CANDIDATE_REF` explicitly to a published branch or tag that carries the commit you mean;
|
|
135
|
+
a bare clone selects the default branch, not a promised candidate. A validation branch is not a
|
|
136
|
+
release acceptance. A checkout that already holds the intended commit skips the clone and the
|
|
137
|
+
`git checkout`.
|
|
138
|
+
|
|
125
139
|
This owns `~/.local/bin/entwurf` as a symlink to that checkout's `run.sh` and fails if the
|
|
126
140
|
link is foreign, outside PATH, or shadowed by another command. It detects and wires
|
|
127
141
|
pi/Claude/agy/Copilot/OMP/Codex by presence and prints a computed per-component
|
|
@@ -207,18 +221,21 @@ physical-Darwin receipt. `entwurf install` may write `.pi/settings.json`
|
|
|
207
221
|
on Darwin; that is wiring, not a rail receipt. If `pi` is on PATH,
|
|
208
222
|
`setup` names the pi row FAIL with the §1 Darwin wording.
|
|
209
223
|
|
|
210
|
-
Install
|
|
224
|
+
Install this candidate's exact Pi floor, then wire the project from the candidate's source
|
|
225
|
+
checkout (§1; `entwurf` is the link `./run.sh setup` made to it). The published 0.41.0 instead
|
|
226
|
+
takes the Pi 1.3.x its own setup guide names and the global package root,
|
|
227
|
+
`$(npm root -g)/@junghanacs/entwurf`, in place of the checkout:
|
|
211
228
|
|
|
212
229
|
```bash
|
|
213
|
-
npm install -g @earendil-works/pi-coding-agent@1.
|
|
230
|
+
npm install -g @earendil-works/pi-coding-agent@1.4.0
|
|
214
231
|
pi --version
|
|
215
232
|
|
|
216
233
|
cd ~/entwurf-smoke
|
|
217
234
|
entwurf install .
|
|
218
|
-
pi -e
|
|
235
|
+
pi -e ~/repos/gh/entwurf --list-models entwurf
|
|
219
236
|
```
|
|
220
237
|
|
|
221
|
-
The supported range is `>=1.
|
|
238
|
+
The supported range is `>=1.4.0 <1.5`. It is a hard minimum: installing this
|
|
222
239
|
release onto a 0.83.x pi host upgrades the runtime rather than keeping the older
|
|
223
240
|
minor. A host using only the external MCP bridge can skip pi until it needs a
|
|
224
241
|
control socket; no delivery rail launches a pi process.
|
|
@@ -240,8 +257,15 @@ A plain `pi` can load the package without becoming a citizen.
|
|
|
240
257
|
In that session ask for `entwurf_self`, then `entwurf_peers`. Pick an existing
|
|
241
258
|
live garden id and ask for `entwurf_v2` with `intent: "fire-and-forget"` and your
|
|
242
259
|
message. These are harness tools, not shell subcommands; pi exposes them through
|
|
243
|
-
Pi's built-in MCP as `mcp__entwurf_bridge__<verb>`. The bridge has
|
|
244
|
-
pi hides `entwurf_inbox_read` and `entwurf_register_native`, leaving
|
|
260
|
+
Pi's built-in MCP as `mcp__entwurf_bridge__<verb>`. The 0.41.0 bridge has nine tools;
|
|
261
|
+
pi hides `entwurf_inbox_read` and `entwurf_register_native`, leaving seven active.
|
|
262
|
+
The added `entwurf_retire_call` is only for one target/action explicitly requested by the
|
|
263
|
+
human operator, never autonomous cleanup. Where a host provides or is configured with an approval
|
|
264
|
+
step, that step applies as well; Pi and pi-durable 1.3.0 have no built-in approval prompt by
|
|
265
|
+
default. The configured Claude ACP child is also [set to run without per-call permission prompts](./acp-backend-rail.md#configured-claude-acp-permissions); ACP retirement remains unmeasured.
|
|
266
|
+
Tool availability, annotations and caller identity are not a grant.
|
|
267
|
+
These candidate counts do not claim that an older installed package or running host has rolled
|
|
268
|
+
forward; see [applying new source to a running host](#applying-new-source-to-a-running-host).
|
|
245
269
|
Tool-call ingress is MCP; delivery to a pi citizen is still its record-addressed
|
|
246
270
|
control address (a Unix socket; a named pipe on the experimental native-Windows Pi
|
|
247
271
|
preview). A boot/listing from `check-bridge` is not a model-turn or
|
|
@@ -315,7 +339,7 @@ by `entwurf pi` or by registering ordinary Pi's adapter.
|
|
|
315
339
|
What the Entwurf package supplies:
|
|
316
340
|
|
|
317
341
|
1. **The pi SDK set** — `@earendil-works/{pi-coding-agent, pi-ai, pi-tui, pi-agent-core,
|
|
318
|
-
pi-durable, chord, pi-mcp, pi-codemode, pi-telemetry}` at exactly `1.
|
|
342
|
+
pi-durable, chord, pi-mcp, pi-codemode, pi-telemetry}` at exactly `1.4.0`, plus
|
|
319
343
|
`proper-lockfile`, as Entwurf's own production dependencies (declared once in
|
|
320
344
|
`pi/pi-durable/overlay/upstream-pin.json` `sdk`). Model data rides the published
|
|
321
345
|
`pi-ai`; there is no catalog copy.
|
|
@@ -328,7 +352,7 @@ What the Entwurf package supplies:
|
|
|
328
352
|
(`pi/pi-durable/carrier-resolver.mjs`), which maps them to the published package's
|
|
329
353
|
own `dist/` as installed — never through a source checkout.
|
|
330
354
|
|
|
331
|
-
The upstream [library guide](https://github.com/earendil-works/pi/blob/v1.
|
|
355
|
+
The upstream [library guide](https://github.com/earendil-works/pi/blob/v1.4.0/packages/durable/README.md)
|
|
332
356
|
and the [experimental app guide](https://github.com/earendil-works/pi/blob/v1.1.0/packages/coding-agent/src/experimental/durable/README.md)
|
|
333
357
|
(last published at v1.1.0) describe what the carrier is. Running the bare upstream app does not attach Entwurf's
|
|
334
358
|
garden contact. Entwurf does not rebuild the controller/model/auth/storage system or
|
|
@@ -413,7 +437,7 @@ tree (for example an override that binds pi-durable's `pi-ai` to an older publis
|
|
|
413
437
|
manifest field; it is a known constraint of supplying both from one package, accepted for 0.32,
|
|
414
438
|
not a claim that every load path is harmless.
|
|
415
439
|
|
|
416
|
-
The ordinary Pi CLI on PATH is a separate subject (§2, range `>=1.
|
|
440
|
+
The ordinary Pi CLI on PATH is a separate subject (§2, range `>=1.4.0 <1.5`): a host Pi is
|
|
417
441
|
not needed to run pi-durable, and a host inside the range need not be the same copy as the
|
|
418
442
|
carrier's. Native Pi owns credentials, model settings and durable SQLite sessions; keep them
|
|
419
443
|
in place rather than copying credentials or migrating ordinary Pi transcripts. Native
|
|
@@ -424,6 +448,29 @@ to the app's own model resolution; the checkout contact/send/receive gates run t
|
|
|
424
448
|
with scripted S plus native H; Pi ↔ pi-durable LIVE on this supply surface is a separate release
|
|
425
449
|
proof ([#130](https://github.com/junghan0611/entwurf/issues/130)).
|
|
426
450
|
|
|
451
|
+
### Moving durable sessions from the Pi 1.3 SDK to 1.4
|
|
452
|
+
|
|
453
|
+
This candidate is unpublished, and moving a live host onto it is a separate operator decision. When
|
|
454
|
+
you do:
|
|
455
|
+
|
|
456
|
+
1. **Confirm the old owner is gone.** Close the 0.41 (SDK 1.3) durable session normally and check
|
|
457
|
+
that its process has exited. A stale session lock alone is not proof.
|
|
458
|
+
2. **Back up first.** Before the first 1.4 open, copy the target session directory whole
|
|
459
|
+
(`session.sqlite` with any `-wal`/`-shm`) as one consistent set.
|
|
460
|
+
3. **Choose the session by name.** Reopen with the `--resume` picker and pick the entry you mean;
|
|
461
|
+
do not use `--continue` on a host with more than one session.
|
|
462
|
+
4. **Package downgrade is not data rollback.** The first 1.4 open migrates SQLite storage to
|
|
463
|
+
schema 2, which SDK 1.3 (Entwurf 0.41) refuses to open. Reinstalling the old package does not
|
|
464
|
+
undo that. Restoring the backup does, and work done after the 1.4 open is not in the backup.
|
|
465
|
+
5. **Inspect parked work before retrying.** On its first open, 1.4 counts a task that SDK 1.3 left
|
|
466
|
+
running as one restart, and after repeated cut-offs it parks the task (`restart_limit`). Read
|
|
467
|
+
what the task already did before you resend anything: recovery of a parked model turn is not
|
|
468
|
+
measured, and its side effects or outcome may be unknown. The existing conversation abort is
|
|
469
|
+
cancellation, not retry; it need not affect background tasks.
|
|
470
|
+
|
|
471
|
+
The measurements behind these points, and what they do not cover, are in the
|
|
472
|
+
[#148 checkpoint](https://github.com/junghan0611/entwurf/issues/148#issuecomment-6339048361).
|
|
473
|
+
|
|
427
474
|
### Maintainer: re-emitting the carrier
|
|
428
475
|
|
|
429
476
|
Only a maintainer changing the pi-durable app runs `scripts/emit-pi-durable-carrier.ts`. Its
|
|
@@ -497,7 +544,7 @@ entwurf install-meta-bridge
|
|
|
497
544
|
entwurf doctor-meta-bridge
|
|
498
545
|
```
|
|
499
546
|
|
|
500
|
-
The supported floor `>=2.1.
|
|
547
|
+
The supported floor `>=2.1.306` is enforced by package metadata through installer
|
|
501
548
|
and doctor gates. Older Claude versions validate an exec-form hook but silently drop
|
|
502
549
|
its `args` at runtime, so there is no shell-form fallback.
|
|
503
550
|
|
|
@@ -640,7 +687,7 @@ as a note rather than a fault.
|
|
|
640
687
|
|
|
641
688
|
**entwurf sets no OMP version floor in code, and will not grow one on schedule.** Detection is
|
|
642
689
|
presence-only (`command -v "${OMP_BIN:-omp}"`); there is no `entwurf.ompFloor`, no coherence
|
|
643
|
-
gate, and no exact pin — unlike Node (`engines.node`), pi (`>=1.
|
|
690
|
+
gate, and no exact pin — unlike Node (`engines.node`), pi (`>=1.4.0 <1.5`) and Claude Code
|
|
644
691
|
(`entwurf.claudeCodeFloor`), each of which has an enforcement point. That asymmetry is a
|
|
645
692
|
decision, not an omission. A floor is the answer to a vendor that fails SILENTLY — Claude Code
|
|
646
693
|
earned one because an older binary validates the exec manifest, drops `args` at runtime, and
|
|
@@ -1158,11 +1205,20 @@ may be missing or stale. Installed mode never bootstraps pnpm in `node_modules`.
|
|
|
1158
1205
|
|
|
1159
1206
|
### Applying new source to a running host
|
|
1160
1207
|
|
|
1161
|
-
New source on disk is not a fresh runtime.
|
|
1208
|
+
New source on disk is not a fresh runtime. From a checkout, each surface loads different bytes, and each refreshes differently:
|
|
1209
|
+
|
|
1210
|
+
| Surface | Loads | Refreshed by |
|
|
1211
|
+
|---|---|---|
|
|
1212
|
+
| pi-durable host (contact and bridge) | compiled `mcp/entwurf-bridge/dist/…`, even from a checkout (the bootstrap's `PACKAGED_BRIDGE_ENTRY`) | a build, then a new host process |
|
|
1213
|
+
| ordinary Pi's built-in MCP bridge | the same compiled `dist` entry (`entwurfBridgeCompiledEntry`); the Pi extension itself loads from the package source | a build, then a new Pi process |
|
|
1214
|
+
| an MCP host registered through `start.sh` (Claude Code, Codex, Copilot, OMP, agy) | the TypeScript source in a checkout (compiled only under `node_modules`) | a new bridge child; no build |
|
|
1215
|
+
| installed hook/extension copies (Claude Code meta-bridge, Codex birth, Copilot birth and receive, OMP birth and receive) | their own installed copy, neither `dist` nor the checkout source | that unit's owning installer, then a new vendor session |
|
|
1216
|
+
|
|
1217
|
+
An already-running process keeps the modules it loaded, whatever is on disk. So, for a host that has to run new bridge code:
|
|
1162
1218
|
|
|
1163
1219
|
1. Build once from the checkout (`pnpm run build-bridge`, or its body `bash scripts/with-dist-lock.sh bash scripts/build-bridge.sh` when no package lifecycle should run).
|
|
1164
1220
|
2. Compare the whole compiled output with the accepted candidate's, as a file-set and hash manifest. A hash of the selected entry alone is not enough: the entry can stay identical while a helper it loads differs. Check the pinned SDK set separately, and that the new process started after the build.
|
|
1165
|
-
3. Close the visible session and reopen it with its own native same-id resume.
|
|
1221
|
+
3. Close the visible session and reopen it with its own native same-id resume. Closing a working sibling is an operator decision for that session, not something an agent does because new bytes landed.
|
|
1166
1222
|
4. Confirm `entwurf_self` names the same garden id, and that the receiver/owner join points at the new process, whose bridge child started after the build.
|
|
1167
1223
|
|
|
1168
1224
|
A per-component setup PASS or a `git pull` does not show which bytes a running bridge loaded. On 2026-10-20 one host of the #107 pair still selected a pre-feature dist after a pull and a green setup. The cause was not traced. A hash check caught it before the first message, and a manual build and visible resume fixed it. Entwurf adds no auto-build, reload or watcher for this.
|
|
@@ -31,16 +31,18 @@
|
|
|
31
31
|
* seat, #105; nothing is ever created), optionally at ONE literal requested
|
|
32
32
|
* cwd (cross-repo fresh, #73); returns a LAUNCH receipt only, and the new
|
|
33
33
|
* address arrives later as the sender envelope of the sibling's nonce callback.
|
|
34
|
-
* - entwurf_resume_call — reopen ONE DORMANT pi citizen under its OWN garden id
|
|
35
|
-
* window; target-only,
|
|
36
|
-
* apart.
|
|
34
|
+
* - entwurf_resume_call — reopen ONE DORMANT pi or Claude Code citizen under its OWN garden id
|
|
35
|
+
* in a visible window; target-only, submits no prompt, LAUNCH and OBSERVATION
|
|
36
|
+
* receipts stay apart, the caller is attributed but grants nothing.
|
|
37
|
+
* - entwurf_retire_call — one operator-requested target, one retirement attempt; actor is not
|
|
38
|
+
* permission, and action / append-record / lock-release receipts stay separate.
|
|
37
39
|
* - entwurf_callback — ZERO-ARGUMENT fresh-sibling callback. Reads ENTWURF_CALLBACK_TARGET +
|
|
38
40
|
* ENTWURF_CALLBACK_NONCE from this process env (launcher-injected), validates
|
|
39
41
|
* grammar, and dispatches through the existing v2 runner. Refuses by name
|
|
40
42
|
* when env is absent/malformed or this process is a Codex-provenance bridge.
|
|
41
43
|
* No model-supplied target fallback.
|
|
42
44
|
*
|
|
43
|
-
* That list is the WHOLE public surface —
|
|
45
|
+
* That list is the WHOLE public surface — nine verbs — and `check-entwurf-bridge-boot`
|
|
44
46
|
* (G1f) holds it as an exact set on the runtime tools/list, so a verb added or dropped
|
|
45
47
|
* here without a decision is red rather than merely undocumented.
|
|
46
48
|
*
|
|
@@ -77,6 +79,8 @@ import { ENTWURF_PEERS_RENDER_LIMIT, renderEntwurfPeers } from "../../../pi-exte
|
|
|
77
79
|
import { computeSelfAddressability } from "../../../pi-extensions/lib/entwurf-self-address.js";
|
|
78
80
|
import { nativePushSupported } from "../../../pi-extensions/lib/entwurf-v2-contract.js";
|
|
79
81
|
import { resolveMailboxWakeModeCapability } from "../../../pi-extensions/lib/entwurf-v2-decider.js";
|
|
82
|
+
import { retireCitizen } from "../../../pi-extensions/lib/entwurf-v2-retire.js";
|
|
83
|
+
import { makeProductionRetireDeps } from "../../../pi-extensions/lib/entwurf-v2-retire-production.js";
|
|
80
84
|
import { runAndRenderEntwurfV2FromSurface } from "../../../pi-extensions/lib/entwurf-v2-surface.js";
|
|
81
85
|
import { makeVisibleResumeDeps, renderVisibleResume, visibleResume, } from "../../../pi-extensions/lib/entwurf-v2-visible-resume.js";
|
|
82
86
|
import { dispatchFreshCall, renderDispatchedFreshCall } from "../../../pi-extensions/lib/fresh-call-dispatch.js";
|
|
@@ -686,7 +690,7 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling beside you and
|
|
|
686
690
|
// as an unused suppression while the escape goes back to being fixable.
|
|
687
691
|
// biome-ignore lint/complexity/noUselessEscapeInRegex: emitted to a Rust regex validator, see above
|
|
688
692
|
.regex(/^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/)
|
|
689
|
-
.describe("Required runtime model: canonical provider/model for pi, provider/<exact catalog model id> for pi-durable, a Claude Code model id/alias, or a Copilot/OMP/Codex model name."),
|
|
693
|
+
.describe("Required runtime model: canonical provider/model for pi (a bare alias is refused), provider/<exact catalog model id> for pi-durable, a Claude Code model id/alias, or a Copilot/OMP/Codex model name. Neither pi nor pi-durable may name the openrouter provider."),
|
|
690
694
|
task: z
|
|
691
695
|
.string()
|
|
692
696
|
.min(1)
|
|
@@ -763,36 +767,94 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling beside you and
|
|
|
763
767
|
// resume` cannot open a window on its own and `mux-resume-call` cannot read a record — that is
|
|
764
768
|
// the import fence in docs/mux-launch-rail.md §11, expressed as a function argument.
|
|
765
769
|
const visibleResumeLaunch = (input) => {
|
|
766
|
-
const launched = resumeCall({ cwd: input.cwd, runtimeArgs: input.runtimeArgs });
|
|
770
|
+
const launched = resumeCall({ backend: input.backend, cwd: input.cwd, runtimeArgs: input.runtimeArgs });
|
|
767
771
|
return launched.ok
|
|
768
772
|
? { ok: true, handle: launched.receipt }
|
|
769
773
|
: { ok: false, reason: launched.reason, hint: RESUME_CALL_REJECT_HINT[launched.reason] };
|
|
770
774
|
};
|
|
771
|
-
server.tool("entwurf_resume_call", "Reopen ONE DORMANT pi citizen under its OWN garden id, in a visible window in the operator's
|
|
772
|
-
"session. The record supplies everything — which
|
|
773
|
-
"the only input is the target id: there is no model override, no task, and no
|
|
774
|
-
"
|
|
775
|
-
"
|
|
776
|
-
"
|
|
777
|
-
"
|
|
778
|
-
"
|
|
779
|
-
"
|
|
780
|
-
"
|
|
775
|
+
server.tool("entwurf_resume_call", "Reopen ONE DORMANT pi or Claude Code citizen under its OWN garden id, in a visible window in the operator's " +
|
|
776
|
+
"own tmux session. The record supplies everything — which session, which cwd, and for pi which transcript, " +
|
|
777
|
+
"model and provider — so the only input is the target id: there is no model override, no task, and no " +
|
|
778
|
+
"prompt, and a Claude Code session comes back as `claude --resume <its session id>` with none of the model " +
|
|
779
|
+
"or permission flags it was once started with. This verb submits NO prompt; what the runtime itself does " +
|
|
780
|
+
"after it comes back (for Claude Code, its hook re-arms the doorbell and unread mail may then wake a turn) " +
|
|
781
|
+
"is not measured, and talking to it is still entwurf_v2 fire-and-forget. You get TWO receipts and they mean " +
|
|
782
|
+
"different things: a LAUNCH receipt (tmux made a window and was asked to start the runtime) and an " +
|
|
783
|
+
"OBSERVATION receipt (pi: the control socket answered under the same id; Claude Code: a NEW live process in " +
|
|
784
|
+
"the window this call opened re-armed its receiver marker for the same session with a fresh arm binding the " +
|
|
785
|
+
"record also carries; or resume-unobserved; or record-unreadable with its cause). Unobserved is a real " +
|
|
786
|
+
"outcome, not an error to retry — the window is visible, so read it. A citizen that is already LIVE is " +
|
|
787
|
+
"refused: address it with entwurf_v2 instead. A Claude Code citizen is reopened only when the last validated " +
|
|
788
|
+
"owner the Entwurf hook armed for its session is proven gone AND that arm is still the record's last one; " +
|
|
789
|
+
"no marker, an unreadable or mismatched one, an inconclusive owner or an unbound one is refused, and a " +
|
|
790
|
+
"process holding the session without a committed hook attach is invisible to that proof. Other backends " +
|
|
791
|
+
"have no same-id resume. Requires that this agent itself runs inside tmux (inside herdr it is refused).", {
|
|
781
792
|
target: z
|
|
782
793
|
.string()
|
|
783
794
|
.min(1)
|
|
784
795
|
.regex(/^\d{8}T\d{6}-[0-9a-f]{6}$/)
|
|
785
|
-
.describe("Garden id of the DORMANT pi citizen to reopen (discover with entwurf_peers)."),
|
|
786
|
-
}, async ({ target }) => {
|
|
796
|
+
.describe("Garden id of the DORMANT pi or Claude Code citizen to reopen (discover with entwurf_peers)."),
|
|
797
|
+
}, async ({ target }, extra) => {
|
|
798
|
+
// Who asked, from the same authoritative caller identity fresh_call reads. Attribution only:
|
|
799
|
+
// an anonymous caller is reported as unattributed, never refused, and a record-backed one is
|
|
800
|
+
// granted nothing the target's record and live facts do not already decide.
|
|
801
|
+
let actor = null;
|
|
802
|
+
try {
|
|
803
|
+
actor = (await buildAuthoritativeSelfEnvelope({ requestMeta: extra._meta })).envelope.sessionId;
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
if (!(err instanceof EntwurfEnvelopeWiringError)) {
|
|
807
|
+
return textErr(`entwurf_resume_call error: ${err instanceof Error ? err.message : String(err)}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
787
810
|
try {
|
|
788
811
|
const result = await visibleResume(target, makeVisibleResumeDeps(visibleResumeLaunch));
|
|
789
|
-
const rendered = renderVisibleResume(result);
|
|
812
|
+
const rendered = renderVisibleResume(result, { actor });
|
|
790
813
|
return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
|
|
791
814
|
}
|
|
792
815
|
catch (err) {
|
|
793
816
|
return textErr(`entwurf_resume_call error: ${err instanceof Error ? err.message : String(err)}`);
|
|
794
817
|
}
|
|
795
818
|
});
|
|
819
|
+
server.tool("entwurf_retire_call", "Only when the human operator explicitly requested this particular target/work: make ONE retirement attempt for that request. " +
|
|
820
|
+
"Development work, a role or a record-backed actor is NOT a grant. Use host approval where provided/configured; " +
|
|
821
|
+
"Pi and pi-durable have no built-in per-call approval prompt by default. An explicit operator request and " +
|
|
822
|
+
"agent operating policy are required; attribution and MCP annotations are hints, not grants or proof of approval. " +
|
|
823
|
+
"Never use for autonomous cleanup or timers. Never automatically retry an unknown, cancelled or lost result. " +
|
|
824
|
+
"Target must be an existing Pi or Claude Code garden id; self retirement refuses. Pi shutdown-requested is " +
|
|
825
|
+
"only an ACK, not process exit, flush or model silence. Claude Code exited means that fd-pinned process only, " +
|
|
826
|
+
"not conversation end, children or flush. Do not manually /resume or /clear the target during the request. " +
|
|
827
|
+
"The JSON receipt preserves outcome, record and release separately; returning a receipt does not mean the " +
|
|
828
|
+
"target exited. Cancellation can suppress the wire response without undoing an action. For plural explicit " +
|
|
829
|
+
"requests, invoke once for each named target; never infer a roster or expand the request.", {
|
|
830
|
+
target: z
|
|
831
|
+
.string()
|
|
832
|
+
.min(1)
|
|
833
|
+
.regex(/^\d{8}T\d{6}-[0-9a-f]{6}$/)
|
|
834
|
+
.describe("Exact garden id of the Pi or Claude Code target the human operator explicitly requested to retire."),
|
|
835
|
+
}, { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, async ({ target }, extra) => {
|
|
836
|
+
try {
|
|
837
|
+
const receipt = await retireCitizen(target, makeProductionRetireDeps({
|
|
838
|
+
resolveCaller: async () => (await resolveAuthoritativeSender({ requestMeta: extra._meta }))?.envelope ?? null,
|
|
839
|
+
signal: extra.signal,
|
|
840
|
+
}));
|
|
841
|
+
// isError reports uncertainty/faults, NOT a common Pi-ACK/CC-exit success boundary.
|
|
842
|
+
// Named refusals are completed decisions. Always preserve the full three-axis JSON.
|
|
843
|
+
const outcome = receipt.outcome;
|
|
844
|
+
const uncertain = outcome.kind === "unknown" ||
|
|
845
|
+
outcome.kind === "preflight-error" ||
|
|
846
|
+
(outcome.kind === "pi" && outcome.result.boundary === "unknown") ||
|
|
847
|
+
(outcome.kind === "claude-code" && outcome.result.outcome === "unknown");
|
|
848
|
+
const recordFault = receipt.record.status === "requested-append-error" || receipt.record.status === "outcome-append-error";
|
|
849
|
+
const releaseFault = receipt.release.status !== "released" && receipt.release.status !== "not-acquired";
|
|
850
|
+
const json = JSON.stringify(receipt);
|
|
851
|
+
return uncertain || recordFault || releaseFault ? textErr(json) : textOk(json);
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
return textErr(`entwurf_retire_call error: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
855
|
+
"The outcome may be unknown; this does not prove no action. Do not automatically retry.");
|
|
856
|
+
}
|
|
857
|
+
});
|
|
796
858
|
server.tool("entwurf_callback", "ZERO-ARGUMENT callback for a fresh sibling this process was launched as. Reads " +
|
|
797
859
|
"ENTWURF_CALLBACK_TARGET and ENTWURF_CALLBACK_NONCE from this process environment " +
|
|
798
860
|
"(injected by the launcher next to the identity scrub), validates garden-id and nonce " +
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""cc-retire-pidfd — terminate ONE expected Linux process through a pidfd, or say why not (#147 G3).
|
|
2
|
+
|
|
3
|
+
A numeric PID names whichever process holds that number NOW. Checking a PID and then signalling
|
|
4
|
+
it leaves a window in which the process can exit and the number be reused, so the signal lands on
|
|
5
|
+
a stranger. A pidfd names the process it was opened on, for as long as the fd is open: a signal
|
|
6
|
+
sent through it can never reach a later holder of the number. This helper closes exactly that
|
|
7
|
+
check -> signal race, and nothing else.
|
|
8
|
+
|
|
9
|
+
python3 -I -S cc-retire-pidfd.py <expected-pid> <expected-start-key>
|
|
10
|
+
|
|
11
|
+
Order, and why each step is where it is:
|
|
12
|
+
|
|
13
|
+
1. Validate the inputs: a decimal pid > 1 and a `linux:<starttime>` start key (the scheme
|
|
14
|
+
`processStartKey` in meta-session.ts mints from /proc/<pid>/stat field 22).
|
|
15
|
+
2. pidfd_open(pid). From here the fd pins the process the number named at this instant.
|
|
16
|
+
3. Re-read /proc/<pid>/stat and compare the start key. /proc is a NUMERIC path, so this read
|
|
17
|
+
could already describe a reused number...
|
|
18
|
+
4. ...which is why the fd is then asked whether ITS process has exited. Not exited + key match
|
|
19
|
+
ties the fd and the stat to the same live process.
|
|
20
|
+
5. Print `{"phase": "ready"}` and wait, bounded, for ONE complete control frame on stdin — at
|
|
21
|
+
most CONTROL_MAX bytes of ASCII ending in a newline. Only the exact frame `go\n` proceeds;
|
|
22
|
+
`cancel\n`, EOF (with or without partial bytes), an oversized or non-ASCII frame, any other
|
|
23
|
+
line, a read failure, or the deadline (which partial input never extends) sends nothing. The caller re-checks its own
|
|
24
|
+
record/marker binding while the fd is held; this helper reads no record, marker, role or
|
|
25
|
+
permission and makes no decision of its own about WHETHER to terminate.
|
|
26
|
+
6. On `go`: ask the fd again (exited -> no signal), then pidfd_send_signal(fd, SIGTERM, None, 0)
|
|
27
|
+
ONCE, then poll the same fd for its exit within a fixed bound.
|
|
28
|
+
|
|
29
|
+
What it does NOT do, ever: signal a numeric pid, a process group, any signal but SIGTERM, a second
|
|
30
|
+
signal, an escalation, a retry, or a fallback to os.kill / kill(1) / Node's process.kill. A process
|
|
31
|
+
that ignores SIGTERM is left running and reported as `exit-unobserved`.
|
|
32
|
+
|
|
33
|
+
What the result does NOT mean:
|
|
34
|
+
- `signal-requested` means the kernel accepted the signal for that process, nothing more.
|
|
35
|
+
- `exited` means THAT process exited (its pidfd became readable). It is not its exit code, not
|
|
36
|
+
proof it flushed anything, and not proof its children are gone.
|
|
37
|
+
- A pidfd pins a PROCESS, not a conversation: a process that switched its native session after
|
|
38
|
+
the caller's last check is still the process this terminates.
|
|
39
|
+
- The start key is the same tick-resolution, same-boot value meta-session.ts compares; it is not
|
|
40
|
+
a cryptographic identity. The helper, the caller and the target must share one host, one PID
|
|
41
|
+
namespace and one procfs.
|
|
42
|
+
|
|
43
|
+
Output: JSON lines on stdout. Every normal protocol end prints `{"outcome": ..., ...}` as its last
|
|
44
|
+
line; a crash or a broken pipe can end without one, and a caller must read a missing outcome as
|
|
45
|
+
unknown — after `signal-requested` it means the signal may have been sent. Exit codes:
|
|
46
|
+
0 exited — SIGTERM was requested through the fd and that process's exit was observed
|
|
47
|
+
2 refused — nothing was opened or nothing was signalled: bad input, capability absent,
|
|
48
|
+
pid not found, permission, /proc unreadable, start-key mismatch, open error
|
|
49
|
+
3 not-signalled — the fd was held and NO signal was sent: cancelled, EOF, truncated / oversized
|
|
50
|
+
/ bad control, control read failure, go deadline, or the process had already
|
|
51
|
+
exited
|
|
52
|
+
4 unknown-after-signal — SIGTERM was requested but the exit was not observed in the bound, or
|
|
53
|
+
observing it failed; nothing is retried
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
import errno
|
|
57
|
+
import json
|
|
58
|
+
import os
|
|
59
|
+
import re
|
|
60
|
+
import select
|
|
61
|
+
import signal
|
|
62
|
+
import sys
|
|
63
|
+
import time
|
|
64
|
+
|
|
65
|
+
# Fixed on purpose — no flag or environment knob, so an outcome means the same thing everywhere.
|
|
66
|
+
GO_DEADLINE_S = 10.0
|
|
67
|
+
EXIT_WAIT_S = 5.0
|
|
68
|
+
|
|
69
|
+
# Linux PID_MAX_LIMIT (2^22): no pid_t a Linux kernel hands out is larger, so anything above it is
|
|
70
|
+
# malformed input, refused before any syscall.
|
|
71
|
+
PID_MAX = 4194304
|
|
72
|
+
# PID_MAX has 7 digits; fullmatch on this bound runs BEFORE int(), so no input reaches int()'s
|
|
73
|
+
# string-length limit and every over-long decimal is the named malformed-pid refusal.
|
|
74
|
+
PID_RE = re.compile(r"[0-9]{1,7}")
|
|
75
|
+
KEY_RE = re.compile(r"linux:[0-9]{1,20}")
|
|
76
|
+
CONTROL_MAX = 16
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def emit(obj):
|
|
80
|
+
sys.stdout.write(json.dumps(obj, sort_keys=True) + "\n")
|
|
81
|
+
sys.stdout.flush()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def finish(code, outcome, **facts):
|
|
85
|
+
emit(dict(outcome=outcome, **facts))
|
|
86
|
+
sys.exit(code)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def start_key(pid):
|
|
90
|
+
"""`linux:<starttime>` from /proc/<pid>/stat, split after the LAST ')' (comm may hold parens)."""
|
|
91
|
+
with open("/proc/%d/stat" % pid, "r", encoding="ascii", errors="strict") as fh:
|
|
92
|
+
stat = fh.read()
|
|
93
|
+
fields = stat[stat.rindex(")") + 1:].split()
|
|
94
|
+
starttime = fields[19]
|
|
95
|
+
if not starttime.isdigit():
|
|
96
|
+
raise ValueError("starttime field is not numeric")
|
|
97
|
+
return "linux:" + starttime
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def exited(fd):
|
|
101
|
+
"""True when the pidfd's process has exited (readable); raises when the fd cannot be observed."""
|
|
102
|
+
poller = select.poll()
|
|
103
|
+
poller.register(fd, select.POLLIN)
|
|
104
|
+
events = poller.poll(0)
|
|
105
|
+
for _, mask in events:
|
|
106
|
+
if mask & (select.POLLNVAL | select.POLLERR):
|
|
107
|
+
raise OSError(errno.EBADF, "pidfd not observable")
|
|
108
|
+
if mask & select.POLLIN:
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def read_control(deadline):
|
|
114
|
+
"""ONE complete control frame from stdin before `deadline` (monotonic; partial input does not
|
|
115
|
+
extend it). Returns (kind, line): ("line", text) for a complete ASCII frame, or one of
|
|
116
|
+
("eof",), ("truncated",), ("oversize",), ("bad-encoding",), ("deadline",), ("read-failed",)."""
|
|
117
|
+
buf = b""
|
|
118
|
+
stdin = sys.stdin.fileno()
|
|
119
|
+
while True:
|
|
120
|
+
remaining = deadline - time.monotonic()
|
|
121
|
+
if remaining <= 0:
|
|
122
|
+
return ("deadline", None)
|
|
123
|
+
try:
|
|
124
|
+
ready, _, _ = select.select([stdin], [], [], remaining)
|
|
125
|
+
if not ready:
|
|
126
|
+
continue
|
|
127
|
+
chunk = os.read(stdin, CONTROL_MAX + 1)
|
|
128
|
+
except OSError:
|
|
129
|
+
return ("read-failed", None)
|
|
130
|
+
if not chunk:
|
|
131
|
+
return ("eof", None) if not buf else ("truncated", None)
|
|
132
|
+
buf += chunk
|
|
133
|
+
if b"\n" in buf:
|
|
134
|
+
frame = buf.split(b"\n", 1)[0]
|
|
135
|
+
if len(frame) > CONTROL_MAX:
|
|
136
|
+
return ("oversize", None)
|
|
137
|
+
try:
|
|
138
|
+
return ("line", frame.decode("ascii"))
|
|
139
|
+
except UnicodeDecodeError:
|
|
140
|
+
return ("bad-encoding", None)
|
|
141
|
+
if len(buf) > CONTROL_MAX:
|
|
142
|
+
return ("oversize", None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv):
|
|
146
|
+
if len(argv) != 3:
|
|
147
|
+
finish(2, "refused", reason="usage")
|
|
148
|
+
raw_pid, key = argv[1], argv[2]
|
|
149
|
+
if not PID_RE.fullmatch(raw_pid) or not 1 < int(raw_pid) <= PID_MAX:
|
|
150
|
+
finish(2, "refused", reason="malformed-pid")
|
|
151
|
+
if not KEY_RE.fullmatch(key):
|
|
152
|
+
finish(2, "refused", reason="malformed-start-key")
|
|
153
|
+
pid = int(raw_pid)
|
|
154
|
+
if not sys.platform.startswith("linux") or not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
|
|
155
|
+
finish(2, "refused", reason="capability-unsupported")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
fd = os.pidfd_open(pid, 0)
|
|
159
|
+
except OSError as err:
|
|
160
|
+
if err.errno == errno.ESRCH:
|
|
161
|
+
finish(2, "refused", reason="pid-not-found")
|
|
162
|
+
if err.errno == errno.EPERM:
|
|
163
|
+
finish(2, "refused", reason="permission-denied")
|
|
164
|
+
if err.errno in (errno.ENOSYS, errno.EINVAL):
|
|
165
|
+
finish(2, "refused", reason="capability-unsupported", errno=errno.errorcode.get(err.errno, err.errno))
|
|
166
|
+
finish(2, "refused", reason="open-failed", errno=errno.errorcode.get(err.errno, err.errno))
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
try:
|
|
170
|
+
observed = start_key(pid)
|
|
171
|
+
except (OSError, ValueError, IndexError, UnicodeError):
|
|
172
|
+
finish(2, "refused", reason="proc-unreadable")
|
|
173
|
+
if observed != key:
|
|
174
|
+
finish(2, "refused", reason="start-key-mismatch")
|
|
175
|
+
try:
|
|
176
|
+
if exited(fd):
|
|
177
|
+
finish(3, "already-exited")
|
|
178
|
+
except OSError:
|
|
179
|
+
finish(2, "refused", reason="observe-failed")
|
|
180
|
+
|
|
181
|
+
emit({"phase": "ready", "pid": pid})
|
|
182
|
+
kind, line = read_control(time.monotonic() + GO_DEADLINE_S)
|
|
183
|
+
if kind == "deadline":
|
|
184
|
+
finish(3, "go-deadline")
|
|
185
|
+
if kind == "eof":
|
|
186
|
+
finish(3, "cancelled-eof")
|
|
187
|
+
if kind == "truncated":
|
|
188
|
+
finish(3, "truncated-control")
|
|
189
|
+
if kind == "oversize":
|
|
190
|
+
finish(3, "oversize-control")
|
|
191
|
+
if kind == "read-failed":
|
|
192
|
+
finish(3, "control-read-failed")
|
|
193
|
+
if kind != "line":
|
|
194
|
+
finish(3, "bad-control")
|
|
195
|
+
if line == "cancel":
|
|
196
|
+
finish(3, "cancelled")
|
|
197
|
+
if line != "go":
|
|
198
|
+
finish(3, "bad-control")
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
if exited(fd):
|
|
202
|
+
finish(3, "already-exited")
|
|
203
|
+
except OSError:
|
|
204
|
+
finish(3, "observe-failed-before-signal")
|
|
205
|
+
try:
|
|
206
|
+
signal.pidfd_send_signal(fd, signal.SIGTERM, None, 0)
|
|
207
|
+
except OSError as err:
|
|
208
|
+
if err.errno == errno.ESRCH:
|
|
209
|
+
finish(3, "already-exited")
|
|
210
|
+
if err.errno == errno.EPERM:
|
|
211
|
+
finish(2, "refused", reason="permission-denied")
|
|
212
|
+
finish(2, "refused", reason="signal-failed", errno=errno.errorcode.get(err.errno, err.errno))
|
|
213
|
+
emit({"phase": "signal-requested", "signal": "SIGTERM"})
|
|
214
|
+
|
|
215
|
+
poller = select.poll()
|
|
216
|
+
poller.register(fd, select.POLLIN)
|
|
217
|
+
deadline = time.monotonic() + EXIT_WAIT_S
|
|
218
|
+
while True:
|
|
219
|
+
remaining = deadline - time.monotonic()
|
|
220
|
+
if remaining <= 0:
|
|
221
|
+
finish(4, "exit-unobserved", waitedS=EXIT_WAIT_S)
|
|
222
|
+
try:
|
|
223
|
+
events = poller.poll(int(remaining * 1000) + 1)
|
|
224
|
+
except OSError:
|
|
225
|
+
finish(4, "observe-failed")
|
|
226
|
+
for _, mask in events:
|
|
227
|
+
if mask & (select.POLLNVAL | select.POLLERR):
|
|
228
|
+
finish(4, "observe-failed")
|
|
229
|
+
if mask & select.POLLIN:
|
|
230
|
+
finish(0, "exited")
|
|
231
|
+
finally:
|
|
232
|
+
os.close(fd)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
main(sys.argv)
|