@consiliency/agent-board-cli 1.25.0 → 1.26.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.
- package/claude-marketplace/.claude-plugin/marketplace.json +14 -0
- package/claude-marketplace/plugins/agent-board/.claude-plugin/plugin.json +19 -0
- package/claude-marketplace/plugins/agent-board/hooks/hooks.json +25 -0
- package/claude-marketplace/plugins/agent-board/hooks/wake-hook.sh +288 -0
- package/dist/assets.d.ts +4 -0
- package/dist/assets.js +9 -0
- package/dist/assets.json +1 -1
- package/dist/claude_marketplace.d.ts +117 -0
- package/dist/claude_marketplace.js +306 -0
- package/dist/doctor.d.ts +17 -1
- package/dist/doctor.js +20 -2
- package/dist/doctor_scopes.d.ts +34 -2
- package/dist/doctor_scopes.js +128 -20
- package/dist/setup_writers.d.ts +25 -6
- package/dist/setup_writers.js +295 -32
- package/package.json +2 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "message-board",
|
|
3
|
+
"description": "Consiliency Message Board plugins for Claude Code.",
|
|
4
|
+
"owner": {
|
|
5
|
+
"name": "Consiliency"
|
|
6
|
+
},
|
|
7
|
+
"plugins": [
|
|
8
|
+
{
|
|
9
|
+
"name": "agent-board",
|
|
10
|
+
"source": "./plugins/agent-board",
|
|
11
|
+
"description": "The agent-board MCP server, pinned, and the local wake hook that nudges an idle session."
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-board",
|
|
3
|
+
"version": "1.31.1",
|
|
4
|
+
"description": "Message Board for Claude Code: the agent-board MCP server and the local wake hook that nudges an idle session when its inbox has unhandled items.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Consiliency"
|
|
7
|
+
},
|
|
8
|
+
"mcpServers": {
|
|
9
|
+
"agent-board": {
|
|
10
|
+
"command": "npx",
|
|
11
|
+
"args": [
|
|
12
|
+
"-y",
|
|
13
|
+
"@consiliency/agent-board-mcp@1.31.1",
|
|
14
|
+
"--harness",
|
|
15
|
+
"claude"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wake-hook.sh\" SessionStart"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"FileChanged": [
|
|
14
|
+
{
|
|
15
|
+
"hooks": [
|
|
16
|
+
{
|
|
17
|
+
"type": "command",
|
|
18
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wake-hook.sh\" FileChanged",
|
|
19
|
+
"asyncRewake": true
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The Claude Code side of the local wake signal (Consiliency/message-board#319), shipped as the
|
|
3
|
+
# `agent-board` plugin's hook (CCPLUGIN, IF-0-CCPLUGIN-1). `hooks/hooks.json` runs it as
|
|
4
|
+
#
|
|
5
|
+
# bash "${CLAUDE_PLUGIN_ROOT}/hooks/wake-hook.sh" SessionStart
|
|
6
|
+
# bash "${CLAUDE_PLUGIN_ROOT}/hooks/wake-hook.sh" FileChanged (asyncRewake: true)
|
|
7
|
+
#
|
|
8
|
+
# It lived at `scripts/wake/wake-hook.sh` and was installed by hand into settings; it moved here so a
|
|
9
|
+
# plugin install carries it, and this is the only tracked copy.
|
|
10
|
+
#
|
|
11
|
+
# THE WAKE SHAPE IS B, MEASURED (plans/CCPLUGIN-SPIKE.md, 2026-09-26, Claude Code 2.1.283). The
|
|
12
|
+
# FileChanged hook is registered with `asyncRewake`, so Claude Code runs it in the background and, when
|
|
13
|
+
# it exits 2, wakes the model with its STDERR as a system reminder -- framed by the harness as
|
|
14
|
+
# `Stop hook blocking error from command "FileChanged": <stderr>`; "error" is the harness's label, not
|
|
15
|
+
# ours. A synchronous `additionalContext` on stdout (what this hook used to print) starts no turn in an
|
|
16
|
+
# idle session. So on FileChanged:
|
|
17
|
+
#
|
|
18
|
+
# * a nudge -> exactly the frozen sentence (WAKE_NUDGE, IF-0-WAKESELF-2) on stderr, nothing on
|
|
19
|
+
# stdout, exit 2;
|
|
20
|
+
# * silence -> nothing on either stream, exit 0.
|
|
21
|
+
#
|
|
22
|
+
# SessionStart is unchanged in shape: synchronous, exit 0, the registration JSON on stdout.
|
|
23
|
+
#
|
|
24
|
+
# NOTHING THE HOOK IS HANDED REACHES WHAT IT EMITS (EC-CCPLUGIN-4). A woken model reads stderr as an
|
|
25
|
+
# instruction, and the payload, the environment and the working directory are not ours. Every emission
|
|
26
|
+
# below is a fixed literal; the only interpolations anywhere are the JSON-escaped signal path in
|
|
27
|
+
# `watchPaths` and the log line's timestamp, event (`$1`) and `armed=` fields. A test scans this file
|
|
28
|
+
# for any other expanded printf/echo and fails by line.
|
|
29
|
+
#
|
|
30
|
+
# WHAT THIS REPLACES. The wake spike wired two hooks as
|
|
31
|
+
#
|
|
32
|
+
# bash .claude/wake-spike/probe.sh SessionStart && printf '{"hookSpecificOutput":{...watchPaths...}}'
|
|
33
|
+
#
|
|
34
|
+
# and then `probe.sh` was deleted. `.claude/wake-spike/` is GITIGNORED (.gitignore:30), so nothing
|
|
35
|
+
# preserved the script and nothing noticed it go; the missing file exited 127, the `&&`
|
|
36
|
+
# short-circuited, and THE REGISTRATION WAS NEVER PRINTED. No watch registered means `FileChanged`
|
|
37
|
+
# could never fire either -- one deleted file silently disabled both hooks. So this file is
|
|
38
|
+
# tracked, and SessionStart prints its registration (or a named refusal) and always exits 0. The `&&`
|
|
39
|
+
# is not reordered, it is gone. (FileChanged's exit 2 is the wake itself, not a failure.)
|
|
40
|
+
#
|
|
41
|
+
# WHY THIS WATCHES ONE FILE AND NOT THE WAKE ROOT -- a review board caught this and it is the
|
|
42
|
+
# whole point of the path layout. `wakeSignalPath` is per (machine, agent, session) precisely so
|
|
43
|
+
# "one identity running two sessions must not share a signal, or an item for one blocks the other"
|
|
44
|
+
# (#319). An earlier version of this script registered the shared ROOT and then emitted attention
|
|
45
|
+
# on ANY FileChanged with no path filter, which threw that isolation away at the only consumer that
|
|
46
|
+
# exists: every session watching the root woke on every other session's item. Distinct filenames
|
|
47
|
+
# isolate nothing if the consumer watches their common parent.
|
|
48
|
+
#
|
|
49
|
+
# Registering the EXACT signal file makes the isolation structural rather than a filter someone
|
|
50
|
+
# must maintain -- a session cannot be woken by a path it is not watching. It also removes the
|
|
51
|
+
# question of whether `watchPaths` recurses into subdirectories, which is unverified here.
|
|
52
|
+
set -uo pipefail
|
|
53
|
+
|
|
54
|
+
EVENT="${1:-unknown}"
|
|
55
|
+
ARMED=no
|
|
56
|
+
# THE LOG IS WRITTEN FROM AN EXIT TRAP, so every path -- the nudge's `exit 2` included -- leaves exactly
|
|
57
|
+
# one line. It used to be written after the `case`, which an early exit skips (a review seat on the
|
|
58
|
+
# CCPLUGIN plan caught that the nudge path would log nothing). Fields: timestamp, event, armed=; the
|
|
59
|
+
# write is best-effort and silent, because a failing log must never become output or a missed wake.
|
|
60
|
+
on_exit() {
|
|
61
|
+
local LOG="${MESSAGE_BOARD_WAKE_LOG:-}"
|
|
62
|
+
[ -n "$LOG" ] || return 0
|
|
63
|
+
{ printf '%s\t%s\tarmed=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$EVENT" "$ARMED" >> "$LOG"; } 2>/dev/null || true
|
|
64
|
+
}
|
|
65
|
+
trap on_exit EXIT
|
|
66
|
+
WAKE_DIR="${MESSAGE_BOARD_WAKE_DIR:-$HOME/.message-board/wake}"
|
|
67
|
+
# STRIP TRAILING SLASHES so this end and the producer agree as STRINGS, not merely as paths.
|
|
68
|
+
# The producer builds its path with Node's `join()`, which normalises `a//b` to `a/b`; this script
|
|
69
|
+
# interpolates, which does not. A `MESSAGE_BOARD_WAKE_DIR` ending in `/` therefore produced
|
|
70
|
+
# `…/wake//claw/…` here against `…/wake/claw/…` there. The kernel collapses the doubled slash so the
|
|
71
|
+
# WATCH still binds — but the FileChanged arm below compares the delivered `file_path` against
|
|
72
|
+
# `$SIGNAL` for whole-string equality, and its documented answer to a present-but-non-matching
|
|
73
|
+
# `file_path` is SILENCE. So the session would look armed and never wake. Found by a review board on
|
|
74
|
+
# the PR that first made anything raise. A bare "/" is left alone rather than emptied.
|
|
75
|
+
while [ "${WAKE_DIR}" != "/" ] && [ "${WAKE_DIR%/}" != "${WAKE_DIR}" ]; do
|
|
76
|
+
WAKE_DIR="${WAKE_DIR%/}"
|
|
77
|
+
done
|
|
78
|
+
# THE MACHINE ID IS DERIVED, like the session id, and for the same two reasons: requiring an export
|
|
79
|
+
# is a launch-time demand the board's harness-agnostic principle forbids, and a hook that refuses to
|
|
80
|
+
# arm without one ships the feature DARK on a default install.
|
|
81
|
+
#
|
|
82
|
+
# SHORT FORM, to agree with the producer BYTE FOR BYTE. Node's `os.hostname()` may return an FQDN
|
|
83
|
+
# where `hostname -s` returns the short name; the producer strips everything after the first dot and
|
|
84
|
+
# so does this, because a one-segment disagreement is a signal raised where nothing is watching.
|
|
85
|
+
# TRUNCATE ONLY A DERIVED VALUE. A DECLARATION IS VERBATIM.
|
|
86
|
+
#
|
|
87
|
+
# The first version of this stripped unconditionally, and that reintroduced — on the declared
|
|
88
|
+
# branch — the exact divergence it was written to fix. The producer returns a declared
|
|
89
|
+
# MESSAGE_BOARD_WAKE_MACHINE_ID untouched, so declaring `box.example.com` gave the producer
|
|
90
|
+
# `box.example.com` and this end `box`: two paths, both ends reporting armed, nobody woken. A review
|
|
91
|
+
# board reproduced it against the shipped artifacts. It is the fourth time in this feature that a
|
|
92
|
+
# value derived independently on two sides has diverged, and the first where the fix for one
|
|
93
|
+
# divergence created the next.
|
|
94
|
+
#
|
|
95
|
+
# So the rule is per-BRANCH and identical on both ends: a declared value is used exactly as given
|
|
96
|
+
# (an operator who writes a dotted name meant it, and the charset permits dots); only the host name
|
|
97
|
+
# this end reads for itself is truncated at the first dot, because `os.hostname()` and `hostname`
|
|
98
|
+
# disagree about FQDNs.
|
|
99
|
+
if [ -n "${MESSAGE_BOARD_WAKE_MACHINE_ID:-}" ]; then
|
|
100
|
+
MACHINE_ID="$MESSAGE_BOARD_WAKE_MACHINE_ID"
|
|
101
|
+
else
|
|
102
|
+
MACHINE_ID="$(hostname 2>/dev/null || true)"
|
|
103
|
+
MACHINE_ID="${MACHINE_ID%%.*}"
|
|
104
|
+
fi
|
|
105
|
+
# NO AGENT SEGMENT. It used to be required here, from MESSAGE_BOARD_WAKE_AGENT_ID, and that was the
|
|
106
|
+
# last launch-time export this feature demanded — forbidden by the board's harness-agnostic
|
|
107
|
+
# principle, and the reason a mismatch with the producer's own `whoami`-derived id was a permanent
|
|
108
|
+
# silent no-wake with BOTH ends reporting armed. The segment carried no addressing information: a
|
|
109
|
+
# session belongs to one agent, so the session segment already separates every case.
|
|
110
|
+
# See `specs/wake-signal-contract.md` §2.
|
|
111
|
+
# SESSION IDENTITY IS DERIVED IN-SESSION, not exported by an operator.
|
|
112
|
+
#
|
|
113
|
+
# This used to read the explicit variable alone, and `docs/local-wake-signal.md` told the operator
|
|
114
|
+
# to `export MESSAGE_BOARD_WAKE_SESSION_ID=<something unique>`. Two things are wrong with that, and
|
|
115
|
+
# the second is the serious one:
|
|
116
|
+
#
|
|
117
|
+
# 1. It is a LAUNCH-TIME ask, which the board's governing principle forbids — the board is
|
|
118
|
+
# harness-agnostic and may not demand per-session flags, env vars or wrapper scripts. An
|
|
119
|
+
# INSTALL-time ask (adding this hook) is legitimate; "remember to export this every time you
|
|
120
|
+
# start a session" is not.
|
|
121
|
+
# 2. A human asked to invent "a value unique to THIS session" reuses one, and two sessions from
|
|
122
|
+
# one shell then COLLAPSE TO ONE ADDRESSEE — they share a signal path, so each wakes on the
|
|
123
|
+
# other's items. Session identity is the ADDRESSING UNIT; sharing it is not a limitation, it
|
|
124
|
+
# is a misdelivery.
|
|
125
|
+
#
|
|
126
|
+
# MEASURED 2026-09-09, inside a live Claude Code session: `CLAUDE_CODE_SESSION_ID` is set and is a
|
|
127
|
+
# 36-character UUID, alongside `CLAUDECODE=1`. The value we needed was already inside the session,
|
|
128
|
+
# so no operator has to produce it.
|
|
129
|
+
#
|
|
130
|
+
# The explicit variable still WINS when set — it is the declaration path for a harness whose own
|
|
131
|
+
# session id we cannot read (roadmap Tier 2), and it is what the tests drive. What changes is that
|
|
132
|
+
# Claude Code no longer needs it.
|
|
133
|
+
#
|
|
134
|
+
# Keyed on a POSITIVE, harness-specific marker rather than a vendor prefix: the same measurement
|
|
135
|
+
# found an ambient `CODEX_SSH_SKIP_APP_SERVER_BOOT` in a session codex did not launch, so a
|
|
136
|
+
# prefix-sniffing detector would have misidentified it.
|
|
137
|
+
SESSION_ID="${MESSAGE_BOARD_WAKE_SESSION_ID:-}"
|
|
138
|
+
if [ -z "$SESSION_ID" ] && [ -n "${CLAUDECODE:-}" ]; then
|
|
139
|
+
SESSION_ID="${CLAUDE_CODE_SESSION_ID:-}"
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
# Identity is REQUIRED to register a watch. Falling back to the shared root is exactly the defect
|
|
143
|
+
# described above, so the fallback is "register nothing", never "register everything".
|
|
144
|
+
#
|
|
145
|
+
# THE SEGMENT CHARSET IS THE SAME RULE THE PRODUCER ENFORCES, stated once on each side.
|
|
146
|
+
# `assertSafeSegment` in wake_signal.ts refuses anything outside [A-Za-z0-9._-]+ (and refuses "."
|
|
147
|
+
# and ".."), so before this check the two layers DISAGREED, in both damaging directions:
|
|
148
|
+
#
|
|
149
|
+
# * a segment containing `..` made the hook create and register a file OUTSIDE $WAKE_DIR — an
|
|
150
|
+
# advisor board demonstrated `CLAUDE_CODE_SESSION_ID='../../../escaped'` doing exactly that;
|
|
151
|
+
# * any segment with a `/`, a space or a quote produced a watch on a path the producer can NEVER
|
|
152
|
+
# raise, because the producer would throw on the same value. That is a permanent, silent
|
|
153
|
+
# no-wake: the session looks armed and can never be woken.
|
|
154
|
+
#
|
|
155
|
+
# Refusing here rather than sanitising is deliberate and matches the producer: a sanitised identity
|
|
156
|
+
# is a DIFFERENT identity, and silently watching someone else's path is the defect this whole file
|
|
157
|
+
# exists to prevent. `case` with a glob, not a regex, so it works in any POSIX shell.
|
|
158
|
+
safe_segment() {
|
|
159
|
+
case "$1" in
|
|
160
|
+
"" | "." | ".." ) return 1 ;;
|
|
161
|
+
*[!A-Za-z0-9._-]* ) return 1 ;;
|
|
162
|
+
* ) return 0 ;;
|
|
163
|
+
esac
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if safe_segment "$MACHINE_ID" && safe_segment "$SESSION_ID"; then
|
|
167
|
+
SIGNAL="$WAKE_DIR/$MACHINE_ID/$SESSION_ID/inbox"
|
|
168
|
+
ARMED=yes
|
|
169
|
+
else
|
|
170
|
+
SIGNAL=""
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
# THE SIGNAL PATH, JSON-ESCAPED. A wake dir containing `"` or `\` would otherwise emit malformed JSON,
|
|
174
|
+
# and a malformed registration fails the same silent way the `&&` did. Escapes `\` first, then `"`.
|
|
175
|
+
# Parameter expansion rather than the `printf | sed` pipe it used to be: the hook runs under bash by
|
|
176
|
+
# contract (hooks.json), and keeping every printf in this file an EMITTER is what lets the
|
|
177
|
+
# interpolation scan judge them all without a "piped printf is fine" exemption.
|
|
178
|
+
SIGNAL_JSON="${SIGNAL//\\/\\\\}"
|
|
179
|
+
SIGNAL_JSON="${SIGNAL_JSON//\"/\\\"}"
|
|
180
|
+
# A control character cannot be carried faithfully by that escaping (it would be `\uXXXX` in JSON and
|
|
181
|
+
# raw here), so both branches below treat it specially.
|
|
182
|
+
SIGNAL_HAS_CNTRL=no
|
|
183
|
+
case "$SIGNAL" in *[[:cntrl:]]*) SIGNAL_HAS_CNTRL=yes ;; esac
|
|
184
|
+
|
|
185
|
+
case "$EVENT" in
|
|
186
|
+
SessionStart)
|
|
187
|
+
if [ -n "$SIGNAL" ] && [ "$SIGNAL_HAS_CNTRL" = yes ]; then
|
|
188
|
+
# REGISTRATION MUST BE VALID JSON OR IT IS SILENTLY NOTHING, and the two branches of this
|
|
189
|
+
# script disagreed about that: FileChanged already refuses to trust an exact comparison on a
|
|
190
|
+
# path containing a control character (see below), while SessionStart escaped only `"` and
|
|
191
|
+
# `\` and emitted the rest raw. A tab or newline in MESSAGE_BOARD_WAKE_DIR therefore produced
|
|
192
|
+
# malformed JSON, the registration failed, and NOTHING was watched — the
|
|
193
|
+
# silent-registration-failure this file's header exists to prevent, reached through the one
|
|
194
|
+
# branch that had no guard. Found by two review seats on PR #453.
|
|
195
|
+
#
|
|
196
|
+
# Refuse rather than emit: a control character in a wake dir is an operator mistake, and an
|
|
197
|
+
# armed-looking session that watches nothing is worse than a named refusal. The asymmetry with
|
|
198
|
+
# FileChanged (which WAKES on the same condition) is deliberate and is the fail-safe direction
|
|
199
|
+
# on each side — registration failing closed means "you are not armed, and here is why";
|
|
200
|
+
# delivery failing open means "a wasted turn beats a missed wake". Logged as armed=no: nothing
|
|
201
|
+
# was registered.
|
|
202
|
+
ARMED=no
|
|
203
|
+
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Message Board wake signal is NOT armed: the wake directory contains a control character, which cannot be registered as a watch path. Set MESSAGE_BOARD_WAKE_DIR to a path without control characters."}}\n'
|
|
204
|
+
elif [ -n "$SIGNAL" ]; then
|
|
205
|
+
# Ensure the watch target exists before it is registered: a watch on a path that does not
|
|
206
|
+
# exist yet may never bind. Created empty and only when absent, so this never bumps the
|
|
207
|
+
# mtime of a signal a running daemon already raised.
|
|
208
|
+
mkdir -p "$(dirname "$SIGNAL")" 2>/dev/null || true
|
|
209
|
+
[ -e "$SIGNAL" ] || : > "$SIGNAL" 2>/dev/null || true
|
|
210
|
+
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","watchPaths":["%s"]}}\n' "$SIGNAL_JSON"
|
|
211
|
+
else
|
|
212
|
+
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Message Board wake signal is NOT armed: the machine and session ids are both derived in-session on Claude Code; set MESSAGE_BOARD_WAKE_MACHINE_ID or MESSAGE_BOARD_WAKE_SESSION_ID only on a harness whose own values cannot be read. Refusing to watch the shared wake root, which would wake this session on another session'"'"'s items."}}\n'
|
|
213
|
+
fi
|
|
214
|
+
exit 0
|
|
215
|
+
;;
|
|
216
|
+
FileChanged)
|
|
217
|
+
# DEFENCE IN DEPTH ON THE CONSUMER SIDE. Registering the exact signal file fixes what this
|
|
218
|
+
# session SUBSCRIBES to; it does not filter what is DELIVERED. A red-team seat pointed out that
|
|
219
|
+
# another watcher registered in the same session would route its unrelated events through this
|
|
220
|
+
# same handler, and this arm would announce inbox items on every one of them. So the delivered
|
|
221
|
+
# path is checked here as well.
|
|
222
|
+
#
|
|
223
|
+
# THE TWO FAILURE DIRECTIONS ARE NOT SYMMETRIC, and that decides the fallback. Emitting when we
|
|
224
|
+
# should not costs the agent a turn on a canonical fetch that finds nothing. Staying silent
|
|
225
|
+
# when we should not means the agent is NEVER woken -- which is the silent-disable failure this
|
|
226
|
+
# whole change exists to remove, reappearing one layer further in. So:
|
|
227
|
+
#
|
|
228
|
+
# * unarmed (no identity, nothing registered) -> ALWAYS silent. Any event reaching an
|
|
229
|
+
# unarmed session is certainly not ours, because we asked for nothing.
|
|
230
|
+
# * a payload naming a path that is not ours -> silent.
|
|
231
|
+
#
|
|
232
|
+
# THE DELIVERED PATH IS COMPARED FOR EXACT EQUALITY, in its JSON-ESCAPED form, against the
|
|
233
|
+
# `file_path` key. Two review rounds moved this comparison, and each move was found to be
|
|
234
|
+
# wrong one layer further out:
|
|
235
|
+
#
|
|
236
|
+
# * round 4: `$SIGNAL` is a DECODED path but arrives inside JSON where `"` and `\` are
|
|
237
|
+
# escaped, so comparing the decoded form never matched and a wake dir like /tmp/a"b
|
|
238
|
+
# suppressed every wake. The fix compared the raw form OR the escaped form.
|
|
239
|
+
# * round 5: both of those were SUBSTRING matches. A red-team seat showed a payload naming
|
|
240
|
+
# `$SIGNAL.extra` or `$SIGNAL/child` woke the session, and that the raw form had an
|
|
241
|
+
# encoding collision -- a dir with two literal backslashes matched the JSON of a dir with
|
|
242
|
+
# one. Measured before fixing: all three false-positives reproduced against the shipped
|
|
243
|
+
# script.
|
|
244
|
+
#
|
|
245
|
+
# So: the payload's `"file_path"` value (the key Claude Code sends -- verified in the harness
|
|
246
|
+
# binary, `hook_event_name:"FileChanged",file_path:n,event:r`) is matched as the WHOLE quoted
|
|
247
|
+
# string `"file_path":"<escaped SIGNAL>"`. The closing quote is what rejects a suffix or
|
|
248
|
+
# sub-path; escaping only one side is what removes the collision. Optional whitespace around
|
|
249
|
+
# the colon is normalised first so a pretty-printed payload cannot turn a match into silence.
|
|
250
|
+
#
|
|
251
|
+
# THE FAILURE DIRECTIONS ARE STILL ASYMMETRIC, and the fallbacks follow that:
|
|
252
|
+
# * a payload we cannot judge -- empty, or without a `file_path` key at all -- NUDGES. The
|
|
253
|
+
# subscription is already narrowed to one file, so the residual risk is a wasted turn.
|
|
254
|
+
# * a signal path this script cannot escape faithfully NUDGES on every delivery: the escaping
|
|
255
|
+
# handles `"` and `\` only, and a control character in the wake dir would be `\uXXXX`
|
|
256
|
+
# in the payload but raw here, so exact comparison would be silently false. Such a dir is
|
|
257
|
+
# pathological; it is handled by never letting it produce silence.
|
|
258
|
+
# * a `file_path` that is present and is NOT this session's signal -> silent.
|
|
259
|
+
#
|
|
260
|
+
# EVERY NUDGE IS THE SAME SENTENCE (outcome B). The fail-open cases used to append their reason
|
|
261
|
+
# ("the delivered payload carried no file_path…", "this wake dir contains a control character…")
|
|
262
|
+
# to an additionalContext. They now converge on the one frozen sentence: a woken model needs to
|
|
263
|
+
# know THAT there is something and where to read it, and one fixed string is the whole of what
|
|
264
|
+
# EC-CCPLUGIN-4 has to prove about stderr.
|
|
265
|
+
PAYLOAD="$(cat 2>/dev/null || true)"
|
|
266
|
+
WANT="\"file_path\":\"$SIGNAL_JSON\""
|
|
267
|
+
NUDGE=no
|
|
268
|
+
if [ -z "$SIGNAL" ]; then
|
|
269
|
+
: # unarmed: this session registered no watch, so this event belongs to someone else.
|
|
270
|
+
elif [ -z "$PAYLOAD" ] || ! grep -qF '"file_path"' <<<"$PAYLOAD" 2>/dev/null; then
|
|
271
|
+
NUDGE=yes # nothing to judge on: prefer a wasted turn over a missed wake.
|
|
272
|
+
elif [ "$SIGNAL_HAS_CNTRL" = yes ]; then
|
|
273
|
+
NUDGE=yes # the exact comparison cannot be trusted for this path; never let that become silence.
|
|
274
|
+
elif sed -e 's/"file_path"[[:space:]]*:[[:space:]]*"/"file_path":"/g' <<<"$PAYLOAD" 2>/dev/null | grep -qF "$WANT" 2>/dev/null; then
|
|
275
|
+
NUDGE=yes
|
|
276
|
+
fi
|
|
277
|
+
if [ "$NUDGE" = yes ]; then
|
|
278
|
+
# The one emitter of the wake. The signal is zero bytes by construction and is never read
|
|
279
|
+
# here, so nothing that could carry a message body or a bearer secret reaches the model.
|
|
280
|
+
# SHARED WITH packages/agent-board-client/src/nudge.ts (WAKE_NUDGE), pinned by W34.
|
|
281
|
+
printf 'Message Board: this session has unhandled inbox items. Read them with the canonical fetch (agent_board.v_agent_wake_recovery) before acting.\n' >&2
|
|
282
|
+
exit 2
|
|
283
|
+
fi
|
|
284
|
+
exit 0
|
|
285
|
+
;;
|
|
286
|
+
esac
|
|
287
|
+
|
|
288
|
+
exit 0
|
package/dist/assets.d.ts
CHANGED
|
@@ -16,7 +16,11 @@ export declare class BakedAssetsError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
export declare const ASSETS_URL: URL;
|
|
18
18
|
export declare const KEYSLOT_URL: URL;
|
|
19
|
+
/** The packaged Claude Code marketplace (#549): `.claude-plugin/marketplace.json` + `plugins/agent-board/**`. */
|
|
20
|
+
export declare const CLAUDE_MARKETPLACE_URL: URL;
|
|
19
21
|
/** The build's baked versions and release tag, validated field by field. */
|
|
20
22
|
export declare function bakedAssets(file?: string | URL): BakedAssets;
|
|
21
23
|
/** The packaged keyslot directory (no trailing slash). Throws when the build did not copy it. */
|
|
22
24
|
export declare function resolveKeyslotDir(url?: URL): string;
|
|
25
|
+
/** The packaged marketplace directory (no trailing slash). Throws when the build did not copy it. */
|
|
26
|
+
export declare function resolveMarketplaceDir(url?: URL): string;
|
package/dist/assets.js
CHANGED
|
@@ -22,6 +22,8 @@ export class BakedAssetsError extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
export const ASSETS_URL = new URL("../dist/assets.json", import.meta.url);
|
|
24
24
|
export const KEYSLOT_URL = new URL("../keyslot/", import.meta.url);
|
|
25
|
+
/** The packaged Claude Code marketplace (#549): `.claude-plugin/marketplace.json` + `plugins/agent-board/**`. */
|
|
26
|
+
export const CLAUDE_MARKETPLACE_URL = new URL("../claude-marketplace/", import.meta.url);
|
|
25
27
|
/** The build's baked versions and release tag, validated field by field. */
|
|
26
28
|
export function bakedAssets(file = ASSETS_URL) {
|
|
27
29
|
let raw;
|
|
@@ -59,3 +61,10 @@ export function resolveKeyslotDir(url = KEYSLOT_URL) {
|
|
|
59
61
|
throw new BakedAssetsError("keyslot/ is absent: the cli build did not copy the keyslot scripts");
|
|
60
62
|
return dir;
|
|
61
63
|
}
|
|
64
|
+
/** The packaged marketplace directory (no trailing slash). Throws when the build did not copy it. */
|
|
65
|
+
export function resolveMarketplaceDir(url = CLAUDE_MARKETPLACE_URL) {
|
|
66
|
+
const dir = fileURLToPath(url).replace(/\/+$/, "");
|
|
67
|
+
if (!existsSync(`${dir}/.claude-plugin/marketplace.json`))
|
|
68
|
+
throw new BakedAssetsError("claude-marketplace/ is absent: the cli build did not copy the Claude Code marketplace");
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
package/dist/assets.json
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** The manifest's key in a tree read by `readTree`: always `/`-separated. */
|
|
2
|
+
export declare const MARKETPLACE_MANIFEST_KEY = ".claude-plugin/marketplace.json";
|
|
3
|
+
/** THE per-user directory setup materialises the packaged marketplace into. User and local scope share it. */
|
|
4
|
+
export declare function claudeMarketplaceDir(env: Record<string, string | undefined>, home: string): string;
|
|
5
|
+
/** A declared directory path is ours when it resolves to `claudeMarketplaceDir` for this env. */
|
|
6
|
+
export declare function isOurMarketplacePath(path: unknown, env: Record<string, string | undefined>, home: string): boolean;
|
|
7
|
+
export interface TreeFile {
|
|
8
|
+
bytes: Buffer;
|
|
9
|
+
executable: boolean;
|
|
10
|
+
}
|
|
11
|
+
export type TreeRead = {
|
|
12
|
+
kind: "missing";
|
|
13
|
+
} | {
|
|
14
|
+
kind: "unusable";
|
|
15
|
+
}
|
|
16
|
+
/** Round 5: an entry inside the tree is owned by another uid or writable by others (`path` is it). */
|
|
17
|
+
| {
|
|
18
|
+
kind: "untrusted";
|
|
19
|
+
path: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: "ok";
|
|
22
|
+
files: Map<string, TreeFile>;
|
|
23
|
+
};
|
|
24
|
+
/** The owner and mode rule for every entry INSIDE a tree that runs (the materialised marketplace). */
|
|
25
|
+
export interface TreeTrust {
|
|
26
|
+
uid: number;
|
|
27
|
+
platform?: NodeJS.Platform;
|
|
28
|
+
/** Injectable for tests: ownership a test cannot create without root. Default `fs.lstatSync`. */
|
|
29
|
+
lstat?: (path: string) => Pick<TrustStats, "uid" | "mode">;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Every regular file under `dir`. `missing` when `dir` does not exist; `unusable` when it is not a
|
|
33
|
+
* plain tree. With `trust` (the materialised directory, never the packaged copy, which a root-owned
|
|
34
|
+
* global install legitimately owns): every directory and file under `dir` must be owned by this uid
|
|
35
|
+
* and not group/world-writable, else `untrusted` naming the first offender — a hook another uid can
|
|
36
|
+
* rewrite is a hook another uid runs as you. Skipped on win32, as elsewhere.
|
|
37
|
+
*/
|
|
38
|
+
export declare function readTree(dir: string, trust?: TreeTrust): TreeRead;
|
|
39
|
+
/** Same file set, same bytes, same exec bit. */
|
|
40
|
+
export declare function sameTree(a: ReadonlyMap<string, TreeFile>, b: ReadonlyMap<string, TreeFile>): boolean;
|
|
41
|
+
export type TrustCause = "symlink" | "not_directory" | "foreign_owner" | "writable" | "symlink_loop";
|
|
42
|
+
export interface TrustProblem {
|
|
43
|
+
path: string;
|
|
44
|
+
cause: TrustCause;
|
|
45
|
+
/** Ours (the directory or its parent) and only too permissive: setup may tighten it, doctor may not. */
|
|
46
|
+
tightenable: boolean;
|
|
47
|
+
/** For `writable`: the command that removes the write bit named. */
|
|
48
|
+
command?: string;
|
|
49
|
+
}
|
|
50
|
+
/** What `checkTrustChain` needs to see of one path: an injectable subset of `fs.Stats`. */
|
|
51
|
+
export interface TrustStats {
|
|
52
|
+
uid: number;
|
|
53
|
+
gid: number;
|
|
54
|
+
mode: number;
|
|
55
|
+
isDirectory(): boolean;
|
|
56
|
+
isSymbolicLink(): boolean;
|
|
57
|
+
}
|
|
58
|
+
export interface TrustOptions {
|
|
59
|
+
uid: number;
|
|
60
|
+
platform?: NodeJS.Platform;
|
|
61
|
+
/** This process's primary gid (default `process.getgid()`). */
|
|
62
|
+
gid?: number;
|
|
63
|
+
/** This user's login name (default `os.userInfo().username`). */
|
|
64
|
+
userName?: string;
|
|
65
|
+
/** The text of `/etc/group` (default: read it; unreadable → no group is proven private). */
|
|
66
|
+
groupFile?: string | null;
|
|
67
|
+
/** The text of `/etc/passwd` (default: read it; unreadable → no group is proven private). */
|
|
68
|
+
passwdFile?: string | null;
|
|
69
|
+
/** Injectable for tests: ownership a test cannot create without root. Default `fs.lstatSync`. */
|
|
70
|
+
lstat?: (path: string) => TrustStats;
|
|
71
|
+
/** Injectable for tests. Default `fs.readlinkSync`. */
|
|
72
|
+
readlink?: (path: string) => string;
|
|
73
|
+
}
|
|
74
|
+
/** Symlinks followed before a path is refused as a loop (Linux's MAXSYMLINKS). */
|
|
75
|
+
export declare const MAX_SYMLINK_HOPS = 40;
|
|
76
|
+
/**
|
|
77
|
+
* The user's PRIVATE group (USERGROUPS_ENAB: Ubuntu, Debian, Fedora, RHEL make `~/.local` 0775 owned
|
|
78
|
+
* `<user>:<user>`). Proven only from the local files, and only when BOTH say no one else is in it:
|
|
79
|
+
* * `/etc/group` has exactly one entry for the gid, named as the user, whose members are a subset
|
|
80
|
+
* of {the user} (some tools list the user in their own group);
|
|
81
|
+
* * `/etc/passwd` has exactly one account whose primary gid is that gid, and it is this user (name
|
|
82
|
+
* AND uid) — two accounts sharing a primary gid share its write bit (round 4).
|
|
83
|
+
* The gid must also be this process's primary gid. An account or group only in NSS/LDAP, a shared
|
|
84
|
+
* group (SUSE `users`) or an unreadable file proves nothing: refused.
|
|
85
|
+
*/
|
|
86
|
+
export declare function isPrivateGroup(gid: number, options: Pick<TrustOptions, "uid" | "gid" | "userName" | "groupFile" | "passwdFile">): boolean;
|
|
87
|
+
/** One directory the resolver entered, with its identity: the pins setup re-checks. */
|
|
88
|
+
export interface VisitedDirectory {
|
|
89
|
+
path: string;
|
|
90
|
+
dev: number;
|
|
91
|
+
ino: number;
|
|
92
|
+
/** The marketplace directory itself, its `agent-board` parent, or an ancestor. */
|
|
93
|
+
role: "dir" | "parent" | "ancestor";
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Round 4: ONE resolver, walking `dir` as the kernel does — from `/`, one component at a time. Every
|
|
97
|
+
* directory ENTERED is judged (the marketplace directory and its `agent-board` parent strictly; every
|
|
98
|
+
* other by the ancestor rules). A symlink component is judged by its own owner (its containing
|
|
99
|
+
* directory was judged when entered), and its `readlink` is spliced into the remaining components
|
|
100
|
+
* (absolute → from `/`, relative → from the current directory), so EVERY hop's containing
|
|
101
|
+
* directory is judged, however many hops there are. More than `MAX_SYMLINK_HOPS` links is a loop:
|
|
102
|
+
* refused. The walk stops at the first missing component: what setup will create. The marketplace
|
|
103
|
+
* directory and its parent are the last two components however the path is spliced, and neither may
|
|
104
|
+
* be a symlink. On win32 (no uid, no POSIX mode) only that symlink rule applies.
|
|
105
|
+
*/
|
|
106
|
+
export declare function resolveTrustChain(dir: string, options: TrustOptions): {
|
|
107
|
+
problem: TrustProblem | null;
|
|
108
|
+
visited: VisitedDirectory[];
|
|
109
|
+
};
|
|
110
|
+
/** The first untrusted path, or null (see `resolveTrustChain`). */
|
|
111
|
+
export declare function checkTrustChain(dir: string, options: TrustOptions): TrustProblem | null;
|
|
112
|
+
/** The human words for a trust problem, for a `fix`. */
|
|
113
|
+
export declare function describeTrustProblem(problem: TrustProblem): string;
|
|
114
|
+
/** The version in a marketplace tree's `plugins/agent-board/.claude-plugin/plugin.json`, or null. */
|
|
115
|
+
export declare function pluginVersionIn(files: ReadonlyMap<string, TreeFile>): string | null;
|
|
116
|
+
/** `x.y.z` numeric comparison (sign), or null when either side is not a plain release version. */
|
|
117
|
+
export declare function compareVersions(a: string | null, b: string | null): number | null;
|