@junghanacs/entwurf 0.15.1 → 0.16.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 (91) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +354 -0
  3. package/DELIVERY.md +3 -2
  4. package/README.md +68 -88
  5. package/VERIFY.md +4 -1
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +1 -1
  8. package/docs/external-mcp-host.md +147 -9
  9. package/docs/setup-clean-host.md +125 -6
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
  11. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +19 -10
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
  20. package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
  21. package/mcp/entwurf-bridge/src/index.ts +65 -19
  22. package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
  23. package/mcp/tsconfig.json +10 -0
  24. package/package.json +19 -9
  25. package/pi/entwurf-capabilities.json +1 -0
  26. package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
  27. package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
  28. package/pi-extensions/entwurf-control.ts +12 -9
  29. package/pi-extensions/lib/acp/backend-adapter.ts +19 -9
  30. package/pi-extensions/lib/acp/backend.ts +125 -7
  31. package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
  32. package/pi-extensions/lib/entwurf-self-address.ts +18 -7
  33. package/pi-extensions/lib/meta-sender-identity.ts +1 -1
  34. package/pi-extensions/lib/meta-session.ts +219 -5
  35. package/pi-extensions/lib/mux-fresh-call.ts +171 -17
  36. package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
  37. package/pi-extensions/meta-bridge-omp.ts +1244 -0
  38. package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
  39. package/run.sh +399 -31
  40. package/scripts/check-acp-launch-namespace.ts +127 -0
  41. package/scripts/check-acp-prompt-lifecycle.ts +145 -2
  42. package/scripts/check-copilot-birth-hook.ts +28 -1
  43. package/scripts/check-entwurf-self-address.ts +31 -0
  44. package/scripts/check-gate-qualification.ts +7 -3
  45. package/scripts/check-harness-admission-parity.ts +143 -0
  46. package/scripts/check-meta-doctor-oracle.sh +20 -0
  47. package/scripts/check-omp-birth-hook.ts +1049 -0
  48. package/scripts/check-omp-fresh-preflight.ts +208 -0
  49. package/scripts/check-omp-receive-arm.ts +516 -0
  50. package/scripts/check-setup-qualification.sh +40 -2
  51. package/scripts/copilot-bridge-oracle.sh +14 -6
  52. package/scripts/fake-copilot-vendor.sh +4 -2
  53. package/scripts/inventory-verification-surface.ts +1 -1
  54. package/scripts/meta-bridge-hook-log.sh +9 -1
  55. package/scripts/mutants/acp-launch-namespace.json +34 -0
  56. package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
  57. package/scripts/mutants/copilot-birth.json +10 -10
  58. package/scripts/mutants/mux-fresh-call.json +22 -22
  59. package/scripts/mutants/omp-birth.json +173 -0
  60. package/scripts/mutants/omp-fresh.json +300 -0
  61. package/scripts/mutants/omp-receive.json +135 -0
  62. package/scripts/mutants/pack-install.json +2 -2
  63. package/scripts/mutants/self-address.json +34 -0
  64. package/scripts/mutants/setup-verdict.json +35 -0
  65. package/scripts/omp-bridge-doctor.sh +315 -0
  66. package/scripts/omp-bridge-install.sh +221 -0
  67. package/scripts/omp-bridge-oracle.sh +154 -0
  68. package/scripts/omp-bridge-uninstall.sh +57 -0
  69. package/scripts/omp-config-xdev.py +310 -0
  70. package/scripts/omp-config-xdev.sh +76 -0
  71. package/scripts/omp-mcp-bridge.sh +320 -0
  72. package/scripts/omp-mcp-config.py +392 -0
  73. package/scripts/omp-receive-doctor.sh +246 -0
  74. package/scripts/omp-receive-facts.ts +106 -0
  75. package/scripts/omp-receive-install.sh +228 -0
  76. package/scripts/omp-receive-uninstall.sh +60 -0
  77. package/scripts/omp-tool-surface.py +400 -0
  78. package/scripts/raw-acp-child-exit-measure/README.md +285 -0
  79. package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
  80. package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
  81. package/scripts/raw-omp-measure/README.md +420 -0
  82. package/scripts/raw-omp-measure/probe-extension.ts +76 -0
  83. package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
  84. package/scripts/raw-omp-measure/source-audit.md +414 -0
  85. package/scripts/smoke-omp-bridge-state.sh +221 -0
  86. package/scripts/smoke-omp-fresh-live.ts +497 -0
  87. package/scripts/smoke-omp-mcp-state.sh +327 -0
  88. package/scripts/smoke-omp-receive-live.ts +340 -0
  89. package/scripts/smoke-omp-receive-state.sh +196 -0
  90. package/scripts/smoke-setup-verdict.sh +48 -3
  91. package/scripts/tsconfig.json +2 -0
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env python3
2
+ """Read omp's resolved agent-dir config.yml for the MCP tool-surface.
3
+
4
+ stdlib only. This is a RUNTIME read of ``tools.xdev`` / ``tools.xdevInlineDevices``
5
+ (Hard Rule 13 — never an ownership claim). Absent file or absent key means the
6
+ vendor default applies: ``tools.xdev: true`` and an empty inline allowlist
7
+ (oh-my-pi ``settings-schema.ts`` ``tools.xdev`` default true,
8
+ ``tools.xdevInlineDevices`` default ``[]``, measured omp 18.0.0).
9
+
10
+ The doctor, not this leaf, decides RED vs note. This leaf reports the effective
11
+ state and whether any inline glob covers our dialect send-tool name.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import fnmatch
17
+ import os
18
+ import sys
19
+
20
+ # Vendor load order: config.yml then config.yaml (utils/src/dirs.ts MAIN_CONFIG_FILENAMES).
21
+ MAIN_CONFIG_FILENAMES = ("config.yml", "config.yaml")
22
+
23
+ # omp mints mcp__<server>_<tool> after lowercasing and replacing [^a-z_]+ with _.
24
+ # The send tool `entwurf_v2` therefore surfaces as mcp__entwurf_bridge_entwurf_v.
25
+ # A glob "covers our server key" when it matches that dialect name.
26
+ COVER_NAME = "mcp__entwurf_bridge_entwurf_v"
27
+
28
+ TRUE_WORDS = {"true", "yes", "on", "y"}
29
+ FALSE_WORDS = {"false", "no", "off", "n"}
30
+
31
+
32
+ def die(code: int, message: str) -> None:
33
+ sys.stderr.write(message.rstrip("\n") + "\n")
34
+ raise SystemExit(code)
35
+
36
+
37
+ def strip_comment(raw: str) -> str:
38
+ in_single = False
39
+ in_double = False
40
+ escaped = False
41
+ for index, char in enumerate(raw):
42
+ if escaped:
43
+ escaped = False
44
+ continue
45
+ if char == "\\" and in_double:
46
+ escaped = True
47
+ continue
48
+ if char == "'" and not in_double:
49
+ in_single = not in_single
50
+ continue
51
+ if char == '"' and not in_single:
52
+ in_double = not in_double
53
+ continue
54
+ if char == "#" and not in_single and not in_double:
55
+ return raw[:index].rstrip()
56
+ return raw.rstrip()
57
+
58
+
59
+ def split_flow_items(inner: str) -> list[str] | None:
60
+ """Split a flow collection's body on its TOP-LEVEL commas (quote/nest aware)."""
61
+ items: list[str] = []
62
+ buf: list[str] = []
63
+ in_single = False
64
+ in_double = False
65
+ depth = 0
66
+ for char in inner:
67
+ if char == "'" and not in_double:
68
+ in_single = not in_single
69
+ buf.append(char)
70
+ continue
71
+ if char == '"' and not in_single:
72
+ in_double = not in_double
73
+ buf.append(char)
74
+ continue
75
+ if not in_single and not in_double:
76
+ if char in "[{":
77
+ depth += 1
78
+ elif char in "]}":
79
+ depth -= 1
80
+ elif char == "," and depth == 0:
81
+ items.append("".join(buf).strip())
82
+ buf = []
83
+ continue
84
+ buf.append(char)
85
+ if in_single or in_double or depth != 0:
86
+ return None
87
+ items.append("".join(buf).strip())
88
+ return items
89
+
90
+
91
+ def parse_flow_seq(text: str) -> list[object] | None:
92
+ body = text.strip()
93
+ if not (body.startswith("[") and body.endswith("]")):
94
+ return None
95
+ inner = body[1:-1].strip()
96
+ if inner == "":
97
+ return []
98
+ parts = split_flow_items(inner)
99
+ if parts is None:
100
+ return None
101
+ return [parse_scalar(part) for part in parts]
102
+
103
+
104
+ def parse_flow_map(text: str) -> dict[str, object] | None:
105
+ """The flow-mapping form the VENDOR itself writes.
106
+
107
+ omp's own settings writer emits `modelRoles:` followed by an indented `{}`
108
+ (measured on omp 18.0.0, an untouched operator config). The block-only reader
109
+ read that line as a malformed mapping and returned None for the WHOLE file, so
110
+ a perfectly ordinary vendor config classified as `unreadable` and the MCP
111
+ doctor went RED for a reason that had nothing to do with tools.xdev.
112
+ """
113
+ body = text.strip()
114
+ if not (body.startswith("{") and body.endswith("}")):
115
+ return None
116
+ inner = body[1:-1].strip()
117
+ if inner == "":
118
+ return {}
119
+ parts = split_flow_items(inner)
120
+ if parts is None:
121
+ return None
122
+ mapping: dict[str, object] = {}
123
+ for part in parts:
124
+ if part == "":
125
+ return None
126
+ pair = split_key_value(part)
127
+ if pair is None:
128
+ return None
129
+ key, raw = pair
130
+ mapping[key] = parse_scalar(raw)
131
+ return mapping
132
+
133
+
134
+ def parse_scalar(raw: str) -> object:
135
+ text = raw.strip()
136
+ if text == "" or text in ("~", "null", "Null", "NULL"):
137
+ return None
138
+ if (len(text) >= 2) and ((text[0] == text[-1] == '"') or (text[0] == text[-1] == "'")):
139
+ return text[1:-1]
140
+ if text.startswith("[") and text.endswith("]"):
141
+ parsed = parse_flow_seq(text)
142
+ return parsed if parsed is not None else text
143
+ if text.startswith("{") and text.endswith("}"):
144
+ parsed_map = parse_flow_map(text)
145
+ return parsed_map if parsed_map is not None else text
146
+ folded = text.casefold()
147
+ if folded in TRUE_WORDS:
148
+ return True
149
+ if folded in FALSE_WORDS:
150
+ return False
151
+ return text
152
+
153
+
154
+ def split_key_value(stripped: str) -> tuple[str, str] | None:
155
+ in_single = False
156
+ in_double = False
157
+ for index, char in enumerate(stripped):
158
+ if char == "'" and not in_double:
159
+ in_single = not in_single
160
+ continue
161
+ if char == '"' and not in_single:
162
+ in_double = not in_double
163
+ continue
164
+ if char == ":" and not in_single and not in_double:
165
+ key = stripped[:index].strip()
166
+ if not key:
167
+ return None
168
+ if (len(key) >= 2) and ((key[0] == key[-1] == '"') or (key[0] == key[-1] == "'")):
169
+ key = key[1:-1]
170
+ return key, stripped[index + 1 :].strip()
171
+ return None
172
+
173
+
174
+ def parse_block(lines: list[tuple[int, str]], start: int, indent: int) -> tuple[object, int]:
175
+ """Parse a mapping or list whose items are indented strictly deeper than ``indent``."""
176
+ if start >= len(lines):
177
+ return {}, start
178
+ first_indent, first_text = lines[start]
179
+ if first_indent <= indent:
180
+ return {}, start
181
+ if first_text.startswith("{") or first_text.startswith("["):
182
+ # `key:` on one line, a flow collection indented under it on the next — the
183
+ # shape the vendor's own writer produces for an empty map (`modelRoles:\n {}`).
184
+ flow = parse_flow_map(first_text) if first_text.startswith("{") else parse_flow_seq(first_text)
185
+ if flow is None:
186
+ return None, start
187
+ index = start + 1
188
+ if index < len(lines) and lines[index][0] >= first_indent:
189
+ return None, start # a flow collection is the WHOLE block or nothing
190
+ return flow, index
191
+ if first_text.startswith("- "):
192
+ items: list[object] = []
193
+ index = start
194
+ while index < len(lines):
195
+ item_indent, item_text = lines[index]
196
+ if item_indent < first_indent:
197
+ break
198
+ if item_indent > first_indent:
199
+ return None, start
200
+ if not item_text.startswith("- "):
201
+ return None, start
202
+ rest = item_text[2:].strip()
203
+ index += 1
204
+ if rest == "" or split_key_value(rest) is not None:
205
+ nested, index = parse_block(lines, index, item_indent)
206
+ if nested is None:
207
+ return None, start
208
+ if rest == "":
209
+ items.append(nested)
210
+ else:
211
+ pair = split_key_value(rest)
212
+ if pair is None:
213
+ return None, start
214
+ key, raw = pair
215
+ node: dict[str, object] = dict(nested) if isinstance(nested, dict) else {}
216
+ if raw == "":
217
+ child, index = parse_block(lines, index, item_indent)
218
+ if child is None:
219
+ return None, start
220
+ node[key] = child
221
+ else:
222
+ node[key] = parse_scalar(raw)
223
+ items.append(node)
224
+ else:
225
+ items.append(parse_scalar(rest))
226
+ return items, index
227
+
228
+ mapping: dict[str, object] = {}
229
+ index = start
230
+ while index < len(lines):
231
+ item_indent, item_text = lines[index]
232
+ if item_indent < first_indent:
233
+ break
234
+ if item_indent > first_indent:
235
+ return None, start
236
+ pair = split_key_value(item_text)
237
+ if pair is None:
238
+ return None, start
239
+ key, raw = pair
240
+ index += 1
241
+ if raw == "":
242
+ child, index = parse_block(lines, index, item_indent)
243
+ if child is None:
244
+ return None, start
245
+ mapping[key] = child
246
+ else:
247
+ mapping[key] = parse_scalar(raw)
248
+ return mapping, index
249
+
250
+
251
+ def load_top_mapping(text: str) -> dict[str, object] | None:
252
+ lines: list[tuple[int, str]] = []
253
+ for raw in text.splitlines():
254
+ if "\t" in raw.split("#", 1)[0]:
255
+ return None
256
+ stripped_nl = raw.rstrip("\n")
257
+ if stripped_nl.strip() == "":
258
+ continue
259
+ body = strip_comment(stripped_nl)
260
+ if body.strip() == "":
261
+ continue
262
+ if body.strip() in ("---", "...") or body.strip().startswith("%"):
263
+ continue
264
+ indent = len(body) - len(body.lstrip(" "))
265
+ lines.append((indent, body.lstrip(" ")))
266
+ if not lines:
267
+ return {}
268
+ mapping, index = parse_block(lines, 0, -1)
269
+ if mapping is None or index != len(lines) or not isinstance(mapping, dict):
270
+ return None
271
+ return mapping
272
+
273
+
274
+ def covers(globs: list[str]) -> bool:
275
+ for pattern in globs:
276
+ if not pattern:
277
+ continue
278
+ if fnmatch.fnmatchcase(COVER_NAME, pattern):
279
+ return True
280
+ return False
281
+
282
+
283
+ def emit(fields: list[tuple[str, str]]) -> None:
284
+ for key, value in fields:
285
+ sys.stdout.write(f"{key} {value}\n")
286
+
287
+
288
+ def report_unreadable(path: str) -> None:
289
+ emit([("file", path), ("verdict", "unreadable")])
290
+
291
+
292
+ def classify(agent_dir: str) -> None:
293
+ chosen = None
294
+ for name in MAIN_CONFIG_FILENAMES:
295
+ candidate = os.path.join(agent_dir, name)
296
+ if os.path.lexists(candidate):
297
+ chosen = candidate
298
+ break
299
+ if chosen is None:
300
+ emit(
301
+ [
302
+ ("file", "absent"),
303
+ ("xdev-key", "absent"),
304
+ ("xdev", "default-true"),
305
+ ("inline-key", "absent"),
306
+ ("covers", "no"),
307
+ ("verdict", "xdev-on-uncovered"),
308
+ ]
309
+ )
310
+ return
311
+
312
+ path = os.path.abspath(chosen)
313
+ if os.path.isdir(path):
314
+ report_unreadable(path)
315
+ return
316
+ try:
317
+ with open(path, "r", encoding="utf-8") as handle:
318
+ text = handle.read()
319
+ except OSError:
320
+ report_unreadable(path)
321
+ return
322
+
323
+ mapping = load_top_mapping(text)
324
+ if mapping is None:
325
+ report_unreadable(path)
326
+ return
327
+
328
+ tools = mapping.get("tools", None)
329
+ if tools is None:
330
+ emit(
331
+ [
332
+ ("file", path),
333
+ ("xdev-key", "absent"),
334
+ ("xdev", "default-true"),
335
+ ("inline-key", "absent"),
336
+ ("covers", "no"),
337
+ ("verdict", "xdev-on-uncovered"),
338
+ ]
339
+ )
340
+ return
341
+ if not isinstance(tools, dict):
342
+ report_unreadable(path)
343
+ return
344
+
345
+ xdev_present = "xdev" in tools
346
+ xdev_value = tools.get("xdev") if xdev_present else None
347
+ inline_present = "xdevInlineDevices" in tools
348
+ inline_value = tools.get("xdevInlineDevices") if inline_present else None
349
+
350
+ globs: list[str] = []
351
+ if inline_present:
352
+ # Vendor compileInlineGlobs drops a non-array (a scalar is an empty allowlist).
353
+ if isinstance(inline_value, list):
354
+ globs = [item for item in inline_value if isinstance(item, str)]
355
+ elif inline_value is None:
356
+ globs = []
357
+ else:
358
+ globs = []
359
+
360
+ covered = covers(globs)
361
+
362
+ if not xdev_present or xdev_value is None:
363
+ verdict = "xdev-on-uncovered"
364
+ xdev_field = "default-true"
365
+ xdev_key = "absent"
366
+ elif xdev_value is False:
367
+ verdict = "xdev-off"
368
+ xdev_field = "false"
369
+ xdev_key = "present"
370
+ elif xdev_value is True:
371
+ xdev_field = "true"
372
+ xdev_key = "present"
373
+ verdict = "xdev-on-covered" if covered else "xdev-on-uncovered"
374
+ else:
375
+ report_unreadable(path)
376
+ return
377
+
378
+ emit(
379
+ [
380
+ ("file", path),
381
+ ("xdev-key", xdev_key),
382
+ ("xdev", xdev_field),
383
+ ("inline-key", "present" if inline_present else "absent"),
384
+ ("covers", "yes" if covered else "no"),
385
+ ("verdict", verdict),
386
+ ]
387
+ )
388
+
389
+
390
+ def main(argv: list[str]) -> None:
391
+ if len(argv) != 2:
392
+ die(5, "usage: omp-tool-surface.py <absolute-agent-dir>")
393
+ agent_dir = argv[1]
394
+ if not os.path.isabs(agent_dir):
395
+ die(5, "omp-tool-surface: agent dir must be absolute")
396
+ classify(agent_dir)
397
+
398
+
399
+ if __name__ == "__main__":
400
+ main(sys.argv)
@@ -0,0 +1,285 @@
1
+ # Raw measurement — the ACP Claude child that leaves at exit 0 (#72)
2
+
3
+ Receipts for the 2026-09-01 diagnosis pass on
4
+ [#72](https://github.com/junghan0611/entwurf/issues/72). Everything below was read
5
+ off pi session transcripts and vendor `dist/` on the named hosts. Nothing here is
6
+ a repair. The **cause is closed** — see §"ANSWERED": the child is shot by
7
+ `acp-zombie-reaper.service`, a timer installed on oracle for a *different*
8
+ harness's bug. The four retired candidates below stay because they are what
9
+ cleared the ground for it.
10
+
11
+ Evidence grade is on every claim. `measured here` names the file the row came
12
+ from; `read at <path:line>` names source that was read, not run.
13
+
14
+ - `reaper-correlation.py` — lines the anomalous turns up against this host's
15
+ reaper SIGTERM events. Zero events on a host without the unit; that null is
16
+ itself the receipt for the split.
17
+ - `acp-turn-population.py` — run per host, `> acp-pop-<host>.json`. One row per
18
+ ACP-backed assistant turn: outcome class, new-vs-reuse, tool-call count,
19
+ totalTokens, child age, and whether the previous child had already died between
20
+ turns. Re-runnable; the numbers below are its output.
21
+
22
+ ## The signature, from logs rather than a screen
23
+
24
+ The #72 handoff carried the signature as quotes read off Termux screenshots. This
25
+ pass re-read it from the host's own transcript, which is the receipt a screenshot
26
+ is not.
27
+
28
+ Measured here — `oracle:~/.pi/agent/sessions/--home-junghan-nixos-config--/2026-08-31T09-50-53-520Z_01a0573a-9a50-78f2-bbb3-d833d5df50b8.jsonl`,
29
+ records **173 / 175 / 177**, field `message.errorMessage`. Byte-identical to the
30
+ screenshots plus one line they cut off (`(Use \`node --trace-warnings ...\`)`).
31
+
32
+ Child transcripts, measured here — `oracle:~/.pi/agent/claude-config-overlay/projects/-home-junghan-nixos-config/`
33
+ `a190b806-91a3-4d74-a760-668ae60d57f2.jsonl`, `c798f097-9b28-432d-af2c-e9f73ef023ce.jsonl`,
34
+ `b40bce08-3556-42fa-b358-76b09212632f.jsonl`. Lifetimes 1177s / 957s / 983s.
35
+
36
+ thinkpad holds **no** sample: all three sessionIds are absent from its
37
+ `~/.pi/agent/sessions` and overlay `projects` (measured here, 2026-09-01).
38
+
39
+ ## Three readings the handoff carried that the sources do not support
40
+
41
+ 1. **"All three samples are new children, not retained reuse."** Record 173 opens
42
+ with `[acp: reusing live session]` — it is a reuse turn. The `resume=none` in
43
+ the stderr tail is the *child's own spawn line*, and that buffer is
44
+ session-scoped by design, so it outlives the turn and says nothing about it
45
+ (read at `pi-extensions/lib/acp/backend.ts:207-212`, `:1206-1210`). The split
46
+ is 1 reuse + 2 new.
47
+
48
+ 2. **"`[tool:start] Terminal` is where it fails."** `Terminal` is the placeholder
49
+ *title* the vendor gives a `Bash` tool call whose `input.command` has not
50
+ finished streaming (read at
51
+ `node_modules/@agentclientprotocol/claude-agent-acp/dist/tools.js:38`). Every
52
+ Bash call opens that way, in successful turns too. The real shape is: the last
53
+ `tool:start` has no matching `tool:done`.
54
+
55
+ 3. **"`[Request interrupted by user for tool use]` in the child transcript."**
56
+ It lands 0.2–0.4s *before* pi seals the turn — it is `dispose()`'s abort
57
+ artifact, the same misattribution the 2026-08-20 issue comment already named,
58
+ in its `for tool use` variant. Consequence, not cause.
59
+
60
+ ## Four candidate causes, retired by counterexample
61
+
62
+ | candidate | retired by | receipt |
63
+ |---|---|---|
64
+ | the `CLAUDE_SDK_CAN_USE_TOOL_SHADOWED` warning | child `a190b806` printed it at spawn (`11:33:24Z`), then delivered a **successful** turn (record 171, `stopReason=stop`, `11:34:32Z`), then died on the next turn (record 173). The tail is the last 1000 chars of the session-scoped buffer and holds only the spawn lines, so nothing else was written across that success. | oracle session jsonl, records 171/173 + child transcript |
65
+ | the `bypassPermissions` config overlay | `~/.pi/agent/claude-config-overlay/settings.json` is **byte-identical** on thinkpad and oracle. thinkpad: 0 exit-0 deaths in 507 turns. The overlay is a constant across the split. | `cat` on each host, 2026-09-01 |
66
+ | context size | oracle **successes** reached `totalTokens` 416111 — above all three failures (382174 / 390831 / 404972). thinkpad successes reach 494431. | population output |
67
+ | tool-call volume in one turn | on oracle no success exceeded 39 tool starts while all four failures were 47/73/61/67 — but thinkpad succeeds at 138, 111, 89, 78. Real correlation inside oracle; refuted as a cause. | population output |
68
+
69
+ Elapsed time has no *constant* — lifetimes 1177s / 957s / 983s (+969.9s for the
70
+ 2026-08-20 sample) share no value — but it is the axis after all: every one is
71
+ over the reaper's 900s floor (§"ANSWERED"). Read this row as "no constant", not
72
+ as "age is irrelevant". The 2026-07-30
73
+ `prompt timed out after 600000ms` turns are a **different, already-removed**
74
+ failure and the classifier keeps them in their own bucket so they cannot be
75
+ folded in.
76
+
77
+ ## The population
78
+
79
+ | host | ACP turns | EXIT0 mid-turn | died between turns | max starts on a success | max totalTokens on a success | pi |
80
+ |---|---|---|---|---|---|---|
81
+ | oracle (aarch64, 4 cores) | 360 | **4** | **4** | 39 | 416111 | 0.84.3 |
82
+ | thinkpad (x86_64) | 507 | **0** | **0** | 138 | 494431 | 0.84.3 at collection; 0.84.4 since |
83
+
84
+ All four oracle EXIT0 turns are `claude-opus-5` (44 opus-5 turns on that host).
85
+ thinkpad ran 340 opus-5 turns with none. So the model is not the discriminator
86
+ either.
87
+
88
+ **What the split is bounded by, so it is not read as "the host is cursed":** same
89
+ pi (0.84.3 at collection), same node (v24.18.1), same
90
+ `@agentclientprotocol/claude-agent-acp` 0.70.0, same ACP SDK 1.3.0, byte-identical
91
+ overlay. Claude CLI differs by a patch (oracle 2.1.245 / thinkpad 2.1.241) and the
92
+ architecture differs (aarch64 / x86_64). Neither is the discriminator: it is the
93
+ `acp-zombie-reaper` timer that exists only on oracle (§"ANSWERED").
94
+
95
+ The thinkpad rows were collected under pi 0.84.3 and that host is now on 0.84.4,
96
+ so any further thinkpad rows belong in a separate bucket.
97
+
98
+ ## Where the exit(0) comes from
99
+
100
+ Read at `node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js:81-95`:
101
+
102
+ ```js
103
+ async function shutdown() { await agent.dispose().catch(...); process.exit(0); }
104
+ connection.closed.then(shutdown);
105
+ process.on("SIGTERM", shutdown);
106
+ process.on("SIGINT", shutdown);
107
+ ```
108
+
109
+ Three paths, one exit code. `exit 0, no signal` is what all three produce, so the
110
+ exit fact `df7525f` preserved cannot by itself say which fired.
111
+
112
+ entwurf's side saw a close carrying **no reason** — a clean stdout EOF rather than
113
+ an errored stream (read at `pi-extensions/lib/acp/backend.ts:355-365`). So the
114
+ child went first; pi did not close it.
115
+
116
+ ## ANSWERED (2026-09-01, oracle) — it is SIGTERM, from a timer on this host
117
+
118
+ The open question below is closed. It was not derivable from the artifacts the
119
+ previous pass had; it is derivable from one this pass added — **the host's own
120
+ systemd user journal**, which no reading had opened.
121
+
122
+ Measured here — `journalctl --user -b 0` on oracle, and `reaper-correlation.py`,
123
+ which re-derives the table below from `acp-pop-oracle.json` plus that journal:
124
+
125
+ | turn (UTC) | kind | nearest reap | delta |
126
+ |---|---|---|---|
127
+ | 2026-08-31T11:53:01.452Z | EXIT0 | 11:53:01Z pid=1107102 | **0.5s** |
128
+ | 2026-08-31T12:09:26.272Z | EXIT0 | 12:09:26Z pid=1147382 | **0.3s** |
129
+ | 2026-08-31T12:25:52.596Z | EXIT0 | 12:25:52Z pid=1162114 | **0.6s** |
130
+ | 2026-08-20T09:19:52.107Z | EXIT0 | 09:19:52Z pid=3872971 | **0.1s** |
131
+ | 2026-08-31T06:13:15.421Z | between | 06:00:52Z pid=382085 | 743.4s idle |
132
+ | 2026-08-31T06:35:07.230Z | between | 06:29:52Z pid=413476 | 315.2s idle |
133
+ | 2026-08-31T12:56:18.364Z | between | 12:43:52Z pid=1176076 | 746.4s idle |
134
+
135
+ **The pid closes it.** `(node:<pid>)` in the stderr tail is what node's
136
+ `emitWarning` stamps with the emitting process's OWN pid — so the #72 signature
137
+ carried the child's pid all along. Measured here, by `\(node:(\d+)\)` over the
138
+ same `message.errorMessage` fields, against the journal's reaping lines:
139
+
140
+ | record | node pid in the tail | pid the reaper SIGTERMed | reap (KST) |
141
+ |---|---|---|---|
142
+ | 173 | 1107102 | **1107102** | 20:53:01 |
143
+ | 175 | 1147382 | **1147382** | 21:09:26 |
144
+ | 177 | 1162114 | **1162114** | 21:25:52 |
145
+
146
+ Identical in all three. Timestamp agreement alone would leave coincidence and
147
+ reverse-causation open; pid identity does not. Note what this costs the earlier
148
+ readings: the warning line retired as a candidate cause was, the whole time,
149
+ naming the killer.
150
+
151
+ **All four EXIT0 turns land inside one second of a SIGTERM this host sent on
152
+ purpose.** The three surviving `between` rows are the first turn after a reap
153
+ that killed an idle child — the operator never sees those, pi just opens a new
154
+ one. That is **7 turns carrying 8 flags** (2026-08-20 is EXIT0 and `between` at
155
+ once), not eight turns — the table has seven rows and the prose must match it.
156
+
157
+ **Extended to 12/12 across two boots** (measured by fable, oracle, 2026-09-01,
158
+ independent re-extraction). boot -1's journal survives and covers 2026-07-02 to
159
+ 08-16, so the samples that PREDATE this boot close too:
160
+
161
+ | sample | node pid in tail | reap | delta |
162
+ |---|---|---|---|
163
+ | 2026-07-30T11:04:23.555Z — **the sample that opened #72** | 3662184 | Jul 30 20:04:23 | 0.555s |
164
+ | 2026-08-16T10:16:51.415Z — the original field report | 501006 | Aug 16 19:16:51 | ~0s |
165
+ | 2026-08-07T08:23:23.559Z (CLOSED-other) | — | 17:23:23 | 0.559s |
166
+ | 2026-08-07T08:41:23.544Z (CLOSED-other) | — | 17:41:23 | 0.544s |
167
+
168
+ So every anomalous termination in the population — EXIT0 4 + between 4 +
169
+ CLOSED-other 4 — corresponds to a reap. **The founding sample and the last
170
+ sample have one cause.** The reaper script's mtime is 2026-04-18, earlier than
171
+ every sample.
172
+
173
+ **Evidence grade, stated honestly.** The rows with a pid in a transcript
174
+ (173/175/177, 07-30, 08-16) are pid+timestamp double-locked. The four
175
+ `between`/prior-child deaths (382085, 413476, 1176076, 3857303) rest on
176
+ **journal alone** — a between-turn death leaves no stderr tail to carry a pid.
177
+ Timestamp, lifetime and mechanism all agree, but that is a weaker grade and is
178
+ labelled as such rather than folded into the locked set.
179
+
180
+ The killer, read at `/home/junghan/openclaw/scripts/acp-zombie-reaper.sh`, driven
181
+ by `~/.config/systemd/user/acp-zombie-reaper.timer` (`OnUnitActiveSec=5min`,
182
+ `ACP_REAPER_AGE_SECS=900`):
183
+
184
+ ```sh
185
+ mapfile -t stale_acp < <(ps -eo pid,etimes,user,args --no-headers |
186
+ awk -v age="$AGE_SECS" '$2 > age && $0 ~ /claude-agent-acp/ ...')
187
+ ...
188
+ kill_pid "$pid" TERM # → process.on("SIGTERM", shutdown) → dispose() → exit(0)
189
+ ```
190
+
191
+ So of the three exit-0 doors, it is **SIGTERM**. Every downstream fact follows:
192
+ exit 0 with no signal recorded (the handler catches it and leaves cleanly), the
193
+ reasonless stdout EOF, and the `[Request interrupted by user...]` line as
194
+ `dispose()`'s abort artifact.
195
+
196
+ **Why nothing in the previous pass could see it.** The reaper matches on the
197
+ process *name*, and entwurf's ACP child carries the same name as the one acpx
198
+ leaks. It is a workaround for a **different harness's** bug (openclaw acpx 0.5.3,
199
+ upstream PR #245) that fires on any `claude-agent-acp` older than 900s. Its own
200
+ comment states the assumption that fails here — *"Legitimate ACP turns complete
201
+ well under 60s"* — which is true of a turn and false of entwurf's **retained**
202
+ child, whose age is the age of the *session*. A session older than 15 minutes is
203
+ shot at, every 5 minutes, forever.
204
+
205
+ This also names the host discriminator the previous pass left open, and retires
206
+ the last of the elapsed-time reading: lifetimes 1177s / 957s / 983s / 969.9s have
207
+ no constant because the axis is not a constant but a **floor** — every one of
208
+ them is over 900. The script lives in `~/openclaw/scripts/`, installed on oracle
209
+ by hand; it is not in `nixos-config`, so thinkpad never had it. That is the whole
210
+ of the 4-vs-0 split. Not the architecture, not the Claude CLI patch level.
211
+
212
+ **What this does NOT say.** It is not an entwurf defect, and the fix is not in
213
+ this repo's runtime: a correctly working child was shot by an unrelated janitor.
214
+ What #72 may still owe is *diagnosis* — the turn's error text says the child
215
+ "ended (exit code 0)" and gives the operator nothing to distinguish a clean
216
+ external kill from a vendor fault. Any such change is still bounded by the
217
+ issue's repair fence (no timeout, no blind replay, no watcher/supervisor/retry).
218
+ Disposition is GLG's.
219
+
220
+ Repair candidates for the reaper itself, none of them taken here (it is another
221
+ project's file on GLG's operating host):
222
+
223
+ - narrow Phase 1 the way Phase 2 already is — it reaps orphan `claude` only when
224
+ `PPID == 1`, while Phase 1 reaps *any* `claude-agent-acp` by age alone. A child
225
+ with a live parent is somebody's working session.
226
+ - raise `ACP_REAPER_AGE_SECS` above a plausible session lifetime (a palliative,
227
+ not a fix — it only moves the floor).
228
+ - retire the unit if acpx PR #245 has landed upstream.
229
+
230
+ ## The open question, stated so it is measurable
231
+
232
+ > Which of the three exit-0 paths fires — `connection.closed`, `SIGTERM`, or
233
+ > `SIGINT`?
234
+
235
+ It is not derivable from the artifacts we have. It **is** separable from outside
236
+ by whether a signal was delivered. Any probe for that is a launch-seam change and
237
+ therefore GLG's decision, and it owes its own gate cell plus a mutant that dies
238
+ for the right reason before it is worth anything.
239
+
240
+ That question is answered above (SIGTERM). This section is kept as the record
241
+ of how it was stated before it was closed.
242
+
243
+ ## The repair, measured on the host that produced the failure
244
+
245
+ The launcher (`pi-extensions/lib/acp/claude-acp-launch.js`) is a claim about two
246
+ things a unit gate cannot see: what `ps` shows, and what a real signal does.
247
+ Reproduced here on oracle, 2026-09-01 — run it again with the block below.
248
+
249
+ ```sh
250
+ mkfifo /tmp/f
251
+ node pi-extensions/lib/acp/claude-acp-launch.js < /tmp/f 2> /tmp/live.err &
252
+ NPID=$!; exec 3> /tmp/f; sleep 2
253
+ tr '\0' ' ' < /proc/$NPID/cmdline; echo
254
+ # the janitor's own Phase 1 selector, with the age threshold forced to 0
255
+ ps -eo pid,etimes,user,args --no-headers -p $NPID |
256
+ awk -v age=0 '$2 > age && $0 ~ /claude-agent-acp/ && $0 !~ /awk/ { print "MATCH " $1 }'
257
+ kill -TERM $NPID; wait $NPID; echo "exit code: $?"; cat /tmp/live.err
258
+ ```
259
+
260
+ Output:
261
+
262
+ ```
263
+ node pi-extensions/lib/acp/claude-acp-launch.js
264
+ <- no MATCH line: the selector finds nothing
265
+ exit code: 0
266
+ ENTWURF_ACP_LAUNCH_SIGNAL=SIGTERM
267
+ ```
268
+
269
+ Three facts, in the order they matter:
270
+
271
+ 1. **`/proc/<pid>/cmdline` carries no vendor name.** The vendor runs, but as an
272
+ `import` inside this process — so the string the janitor scans is ours.
273
+ 2. **The janitor's own awk, with `age` forced to 0 so every process qualifies,
274
+ selects nothing.** Not "would probably not match": its selector, run against
275
+ the real `ps` row.
276
+ 3. **A real SIGTERM still ends at exit 0** — the vendor's handler is intact and
277
+ nothing was made signal-immune — **and the frame is on stderr.** That line is
278
+ what the backend lifts into `launch observed SIGTERM before child exit`, which
279
+ is the fact this whole investigation had to reconstruct from a journal.
280
+
281
+ Independently reproduced by a second session (fable, oracle, 2026-09-01), which
282
+ also confirmed the adjacent paths: stdin EOF alone exits 0 (the `connection.closed`
283
+ door, live), a SIGTERM arriving 0.15s into the import still terminates (the sink
284
+ guard stands down and the re-raised signal lands), and a launcher copied outside
285
+ the repo exits 1 rather than degrading.