@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.
@@ -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
@@ -2,5 +2,5 @@
2
2
  "mcpVersion": "1.31.1",
3
3
  "piVersion": "0.4.1",
4
4
  "opencodeVersion": "0.2.1",
5
- "releaseTag": "v1.53.0"
5
+ "releaseTag": "v1.54.0"
6
6
  }
@@ -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;