@junghanacs/entwurf 0.16.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/AGENTS.md +4 -2
- package/CHANGELOG.md +333 -0
- package/VERIFY.md +8 -1
- package/docs/acp-backend-rail.md +25 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +15 -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 +4 -4
- 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/acp/acp-client.ts +57 -4
- package/pi-extensions/lib/acp/backend-adapter.ts +59 -0
- package/pi-extensions/lib/acp/backend.ts +453 -11
- package/pi-extensions/lib/acp/event-mapper.ts +43 -6
- 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/run.sh +21 -0
- package/scripts/check-acp-stop-reason.ts +8 -2
- package/scripts/check-acp-usage-accounting.ts +1074 -0
- 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-gate-qualification.ts +1 -0
- 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/lib/pi-record-discovery.ts +47 -0
- 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/mutants/acp-stream-hooks.json +4 -2
- package/scripts/mutants/acp-usage-accounting.json +181 -0
- 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-acp-bundled-mcp-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +2 -2
- package/scripts/smoke-entwurf-v2-matrix-live.ts +60 -10
- 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
- package/scripts/smoke-mux-lifecycle-live.ts +46 -2
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lab-statusline.sh — MEASUREMENT ONLY. Not the product statusline.
|
|
3
|
+
#
|
|
4
|
+
# P2a/P2b instrument (issue #98). The product surface is
|
|
5
|
+
# `scripts/meta-bridge-statusline.sh`; this file must never be installed as it.
|
|
6
|
+
# It exists to answer ONE question the binary read could not settle:
|
|
7
|
+
#
|
|
8
|
+
# Does Claude Code RE-EXECUTE the statusline command
|
|
9
|
+
# (P2a) on the turn an asyncRewake doorbell creates, and
|
|
10
|
+
# (P2b) after a mid-turn tool call drains the mailbox?
|
|
11
|
+
#
|
|
12
|
+
# Method: every invocation appends one line to $CC_LAB_STATUSLINE_LOG carrying
|
|
13
|
+
# the wall clock and the unread count it observed. A new line = the command ran.
|
|
14
|
+
# The count on that line = what a real badge WOULD have drawn at that moment.
|
|
15
|
+
# So the log alone decides both P2a (a line appears when mail lands) and P2b
|
|
16
|
+
# (a line with count 0 appears after the read) — no badge needed on screen.
|
|
17
|
+
# That is why the "you must ship the badge before you can measure it"
|
|
18
|
+
# chicken-and-egg does not hold.
|
|
19
|
+
#
|
|
20
|
+
# CONTRACT: never exit non-zero, never block. Claude renders this every update;
|
|
21
|
+
# a broken exit would put a broken line in front of the operator. Failures
|
|
22
|
+
# degrade to a visible marker, never to a silent zero — the same honesty rule
|
|
23
|
+
# the product badge will need (`✉?` != `✉0`).
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
LOG="${CC_LAB_STATUSLINE_LOG:-/tmp/cc-lab-statusline.log}"
|
|
27
|
+
ROOT="${CC_MAILBOX_ROOT:-}"
|
|
28
|
+
|
|
29
|
+
input=$(cat 2>/dev/null || true)
|
|
30
|
+
|
|
31
|
+
# session_id lets us find THIS session's lab mailbox. The lab addresses mailboxes
|
|
32
|
+
# by Claude's own session_id (cc-enqueue-addressed.sh), not by garden id.
|
|
33
|
+
sid=$(printf '%s' "$input" | python3 -c \
|
|
34
|
+
'import json,sys;print(json.load(sys.stdin).get("session_id",""))' 2>/dev/null || true)
|
|
35
|
+
|
|
36
|
+
# Unread = what `entwurf_inbox_read` would still return: every *.msg (not yet rung)
|
|
37
|
+
# plus every *.msg.delivered (rung, but not yet read). A file already archived to
|
|
38
|
+
# *.msg.delivered.read is NOT unread. This is the same set the product counts at
|
|
39
|
+
# meta-session.ts:2550. `?` means "could not measure" and is kept distinct from
|
|
40
|
+
# `0` on purpose: a statusline that fails must not draw a false zero.
|
|
41
|
+
#
|
|
42
|
+
# ADDRESSING DIFFERS FROM THE PRODUCT. The lab addresses a mailbox by Claude's own
|
|
43
|
+
# `session_id` (that is what cc-enqueue-addressed.sh writes); the product
|
|
44
|
+
# addresses it by GARDEN ID. Only the directory lookup changes -- the counted set,
|
|
45
|
+
# and therefore what P2a/P2b prove about re-execution, is the same.
|
|
46
|
+
count="?"
|
|
47
|
+
if [ -n "$ROOT" ] && [ -n "$sid" ] && [ -d "$ROOT/$sid" ]; then
|
|
48
|
+
n=0
|
|
49
|
+
for f in "$ROOT/$sid"/*.msg "$ROOT/$sid"/*.msg.delivered; do
|
|
50
|
+
[ -e "$f" ] && n=$((n + 1))
|
|
51
|
+
done
|
|
52
|
+
count="$n"
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
printf '%s invoked sid=%s unread=%s\n' \
|
|
56
|
+
"$(date '+%H:%M:%S.%3N')" "${sid:-NONE}" "$count" >>"$LOG" 2>/dev/null || true
|
|
57
|
+
|
|
58
|
+
if [ "$count" = "?" ]; then
|
|
59
|
+
printf 'LAB ✉? (unmeasurable)'
|
|
60
|
+
else
|
|
61
|
+
printf 'LAB ✉%s' "$count"
|
|
62
|
+
fi
|
|
63
|
+
exit 0
|
|
@@ -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
|