@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.
Files changed (128) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +55 -2
  3. package/CHANGELOG.md +562 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +18 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +60 -45
  8. package/VERIFY.md +14 -11
  9. package/demo/README.md +1 -1
  10. package/docs/acp-backend-rail.md +6 -2
  11. package/docs/external-mcp-host.md +8 -6
  12. package/docs/fresh-cut-policy.md +9 -1
  13. package/docs/setup-clean-host.md +76 -20
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  33. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  34. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  35. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  36. package/mcp/entwurf-bridge/src/index.ts +93 -19
  37. package/mcp/entwurf-bridge/test.sh +2 -2
  38. package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
  39. package/package.json +15 -14
  40. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  41. package/pi/pi-durable/carrier/tui.js +18 -3
  42. package/pi/pi-durable/overlay/upstream-pin.json +12 -12
  43. package/pi-extensions/entwurf-control.ts +55 -20
  44. package/pi-extensions/lib/acp/backend-adapter.ts +5 -3
  45. package/pi-extensions/lib/acp/backend.ts +12 -9
  46. package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  47. package/pi-extensions/lib/cc-retire-request.ts +553 -0
  48. package/pi-extensions/lib/control-retire.ts +195 -0
  49. package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
  50. package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
  51. package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
  52. package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
  53. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
  54. package/pi-extensions/lib/fresh-call-composition.ts +15 -0
  55. package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
  56. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  57. package/pi-extensions/lib/meta-session.ts +140 -16
  58. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  59. package/pi-extensions/lib/mux-launch.ts +29 -0
  60. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  61. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  62. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  63. package/pi-extensions/lib/retirement-event.ts +253 -0
  64. package/pi-extensions/meta-bridge-hook.ts +142 -13
  65. package/pi-extensions/meta-bridge-pi-durable.ts +57 -3
  66. package/run.sh +123 -34
  67. package/scripts/agy-bridge-config.py +4 -1
  68. package/scripts/build-bridge.sh +14 -0
  69. package/scripts/check-capability-bundle-reach.ts +135 -2
  70. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  71. package/scripts/check-entwurf-fact-provider.ts +1 -0
  72. package/scripts/check-entwurf-v2-production.ts +2 -0
  73. package/scripts/check-entwurf-v2-surface.ts +7 -0
  74. package/scripts/check-gate-qualification.ts +14 -8
  75. package/scripts/check-herdr-activation.ts +2 -2
  76. package/scripts/check-herdr-placement.ts +1 -0
  77. package/scripts/check-hook-launch-topology.ts +6 -15
  78. package/scripts/check-install-container.sh +2 -2
  79. package/scripts/check-meta-doctor-oracle.sh +27 -1
  80. package/scripts/check-meta-facts.ts +8 -3
  81. package/scripts/check-meta-hook-session-switch.ts +230 -13
  82. package/scripts/check-meta-identity-consumers.ts +1 -0
  83. package/scripts/check-meta-listing.ts +1 -0
  84. package/scripts/check-meta-manifest-schema.py +1 -1
  85. package/scripts/check-meta-session.ts +31 -0
  86. package/scripts/check-meta-v3-record.ts +55 -1
  87. package/scripts/check-mux-resume-call.ts +35 -9
  88. package/scripts/check-peer-facts.ts +1 -0
  89. package/scripts/check-pi-durable-contact.ts +3 -1
  90. package/scripts/check-pi-durable-send.ts +4 -2
  91. package/scripts/check-pi-mcp-bridge.ts +1 -0
  92. package/scripts/check-resume-launch-identity.ts +62 -23
  93. package/scripts/lib/codex-fresh-live-protocol.ts +3 -2
  94. package/scripts/lib/meta-hook-bundle.ts +72 -0
  95. package/scripts/meta-bridge-claude-floor.sh +13 -9
  96. package/scripts/meta-bridge-doctor.sh +17 -0
  97. package/scripts/meta-bridge-install.sh +6 -0
  98. package/scripts/meta-facts.ts +1 -1
  99. package/scripts/mutants/bridge-boot-resume.json +5 -2
  100. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  101. package/scripts/mutants/cc-retire-request.json +171 -0
  102. package/scripts/mutants/control-retire.json +105 -0
  103. package/scripts/mutants/control-send-receipt.json +1 -1
  104. package/scripts/mutants/copilot-receive.json +2 -6
  105. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  106. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  107. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  108. package/scripts/mutants/meta-identity.json +57 -0
  109. package/scripts/mutants/mux-fresh-call.json +1 -1
  110. package/scripts/mutants/mux-resume-call.json +40 -4
  111. package/scripts/mutants/pack-install.json +4 -4
  112. package/scripts/mutants/pi-durable.json +139 -0
  113. package/scripts/mutants/pi-floor.json +5 -5
  114. package/scripts/mutants/pi-retire-request.json +72 -0
  115. package/scripts/mutants/resume-launch-identity.json +35 -8
  116. package/scripts/mutants/retirement-event.json +195 -0
  117. package/scripts/mutants/v2-surface.json +16 -0
  118. package/scripts/mutants/v2-visible-resume.json +283 -13
  119. package/scripts/probe-bridge-command.ts +1 -0
  120. package/scripts/smoke-agy-install-state.sh +2 -2
  121. package/scripts/smoke-codex-fresh-live.ts +2 -1
  122. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  123. package/scripts/smoke-herdr-raw-install-live.sh +15 -7
  124. package/scripts/smoke-meta-install-state.sh +4 -4
  125. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  126. package/scripts/smoke-omp-mcp-state.sh +1 -1
  127. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  128. package/scripts/smoke-pi-provider-state.sh +3 -3
@@ -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.3.0 <1.4` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
33
- | Native durable (`pi-durable`) | supplied by the Entwurf package (#130): Entwurf's owned app carrier plus the exact 1.3.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) |
34
- | Claude Code | optional, **`>=2.1.217`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
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.217 is required for the managed exec-hook lifecycle. The package
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 eight garden tools; it does not prove a backend model turn or native hook.
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 the exact release floor, then wire the project:
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.3.0
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 "$(npm root -g)/@junghanacs/entwurf" --list-models entwurf
235
+ pi -e ~/repos/gh/entwurf --list-models entwurf
219
236
  ```
220
237
 
221
- The supported range is `>=1.3.0 <1.4`. It is a hard minimum: installing this
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 eight tools;
244
- pi hides `entwurf_inbox_read` and `entwurf_register_native`, leaving six active.
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.3.0`, plus
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.3.0/packages/durable/README.md)
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.3.0 <1.4`): a host Pi is
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.217` is enforced by package metadata through installer
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.3.0 <1.4`) and Claude Code
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. A pi-durable host spawns the compiled bridge `mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js` even from a checkout (the bootstrap's `PACKAGED_BRIDGE_ENTRY`). Ordinary `start.sh` in a checkout runs the TypeScript source instead. An already-running bridge keeps the modules it loaded. So, for a host that has to run new bridge code:
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 in a visible
35
- * window; target-only, runs no turn, LAUNCH and OBSERVATION receipts stay
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 — eight verbs — and `check-entwurf-bridge-boot`
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 own tmux " +
772
- "session. The record supplies everything — which transcript, which model, which provider, which cwd — so " +
773
- "the only input is the target id: there is no model override, no task, and no prompt. This runs NO turn: " +
774
- "the window comes back with the conversation and waits, and talking to it is still entwurf_v2 " +
775
- "fire-and-forget on the socket this call stands up. You get TWO receipts and they mean different things: a " +
776
- "LAUNCH receipt (tmux made a window and was asked to start pi) and an OBSERVATION receipt (the control " +
777
- "socket answered under the same id, or resume-unobserved). Unobserved is a real outcome, not an error to " +
778
- "retry — the window is visible, so read it. A citizen that is already LIVE is refused: address it with " +
779
- "entwurf_v2 instead. Only pi citizens have a same-id resume, because only they stand a control socket up. " +
780
- "Requires that this agent itself runs inside tmux.", {
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)