claude-slack-channel-bots 0.6.5 → 0.6.7

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/README.md CHANGED
@@ -38,7 +38,7 @@ See the sections below for manual configuration details if you prefer not to use
38
38
 
39
39
  - [Bun](https://bun.sh) `>= 1.0.21` (agent-director minimum)
40
40
  - [Claude Code](https://claude.ai/code) installed and authenticated
41
- - [`agent-director`](https://github.com/gabemahoney/agent-director) **runtime dependency** — pulled in transitively when you install this package. Hard required at server boot: CSCB refuses to start if the library is missing, the host platform is unsupported, Bun is too old, or the installed version is below `MIN_AD_VERSION` (`^0.5.5`). agent-director itself requires [tmux](https://github.com/tmux/tmux) on the operator's PATH; CSCB no longer probes for it directly.
41
+ - [`agent-director`](https://github.com/gabemahoney/agent-director) **runtime dependency** — pulled in transitively when you install this package. Hard required at server boot: CSCB refuses to start if the library is missing, the host platform is unsupported, Bun is too old, or the installed version is below `MIN_AD_VERSION` (`^0.6.3`). agent-director itself requires [tmux](https://github.com/tmux/tmux) on the operator's PATH; CSCB no longer probes for it directly.
42
42
  - Slack workspace admin access (to create and configure the Slack app)
43
43
  - **cozempic** (optional) — Python 3.10+ and `pip install cozempic` — used by JSONL path resolution helpers retained for downstream callers.
44
44
 
@@ -54,7 +54,7 @@ See the sections below for manual configuration details if you prefer not to use
54
54
 
55
55
  If the host is unsupported, the SR-5.1 startup gate exits non-zero at server boot with a typed error from agent-director (`ErrPlatformPackageMissing` or `ErrUnsupportedPlatform`) and writes the failure to `~/.claude/channels/slack/startup-errors.log` and stderr. See [Startup errors](#startup-errors) below.
56
56
 
57
- > **Note on agent-director versions.** v0.4.1 is a zombie release (the published tarball is missing `dist/` and cannot be imported). v0.4.2 lacks the `MakeTemplateParams.overwrite` field CSCB needs for the boot-time template refresh. v0.5.4 and earlier lack `allow_pending` on `readPane`/`sendKeys`, causing `ErrSpawnNotInteractive` during dev-channels dialog approval on freshly-spawned bots. CSCB pins `^0.5.5` to get past all three.
57
+ > **Note on agent-director versions.** v0.4.1 is a zombie release (the published tarball is missing `dist/` and cannot be imported). v0.4.2 lacks the `MakeTemplateParams.overwrite` field CSCB needs for the boot-time template refresh. v0.5.4 and earlier lack `allow_pending` on `readPane`/`sendKeys`, causing `ErrSpawnNotInteractive` during dev-channels dialog approval on freshly-spawned bots. v0.6.0 shipped a stale TS shim whose `Client` dropped `getPermission`, whose `buildDecide()` dropped `--request-token`, and whose error catalog omitted `ErrInvalidFlags` / `ErrPermissionRequestNotFound` / `ErrAmbiguousRequest` — each silently breaks the disambiguation relay. v0.6.1–0.6.2 still lack the full `permission_requests` plural projection + composite-key disambiguation surface CSCB depends on for concurrent open requests. CSCB pins `^0.6.3` to get past all of these.
58
58
 
59
59
  ---
60
60
 
@@ -439,6 +439,9 @@ Classes you may see:
439
439
  - `ad-cli-not-executable` — the resolved agent-director CLI binary exists but is not executable. Run `chmod +x` on the binary referenced in the log line.
440
440
  - `ad-version-probe` — `agent-director` was loaded but the `version()` probe failed (subprocess invocation, platform binary, etc.).
441
441
  - `ad-version-stale` — installed `agent-director` is below the minimum version this CSCB requires. Run `bun add agent-director@^<minimum>`.
442
+ - `ad-shim-missing-get-permission` — the installed `agent-director` TS shim's `Client` does not expose `getPermission`. The npm-published package is out of sync with its bundled binary. Reinstall and confirm the resolved version actually ships the method.
443
+ - `ad-shim-catalog-incomplete` — the installed `agent-director` TS error catalog is missing one or more of `ErrInvalidFlags`, `ErrPermissionRequestNotFound`, `ErrAmbiguousRequest`. The log line lists the missing names. Same remediation as `ad-shim-missing-get-permission`.
444
+ - `ad-shim-decide-drops-token` — the installed `agent-director` dist does not include `--request-token` in its bundled JS, meaning `buildDecide()` would resolve permission clicks against the wrong row. Reinstall and confirm `buildDecide` carries the flag.
442
445
  - `ad-call-timeout` — an agent-director verb call exceeded the configured `callTimeoutMs` (default 30 s). Investigate the subprocess or increase the timeout.
443
446
  - `ad-same-user` — `~/.agent-director/state.db` is owned by a different UID than the CSCB process. Reinstall agent-director as the correct user or remove the mismatched file.
444
447
  - `ad-same-user-stat` — Non-ENOENT stat error on the state DB (permissions, I/O). Investigate the file before re-launching.
@@ -501,11 +504,7 @@ This gracefully exits the managed Claude Code sessions, stops and restarts the s
501
504
  For operators upgrading from a pre-`agent-director` install:
502
505
 
503
506
  1. **Install the new CSCB**: `bun remove claude-director` (if present) and `bun install -g claude-slack-channel-bots@^<new>`. The `agent-director` library is pulled in transitively — no separate install step.
504
- 2. **Delete any old relay hooks** (CSCB no longer ships them agent-director's relay machinery owns the tool-permission flow):
505
- ```sh
506
- rm -f ~/.claude/hooks/permission-relay.sh ~/.claude/hooks/ask-relay.sh
507
- ```
508
- Also remove their entries from `~/.claude/settings.json` if you wired them in by hand previously.
507
+ 2. **Delete any old relay hooks** see [Upgrading from pre-Epic-2 (v0.5.x → v0.6.x)](#upgrading-from-pre-epic-2-v05x--v06x) for the cleanup commands.
509
508
  3. **Configure agent-director's `find-missing` sweep**. CSCB does NOT call `client.findMissing(...)`; reconciling stuck rows is the operator's responsibility. Add a cron entry (or systemd timer) that runs `agent-director find-missing` on a cadence that matches your tolerance — e.g. every minute on a busy host:
510
509
  ```cron
511
510
  * * * * * /usr/local/bin/agent-director find-missing --timeout 30s
@@ -518,5 +517,41 @@ For operators upgrading from a pre-`agent-director` install:
518
517
  6. **Optional cleanup**: `~/.claude/channels/slack/sessions.json` and `sessions.json.last` are no longer read or written. CSCB ignores them; you can safely `rm` them after a successful boot.
519
518
  7. **`tmux` is no longer a CSCB-direct prereq** but is still required transitively via agent-director — keep it installed.
520
519
 
521
- After step 1, every CSCB bot is spawned through `client.spawn(...)` with `relay_mode='on'`. The green/red Slack button UX is byte-identical to the pre-migration behavior; the action_id shape changes from `perm_(allow|deny)_<uuid>` to `perm_(allow|deny)_cscb_<channelId>_<request_id>` but this is invisible to end users.
520
+ After step 1, every CSCB bot is spawned through `client.spawn(...)` with `relay_mode='on'`. The green/red Slack button UX is byte-identical to the pre-migration behavior; the action_id shape changes from `perm_(allow|deny)_<uuid>` to `perm_(allow|deny)_cscb_<channelId>_<request_token>` (where `<request_token>` is a UUIDv4 minted by agent-director) but this is invisible to end users.
521
+
522
+ ---
523
+
524
+ ## Upgrading from pre-Epic-2 (v0.5.x → v0.6.x)
525
+
526
+ If you installed CSCB before v0.6.0 you may have legacy artifacts on disk that are no longer needed. Clean them up manually — automatic postinstall migration is tracked under idea b.irf and not yet implemented.
527
+
528
+ ### 1. Remove old relay hook files
529
+
530
+ The `.sh` relay hooks are no longer shipped by CSCB. Delete them if present:
531
+
532
+ ```sh
533
+ rm -f ~/.claude/hooks/permission-relay.sh ~/.claude/hooks/ask-relay.sh
534
+ ```
535
+
536
+ ### 2. Remove orphan settings.json hook entries
537
+
538
+ If you wired the hooks into `~/.claude/settings.json` by hand, remove the stale entries. Use this `jq` filter to check whether any are present:
539
+
540
+ ```sh
541
+ jq '
542
+ (.hooks.PermissionRequest // [] | map(select(.hooks[]?.command | strings | test("\\.sh$")))),
543
+ (.hooks.PreToolUse // [] | map(select(.matcher == "AskUserQuestion" and (.hooks[]?.command | strings | test("\\.sh$")))))
544
+ ' ~/.claude/settings.json 2>/dev/null
545
+ ```
546
+
547
+ Any non-empty arrays in the output are orphan entries. Remove:
548
+
549
+ - Any object inside `hooks.PermissionRequest` whose `hooks[].command` ends in `permission-relay.sh`.
550
+ - Any object inside `hooks.PreToolUse` with `"matcher": "AskUserQuestion"` whose `hooks[].command` ends in `ask-relay.sh`.
551
+
552
+ The modern permission relay runs automatically via agent-director — no `PermissionRequest` or `PreToolUse` hook entries for `.sh` files are needed.
553
+
554
+ ### 3. Note on automated migration
555
+
556
+ A postinstall step that performs this cleanup automatically is tracked under idea b.irf. Until that lands, the steps above are manual.
522
557
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "Multi-session Slack-to-Claude bridge — run multiple Claude Code bots across Slack channels via Socket Mode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,6 @@
9
9
  "files": [
10
10
  "src/*.ts",
11
11
  "!src/*.test.ts",
12
- "hooks/",
13
12
  "scripts/fixup-bun-cache.ts",
14
13
  "slack-app-manifest.yml",
15
14
  "README.md",
@@ -30,7 +29,7 @@
30
29
  "@modelcontextprotocol/sdk": "^1.0.0",
31
30
  "@slack/socket-mode": "^2.0.0",
32
31
  "@slack/web-api": "^7.0.0",
33
- "agent-director": "^0.5.5"
32
+ "agent-director": "^0.6.3"
34
33
  },
35
34
  "devDependencies": {
36
35
  "@types/bun": "^1.0.0",
@@ -343,104 +343,53 @@ Do not modify `access.json` during setup unless the user asks to.
343
343
 
344
344
  ---
345
345
 
346
- ### Step 7 — Check hooks
346
+ ### Step 7 — Verify agent-director is installed
347
347
 
348
- Check whether the relay hooks exist and are executable:
348
+ The modern permission relay runs automatically via agent-director — no
349
+ operator action is required for permission-relay or ask-handling.
350
+ `AskUserQuestion` is denied at the agent-director template level (SR-3.1).
349
351
 
350
- ```bash
351
- ls -la ~/.claude/hooks/permission-relay.sh ~/.claude/hooks/ask-relay.sh 2>/dev/null || echo "NOT_FOUND"
352
- ```
353
-
354
- **If either hook is missing:**
355
-
356
- Provide the copy and chmod commands. The source path depends on how the
357
- package was installed. Try to detect it:
358
-
359
- ```bash
360
- # Attempt to find the package hooks directory
361
- bun pm ls -g 2>/dev/null | grep -o "claude-slack-channel-bots.*" | head -1 || true
362
- ```
363
-
364
- Show the user the exact commands:
365
-
366
- ```bash
367
- mkdir -p ~/.claude/hooks
368
-
369
- # Copy from the installed package (adjust path if needed):
370
- cp "$(npm root -g)/claude-slack-channel-bots/hooks/permission-relay.sh" ~/.claude/hooks/
371
- cp "$(npm root -g)/claude-slack-channel-bots/hooks/ask-relay.sh" ~/.claude/hooks/
372
- chmod +x ~/.claude/hooks/permission-relay.sh ~/.claude/hooks/ask-relay.sh
373
- ```
374
-
375
- Or, to keep them in sync with future package updates, symlink instead:
352
+ Check that agent-director is installed and the `slack-channel-bot` template
353
+ is registered:
376
354
 
377
355
  ```bash
378
- HOOKS_DIR="$(npm root -g)/claude-slack-channel-bots/hooks"
379
- ln -sf "$HOOKS_DIR/permission-relay.sh" ~/.claude/hooks/permission-relay.sh
380
- ln -sf "$HOOKS_DIR/ask-relay.sh" ~/.claude/hooks/ask-relay.sh
356
+ agent-director --version
381
357
  ```
382
358
 
383
- Also remind the user that `curl` and `jq` must be on `PATH` for the hooks to
384
- work.
359
+ The `slack-channel-bot` template is registered automatically at CSCB startup
360
+ via `client.makeTemplate(...)`. Confirm the template exists after a successful
361
+ `claude-slack-channel-bots start` by checking the agent-director template
362
+ registry. If the startup log shows `ad-template-install` in
363
+ `$STATE_DIR/startup-errors.log`, the template registration
364
+ failed — investigate that error before proceeding.
385
365
 
386
- **If both hooks exist**, confirm they are executable (`-x`). If not, show:
387
-
388
- ```bash
389
- chmod +x ~/.claude/hooks/permission-relay.sh ~/.claude/hooks/ask-relay.sh
390
- ```
366
+ If `agent-director` is not on `PATH`, postinstall did not complete successfully.
367
+ Re-run `bun postinstall.ts` from the installed package directory, or reinstall
368
+ CSCB with `bun install -g claude-slack-channel-bots`.
391
369
 
392
370
  ---
393
371
 
394
- ### Step 8 — Check Claude Code settings.json for hook entries
395
-
396
- Read `~/.claude/settings.json` and check whether the `PermissionRequest` and
397
- `PreToolUse` hook entries for the relay scripts are present.
372
+ ### Step 8 — Check settings.json for orphan legacy hook entries
398
373
 
399
- **Check for `PermissionRequest` entry:**
400
-
401
- Look for an entry inside `hooks.PermissionRequest` with:
402
- ```json
403
- { "type": "command", "command": "~/.claude/hooks/permission-relay.sh" }
404
- ```
374
+ Operators upgrading from pre-Epic-2 (v0.5.x) may have `PermissionRequest` or
375
+ `PreToolUse` `AskUserQuestion` entries in `~/.claude/settings.json` that point
376
+ at the old `.sh` files. These are harmless but stale — the `.sh` files no
377
+ longer exist and agent-director owns the relay machinery now.
405
378
 
406
- **Check for `PreToolUse` entry:**
379
+ Check for orphan entries:
407
380
 
408
- Look for an entry inside `hooks.PreToolUse` with:
409
- ```json
410
- { "matcher": "AskUserQuestion", "hooks": [{ "type": "command", "command": "~/.claude/hooks/ask-relay.sh" }] }
411
- ```
412
-
413
- **If either entry is missing OR exists but is missing `"timeout": 2000000`**,
414
- show the user the exact JSON to add or fix. The timeout is critical — without
415
- it Claude Code uses a short default timeout, kills the hook before the
416
- long-poll completes, and falls back to TUI approval. This is the complete
417
- block for both entries:
418
-
419
- ```jsonc
420
- "PermissionRequest": [
421
- {
422
- "matcher": ".*",
423
- "timeout": 2000000,
424
- "hooks": [{ "type": "command", "command": "~/.claude/hooks/permission-relay.sh" }]
425
- }
426
- ],
427
- "PreToolUse": [
428
- {
429
- "matcher": "AskUserQuestion",
430
- "timeout": 2000000,
431
- "hooks": [{ "type": "command", "command": "~/.claude/hooks/ask-relay.sh" }]
432
- }
433
- ]
381
+ ```bash
382
+ jq '
383
+ (.hooks.PermissionRequest // [] | map(select(.hooks[]?.command | strings | test("\\.sh$")))),
384
+ (.hooks.PreToolUse // [] | map(select(.matcher == "AskUserQuestion" and (.hooks[]?.command | strings | test("\\.sh$")))))
385
+ ' ~/.claude/settings.json 2>/dev/null
434
386
  ```
435
387
 
436
- Add these under the top-level `"hooks"` key in `settings.json`. Existing
437
- entries in those arrays should be preserved append, do not replace.
438
-
439
- If `settings.json` does not exist or does not have a `"hooks"` key, show the
440
- user the full minimal structure to add.
441
-
442
- Offer to write the missing entries automatically to `~/.claude/settings.json`.
443
- If the user agrees, make the targeted edits, preserving all existing content.
388
+ If either output array is non-empty, orphan entries are present. Show the user
389
+ the relevant block from `settings.json` and advise manual removal of any
390
+ `PermissionRequest` entries whose `hooks[].command` ends in `permission-relay.sh`,
391
+ and any `PreToolUse` entries with `matcher: "AskUserQuestion"` whose
392
+ `hooks[].command` ends in `ask-relay.sh`.
444
393
 
445
394
  ---
446
395
 
@@ -450,13 +399,13 @@ Print a final summary of what was checked and configured:
450
399
 
451
400
  - Environment variables: set / missing
452
401
  - Token format: valid / invalid
453
- - config.json: populated (N routes) / skeleton
402
+ - ~/.claude/channels/slack/config.json: present and valid (N routes) / missing or skeleton
454
403
  - append_system_prompt_file: configured / skipped
455
404
  - access.json: present / missing
456
- - permission-relay.sh hook: present and executable / missing
457
- - ask-relay.sh hook: present and executable / missing
458
- - settings.json PermissionRequest hook: present / missing
459
- - settings.json PreToolUse hook: present / missing
405
+ - agent-director: installed (version X) / missing
406
+ - slack-channel-bot template: registered (confirmed at startup) / unconfirmed
407
+ - settings.json orphan PermissionRequest (.sh) hooks: none / found (needs cleanup)
408
+ - settings.json orphan PreToolUse AskUserQuestion (.sh) hooks: none / found (needs cleanup)
460
409
 
461
410
  Remind the user:
462
411
 
@@ -27,7 +27,13 @@
27
27
  import { readFileSync } from 'node:fs'
28
28
  import { dirname, join } from 'node:path'
29
29
  import { fileURLToPath } from 'node:url'
30
- import { Client } from 'agent-director'
30
+ import { AgentDirectorError, Client } from 'agent-director'
31
+ import type {
32
+ DecideParams as ADDecideParams,
33
+ DecideResult,
34
+ GetPermissionParams as ADGetPermissionParams,
35
+ GetPermissionResult as ADGetPermissionResult,
36
+ } from 'agent-director'
31
37
 
32
38
  // ---------------------------------------------------------------------------
33
39
  // Constants
@@ -131,3 +137,87 @@ export function resetClientForTests(): void {
131
137
  export function setClientForTests(client: Client): void {
132
138
  singleton = client
133
139
  }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // decide-wire wrapper (SR-4.1, SR-7.2)
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * CSCB decide params — extends `DecideParams` with the `request_token` field
147
+ * (SR-7.2). The snake-case JSON field flows through the subprocess-CLI
148
+ * transport without further marshaling on this side.
149
+ *
150
+ * Typing `request_token` as a required field at the wrapper boundary is how
151
+ * SR-4.1 (unconditional pass-through) is enforced statically — every CSCB
152
+ * decide call site must construct one of these.
153
+ */
154
+ export interface DecideParamsWithToken extends ADDecideParams {
155
+ request_token: string
156
+ }
157
+
158
+ /**
159
+ * Always-include-token decide wrapper. MIN_AD_VERSION is now pinned at
160
+ * `^0.6.0`, which carries `request_token` on `DecideParams` natively. The
161
+ * structural cast is retained because, until the lockfile is refreshed to
162
+ * resolve the bumped pin, the imported `DecideParams` type still comes from
163
+ * the locked `0.5.6` package — same lockfile-lag pattern as
164
+ * `GetResultWithPermissionRequests`.
165
+ *
166
+ * Routing the single CSCB decide call site through this function gives the
167
+ * codebase one definitive serializer for the decide wire shape (SR-7.2).
168
+ */
169
+ export async function decideWithToken(
170
+ client: Pick<Client, 'decide'>,
171
+ params: DecideParamsWithToken,
172
+ ): Promise<DecideResult> {
173
+ return client.decide(params as unknown as ADDecideParams)
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // get-permission wrapper (SR-7.1)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Params + result for AD's `get-permission` verb (shipped in `^0.6.1`+).
182
+ * Re-exported from `agent-director` so the local consumers
183
+ * (`permission-poller`, `permission-click-handler`, tests) reference one
184
+ * source of truth. `decision` / `decision_reason` / `decided_at` are
185
+ * optional + nullable on AD's type because the same row shape covers both
186
+ * open and closed states; `classifyVerdict` collapses any non-canonical
187
+ * combination into the SR-5.2 fail-closed generic-deny path.
188
+ */
189
+ export type GetPermissionParams = ADGetPermissionParams
190
+ export type GetPermissionResult = ADGetPermissionResult
191
+
192
+ /**
193
+ * Local sentinel matcher for AD's `ErrPermissionRequestNotFound`. Matches on
194
+ * `errName` so both the typed class (`^0.6.0`+ exposes it) and the stub's
195
+ * AgentDirectorError-shape resolve cleanly. The errName-based predicate keeps
196
+ * working with the typed class when the lockfile catches up to the bumped
197
+ * pin (same pattern as `ErrInvalidFlags`).
198
+ */
199
+ export function isErrPermissionRequestNotFound(err: unknown): boolean {
200
+ return err instanceof AgentDirectorError && err.errName === 'ErrPermissionRequestNotFound'
201
+ }
202
+
203
+ /**
204
+ * `get-permission` wrapper (SR-7.1). MIN_AD_VERSION is pinned at `^0.6.0`,
205
+ * which carries the verb; the SR-6.1 startup gate is the compatibility
206
+ * boundary that keeps stale-version installs from reaching this path. The
207
+ * wrapper takes a structural client (rather than `Pick<Client, 'getPermission'>`)
208
+ * because the locked `0.5.6` types do not yet expose the method shape, and
209
+ * fails loudly at runtime when the verb is missing as defense-in-depth for
210
+ * the dev/test edge (e.g. stub clients that omit it on purpose).
211
+ */
212
+ export async function getPermission(
213
+ client: { getPermission?: (params: GetPermissionParams) => Promise<GetPermissionResult> },
214
+ params: GetPermissionParams,
215
+ ): Promise<GetPermissionResult> {
216
+ if (typeof client.getPermission !== 'function') {
217
+ throw new Error(
218
+ 'agent-director-client: getPermission verb unavailable — ' +
219
+ 'confirm installed agent-director version meets the MIN_AD_VERSION pin',
220
+ )
221
+ }
222
+ return client.getPermission(params)
223
+ }
@@ -2,8 +2,7 @@
2
2
  * agent-director-startup.ts — SR-5.1 startup gate for the agent-director
3
3
  * library dependency.
4
4
  *
5
- * Runs the four-step boot sequence the SRD requires before any other CSCB
6
- * work:
5
+ * Runs the boot sequence the SRD requires before any other CSCB work:
7
6
  *
8
7
  * 1. import { Client } from 'agent-director' — covered at module load
9
8
  * time; module-not-found surfaces as a top-level import error caught
@@ -14,6 +13,12 @@
14
13
  * ErrBunVersionTooOld; other throws surface verbatim.
15
14
  * 3. await client.version({}) — strip leading 'v',
16
15
  * compare to MIN_AD_VERSION via semverGte.
16
+ * 3.5. API surface probes (SR-6.1 publish-skew defense) — short-circuit,
17
+ * run in order: probeGetPermission (ad-shim-missing-get-permission) →
18
+ * probeErrorCatalog (ad-shim-catalog-incomplete) → probeDecideArgv
19
+ * (ad-shim-decide-drops-token). These exist because a passing version
20
+ * check does not prove the npm-shipped TS shim matches the bundled Go
21
+ * binary; each missing surface silently breaks the permission relay.
17
22
  * 4. stat ~/.agent-director/state.db — compare st_uid to
18
23
  * geteuid(). ENOENT passes (the row is created on first verb call);
19
24
  * other stat errors are fatal.
@@ -27,6 +32,7 @@
27
32
 
28
33
  import * as fs from 'node:fs'
29
34
  import * as os from 'node:os'
35
+ import { fileURLToPath } from 'node:url'
30
36
  import { join } from 'node:path'
31
37
 
32
38
  import {
@@ -57,6 +63,54 @@ const AD_STATE_DB_PATH = join(os.homedir(), '.agent-director', 'state.db')
57
63
  /** Supported platforms surfaced in operator-facing remediation text. */
58
64
  const SUPPORTED_PLATFORMS = ['linux-x64', 'darwin-arm64'] as const
59
65
 
66
+ /**
67
+ * err_names CSCB's runtime branches on. The structural-drift indicator we
68
+ * guard against is whether the `agent-director` dist file ships a class
69
+ * declaration for each — i.e. matches `class <Name> extends ...`. The class
70
+ * declaration is the defining symptom of a shim that's caught up to the
71
+ * bundled Go binary: when one is missing, `instanceof Err<Name>` predicates
72
+ * silently downgrade and the runtime `errorFromEnvelope` falls back to a
73
+ * base `AgentDirectorError` instead of the typed subclass. A bare-identifier
74
+ * match in the dist source is too loose because the shipped shim also
75
+ * carries a metadata array (`{ name: "ErrPermissionRequestNotFound", ... }`)
76
+ * that mentions the names even when the class is absent — so the regex
77
+ * must anchor on `class <Name>\s`.
78
+ */
79
+ const REQUIRED_ERR_NAMES = [
80
+ 'ErrInvalidFlags',
81
+ 'ErrPermissionRequestNotFound',
82
+ 'ErrAmbiguousRequest',
83
+ ] as const
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Private helper: resolve + read the agent-director dist source (memoized)
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Source-text cache for the resolved `agent-director` dist entry. Probes 2
91
+ * and 3 both grep the same file; reading it once keeps boot fast and avoids
92
+ * surprising operators with duplicate I/O failures.
93
+ */
94
+ let cachedAdDistSource: string | Error | null = null
95
+
96
+ /**
97
+ * Resolve `agent-director` via `import.meta.resolve`, convert the file:// URL
98
+ * to a filesystem path, and read the bundled JS as UTF-8. Memoized for the
99
+ * life of the process. Returns the cached `Error` on a previous failure so
100
+ * each probe can surface a uniform `detail` field.
101
+ */
102
+ function readAdDistSource(): string | Error {
103
+ if (cachedAdDistSource !== null) return cachedAdDistSource
104
+ try {
105
+ const resolved = import.meta.resolve('agent-director')
106
+ const distPath = fileURLToPath(resolved)
107
+ cachedAdDistSource = fs.readFileSync(distPath, 'utf-8')
108
+ } catch (err) {
109
+ cachedAdDistSource = err instanceof Error ? err : new Error(String(err))
110
+ }
111
+ return cachedAdDistSource
112
+ }
113
+
60
114
  // ---------------------------------------------------------------------------
61
115
  // Pure helper: semver-gte for the three-segment versions AD ships
62
116
  // ---------------------------------------------------------------------------
@@ -114,6 +168,33 @@ export interface StartupGateDeps {
114
168
  recordStartupError: typeof recordStartupError
115
169
  /** Process exit hook (terminates by default). */
116
170
  exit: (code: number) => never
171
+ /**
172
+ * Probe 1 — verify the wrapper Client exposes the `getPermission` method.
173
+ * Production default checks `typeof client.getPermission === 'function'`.
174
+ * Tests inject a constant boolean.
175
+ */
176
+ probeGetPermission: (client: unknown) => boolean
177
+ /**
178
+ * Probe 2 — verify the AD error catalog includes the err_names CSCB
179
+ * branches on. Production default reads the resolved `agent-director`
180
+ * dist file and matches `class <Name>\s` for each required name — the
181
+ * class-declaration site is the structural symptom of a shim that's
182
+ * caught up to the bundled Go binary. A behavioral `errorFromEnvelope`
183
+ * round-trip is unusable (the helper falls back to a base
184
+ * `AgentDirectorError` with `.errName` copied verbatim from the input),
185
+ * and a bare-identifier match false-positives on the dist's metadata
186
+ * array that names err_names even when the class is missing.
187
+ */
188
+ probeErrorCatalog: () => { ok: true } | { ok: false; missing: string[] }
189
+ /**
190
+ * Probe 3 — verify the wrapper Client's `decide()` argv carries
191
+ * `--request-token`. Production default reads the resolved
192
+ * `agent-director` dist file and greps for the literal `request-token` —
193
+ * a static-file probe (no subprocess, no DB write) chosen over the
194
+ * subprocess fallback per the bug requirements. See the production
195
+ * defaults block for the resolution + read.
196
+ */
197
+ probeDecideArgv: (client: unknown) => Promise<{ ok: true } | { ok: false; detail: string }>
117
198
  }
118
199
 
119
200
  // ---------------------------------------------------------------------------
@@ -138,6 +219,55 @@ const prodDeps: StartupGateDeps = {
138
219
  geteuid: () => process.geteuid?.(),
139
220
  recordStartupError,
140
221
  exit: (code) => process.exit(code),
222
+ probeGetPermission: (client) =>
223
+ typeof (client as { getPermission?: unknown }).getPermission === 'function',
224
+ probeErrorCatalog: () => {
225
+ // Static-file probe: the runtime `errorFromEnvelope` helper falls back
226
+ // to constructing a base `AgentDirectorError` (with `.errName` copied
227
+ // verbatim from the input) when err_name is absent from `ERROR_TABLE`,
228
+ // so a behavioral round-trip cannot tell catalog-present from
229
+ // catalog-absent. A bare-identifier regex is also too loose: the shim
230
+ // carries a metadata array whose `name: "Err..."` literals match every
231
+ // err_name regardless of whether the class is declared. Anchor instead
232
+ // on `class <Name>\s` — the class declaration site is emitted once per
233
+ // typed error and is the defining symptom of a caught-up shim.
234
+ const src = readAdDistSource()
235
+ if (src instanceof Error) {
236
+ // Surface this as "all required names missing" with a diagnostic
237
+ // detail in the first slot, so the operator sees a complete picture.
238
+ return { ok: false, missing: [`<read-failed: ${src.message}>`, ...REQUIRED_ERR_NAMES] }
239
+ }
240
+ const missing: string[] = []
241
+ for (const name of REQUIRED_ERR_NAMES) {
242
+ const re = new RegExp(`class\\s+${name}\\s`)
243
+ if (!re.test(src)) {
244
+ missing.push(name)
245
+ }
246
+ }
247
+ return missing.length === 0 ? { ok: true } : { ok: false, missing }
248
+ },
249
+ probeDecideArgv: async (_client) => {
250
+ // Static-file probe: grep the resolved agent-director dist for the
251
+ // literal `--request-token` flag. Chosen over the subprocess fallback
252
+ // because it's faster, touches no AD state, and a stale shipped shim
253
+ // is exactly the file-content condition we're detecting.
254
+ const src = readAdDistSource()
255
+ if (src instanceof Error) {
256
+ return {
257
+ ok: false,
258
+ detail: `failed to read agent-director dist for argv probe: ${src.message}`,
259
+ }
260
+ }
261
+ if (src.includes('--request-token')) {
262
+ return { ok: true }
263
+ }
264
+ return {
265
+ ok: false,
266
+ detail:
267
+ `agent-director dist does not include the literal '--request-token' — ` +
268
+ `buildDecide() is dropping the field.`,
269
+ }
270
+ },
141
271
  }
142
272
 
143
273
  function mergeDeps(overrides?: Partial<StartupGateDeps>): StartupGateDeps {
@@ -154,7 +284,7 @@ function mergeDeps(overrides?: Partial<StartupGateDeps>): StartupGateDeps {
154
284
  */
155
285
  export type StartupGateOutcome =
156
286
  | { ok: true; client: unknown; adVersion: string }
157
- | { ok: false; phase: 'construct' | 'version' | 'same-user' | 'unexpected'; classLabel: string; message: string }
287
+ | { ok: false; phase: 'construct' | 'version' | 'api-surface' | 'same-user' | 'unexpected'; classLabel: string; message: string }
158
288
 
159
289
  /**
160
290
  * Run the SR-5.1 startup sequence without exiting. The dispatcher
@@ -280,6 +410,56 @@ export async function runStartupGate(
280
410
  }
281
411
  }
282
412
 
413
+ // Step 3.5: API surface probes. The version gate above confirms the AD
414
+ // binary meets MIN_AD_VERSION, but published agent-director npm packages
415
+ // have shipped a stale TS shim that drops methods (getPermission), drops
416
+ // CLI flags (--request-token in buildDecide), and misses err_names in the
417
+ // catalog. Each of those silently breaks CSCB at click-handling time. The
418
+ // probes run 1 → 2 → 3 and short-circuit on first failure so one operator
419
+ // message corresponds to one fix.
420
+ if (!d.probeGetPermission(client)) {
421
+ d.closeClient(client)
422
+ return {
423
+ ok: false,
424
+ phase: 'api-surface',
425
+ classLabel: 'ad-shim-missing-get-permission',
426
+ message:
427
+ `agent-director Client is missing the 'getPermission' method. ` +
428
+ `The installed shim is stale relative to the AD binary (${adVersion}). ` +
429
+ `Run: bun add agent-director@^${MIN_AD_VERSION} (and confirm the resolved version actually ships getPermission).`,
430
+ }
431
+ }
432
+
433
+ const catalogProbe = d.probeErrorCatalog()
434
+ if (!catalogProbe.ok) {
435
+ d.closeClient(client)
436
+ return {
437
+ ok: false,
438
+ phase: 'api-surface',
439
+ classLabel: 'ad-shim-catalog-incomplete',
440
+ message:
441
+ `agent-director TS error catalog is missing required err_names: ` +
442
+ `${catalogProbe.missing.join(', ')}. ` +
443
+ `Envelopes with these names would surface as the base AgentDirectorError ` +
444
+ `instead of typed subclasses, breaking CSCB's instanceof Err<Name> branches. ` +
445
+ `Run: bun add agent-director@^${MIN_AD_VERSION} (confirm the resolved package ships the full catalog).`,
446
+ }
447
+ }
448
+
449
+ const argvProbe = await d.probeDecideArgv(client)
450
+ if (!argvProbe.ok) {
451
+ d.closeClient(client)
452
+ return {
453
+ ok: false,
454
+ phase: 'api-surface',
455
+ classLabel: 'ad-shim-decide-drops-token',
456
+ message:
457
+ `agent-director shim's decide() does not pass --request-token to the CLI: ${argvProbe.detail} ` +
458
+ `Permission clicks would resolve against the wrong row. ` +
459
+ `Run: bun add agent-director@^${MIN_AD_VERSION} (confirm the resolved package's buildDecide includes the flag).`,
460
+ }
461
+ }
462
+
283
463
  // Step 4: same-user check on ~/.agent-director/state.db.
284
464
  let expectedUid = d.geteuid()
285
465
  if (expectedUid === undefined || expectedUid === -1) {
package/src/lib.ts CHANGED
@@ -14,6 +14,14 @@ import { resolve } from 'path'
14
14
  // Constants (re-exported so server.ts and tests share the same values)
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
+ // True iff the standalone GET SSE stream entry "_GET_stream" is present in the
18
+ // MCP SDK transport's internal _streamMapping — used by handleMessage to detect
19
+ // silent-drop conditions before forwarding a Slack message.
20
+ export function hasGetStreamKey(transport: unknown): boolean {
21
+ const mapping = (transport as any)?._streamMapping
22
+ return typeof mapping?.has === 'function' && mapping.has('_GET_stream')
23
+ }
24
+
17
25
  export const MAX_PENDING = 3
18
26
  export const MAX_PAIRING_REPLIES = 2
19
27
  export const PAIRING_EXPIRY_MS = 60 * 60 * 1000 // 1 hour