@junghanacs/entwurf 0.17.1 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +9 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +18 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +23 -7
- package/package.json +1 -1
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
- package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
- package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
- package/pi-extensions/lib/entwurf-v2-surface.ts +18 -3
- package/pi-extensions/lib/meta-session.ts +32 -7
- package/scripts/check-entwurf-v2-mailbox.ts +33 -0
- package/scripts/check-entwurf-v2-runner.ts +18 -0
- package/scripts/check-entwurf-v2-send.ts +13 -1
- package/scripts/check-entwurf-v2-surface.ts +75 -3
- package/scripts/check-hook-launch-topology.ts +70 -1
- package/scripts/check-mailbox-receipt-state.ts +6 -0
- package/scripts/check-meta-doctor-oracle.sh +73 -0
- package/scripts/check-meta-mailbox-state-write.ts +9 -2
- package/scripts/check-meta-manifest-schema.py +19 -1
- package/scripts/check-meta-session.ts +10 -2
- package/scripts/meta-bridge-doctor.sh +18 -3
- package/scripts/meta-bridge-state.py +23 -5
- package/scripts/meta-bridge-statusline.sh +65 -2
- package/scripts/raw-async-delivery/README.md +280 -9
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
- package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
- package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
- package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
- package/scripts/smoke-meta-async-drift.sh +31 -8
- package/scripts/smoke-meta-install-state.sh +170 -11
- package/scripts/smoke-meta-keyset-guard.sh +4 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mailbox-watch.py — P4 prototype (issue #98 option E): the out-of-harness
|
|
3
|
+
observation window.
|
|
4
|
+
|
|
5
|
+
WHY THIS EXISTS
|
|
6
|
+
Mail delivered through the META-MAILBOX rail drops the SAME artifact into
|
|
7
|
+
~/.pi/agent/meta-mailbox/<garden-id>/: a `<stamp>.msg` whose body carries a
|
|
8
|
+
human-shaped envelope (from / session / at / wants reply). The doorbell then
|
|
9
|
+
renames it `.msg.delivered`, and `entwurf_inbox_read` archives it
|
|
10
|
+
`.msg.delivered.read`. Those three suffixes are the per-message truth --
|
|
11
|
+
`state.json` only ever holds a garden-wide "last activity" slot, which is why
|
|
12
|
+
it cannot serve as a per-message receipt.
|
|
13
|
+
|
|
14
|
+
So a single watcher on that directory renders every mailbox-rail message in
|
|
15
|
+
one place, with zero changes to any delivery contract. That is what this
|
|
16
|
+
prints.
|
|
17
|
+
|
|
18
|
+
SCOPE -- read this before believing the output is complete
|
|
19
|
+
This watches ONE directory tree (the given root, `~/.pi/agent/meta-mailbox`
|
|
20
|
+
by default) and prints a line only when a message file's suffix actually
|
|
21
|
+
changes there. That is the whole of what it observes.
|
|
22
|
+
|
|
23
|
+
So it sees a delivery only when the dispatcher chose the META-MAILBOX plan
|
|
24
|
+
and that plan wrote a file. A send that went over a control socket, a send
|
|
25
|
+
that was injected straight into a live conversation, and a send that was
|
|
26
|
+
REJECTED as undeliverable all leave nothing here -- there is no file to
|
|
27
|
+
change. Silence in this window therefore means "no mailbox-rail file
|
|
28
|
+
activity under this root", never "no traffic on the garden".
|
|
29
|
+
|
|
30
|
+
An earlier version of this docstring claimed the opposite ("every rail
|
|
31
|
+
(pi / codex / agy / Claude Code)", "EVERY sibling's traffic"), corrected
|
|
32
|
+
2026-09-03. It is deliberately NOT replaced with a per-backend table: which
|
|
33
|
+
backend takes which plan is the dispatcher's to say, it moves when a backend
|
|
34
|
+
is admitted, and a copy of it here would be the next sentence to rot. The
|
|
35
|
+
authoritative routing lives in pi-extensions/lib/entwurf-v2-decider.ts and
|
|
36
|
+
the domain sets it reads from entwurf-v2-contract.ts. Read those, not this.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import ctypes
|
|
42
|
+
import ctypes.util
|
|
43
|
+
import datetime
|
|
44
|
+
import errno
|
|
45
|
+
import os
|
|
46
|
+
import struct
|
|
47
|
+
import sys
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
IN_CREATE = 0x00000100
|
|
51
|
+
IN_CLOSE_WRITE = 0x00000008
|
|
52
|
+
IN_MOVED_FROM = 0x00000040
|
|
53
|
+
IN_MOVED_TO = 0x00000080
|
|
54
|
+
IN_Q_OVERFLOW = 0x00004000
|
|
55
|
+
IN_ISDIR = 0x40000000
|
|
56
|
+
|
|
57
|
+
# The doorbell's `mv m m.delivered` is a rename WITHIN one directory, so it emits a
|
|
58
|
+
# MOVED_FROM/MOVED_TO pair, not a CREATE. Watching create+close_write alone would
|
|
59
|
+
# miss every delivery transition -- the reason the issue's event list was widened.
|
|
60
|
+
WATCH_MASK = IN_CREATE | IN_CLOSE_WRITE | IN_MOVED_FROM | IN_MOVED_TO
|
|
61
|
+
|
|
62
|
+
EVENT_HDR = struct.Struct("iIII") # wd, mask, cookie, len
|
|
63
|
+
|
|
64
|
+
DEFAULT_ROOT = Path.home() / ".pi" / "agent" / "meta-mailbox"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _libc() -> ctypes.CDLL:
|
|
68
|
+
name = ctypes.util.find_library("c") or "libc.so.6"
|
|
69
|
+
libc = ctypes.CDLL(name, use_errno=True)
|
|
70
|
+
libc.inotify_init1.argtypes = [ctypes.c_int]
|
|
71
|
+
libc.inotify_init1.restype = ctypes.c_int
|
|
72
|
+
libc.inotify_add_watch.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32]
|
|
73
|
+
libc.inotify_add_watch.restype = ctypes.c_int
|
|
74
|
+
return libc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def envelope(path: Path) -> tuple[str, str]:
|
|
78
|
+
"""(sender, first body line) from a mailbox message.
|
|
79
|
+
|
|
80
|
+
The envelope is the human-shaped header the mailbox writer emits; the body
|
|
81
|
+
follows a horizontal rule. Unreadable/renamed-away files yield placeholders --
|
|
82
|
+
a watcher must never crash on a file that moved under it.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
86
|
+
except OSError:
|
|
87
|
+
return ("?", "(unreadable)")
|
|
88
|
+
sender, body_started, first = "?", False, ""
|
|
89
|
+
for line in text.splitlines():
|
|
90
|
+
if not body_started:
|
|
91
|
+
stripped = line.strip()
|
|
92
|
+
if stripped.startswith("from:"):
|
|
93
|
+
sender = stripped[len("from:") :].strip()
|
|
94
|
+
elif stripped.startswith("session:"):
|
|
95
|
+
# The garden id is the reply address; prefer it over the backend label.
|
|
96
|
+
sender = stripped[len("session:") :].strip().split()[0] or sender
|
|
97
|
+
elif set(stripped) == {"─"}:
|
|
98
|
+
body_started = True
|
|
99
|
+
continue
|
|
100
|
+
if line.strip():
|
|
101
|
+
first = line.strip()
|
|
102
|
+
break
|
|
103
|
+
return (sender, first or "(empty body)")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def state_of(name: str) -> str | None:
|
|
107
|
+
"""Map a filename to the per-message state its suffix encodes."""
|
|
108
|
+
if name.endswith(".msg.delivered.read"):
|
|
109
|
+
return "READ"
|
|
110
|
+
if name.endswith(".msg.delivered"):
|
|
111
|
+
return "RUNG"
|
|
112
|
+
if name.endswith(".msg"):
|
|
113
|
+
return "ARRIVED"
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main(argv: list[str]) -> int:
|
|
118
|
+
root = Path(argv[1]).expanduser() if len(argv) > 1 else DEFAULT_ROOT
|
|
119
|
+
if not root.is_dir():
|
|
120
|
+
print(f"mailbox root not found: {root}", file=sys.stderr)
|
|
121
|
+
return 2
|
|
122
|
+
|
|
123
|
+
libc = _libc()
|
|
124
|
+
fd = libc.inotify_init1(0)
|
|
125
|
+
if fd < 0:
|
|
126
|
+
print(f"inotify_init1 failed: {os.strerror(ctypes.get_errno())}", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
wd_dir: dict[int, Path] = {}
|
|
130
|
+
# Names already reported by a directory sweep, so the sweep and the live event
|
|
131
|
+
# for the same file do not print it twice.
|
|
132
|
+
seen: set[str] = set()
|
|
133
|
+
|
|
134
|
+
def report(gid: str, state: str, path: Path) -> None:
|
|
135
|
+
sender, first = envelope(path)
|
|
136
|
+
stamp = datetime.datetime.now().strftime("%H:%M:%S")
|
|
137
|
+
print(f"{stamp} {gid:24} {state:9} {sender} -> {first[:80]}", flush=True)
|
|
138
|
+
|
|
139
|
+
def watch(d: Path, *, sweep: bool = False) -> None:
|
|
140
|
+
wd = libc.inotify_add_watch(fd, str(d).encode(), WATCH_MASK)
|
|
141
|
+
if wd < 0:
|
|
142
|
+
err = ctypes.get_errno()
|
|
143
|
+
# ENOSPC is the watch-limit ceiling; say so plainly instead of dying quiet.
|
|
144
|
+
hint = " (raise fs.inotify.max_user_watches)" if err == errno.ENOSPC else ""
|
|
145
|
+
print(f" ! cannot watch {d.name}: {os.strerror(err)}{hint}", file=sys.stderr)
|
|
146
|
+
return
|
|
147
|
+
wd_dir[wd] = d
|
|
148
|
+
if not sweep:
|
|
149
|
+
return
|
|
150
|
+
# RACE: a citizen's FIRST message can already be on disk before this watch
|
|
151
|
+
# exists. `enqueueMetaMessage` (meta-session.ts:2484-2489) does
|
|
152
|
+
# mkdirSync(dir) and then writeFileSync(messagePath) with nothing in between
|
|
153
|
+
# -- no tmp+rename -- so the .msg can be fully written between our receiving
|
|
154
|
+
# IN_CREATE for the directory and our adding a watch to it. Its CLOSE_WRITE
|
|
155
|
+
# is then gone forever. MEASURED: without this sweep, a
|
|
156
|
+
# `os.mkdir(d); open(d/'x.msg','w').write(...)` pair produced ZERO output.
|
|
157
|
+
# Sweeping right after the watch is added closes the window: anything the
|
|
158
|
+
# watch missed is still on disk, and anything it caught is deduped below.
|
|
159
|
+
try:
|
|
160
|
+
existing = sorted(p for p in d.iterdir() if state_of(p.name))
|
|
161
|
+
except OSError:
|
|
162
|
+
return
|
|
163
|
+
for p in existing:
|
|
164
|
+
if p.name in seen:
|
|
165
|
+
continue
|
|
166
|
+
seen.add(p.name)
|
|
167
|
+
report(d.name, state_of(p.name) or "?", p)
|
|
168
|
+
|
|
169
|
+
watch(root)
|
|
170
|
+
gids = sorted(p for p in root.iterdir() if p.is_dir())
|
|
171
|
+
for d in gids:
|
|
172
|
+
watch(d)
|
|
173
|
+
print(f"# watching {len(gids)} garden mailboxes under {root}", file=sys.stderr)
|
|
174
|
+
print("# TIME GID STATE SENDER -> first line", file=sys.stderr)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
while True:
|
|
178
|
+
buf = os.read(fd, 8192)
|
|
179
|
+
off = 0
|
|
180
|
+
while off < len(buf):
|
|
181
|
+
wd, mask, _cookie, ln = EVENT_HDR.unpack_from(buf, off)
|
|
182
|
+
off += EVENT_HDR.size
|
|
183
|
+
raw = buf[off : off + ln].split(b"\0", 1)[0]
|
|
184
|
+
off += ln
|
|
185
|
+
name = raw.decode("utf-8", "replace")
|
|
186
|
+
# The kernel drops events when the queue fills and reports it as a
|
|
187
|
+
# single wd=-1 event with no name. Saying nothing here would be the
|
|
188
|
+
# exact failure this whole issue is about: traffic that happened and
|
|
189
|
+
# was never shown. Announce the loss instead of swallowing it.
|
|
190
|
+
if mask & IN_Q_OVERFLOW:
|
|
191
|
+
print(
|
|
192
|
+
" ! inotify queue overflow — events were LOST; this window is "
|
|
193
|
+
"incomplete (raise fs.inotify.max_queued_events)",
|
|
194
|
+
file=sys.stderr,
|
|
195
|
+
flush=True,
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
198
|
+
parent = wd_dir.get(wd)
|
|
199
|
+
if parent is None or not name:
|
|
200
|
+
continue
|
|
201
|
+
# A citizen created while we run: watch it AND sweep it, because its
|
|
202
|
+
# first message may already be written (see the race note in watch()).
|
|
203
|
+
if mask & IN_ISDIR and parent == root:
|
|
204
|
+
watch(parent / name, sweep=True)
|
|
205
|
+
continue
|
|
206
|
+
state = state_of(name)
|
|
207
|
+
if state is None:
|
|
208
|
+
continue
|
|
209
|
+
# Print on exactly one event per transition:
|
|
210
|
+
# CLOSE_WRITE — a new .msg whose body is fully flushed. CREATE fires
|
|
211
|
+
# first but the file may still be empty, so printing on CREATE both
|
|
212
|
+
# double-reports (CREATE then CLOSE_WRITE) and can read a half-written
|
|
213
|
+
# envelope. The issue's `-e create` would have hit exactly that.
|
|
214
|
+
# MOVED_TO — the arriving half of the doorbell's in-place rename.
|
|
215
|
+
# MOVED_FROM is the vacating half of the same rename; reporting it too
|
|
216
|
+
# would print every delivery twice under its OLD name.
|
|
217
|
+
if not mask & (IN_CLOSE_WRITE | IN_MOVED_TO):
|
|
218
|
+
continue
|
|
219
|
+
if name in seen:
|
|
220
|
+
seen.discard(name) # the sweep already printed it; let it pass next time
|
|
221
|
+
continue
|
|
222
|
+
report(parent.name, state, parent / name)
|
|
223
|
+
except KeyboardInterrupt:
|
|
224
|
+
return 0
|
|
225
|
+
finally:
|
|
226
|
+
os.close(fd)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
sys.exit(main(sys.argv))
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
"type": "command",
|
|
20
20
|
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/watch-filechanged.sh",
|
|
21
21
|
"asyncRewake": true,
|
|
22
|
-
"timeout": 20
|
|
22
|
+
"timeout": 20,
|
|
23
|
+
"rewakeSummary": "LAB-P1 entwurf inbox: sibling mail arrived",
|
|
24
|
+
"rewakeMessage": "LAB-P1 entwurf mailbox notice:"
|
|
23
25
|
}
|
|
24
26
|
]
|
|
25
27
|
}
|
|
@@ -7,12 +7,21 @@
|
|
|
7
7
|
# subscription session continuation (no `claude -p` spawn).
|
|
8
8
|
#
|
|
9
9
|
# ADDRESSED: the changed path arrives on stdin as `file_path`. The mailbox is
|
|
10
|
-
# simply its directory (<root>/<session_id>/).
|
|
11
|
-
# session's mailbox — no cross-session leakage. Self-contained: it does not even
|
|
10
|
+
# simply its directory (<root>/<session_id>/). Self-contained: it does not even
|
|
12
11
|
# need session_id, the changed-path dirname IS the per-session mailbox.
|
|
13
12
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# CORRECTED 2026-09-03 (#98 Phase 1): this used to add "so this hook reads ONLY its
|
|
14
|
+
# own session's mailbox — no cross-session leakage". The dirname provides no such
|
|
15
|
+
# isolation: this script trusts `file_path` and never checks it against the session.
|
|
16
|
+
# Measured — with the product plugin loaded from user settings, the PRODUCT doorbell
|
|
17
|
+
# processed THIS lab mailbox and raced this hook to `exit 2`. The probe now avoids
|
|
18
|
+
# that ONE coexistence by dropping user settings (`--setting-sources project,local`);
|
|
19
|
+
# any other FileChanged hook in project or local scope would cross-fire the same way.
|
|
20
|
+
# Receipt: README.md "What the probe session actually touches".
|
|
21
|
+
#
|
|
22
|
+
# DOORBELL ONLY: announce "you have mail" + the body path on stderr. Not because
|
|
23
|
+
# stdout is ignored — it is not; the body is `stderr || stdout` (same receipt).
|
|
24
|
+
# stderr is used unconditionally and is never parsed as JSON. Do NOT push imperatives —
|
|
16
25
|
# strong models flag hook-injected commands as prompt injection. The agent
|
|
17
26
|
# self-fetches the body with its own trusted tool.
|
|
18
27
|
set -euo pipefail
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# probe-delivery-transparency.sh — issue #98 Phase 1, probes P1 + P2a + P2b.
|
|
3
|
+
#
|
|
4
|
+
# MEASUREMENT ONLY -- but read the next paragraph before believing that phrase.
|
|
5
|
+
#
|
|
6
|
+
# It edits no product FILE: nothing under pi/meta-bridge/**, pi-extensions/**,
|
|
7
|
+
# mcp/** or scripts/meta-bridge-*.sh, and it installs nothing.
|
|
8
|
+
#
|
|
9
|
+
# Isolation from operator STATE took two tries to get right, and both failures are
|
|
10
|
+
# worth carrying:
|
|
11
|
+
#
|
|
12
|
+
# 1. Without `--setting-sources project,local` the session inherits
|
|
13
|
+
# ~/.claude/settings.json, so the PRODUCT meta-bridge plugin loads next to the
|
|
14
|
+
# lab one and its SessionStart hook mints a REAL garden citizen (a record in
|
|
15
|
+
# ~/.pi/agent/meta-sessions/, a mailbox in ~/.pi/agent/meta-mailbox/).
|
|
16
|
+
# MEASURED: three runs left three citizens behind.
|
|
17
|
+
# 2. Worse, the two doorbells DO cross-fire, and an earlier version of this
|
|
18
|
+
# header claimed they did not. `doorbell.sh` takes `dirname(file_path)` as
|
|
19
|
+
# "its" mailbox unconditionally, so the product doorbell happily processed the
|
|
20
|
+
# LAB mailbox and raced the lab hook to exit 2. When the product hook won, the
|
|
21
|
+
# operator got the DEFAULT "Stop hook feedback" -- from a hook that carries no
|
|
22
|
+
# rewakeSummary -- while hooks.json under test was perfectly correct.
|
|
23
|
+
# MEASURED: two consecutive runs failed P1 that way. The transcript proved it:
|
|
24
|
+
# product wording ("[entwurf inbox] ... entwurf_inbox_read ... lastReadAt")
|
|
25
|
+
# pointing at the lab path /tmp/cc-p98-probe/mailbox/<sid>/.
|
|
26
|
+
#
|
|
27
|
+
# So P1 was non-deterministic until the setting sources were cut. Dropping user
|
|
28
|
+
# settings fixes both: no product plugin, no minted citizen, no doorbell race.
|
|
29
|
+
# `cleanup_citizens` stays as a belt-and-braces sweep for anything a run under the
|
|
30
|
+
# old flags left behind; it deletes only records whose cwd matches this probe's
|
|
31
|
+
# throwaway /tmp path, and reports rather than removes a non-empty mailbox.
|
|
32
|
+
#
|
|
33
|
+
# Claude still writes ~/.claude/projects/<cwd>/<sid>.jsonl and
|
|
34
|
+
# ~/.claude/sessions/<pid>.json of its own accord -- receipt (ii) below IS one of
|
|
35
|
+
# those files. That is Claude's own bookkeeping and is left alone.
|
|
36
|
+
#
|
|
37
|
+
# WHAT EACH PROBE DECIDES
|
|
38
|
+
# P1 hooks.json `rewakeSummary` / `rewakeMessage` on an asyncRewake hook:
|
|
39
|
+
# does the operator's row stop saying "Stop hook feedback", and does the
|
|
40
|
+
# model stop being told "Stop hook blocking error"? Binary reads say both
|
|
41
|
+
# fields are ungated for a local plugin (only the stdout-JSON rewakeSummary
|
|
42
|
+
# is first-party gated). This is the live confirmation, and the receipt
|
|
43
|
+
# that retires README lesson #4.
|
|
44
|
+
# P2a Does Claude RE-EXECUTE the statusline command on the turn an asyncRewake
|
|
45
|
+
# doorbell creates? If not, a statusline unread badge cannot be the primary
|
|
46
|
+
# surface.
|
|
47
|
+
# P2b Does it re-execute again after a MID-TURN tool call drains the mailbox?
|
|
48
|
+
# If not, the badge would keep showing unread after the model already read
|
|
49
|
+
# -- the badge would lie in the direction that matters, so option B would
|
|
50
|
+
# WEAKEN the doorbell-rang-vs-model-read distinction instead of showing it.
|
|
51
|
+
#
|
|
52
|
+
# P2a/P2b need no badge on screen: lab-statusline.sh logs one line per
|
|
53
|
+
# invocation with the count it observed, so the log alone answers both.
|
|
54
|
+
#
|
|
55
|
+
# COST: one interactive subscription session; the wake itself is a continuation,
|
|
56
|
+
# not a `claude -p` spawn.
|
|
57
|
+
# USAGE: ./probe-delivery-transparency.sh [keep] (`keep` leaves tmux alive)
|
|
58
|
+
set -euo pipefail
|
|
59
|
+
|
|
60
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
61
|
+
PLUGIN="$HERE/plugin-entwurf-receive"
|
|
62
|
+
T="${CC_PROBE_DIR:-/tmp/cc-p98-probe}"
|
|
63
|
+
ROOT="$T/mailbox"
|
|
64
|
+
CWD="$T/cwd"
|
|
65
|
+
SLOG="$T/statusline.log"
|
|
66
|
+
SESSION="ccp98"
|
|
67
|
+
KEEP="${1:-}"
|
|
68
|
+
|
|
69
|
+
pass=0 fail=0
|
|
70
|
+
ok() {
|
|
71
|
+
echo " PASS: $*"
|
|
72
|
+
pass=$((pass + 1))
|
|
73
|
+
}
|
|
74
|
+
bad() {
|
|
75
|
+
echo " FAIL: $*"
|
|
76
|
+
fail=$((fail + 1))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
rm -rf "$T"
|
|
80
|
+
mkdir -p "$ROOT" "$CWD/.claude"
|
|
81
|
+
: >"$SLOG"
|
|
82
|
+
|
|
83
|
+
# The lab statusline is wired through the PROJECT settings of a throwaway /tmp
|
|
84
|
+
# cwd, so it overrides the operator's statusline for THIS session only -- no file
|
|
85
|
+
# in ~/.claude is edited. (Claude still WRITES session/transcript files there of
|
|
86
|
+
# its own accord; see the header.)
|
|
87
|
+
cat >"$CWD/.claude/settings.json" <<JSON
|
|
88
|
+
{
|
|
89
|
+
"statusLine": {
|
|
90
|
+
"type": "command",
|
|
91
|
+
"command": "$HERE/lab-statusline.sh"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
JSON
|
|
95
|
+
|
|
96
|
+
# Remove the garden citizens this probe's session minted. Matching is on the
|
|
97
|
+
# record's own `cwd` field against this probe's throwaway /tmp cwd -- an exact
|
|
98
|
+
# string compare on a path no real citizen can have. A record that does not match
|
|
99
|
+
# is never touched, and anything unexpected is printed instead of deleted.
|
|
100
|
+
cleanup_citizens() {
|
|
101
|
+
local cwd="$1"
|
|
102
|
+
python3 - "$cwd" <<'PY'
|
|
103
|
+
import json, shutil, sys
|
|
104
|
+
from pathlib import Path
|
|
105
|
+
|
|
106
|
+
cwd = sys.argv[1]
|
|
107
|
+
recs = Path.home() / ".pi" / "agent" / "meta-sessions"
|
|
108
|
+
boxes = Path.home() / ".pi" / "agent" / "meta-mailbox"
|
|
109
|
+
if not recs.is_dir():
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
removed = []
|
|
113
|
+
for f in sorted(recs.glob("*.meta.json")):
|
|
114
|
+
try:
|
|
115
|
+
d = json.loads(f.read_text())
|
|
116
|
+
except Exception:
|
|
117
|
+
continue
|
|
118
|
+
if d.get("cwd") != cwd:
|
|
119
|
+
continue
|
|
120
|
+
gid = d.get("gardenId") or f.name.removesuffix(".meta.json")
|
|
121
|
+
box = boxes / gid
|
|
122
|
+
# Refuse to delete a mailbox that holds real traffic: only the SessionStart
|
|
123
|
+
# signal file is expected here. Anything else means this citizen was actually
|
|
124
|
+
# used, and a probe must not destroy evidence -- say so and leave it.
|
|
125
|
+
leftovers = sorted(p.name for p in box.iterdir()) if box.is_dir() else []
|
|
126
|
+
if [n for n in leftovers if n != "inbox.signal" and not n.endswith(".log")]:
|
|
127
|
+
print(f" ! left {gid}: mailbox is not empty ({', '.join(leftovers)})")
|
|
128
|
+
continue
|
|
129
|
+
if box.is_dir():
|
|
130
|
+
shutil.rmtree(box)
|
|
131
|
+
f.unlink()
|
|
132
|
+
removed.append(gid)
|
|
133
|
+
|
|
134
|
+
if removed:
|
|
135
|
+
print(f" cleaned {len(removed)} probe-minted citizen(s): {', '.join(removed)}")
|
|
136
|
+
else:
|
|
137
|
+
print(" no probe-minted citizens to clean")
|
|
138
|
+
PY
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
sid_for_cwd() {
|
|
142
|
+
python3 - "$1" <<'PY'
|
|
143
|
+
import json, glob, os, sys
|
|
144
|
+
cwd = sys.argv[1]
|
|
145
|
+
for f in glob.glob(os.path.expanduser("~/.claude/sessions/*.json")):
|
|
146
|
+
try: d = json.load(open(f))
|
|
147
|
+
except Exception: continue
|
|
148
|
+
if d.get("cwd") == cwd and d.get("kind") == "interactive":
|
|
149
|
+
print(d.get("sessionId")); break
|
|
150
|
+
PY
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
echo "== SETUP =="
|
|
154
|
+
echo " claude: $(claude --version 2>/dev/null)"
|
|
155
|
+
echo " plugin: $PLUGIN"
|
|
156
|
+
echo " mailbox: $ROOT"
|
|
157
|
+
echo " slog: $SLOG"
|
|
158
|
+
echo " hooks.json FileChanged entry under test:"
|
|
159
|
+
python3 -c 'import json,sys;print(" "+json.dumps(json.load(open(sys.argv[1]))["hooks"]["FileChanged"][0]["hooks"][0],ensure_ascii=False))' \
|
|
160
|
+
"$PLUGIN/hooks/hooks.json"
|
|
161
|
+
|
|
162
|
+
tmux kill-session -t "$SESSION" 2>/dev/null || true
|
|
163
|
+
tmux new-session -d -s "$SESSION" -x 220 -y 50
|
|
164
|
+
tmux send-keys -t "$SESSION" "export CC_MAILBOX_ROOT='$ROOT' CC_LAB_STATUSLINE_LOG='$SLOG'" Enter
|
|
165
|
+
# --setting-sources project,local DROPS ~/.claude/settings.json, which is where
|
|
166
|
+
# the product meta-bridge plugin is enabled (extraKnownMarketplaces +
|
|
167
|
+
# enabledPlugins). Without it the probe was NOT isolated in two ways, both
|
|
168
|
+
# measured: the product SessionStart hook minted a real garden citizen, and the
|
|
169
|
+
# product doorbell -- which takes dirname(file_path) as "its" mailbox
|
|
170
|
+
# unconditionally -- processed the LAB mailbox and won the race to exit 2, so the
|
|
171
|
+
# operator saw the DEFAULT "Stop hook feedback" from a hook that has no
|
|
172
|
+
# rewakeSummary. Two runs failed P1 that way while hooks.json was correct.
|
|
173
|
+
# The project settings under $CWD/.claude still load, so the lab statusline stays.
|
|
174
|
+
tmux send-keys -t "$SESSION" "cd '$CWD' && claude --plugin-dir '$PLUGIN' --setting-sources project,local --dangerously-skip-permissions" Enter
|
|
175
|
+
|
|
176
|
+
echo "== ARM =="
|
|
177
|
+
SID=""
|
|
178
|
+
for _ in $(seq 1 45); do
|
|
179
|
+
[ -z "$SID" ] && SID=$(sid_for_cwd "$CWD")
|
|
180
|
+
[ -n "$SID" ] && [ -f "$ROOT/$SID/hook.log" ] && break
|
|
181
|
+
sleep 1
|
|
182
|
+
done
|
|
183
|
+
[ -n "$SID" ] || {
|
|
184
|
+
echo "FAIL: session never started"
|
|
185
|
+
exit 1
|
|
186
|
+
}
|
|
187
|
+
[ -f "$ROOT/$SID/hook.log" ] || {
|
|
188
|
+
echo "FAIL: watch never armed"
|
|
189
|
+
exit 1
|
|
190
|
+
}
|
|
191
|
+
echo " sessionId: $SID"
|
|
192
|
+
echo " arm: $(cat "$ROOT/$SID/hook.log")"
|
|
193
|
+
|
|
194
|
+
# (iii) schema acceptance: two @internal fields must not make the plugin unloadable.
|
|
195
|
+
# A rejected hooks.json shows up as a hook/plugin error on the pane at startup.
|
|
196
|
+
STARTUP_PANE="$(tmux capture-pane -t "$SESSION" -p)"
|
|
197
|
+
if printf '%s' "$STARTUP_PANE" | grep -qiE 'hook.*(invalid|error|failed)|plugin.*(invalid|error|failed)|unrecognized key|rewake'; then
|
|
198
|
+
bad "P1(iii) schema: startup complained about the hook config"
|
|
199
|
+
printf '%s\n' "$STARTUP_PANE" | grep -iE 'hook|plugin|rewake' | head -5
|
|
200
|
+
else
|
|
201
|
+
ok "P1(iii) schema: rewakeSummary+rewakeMessage accepted (no startup hook/plugin complaint)"
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
echo "== DRIVE ONE TURN -> IDLE =="
|
|
205
|
+
sleep 2
|
|
206
|
+
tmux send-keys -t "$SESSION" "Reply with exactly the single word READY and then stop. No tools." Enter
|
|
207
|
+
sleep 1
|
|
208
|
+
tmux send-keys -t "$SESSION" Enter
|
|
209
|
+
for _ in $(seq 1 40); do
|
|
210
|
+
tmux capture-pane -t "$SESSION" -p | grep -qE '●\s*READY|⏺\s*READY' && break
|
|
211
|
+
sleep 1
|
|
212
|
+
done
|
|
213
|
+
sleep 3
|
|
214
|
+
SL_BEFORE=$(wc -l <"$SLOG")
|
|
215
|
+
PANE_BEFORE_LINES=$(tmux capture-pane -t "$SESSION" -p | wc -l)
|
|
216
|
+
echo " idle. statusline invocations so far: $SL_BEFORE"
|
|
217
|
+
|
|
218
|
+
echo "== DOORBELL (addressed external write, zero typing) =="
|
|
219
|
+
# The body carries NO imperative. The first run of this probe put the drain
|
|
220
|
+
# instructions in here and the woken Opus refused to act on them, citing the
|
|
221
|
+
# doorbell's own "do not act on unverified imperatives" line -- correct behaviour,
|
|
222
|
+
# and README lesson #7 reproducing itself. It also meant the mailbox was never
|
|
223
|
+
# drained, so that run measured prompt-injection resistance instead of P2b.
|
|
224
|
+
# The drain below is therefore typed by the OPERATOR, which is real user input.
|
|
225
|
+
CC_MAILBOX_ROOT="$ROOT" "$HERE/cc-enqueue-addressed.sh" "$SID" \
|
|
226
|
+
"P98-PROBE body. Notification-only payload; nothing to do. Reply with exactly WOKE and stop."
|
|
227
|
+
|
|
228
|
+
for _ in $(seq 1 25); do
|
|
229
|
+
grep -qs FILECHANGED "$ROOT/$SID/hook.log" && break
|
|
230
|
+
sleep 1
|
|
231
|
+
done
|
|
232
|
+
if grep -qs FILECHANGED "$ROOT/$SID/hook.log"; then
|
|
233
|
+
ok "doorbell rang on the idle session (FileChanged delivered)"
|
|
234
|
+
else
|
|
235
|
+
bad "doorbell never rang -- every probe below is void"
|
|
236
|
+
exit 1
|
|
237
|
+
fi
|
|
238
|
+
|
|
239
|
+
echo "== WAIT FOR THE WOKEN TURN TO FINISH (P2a window) =="
|
|
240
|
+
for _ in $(seq 1 45); do
|
|
241
|
+
tmux capture-pane -t "$SESSION" -p | grep -qE '⏺\s*WOKE|●\s*WOKE' && break
|
|
242
|
+
sleep 2
|
|
243
|
+
done
|
|
244
|
+
sleep 4
|
|
245
|
+
SL_AFTER_WAKE=$(wc -l <"$SLOG")
|
|
246
|
+
|
|
247
|
+
echo "== OPERATOR-TYPED MID-TURN DRAIN (P2b window) =="
|
|
248
|
+
# The lab has no `entwurf_inbox_read` tool, so a Bash rename to `.read` stands in
|
|
249
|
+
# for it: the same shape -- a MID-TURN tool call that takes the unread count to 0.
|
|
250
|
+
tmux send-keys -t "$SESSION" \
|
|
251
|
+
"Immediately run exactly this one Bash command, nothing else first: mv $ROOT/$SID/*.msg.delivered $ROOT/$SID/drained.msg.delivered.read" Enter
|
|
252
|
+
sleep 1
|
|
253
|
+
tmux send-keys -t "$SESSION" Enter
|
|
254
|
+
# Poll the FILESYSTEM, not the pane. Pane text is a race: the first corrected run
|
|
255
|
+
# timed out at 90s while the turn was still thinking (high effort), and reported a
|
|
256
|
+
# P2b failure that was really "the drain had not happened yet". The rename itself
|
|
257
|
+
# is the unambiguous signal that the mid-turn tool call landed.
|
|
258
|
+
DRAINED=0
|
|
259
|
+
for _ in $(seq 1 90); do
|
|
260
|
+
if ls "$ROOT/$SID"/*.msg.delivered.read >/dev/null 2>&1; then
|
|
261
|
+
DRAINED=1
|
|
262
|
+
break
|
|
263
|
+
fi
|
|
264
|
+
sleep 2
|
|
265
|
+
done
|
|
266
|
+
if [ "$DRAINED" -eq 1 ]; then
|
|
267
|
+
ok "mid-turn drain landed (mailbox archived to .read)"
|
|
268
|
+
else
|
|
269
|
+
bad "mid-turn drain never landed -- P2b below is VOID, not a measurement"
|
|
270
|
+
fi
|
|
271
|
+
# Give the status area time to render at least once after the tool result.
|
|
272
|
+
sleep 6
|
|
273
|
+
|
|
274
|
+
PANE="$(tmux capture-pane -t "$SESSION" -p)"
|
|
275
|
+
JSONL="$HOME/.claude/projects/$(printf '%s' "$CWD" | sed 's#/#-#g')/$SID.jsonl"
|
|
276
|
+
|
|
277
|
+
echo
|
|
278
|
+
echo "===================== P1: OPERATOR-VISIBLE ROW ====================="
|
|
279
|
+
printf '%s\n' "$PANE" | grep -nE 'Stop hook feedback|LAB-P1' || echo " (neither string on the pane)"
|
|
280
|
+
if printf '%s' "$PANE" | grep -q 'LAB-P1 entwurf inbox: sibling mail arrived'; then
|
|
281
|
+
ok "P1(i) pane row shows our rewakeSummary"
|
|
282
|
+
else
|
|
283
|
+
bad "P1(i) pane row does NOT show our rewakeSummary"
|
|
284
|
+
fi
|
|
285
|
+
if printf '%s' "$PANE" | grep -q 'Stop hook feedback'; then
|
|
286
|
+
bad "P1(i) pane still shows the default 'Stop hook feedback'"
|
|
287
|
+
else
|
|
288
|
+
ok "P1(i) default 'Stop hook feedback' is gone from the pane"
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
echo
|
|
292
|
+
echo "===================== P1: MODEL-VISIBLE PREFIX ====================="
|
|
293
|
+
if [ -f "$JSONL" ]; then
|
|
294
|
+
echo " transcript: $JSONL"
|
|
295
|
+
python3 - "$JSONL" <<'PY'
|
|
296
|
+
import json, sys
|
|
297
|
+
hits = []
|
|
298
|
+
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
|
|
299
|
+
if "meta-session notice" not in line and "Stop hook" not in line and "LAB-P1" not in line:
|
|
300
|
+
continue
|
|
301
|
+
try: rec = json.loads(line)
|
|
302
|
+
except Exception: continue
|
|
303
|
+
txt = json.dumps(rec, ensure_ascii=False)
|
|
304
|
+
for needle in ("Stop hook blocking error", "LAB-P1 entwurf mailbox notice"):
|
|
305
|
+
if needle in txt:
|
|
306
|
+
i = txt.index(needle)
|
|
307
|
+
hits.append((needle, txt[max(0, i - 60):i + 190]))
|
|
308
|
+
for needle, ctx in hits[:4]:
|
|
309
|
+
print(f" [{needle}]\n …{ctx}…")
|
|
310
|
+
if not hits:
|
|
311
|
+
print(" (neither prefix found in the transcript)")
|
|
312
|
+
PY
|
|
313
|
+
if grep -q 'LAB-P1 entwurf mailbox notice' "$JSONL"; then
|
|
314
|
+
ok "P1(ii) model prefix replaced by our rewakeMessage"
|
|
315
|
+
else
|
|
316
|
+
bad "P1(ii) our rewakeMessage did NOT reach the model"
|
|
317
|
+
fi
|
|
318
|
+
if grep -q 'Stop hook blocking error' "$JSONL"; then
|
|
319
|
+
bad "P1(ii) model still framed with 'Stop hook blocking error'"
|
|
320
|
+
else
|
|
321
|
+
ok "P1(ii) 'Stop hook blocking error' framing is gone"
|
|
322
|
+
fi
|
|
323
|
+
else
|
|
324
|
+
bad "P1(ii) transcript not found at $JSONL"
|
|
325
|
+
fi
|
|
326
|
+
|
|
327
|
+
echo
|
|
328
|
+
echo "===================== P2a / P2b: STATUSLINE RE-EXECUTION ====================="
|
|
329
|
+
echo " invocations before doorbell: $SL_BEFORE"
|
|
330
|
+
echo " --- statusline log lines added after idle ---"
|
|
331
|
+
tail -n +$((SL_BEFORE + 1)) "$SLOG" | sed 's/^/ /'
|
|
332
|
+
SL_AFTER=$(wc -l <"$SLOG")
|
|
333
|
+
WAKE_ADDED=$((SL_AFTER_WAKE - SL_BEFORE))
|
|
334
|
+
DRAIN_ADDED=$((SL_AFTER - SL_AFTER_WAKE))
|
|
335
|
+
echo " added by the doorbell turn: $WAKE_ADDED"
|
|
336
|
+
echo " added by the drain turn: $DRAIN_ADDED"
|
|
337
|
+
|
|
338
|
+
if [ "$WAKE_ADDED" -gt 0 ]; then
|
|
339
|
+
ok "P2a statusline RE-EXECUTED on the asyncRewake turn ($WAKE_ADDED invocation(s))"
|
|
340
|
+
else
|
|
341
|
+
bad "P2a statusline did NOT re-execute -- a badge would not appear when mail lands"
|
|
342
|
+
fi
|
|
343
|
+
|
|
344
|
+
# P2a value check: the invocations during the doorbell turn must have SEEN the
|
|
345
|
+
# mail. A re-execution that still reports unread=0 would draw no badge.
|
|
346
|
+
if tail -n +$((SL_BEFORE + 1)) "$SLOG" | head -n "$WAKE_ADDED" | grep -q 'unread=[1-9]'; then
|
|
347
|
+
ok "P2a those invocations observed unread>=1 (a badge would have been drawn)"
|
|
348
|
+
else
|
|
349
|
+
bad "P2a re-executed but never observed the unread mail"
|
|
350
|
+
fi
|
|
351
|
+
|
|
352
|
+
# P2b: after the mid-turn rename, an invocation must observe unread=0. If every
|
|
353
|
+
# line still says unread>=1, the badge would keep claiming mail the model already
|
|
354
|
+
# read -- option B would then weaken the rang-vs-read distinction, not show it.
|
|
355
|
+
if tail -n +$((SL_AFTER_WAKE + 1)) "$SLOG" | grep -q 'unread=0'; then
|
|
356
|
+
ok "P2b an invocation observed unread=0 after the mid-turn drain (badge clears)"
|
|
357
|
+
else
|
|
358
|
+
bad "P2b no invocation observed unread=0 -- badge would stay stale after the read"
|
|
359
|
+
fi
|
|
360
|
+
|
|
361
|
+
echo
|
|
362
|
+
echo "===================== MAILBOX FINAL STATE ====================="
|
|
363
|
+
ls -1 "$ROOT/$SID/" | sed 's/^/ /'
|
|
364
|
+
echo " hook.log: $(cat "$ROOT/$SID/hook.log")"
|
|
365
|
+
|
|
366
|
+
echo
|
|
367
|
+
echo "===================== PANE (tail) ====================="
|
|
368
|
+
printf '%s\n' "$PANE" | grep -vE '^\s*$' | tail -22 | sed 's/^/ /'
|
|
369
|
+
echo "======================================================="
|
|
370
|
+
|
|
371
|
+
echo
|
|
372
|
+
echo "SUMMARY: $pass pass, $fail fail (claude $(claude --version 2>/dev/null | awk '{print $1}'))"
|
|
373
|
+
echo "receipts: pane above, transcript $JSONL, statusline log $SLOG"
|
|
374
|
+
if [ "$KEEP" = "keep" ]; then
|
|
375
|
+
echo "(tmux session '$SESSION' left alive)"
|
|
376
|
+
else
|
|
377
|
+
tmux kill-session -t "$SESSION" 2>/dev/null || true
|
|
378
|
+
fi
|
|
379
|
+
|
|
380
|
+
# The session inherited the operator's settings, so the PRODUCT meta-bridge minted
|
|
381
|
+
# a real citizen for this throwaway cwd. Give it back.
|
|
382
|
+
echo
|
|
383
|
+
echo "== OPERATOR-STATE CLEANUP =="
|
|
384
|
+
cleanup_citizens "$CWD"
|
|
385
|
+
echo " (left in place, written by Claude itself: ~/.claude/projects/$(printf '%s' "$CWD" | sed 's#/#-#g')/ and ~/.claude/sessions/*.json)"
|
|
386
|
+
|
|
387
|
+
[ "$fail" -eq 0 ]
|