@junghanacs/entwurf 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +60 -5
  3. package/CHANGELOG.md +557 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +52 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +37 -17
  8. package/VERIFY.md +23 -10
  9. package/docs/acp-backend-rail.md +5 -1
  10. package/docs/external-mcp-host.md +8 -6
  11. package/docs/fresh-cut-policy.md +9 -1
  12. package/docs/setup-clean-host.md +64 -9
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
  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/remote-pair-receive.js +123 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  36. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  37. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  38. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  39. package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
  40. package/mcp/entwurf-bridge/src/index.ts +118 -24
  41. package/mcp/entwurf-bridge/test.sh +2 -2
  42. package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
  43. package/package.json +5 -5
  44. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  45. package/pi-extensions/entwurf-control.ts +55 -20
  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-mailbox-body.ts +13 -4
  58. package/pi-extensions/lib/meta-session.ts +140 -16
  59. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  60. package/pi-extensions/lib/mux-launch.ts +29 -0
  61. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  62. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  63. package/pi-extensions/lib/remote-pair-receive.ts +184 -0
  64. package/pi-extensions/lib/remote-pair-send.ts +325 -0
  65. package/pi-extensions/lib/remote-pair-transport.ts +81 -0
  66. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  67. package/pi-extensions/lib/retirement-event.ts +253 -0
  68. package/pi-extensions/meta-bridge-hook.ts +142 -13
  69. package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
  70. package/run.sh +122 -7
  71. package/scripts/agy-bridge-config.py +4 -1
  72. package/scripts/build-bridge.sh +14 -0
  73. package/scripts/check-capability-bundle-reach.ts +135 -2
  74. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  75. package/scripts/check-entwurf-fact-provider.ts +1 -0
  76. package/scripts/check-entwurf-v2-production.ts +2 -0
  77. package/scripts/check-entwurf-v2-surface.ts +7 -0
  78. package/scripts/check-gate-qualification.ts +20 -8
  79. package/scripts/check-herdr-activation.ts +2 -2
  80. package/scripts/check-herdr-placement.ts +1 -0
  81. package/scripts/check-hook-launch-topology.ts +6 -15
  82. package/scripts/check-install-container.sh +2 -2
  83. package/scripts/check-meta-doctor-oracle.sh +27 -1
  84. package/scripts/check-meta-facts.ts +8 -3
  85. package/scripts/check-meta-hook-session-switch.ts +230 -13
  86. package/scripts/check-meta-identity-consumers.ts +1 -0
  87. package/scripts/check-meta-listing.ts +1 -0
  88. package/scripts/check-meta-manifest-schema.py +1 -1
  89. package/scripts/check-meta-session.ts +31 -0
  90. package/scripts/check-meta-v3-record.ts +55 -1
  91. package/scripts/check-mux-resume-call.ts +35 -9
  92. package/scripts/check-peer-facts.ts +1 -0
  93. package/scripts/check-pi-durable-contact.ts +3 -1
  94. package/scripts/check-pi-durable-send.ts +4 -2
  95. package/scripts/check-pi-mcp-bridge.ts +1 -0
  96. package/scripts/check-remote-pair-installed.ts +242 -0
  97. package/scripts/check-resume-launch-identity.ts +62 -23
  98. package/scripts/lib/meta-hook-bundle.ts +72 -0
  99. package/scripts/lib/remote-pair-installed-audit.ts +25 -0
  100. package/scripts/meta-bridge-claude-floor.sh +13 -9
  101. package/scripts/meta-bridge-doctor.sh +17 -0
  102. package/scripts/meta-bridge-install.sh +6 -0
  103. package/scripts/meta-facts.ts +1 -1
  104. package/scripts/mutants/bridge-boot-resume.json +5 -2
  105. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  106. package/scripts/mutants/cc-retire-request.json +171 -0
  107. package/scripts/mutants/control-retire.json +105 -0
  108. package/scripts/mutants/control-send-receipt.json +2 -2
  109. package/scripts/mutants/copilot-receive.json +2 -6
  110. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  111. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  112. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  113. package/scripts/mutants/meta-identity.json +57 -0
  114. package/scripts/mutants/mux-fresh-call.json +1 -1
  115. package/scripts/mutants/mux-resume-call.json +40 -4
  116. package/scripts/mutants/pi-durable.json +79 -0
  117. package/scripts/mutants/pi-retire-request.json +72 -0
  118. package/scripts/mutants/remote-pair-display.json +53 -0
  119. package/scripts/mutants/remote-pair-installed-audit.json +28 -0
  120. package/scripts/mutants/remote-pair-receive.json +126 -0
  121. package/scripts/mutants/remote-pair-roundtrip.json +83 -0
  122. package/scripts/mutants/remote-pair-send.json +194 -0
  123. package/scripts/mutants/remote-pair-transport.json +66 -0
  124. package/scripts/mutants/resume-launch-identity.json +35 -8
  125. package/scripts/mutants/retirement-event.json +195 -0
  126. package/scripts/mutants/v2-surface.json +16 -0
  127. package/scripts/mutants/v2-visible-resume.json +283 -13
  128. package/scripts/probe-bridge-command.ts +1 -0
  129. package/scripts/remote-pair-receive.ts +73 -0
  130. package/scripts/smoke-agy-install-state.sh +2 -2
  131. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  132. package/scripts/smoke-herdr-raw-install-live.sh +5 -4
  133. package/scripts/smoke-meta-install-state.sh +4 -4
  134. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  135. package/scripts/smoke-omp-mcp-state.sh +1 -1
  136. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  137. package/scripts/smoke-pi-provider-state.sh +3 -3
@@ -19,8 +19,7 @@ evidence split, and its three states must not collapse into "macOS is supported"
19
19
  unmeasured Darwin doctor reason is per-process environment DISCOVERY
20
20
  (start-key is portable).
21
21
  - **UNSUPPORTED** — native Windows `setup` and its harness rails. macOS is never this.
22
- An experimental native-Windows Pi preview (0.39.0 candidate, not yet released; release
23
- and npm publication pending) exists outside `setup`; see [BASELINE](../BASELINE.md#release-host-baseline).
22
+ An experimental native-Windows Pi preview (core 0.39.0) exists outside `setup`; see [BASELINE](../BASELINE.md#release-host-baseline).
24
23
 
25
24
  ## Requirements
26
25
 
@@ -32,14 +31,14 @@ evidence split, and its three states must not collapse into "macOS is supported"
32
31
  | entwurf | global/project-local `@junghanacs/entwurf`, or a source checkout | operator command and garden capability |
33
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 |
34
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) |
35
- | Claude Code | optional, **`>=2.1.217`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
34
+ | Claude Code | optional, **`>=2.1.306`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
36
35
  | 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 |
37
36
  | 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) |
38
37
  | Antigravity `agy` | optional, operator-installed and authenticated | native-push citizen |
39
38
  | 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 |
40
39
  | Cortex Code | optional, operator-installed and authenticated | Cortex ACP backend |
41
40
 
42
- Claude Code >=2.1.217 is required for the managed exec-hook lifecycle. The package
41
+ Claude Code >=2.1.306 is required for the managed exec-hook lifecycle and same-id resume. The package
43
42
  never supplies or proxies backend credentials.
44
43
 
45
44
  On a physical Darwin host, `python3` may be the CommandLineTools stub (a
@@ -105,7 +104,7 @@ project-local install, or `./run.sh <command>` from a source checkout. Keep that
105
104
  same scope when upgrading and checking; native process restarts are separate (§7).
106
105
 
107
106
  `check-bridge` is auth-free. It proves the installed prebuilt MCP server boots and
108
- lists the eight garden tools; it does not prove a backend model turn or native hook.
107
+ lists the nine garden tools (0.41.0); it does not prove a backend model turn, native hook or retirement permission.
109
108
 
110
109
  Neither npm form installs a harness runtime. `pi`, Claude Code, Copilot CLI, Codex CLI, agy,
111
110
  Cortex, and their authentication remain operator-owned optional prerequisites for the
@@ -241,10 +240,18 @@ A plain `pi` can load the package without becoming a citizen.
241
240
  In that session ask for `entwurf_self`, then `entwurf_peers`. Pick an existing
242
241
  live garden id and ask for `entwurf_v2` with `intent: "fire-and-forget"` and your
243
242
  message. These are harness tools, not shell subcommands; pi exposes them through
244
- Pi's built-in MCP as `mcp__entwurf_bridge__<verb>`. The bridge has eight tools;
245
- pi hides `entwurf_inbox_read` and `entwurf_register_native`, leaving six active.
243
+ Pi's built-in MCP as `mcp__entwurf_bridge__<verb>`. The 0.41.0 bridge has nine tools;
244
+ pi hides `entwurf_inbox_read` and `entwurf_register_native`, leaving seven active.
245
+ The added `entwurf_retire_call` is only for one target/action explicitly requested by the
246
+ human operator, never autonomous cleanup. Where a host provides or is configured with an approval
247
+ step, that step applies as well; Pi and pi-durable 1.3.0 have no built-in approval prompt by
248
+ 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.
249
+ Tool availability, annotations and caller identity are not a grant.
250
+ These candidate counts do not claim that an older installed package or running host has rolled
251
+ forward; see [applying new source to a running host](#applying-new-source-to-a-running-host).
246
252
  Tool-call ingress is MCP; delivery to a pi citizen is still its record-addressed
247
- Unix control socket. A boot/listing from `check-bridge` is not a model-turn or
253
+ control address (a Unix socket; a named pipe on the experimental native-Windows Pi
254
+ preview). A boot/listing from `check-bridge` is not a model-turn or
248
255
  native-rail acceptance.
249
256
 
250
257
  To inspect the ACP provider from a project-local package instead of a global one:
@@ -449,6 +456,34 @@ The owned source is formatted at upstream width (`biome.json` override, indentWi
449
456
  per-file exception. A formatting change to an owned file changes its emitted bytes. So it is
450
457
  re-emitted with `--write`, and its generated digest is reviewed like any other carrier change.
451
458
 
459
+ ## 2c. Optional remote pair between two hosts (#107)
460
+
461
+ This lets one declared pair of existing citizens on two hosts reach each other over SSH that you already run. [DELIVERY](../DELIVERY.md#remote-pair-107-one-declared-pair-across-two-hosts) owns the contract. The machine boundary is set by a person, and nothing here is automatic.
462
+
463
+ 1. **Connect the hosts yourself.** Use a private network (Tailscale or similar is recommended) and SSH keys and authorization you already trust. From each host, `ssh -o BatchMode=yes <alias> true` must succeed non-interactively, and the other host's non-interactive `command -v entwurf` must resolve to the installation you intend. Entwurf edits no SSH config, key, known-hosts file or network setting. Whatever SSH itself writes under your own policy, such as host-key updates, stays yours.
464
+ 2. **Run the accepted bytes on both hosts, in the processes that matter.** See [applying new source to a running host](#applying-new-source-to-a-running-host). A pulled checkout or a green setup line does not, by itself, show what a running bridge loaded.
465
+ 3. **Write one file on each host,** at `remotePairOptInPath()`. That is the pi agent dir unless `ENTWURF_META_SESSIONS_DIR` moves the meta-record store. Check that the sending bridge's environment and the SSH receive environment resolve the same path. Use mode `0600` and never overwrite a file you did not inspect:
466
+
467
+ ```json
468
+ {
469
+ "remoteGardenId": "<the other host's garden id>",
470
+ "ssh": "<your existing alias for the other host>",
471
+ "localGardenId": "<this host's garden id that talks to it>",
472
+ "remotePairFile": "<absolute path of the other host's own file>"
473
+ }
474
+ ```
475
+
476
+ The two files mirror each other: one host's `remoteGardenId` is the other's `localGardenId`. The other host's `remotePairFile` must spell this path as it is normalized, not through a symlinked alias.
477
+
478
+ To check a file, read it with the repository's internal parser from a source checkout. Use the same agent/store-root settings the bridge runs with. This is a read-only dev-clone check: there is no installed operator validation verb, and it performs no send and no model input.
479
+
480
+ ```bash
481
+ node --experimental-strip-types -e 'import("./pi-extensions/lib/remote-pair-send.ts").then(m=>console.log(m.readRemotePairOptIn(m.remotePairOptInPath())))'
482
+ ```
483
+
484
+ Do not validate the configuration by sending a test message.
485
+ 4. **Use it only between siblings you have open.** The measured self-fetch pair must be active and armed, and the target rail's own admission still applies. Delivery never starts, resumes or wakes an OFF citizen. To an active, armed one it rings the normal doorbell, which may start a turn at once in the receiving TUI. Remove the file to switch the pair off; there is nothing to stop or reconnect.
486
+
452
487
  ## 3. Optional Claude Code native citizen
453
488
 
454
489
  This rail is CERTIFIED on Linux; on macOS it is NOT CERTIFIED — pending physical
@@ -469,7 +504,7 @@ entwurf install-meta-bridge
469
504
  entwurf doctor-meta-bridge
470
505
  ```
471
506
 
472
- The supported floor `>=2.1.217` is enforced by package metadata through installer
507
+ The supported floor `>=2.1.306` is enforced by package metadata through installer
473
508
  and doctor gates. Older Claude versions validate an exec-form hook but silently drop
474
509
  its `args` at runtime, so there is no shell-form fallback.
475
510
 
@@ -1128,6 +1163,26 @@ run `pnpm install` when the lockfile changed and `pnpm run build-bridge` before
1128
1163
  artifact-consuming checks such as `./run.sh check-bridge-delivery`; ignored dist
1129
1164
  may be missing or stale. Installed mode never bootstraps pnpm in `node_modules`.
1130
1165
 
1166
+ ### Applying new source to a running host
1167
+
1168
+ New source on disk is not a fresh runtime. From a checkout, each surface loads different bytes, and each refreshes differently:
1169
+
1170
+ | Surface | Loads | Refreshed by |
1171
+ |---|---|---|
1172
+ | 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 |
1173
+ | 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 |
1174
+ | 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 |
1175
+ | 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 |
1176
+
1177
+ An already-running process keeps the modules it loaded, whatever is on disk. So, for a host that has to run new bridge code:
1178
+
1179
+ 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).
1180
+ 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.
1181
+ 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.
1182
+ 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.
1183
+
1184
+ 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.
1185
+
1131
1186
  ## 8. Release acceptance versus host acceptance
1132
1187
 
1133
1188
  - `entwurf check-bridge`: installed MCP bytes boot; no backend auth.
@@ -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";
@@ -84,6 +88,9 @@ import { probeNativeSenderAlive, reconcileSenderIdentityClaims, resolveCodexRequ
84
88
  import { applyOmpBridgeChildRootPolicy, defaultMetaMailboxDir, defaultMetaSessionsDir, makeStoreRecordReader, readActiveStoreEntries, readMetaIdentityByGardenId, readMetaInbox, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "../../../pi-extensions/lib/meta-session.js";
85
89
  import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.js";
86
90
  import { registerNativeConversation } from "../../../pi-extensions/lib/native-push/register.js";
91
+ import { makeLocalTraceFact, readRemotePairOptIn, remotePairOptInPath, runEntwurfV2WithRemoteRoute, } from "../../../pi-extensions/lib/remote-pair-send.js";
92
+ import { processTransport } from "../../../pi-extensions/lib/remote-pair-transport.js";
93
+ import { CONTROL_SOCKET_DIR } from "../../../pi-extensions/lib/socket-discovery.js";
87
94
  import { probeSocketLiveness } from "../../../pi-extensions/lib/socket-probe.js";
88
95
  const HOME = os.homedir();
89
96
  // ============================================================================
@@ -431,11 +438,22 @@ server.tool("entwurf_v2", "CANONICAL DELIVERY SURFACE for garden ids: message, r
431
438
  // Resolved ONCE so the dispatch-moment timestamp is fixed and the control RPC sender
432
439
  // + the mailbox body sender share one envelope. No replyability gate (see above).
433
440
  const sender = await buildSendSenderEnvelope({ requestMeta: extra._meta });
434
- const rendered = await runAndRenderEntwurfV2FromSurface({ target, intent, message, mode, wants_reply },
435
- // No trust-preflight inputs are passed, and none exist to pass: the preflight on this
436
- // path guarded the resume verdict and left with `owned-outcome`. `senderProvider` is
437
- // the whole options surface now.
438
- { senderProvider: () => sender });
441
+ // #107: the operator's remote-pair opt-in is read per send. Absent = off and the local
442
+ // surface below runs exactly as before; a declared target goes over that one route.
443
+ const rendered = await runEntwurfV2WithRemoteRoute({ target, intent, message, mode, wants_reply }, {
444
+ senderProvider: () => sender,
445
+ route: readRemotePairOptIn(remotePairOptInPath()),
446
+ // The decider's canonical socket dir AND this bridge's ENTWURF_DIR: a trace in either is local.
447
+ localTrace: makeLocalTraceFact({
448
+ sessionsDir: defaultMetaSessionsDir(),
449
+ controlSocketDirs: [CONTROL_SOCKET_DIR, ENTWURF_DIR],
450
+ }),
451
+ transport: processTransport,
452
+ // No trust-preflight inputs are passed, and none exist to pass: the preflight on this
453
+ // path guarded the resume verdict and left with `owned-outcome`. `senderProvider` is
454
+ // the whole options surface now.
455
+ runLocal: (params, opts) => runAndRenderEntwurfV2FromSurface(params, opts),
456
+ });
439
457
  return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
440
458
  }
441
459
  catch (err) {
@@ -672,7 +690,7 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling beside you and
672
690
  // as an unused suppression while the escape goes back to being fixable.
673
691
  // biome-ignore lint/complexity/noUselessEscapeInRegex: emitted to a Rust regex validator, see above
674
692
  .regex(/^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/)
675
- .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."),
676
694
  task: z
677
695
  .string()
678
696
  .min(1)
@@ -749,36 +767,94 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling beside you and
749
767
  // resume` cannot open a window on its own and `mux-resume-call` cannot read a record — that is
750
768
  // the import fence in docs/mux-launch-rail.md §11, expressed as a function argument.
751
769
  const visibleResumeLaunch = (input) => {
752
- const launched = resumeCall({ cwd: input.cwd, runtimeArgs: input.runtimeArgs });
770
+ const launched = resumeCall({ backend: input.backend, cwd: input.cwd, runtimeArgs: input.runtimeArgs });
753
771
  return launched.ok
754
772
  ? { ok: true, handle: launched.receipt }
755
773
  : { ok: false, reason: launched.reason, hint: RESUME_CALL_REJECT_HINT[launched.reason] };
756
774
  };
757
- 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 " +
758
- "session. The record supplies everything — which transcript, which model, which provider, which cwd — so " +
759
- "the only input is the target id: there is no model override, no task, and no prompt. This runs NO turn: " +
760
- "the window comes back with the conversation and waits, and talking to it is still entwurf_v2 " +
761
- "fire-and-forget on the socket this call stands up. You get TWO receipts and they mean different things: a " +
762
- "LAUNCH receipt (tmux made a window and was asked to start pi) and an OBSERVATION receipt (the control " +
763
- "socket answered under the same id, or resume-unobserved). Unobserved is a real outcome, not an error to " +
764
- "retry — the window is visible, so read it. A citizen that is already LIVE is refused: address it with " +
765
- "entwurf_v2 instead. Only pi citizens have a same-id resume, because only they stand a control socket up. " +
766
- "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).", {
767
792
  target: z
768
793
  .string()
769
794
  .min(1)
770
795
  .regex(/^\d{8}T\d{6}-[0-9a-f]{6}$/)
771
- .describe("Garden id of the DORMANT pi citizen to reopen (discover with entwurf_peers)."),
772
- }, 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
+ }
773
810
  try {
774
811
  const result = await visibleResume(target, makeVisibleResumeDeps(visibleResumeLaunch));
775
- const rendered = renderVisibleResume(result);
812
+ const rendered = renderVisibleResume(result, { actor });
776
813
  return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
777
814
  }
778
815
  catch (err) {
779
816
  return textErr(`entwurf_resume_call error: ${err instanceof Error ? err.message : String(err)}`);
780
817
  }
781
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
+ });
782
858
  server.tool("entwurf_callback", "ZERO-ARGUMENT callback for a fresh sibling this process was launched as. Reads " +
783
859
  "ENTWURF_CALLBACK_TARGET and ENTWURF_CALLBACK_NONCE from this process environment " +
784
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)