@sjawhar/claude-envoy 0.0.0 → 1.0.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.
Files changed (113) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/.mcp.json +11 -0
  3. package/.omp-plugin/plugin.json +6 -0
  4. package/AGENTS.md +343 -0
  5. package/README.md +639 -0
  6. package/bin/dispatch +6 -0
  7. package/bin/envoy-channel.ts +9 -0
  8. package/bin/envoy-send.ts +33 -0
  9. package/dist/THIRD_PARTY_NOTICES +816 -0
  10. package/dist/dispatch.js +27707 -0
  11. package/dist/envoy-channel.js +57695 -0
  12. package/dist/session-hook.js +26328 -0
  13. package/dist/session-title-hook.js +116 -0
  14. package/hooks/hooks.json +60 -0
  15. package/hooks/session-hook.ts +512 -0
  16. package/hooks/session-title-hook.ts +46 -0
  17. package/legion/.claude-plugin/plugin.json +4 -0
  18. package/legion/agents/deep-worker.md +68 -0
  19. package/legion/agents/oracle.md +35 -0
  20. package/legion/agents/plan-gap-analyst.md +65 -0
  21. package/legion/agents/plan-reviewer.md +104 -0
  22. package/legion/agents/thermonuclear-code-quality.md +30 -0
  23. package/legion/agents/thermonuclear-deep-review.md +30 -0
  24. package/legion/hooks/hooks.json +89 -0
  25. package/legion/skills/ce-simplify-code/LICENSE +21 -0
  26. package/legion/skills/ce-simplify-code/SKILL.md +66 -0
  27. package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
  28. package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
  29. package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
  30. package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
  31. package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
  32. package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
  33. package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
  34. package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
  35. package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
  36. package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
  37. package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
  38. package/legion/skills/legion-architect/SKILL.md +86 -0
  39. package/legion/skills/legion-architect/references/acceptance.md +208 -0
  40. package/legion/skills/legion-architect/references/decomposition.md +129 -0
  41. package/legion/skills/legion-architect/references/design-gate.md +206 -0
  42. package/legion/skills/legion-architect/references/merge-notices.md +91 -0
  43. package/legion/skills/legion-architect/references/wake-routing.md +41 -0
  44. package/legion/skills/legion-controller/SKILL.md +151 -0
  45. package/legion/skills/legion-controller/references/admission.md +22 -0
  46. package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
  47. package/legion/skills/legion-controller/references/daily-report.md +75 -0
  48. package/legion/skills/legion-controller/references/escalation.md +82 -0
  49. package/legion/skills/legion-controller/references/finding-asks.md +181 -0
  50. package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
  51. package/legion/skills/legion-controller/references/session.md +100 -0
  52. package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
  53. package/legion/skills/legion-controller/references/triage.md +96 -0
  54. package/legion/skills/legion-controller/references/wakes.md +132 -0
  55. package/legion/skills/legion-oracle/SKILL.md +48 -0
  56. package/legion/skills/legion-retro/SKILL.md +112 -0
  57. package/legion/skills/legion-retro/references/learnings.md +60 -0
  58. package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
  59. package/legion/skills/legion-worker/SKILL.md +69 -0
  60. package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
  61. package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
  62. package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
  63. package/legion/skills/legion-worker/references/findings.md +199 -0
  64. package/legion/skills/legion-worker/references/merge-gate.md +353 -0
  65. package/legion/skills/legion-worker/references/pr-body.md +218 -0
  66. package/legion/skills/legion-worker/references/review-threads.md +77 -0
  67. package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
  68. package/legion/skills/legion-worker/references/tools.md +708 -0
  69. package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
  70. package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
  71. package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
  72. package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
  73. package/package.json +49 -1
  74. package/scripts/build-inputs.txt +84 -0
  75. package/scripts/build.ts +415 -0
  76. package/scripts/check-packed-skills.sh +50 -0
  77. package/scripts/pack-skills.sh +146 -0
  78. package/scripts/smoke-channel.sh +178 -0
  79. package/scripts/smoke-clear-rebind.sh +164 -0
  80. package/scripts/smoke-session-title.sh +245 -0
  81. package/scripts/verify-build.sh +88 -0
  82. package/skills/agent-secrets/SKILL.md +180 -0
  83. package/skills/asd-ste100/LICENSE +21 -0
  84. package/skills/asd-ste100/SKILL.md +140 -0
  85. package/skills/asd-ste100/examples/before-after.md +92 -0
  86. package/skills/asd-ste100/references/writing-rules.md +59 -0
  87. package/skills/dispatch/SKILL.md +89 -0
  88. package/skills/dispatch-first/SKILL.md +46 -0
  89. package/skills/dispatch-writing-specs/SKILL.md +80 -0
  90. package/skills/envoy/SKILL.md +82 -0
  91. package/skills/envoy/references/check-settlement.md +62 -0
  92. package/skills/envoy/references/deliveries.md +67 -0
  93. package/skills/envoy/references/legion.md +14 -0
  94. package/skills/envoy/references/roles.md +12 -0
  95. package/skills/envoy/references/sources.md +41 -0
  96. package/skills/outside-message/SKILL.md +111 -0
  97. package/skills/plain-words/SKILL.md +88 -0
  98. package/src/channel-forwarder.ts +262 -0
  99. package/src/claude-channel.ts +705 -0
  100. package/src/claude-session.ts +38 -0
  101. package/src/envoy-channel-server.ts +2115 -0
  102. package/src/envoy-client.ts +26 -0
  103. package/src/envoy-off.ts +62 -0
  104. package/src/legion-claim.ts +211 -0
  105. package/src/legion-inbox.ts +110 -0
  106. package/src/legion-phase-hook.ts +554 -0
  107. package/src/legion-tool.ts +69 -0
  108. package/src/replay-state.ts +97 -0
  109. package/src/send-arguments.ts +13 -0
  110. package/src/session-identity.ts +165 -0
  111. package/src/session-state.ts +142 -0
  112. package/src/session-title.ts +251 -0
  113. package/src/subscription-topics.ts +16 -0
@@ -0,0 +1,705 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { readdirSync, readFileSync, readlinkSync } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import { basename, join, resolve, sep } from "node:path"
5
+ import { z } from "zod"
6
+
7
+ /**
8
+ * Whether Claude Code passes this server's channel notifications to the model. It says nothing
9
+ * about that to the server: the MCP handshake is the same with the channel on or off, and a
10
+ * notification sent while it is off is dropped without a word. So the server reads the `claude`
11
+ * process's own command line and applies the gate Claude Code applies to it (as of 2.1.301): the
12
+ * entry must be named by `--channels` or, in an interactive session,
13
+ * `--dangerously-load-development-channels`; a plugin entry must name the marketplace the plugin
14
+ * is installed from; and a `--channels` entry must be allowlisted, which for this plugin means
15
+ * `allowedChannelPlugins` in the managed settings Claude Code applies, since it is not on
16
+ * Anthropic's own list. Claude Code's account and provider checks are not repeated here, and
17
+ * neither is a channel an SDK host turns on after startup (`enableChannel`): the check runs once,
18
+ * so such a session advertises no mode and Dispatch refuses messages Claude Code would pass on.
19
+ */
20
+ export type ChannelGate =
21
+ | { readonly loaded: true }
22
+ | { readonly loaded: false; readonly why: string }
23
+
24
+ /** One `plugin:<name>@<marketplace>` or `server:<name>` entry of either channel flag. */
25
+ export interface ChannelEntry {
26
+ readonly kind: "plugin" | "server"
27
+ readonly name: string
28
+ readonly marketplace?: string
29
+ readonly dev: boolean
30
+ }
31
+
32
+ export interface AllowedChannelPlugin {
33
+ readonly marketplace: string
34
+ readonly plugin: string
35
+ }
36
+
37
+ /** The name `.mcp.json` gives this plugin's server. */
38
+ const PLUGIN_SERVER = "envoy"
39
+
40
+ /** The flags that name channels, and whether each is the development one. */
41
+ const CHANNEL_FLAGS: Readonly<Record<string, boolean>> = {
42
+ "--channels": false,
43
+ "--dangerously-load-development-channels": true,
44
+ }
45
+
46
+ function parseEntry(value: string, dev: boolean): ChannelEntry | undefined {
47
+ if (value.startsWith("plugin:")) {
48
+ const spec = value.slice("plugin:".length)
49
+ const at = spec.indexOf("@")
50
+ if (at <= 0 || at === spec.length - 1) return undefined
51
+ return { kind: "plugin", name: spec.slice(0, at), marketplace: spec.slice(at + 1), dev }
52
+ }
53
+ if (value.startsWith("server:") && value.length > "server:".length) {
54
+ return { kind: "server", name: value.slice("server:".length), dev }
55
+ }
56
+ return undefined
57
+ }
58
+
59
+ // Claude Code 2.1.301's own argument scanner: the options that take one value, the ones whose value
60
+ // is optional (taken when the next argument is not an option), and the ones that take every value
61
+ // up to the next option. Its print-mode test and its channel flags skip those values, and `--`
62
+ // ends the options.
63
+ const VALUE_OPTIONS: ReadonlySet<string> = new Set([
64
+ "--prefill",
65
+ "--prefill-b64",
66
+ "--deep-link-repo",
67
+ "--deep-link-last-fetch",
68
+ "--deep-link-cwd-b64",
69
+ "--handle-uri",
70
+ "--settings",
71
+ "--managed-settings",
72
+ "--setting-sources",
73
+ "--client-data-url",
74
+ "--watch-artifact",
75
+ "--watch-artifact-no-autoreact",
76
+ "--team-name",
77
+ "--agent-id",
78
+ "--agent-name",
79
+ "--agent-color",
80
+ "--parent-session-id",
81
+ "--agent-type",
82
+ "--model",
83
+ "--agent",
84
+ "--routine",
85
+ "--effort",
86
+ "--fast-mode",
87
+ "--permission-mode",
88
+ "--inherit-permission-mode",
89
+ "--proactivity",
90
+ "--debug-file",
91
+ "--system-prompt",
92
+ "--system-prompt-file",
93
+ "--append-system-prompt",
94
+ "--append-system-prompt-file",
95
+ "--system-prompt-snapshot",
96
+ "--append-subagent-system-prompt",
97
+ "--append-subagent-system-prompt-file",
98
+ "--plan-mode-instructions",
99
+ "--permission-prompt-tool",
100
+ "--permission-prompts",
101
+ "--json-schema",
102
+ "--fallback-model",
103
+ "--advisor",
104
+ "--agents",
105
+ "--name",
106
+ "-n",
107
+ "--allowedTools",
108
+ "--allowed-tools",
109
+ "--disallowedTools",
110
+ "--disallowed-tools",
111
+ "--tools",
112
+ "--add-dir",
113
+ "--mcp-config",
114
+ "--betas",
115
+ "--file",
116
+ "--channels",
117
+ "--dangerously-load-development-channels",
118
+ "--plugin-dir",
119
+ "--plugin-dir-no-mcp",
120
+ "--plugin-url",
121
+ "--remote-control-session-name-prefix",
122
+ "--exec",
123
+ "-m",
124
+ "--sdk-url",
125
+ "--thinking",
126
+ "--thinking-display",
127
+ "--max-thinking-tokens",
128
+ "--max-turns",
129
+ "--max-budget-usd",
130
+ "--task-budget",
131
+ "--autocompact",
132
+ "--rewind-files",
133
+ "--resume-session-at",
134
+ "--resume-drops-turn",
135
+ "--workload",
136
+ "--output-format",
137
+ "--input-format",
138
+ "--teammate-mode",
139
+ "--messaging-socket-path",
140
+ "--session-id",
141
+ "--environment",
142
+ "--pool",
143
+ "--ref",
144
+ "--on-branch",
145
+ "--correlation-id",
146
+ "--forward-home-settings",
147
+ "--project-config-root",
148
+ "--attach-serve",
149
+ ])
150
+ const OPTIONAL_VALUE_OPTIONS: ReadonlySet<string> = new Set([
151
+ "-d",
152
+ "--debug",
153
+ "-r",
154
+ "--resume",
155
+ "--from-pr",
156
+ "-w",
157
+ "--worktree",
158
+ "--teleport",
159
+ "--cloud",
160
+ "--remote",
161
+ "--project",
162
+ "--remote-control",
163
+ "--rc",
164
+ "--prompt-suggestions",
165
+ ])
166
+ const MULTI_VALUE_OPTIONS: ReadonlySet<string> = new Set([
167
+ "--allowedTools",
168
+ "--allowed-tools",
169
+ "--disallowedTools",
170
+ "--disallowed-tools",
171
+ "--tools",
172
+ "--add-dir",
173
+ "--mcp-config",
174
+ "--betas",
175
+ "--file",
176
+ "--channels",
177
+ "--dangerously-load-development-channels",
178
+ ])
179
+
180
+ function isOption(argument: string): boolean {
181
+ return argument.length > 1 && argument.startsWith("-")
182
+ }
183
+
184
+ /** The index of the last argument `argv[index]` consumes as its values, as Claude Code skips them. */
185
+ function lastValueIndex(argv: readonly string[], index: number): number {
186
+ const argument = argv[index] ?? ""
187
+ if (OPTIONAL_VALUE_OPTIONS.has(argument)) {
188
+ const next = argv[index + 1]
189
+ return next !== undefined && !isOption(next) ? index + 1 : index
190
+ }
191
+ if (!VALUE_OPTIONS.has(argument)) return index
192
+ let last = index + 1
193
+ if (MULTI_VALUE_OPTIONS.has(argument)) {
194
+ while (last + 1 < argv.length && !isOption(argv[last + 1] ?? "")) last++
195
+ }
196
+ return last
197
+ }
198
+
199
+ /**
200
+ * Whether a command line asks for a non-interactive session, as Claude Code reads it: `-p`,
201
+ * `--print`, `--init-only` or `--sdk-url` as an option, not as another option's value, before `--`.
202
+ */
203
+ export function asksForPrintMode(argv: readonly string[]): boolean {
204
+ for (let index = 1; index < argv.length; index++) {
205
+ const argument = argv[index] ?? ""
206
+ if (argument === "--") return false
207
+ if (
208
+ argument === "-p" ||
209
+ argument === "--print" ||
210
+ argument === "--init-only" ||
211
+ argument.startsWith("--sdk-url")
212
+ ) {
213
+ return true
214
+ }
215
+ index = lastValueIndex(argv, index)
216
+ }
217
+ return false
218
+ }
219
+
220
+ /**
221
+ * The channel entries a `claude` command line names, as Claude Code reads them: each flag, before
222
+ * `--`, takes the values after it up to the next option, or one inline `=` value, and untagged values
223
+ * are dropped; every `--channels` entry comes before every development one, since Claude Code
224
+ * matches the first entry naming the server. Development channels load only in an interactive
225
+ * session, so they are dropped when `interactive` is false. A command line split on whitespace
226
+ * (`ps`, not `exact`) cannot tell an option from a word of a prompt, so none of that reading
227
+ * applies to it: its print mode is never read, `--` ends nothing and no value is skipped, since
228
+ * each could drop a loaded channel and refuse messages the session takes.
229
+ */
230
+ export function claudeChannelEntries(
231
+ argv: readonly string[],
232
+ exact = true,
233
+ stdoutIsTerminal: boolean | undefined = undefined,
234
+ ): ChannelEntry[] {
235
+ const interactive = !exact || (!asksForPrintMode(argv) && stdoutIsTerminal !== false)
236
+ const approved: ChannelEntry[] = []
237
+ const development: ChannelEntry[] = []
238
+ for (let index = 1; index < argv.length; index++) {
239
+ const argument = argv[index] ?? ""
240
+ if (exact && argument === "--") break
241
+ const equals = argument.indexOf("=")
242
+ const flag = equals === -1 ? argument : argument.slice(0, equals)
243
+ const dev = CHANNEL_FLAGS[flag]
244
+ if (dev === undefined) {
245
+ if (exact) index = lastValueIndex(argv, index)
246
+ continue
247
+ }
248
+ const values: string[] = []
249
+ if (equals !== -1) values.push(argument.slice(equals + 1))
250
+ else {
251
+ while (index + 1 < argv.length && !isOption(argv[index + 1] ?? "-")) {
252
+ values.push(argv[index + 1] ?? "")
253
+ index++
254
+ }
255
+ }
256
+ if (dev && !interactive) continue
257
+ for (const value of values) {
258
+ const entry = parseEntry(value, dev)
259
+ if (entry !== undefined) (dev ? development : approved).push(entry)
260
+ }
261
+ }
262
+ return [...approved, ...development]
263
+ }
264
+
265
+ /** A process's command line: its arguments, and whether they are exact (`/proc`) rather than
266
+ * `ps` output split on whitespace. */
267
+ export interface CommandLine {
268
+ readonly argv: readonly string[]
269
+ readonly exact: boolean
270
+ /** Whether the process's stdout is a terminal, when that can be read (`/proc`): Claude Code is
271
+ * non-interactive when it is not, and then ignores development channels. */
272
+ readonly stdoutIsTerminal?: boolean
273
+ /** The process's working directory, when it can be read (`/proc`), which a relative
274
+ * `--plugin-dir` value is relative to. */
275
+ readonly cwd?: string
276
+ }
277
+
278
+ /**
279
+ * Whether the `claude` process with `commandLine` loads this server's channel. An undefined
280
+ * command line could not be read, and is taken as loaded: that is what the server advertised
281
+ * before it could tell, and refusing every message on a guess would be worse.
282
+ */
283
+ export function claudeChannelGate(input: {
284
+ readonly commandLine: CommandLine | undefined
285
+ /** This plugin's name and, when the install path says it, its marketplace; undefined when the
286
+ * server runs from a bare MCP config rather than as a plugin, where only `server:` entries
287
+ * apply (a plugin's server is `plugin:<name>:<server>`, which no `server:` entry names). */
288
+ readonly plugin: { readonly name: string; readonly marketplace: string | undefined } | undefined
289
+ /** The managed settings' `allowedChannelPlugins`, or undefined when they do not set it. */
290
+ readonly allowlist: readonly AllowedChannelPlugin[] | undefined
291
+ }): ChannelGate {
292
+ const { commandLine, plugin, allowlist } = input
293
+ if (commandLine === undefined) return { loaded: true }
294
+ const ours =
295
+ plugin === undefined
296
+ ? "server:<name>"
297
+ : `plugin:${plugin.name}@${plugin.marketplace ?? "<marketplace>"}`
298
+ // A bare config's own server name is not known here, so any `server:` entry counts for it.
299
+ const entry = claudeChannelEntries(
300
+ commandLine.argv,
301
+ commandLine.exact,
302
+ commandLine.stdoutIsTerminal,
303
+ ).find((candidate) =>
304
+ plugin === undefined
305
+ ? candidate.kind === "server"
306
+ : // Claude Code names a plugin's server `plugin:<plugin>:<server>`, which a `server:` entry
307
+ // can name too.
308
+ (candidate.kind === "plugin" && candidate.name === plugin.name) ||
309
+ (candidate.kind === "server" &&
310
+ candidate.name === `plugin:${plugin.name}:${PLUGIN_SERVER}`),
311
+ )
312
+ if (entry === undefined) {
313
+ return {
314
+ loaded: false,
315
+ why: `claude was started without --channels ${ours} (or --dangerously-load-development-channels ${ours})`,
316
+ }
317
+ }
318
+ if (entry.kind === "server") {
319
+ return entry.dev
320
+ ? { loaded: true }
321
+ : {
322
+ loaded: false,
323
+ why: `server:${entry.name} is not on Claude Code's approved channels allowlist (use --dangerously-load-development-channels server:${entry.name})`,
324
+ }
325
+ }
326
+ const asked = `plugin:${entry.name}@${entry.marketplace}`
327
+ // The install path holds the marketplace as Claude Code names its cache directory, with every
328
+ // character outside [A-Za-z0-9_-] made "-", while the flag holds it as written. So both are
329
+ // compared that way; a flag naming `legion-plugins` for a plugin from `legion.plugins` therefore
330
+ // counts as a match here, which Claude Code refuses, since the path no longer says which it was.
331
+ if (
332
+ plugin?.marketplace !== undefined &&
333
+ cacheDirectoryName(entry.marketplace ?? "") !== cacheDirectoryName(plugin.marketplace)
334
+ ) {
335
+ return {
336
+ loaded: false,
337
+ why: `claude asked for ${asked} but this plugin is installed from ${plugin.marketplace}`,
338
+ }
339
+ }
340
+ if (entry.dev) return { loaded: true }
341
+ if (allowlist === undefined) {
342
+ return {
343
+ loaded: false,
344
+ why: `${asked} is not on Claude Code's approved channels allowlist (add it to allowedChannelPlugins in ${MANAGED_SETTINGS_HINT}; for one session use --dangerously-load-development-channels ${asked})`,
345
+ }
346
+ }
347
+ return allowlist.some(
348
+ (allowed) => allowed.marketplace === entry.marketplace && allowed.plugin === entry.name,
349
+ )
350
+ ? { loaded: true }
351
+ : {
352
+ loaded: false,
353
+ why: `${asked} is not on Claude Code's approved channels allowlist (allowedChannelPlugins in ${MANAGED_SETTINGS_HINT})`,
354
+ }
355
+ }
356
+
357
+ /** Where an allowlist goes: the machine's managed-settings.json, unless the account receives
358
+ * server-managed settings, which then own `allowedChannelPlugins` (see `channelAllowlist`). */
359
+ const MANAGED_SETTINGS_HINT = `${join(managedSettingsDirectory(), "managed-settings.json")}, or in the server-managed settings when the account receives them`
360
+
361
+ /** A marketplace name as Claude Code writes it into its plugin cache path. */
362
+ function cacheDirectoryName(marketplace: string): string {
363
+ return marketplace.replace(/[^a-zA-Z0-9\-_]/g, "-")
364
+ }
365
+
366
+ /** The executable process `pid` runs, or undefined when it cannot be read. */
367
+ export function processExecutable(pid: number): string | undefined {
368
+ if (process.platform === "linux") {
369
+ try {
370
+ return readlinkSync(`/proc/${pid}/exe`)
371
+ } catch {
372
+ return undefined
373
+ }
374
+ }
375
+ if (process.platform === "win32") return undefined
376
+ const result = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "txt", "-Fn"], {
377
+ encoding: "utf8",
378
+ timeout: 2_000,
379
+ })
380
+ const first = (result.stdout ?? "").split("\n").find((line) => line.startsWith("n"))
381
+ return first === undefined ? undefined : first.slice(1)
382
+ }
383
+
384
+ /** Whether process `pid`'s stdout is a terminal, from `/proc` on Linux; undefined elsewhere or
385
+ * when it cannot be read. */
386
+ function stdoutIsTerminal(pid: number): boolean | undefined {
387
+ if (process.platform !== "linux") return undefined
388
+ try {
389
+ const target = readlinkSync(`/proc/${pid}/fd/1`)
390
+ return target.startsWith("/dev/pts/") || /^\/dev\/tty[^S]/.test(target) || target === "/dev/tty"
391
+ } catch {
392
+ return undefined
393
+ }
394
+ }
395
+
396
+ /** Process `pid`'s working directory, from `/proc`; undefined when it cannot be read. */
397
+ function workingDirectory(pid: number): string | undefined {
398
+ try {
399
+ return readlinkSync(`/proc/${pid}/cwd`)
400
+ } catch {
401
+ return undefined
402
+ }
403
+ }
404
+
405
+ /**
406
+ * The command line of process `pid`, or undefined when it cannot be read: `/proc` on Linux
407
+ * (exact, with whether its stdout is a terminal), `ps` elsewhere (split on whitespace, which no
408
+ * channel entry contains), and nothing on Windows. Claude Code sets `process.title` to `claude`,
409
+ * which under Node overwrites the arguments `ps` and `/proc` show, so such a command line cannot
410
+ * be read either: on Linux it is the title followed by NUL padding over the old arguments, and
411
+ * elsewhere one word from a program whose name starts with `node` (`node-22`, `nodejs`). A native
412
+ * (Bun) build keeps its arguments, and its one-word command line means `claude` alone.
413
+ */
414
+ export function processCommandLine(
415
+ pid: number,
416
+ executable: (pid: number) => string | undefined = processExecutable,
417
+ ): CommandLine | undefined {
418
+ let commandLine: CommandLine | undefined
419
+ if (process.platform === "linux") {
420
+ try {
421
+ // Each argument ends in one NUL. A process that set its title (Node, for `process.title`)
422
+ // leaves the title alone, which holds no NUL, then NUL padding over its old arguments, so
423
+ // one piece before several empty ones is a title and its arguments are gone. Otherwise the
424
+ // empty pieces past the final NUL are blank arguments (`--name ""`), which keep their place,
425
+ // since dropping one would shift which word is a value.
426
+ const pieces = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0")
427
+ let trailing = 0
428
+ while (pieces.length > 0 && pieces.at(-1) === "") {
429
+ pieces.pop()
430
+ trailing++
431
+ }
432
+ if (trailing > 1 && pieces.length === 1) return undefined
433
+ for (let blank = 1; blank < trailing; blank++) pieces.push("")
434
+ const argv = pieces
435
+ const terminal = stdoutIsTerminal(pid)
436
+ const cwd = workingDirectory(pid)
437
+ commandLine =
438
+ argv.length === 0
439
+ ? undefined
440
+ : {
441
+ argv,
442
+ exact: true,
443
+ ...(terminal === undefined ? {} : { stdoutIsTerminal: terminal }),
444
+ ...(cwd === undefined ? {} : { cwd }),
445
+ }
446
+ } catch {
447
+ commandLine = undefined
448
+ }
449
+ } else if (process.platform !== "win32") {
450
+ const result = spawnSync("ps", ["-ww", "-o", "args=", "-p", String(pid)], {
451
+ encoding: "utf8",
452
+ timeout: 1_000,
453
+ })
454
+ const line = result.status === 0 ? result.stdout.trim() : ""
455
+ commandLine = line === "" ? undefined : { argv: line.split(/\s+/), exact: false }
456
+ }
457
+ if (commandLine === undefined || commandLine.argv.length > 1) return commandLine
458
+ return basename(executable(pid) ?? "").startsWith("node") ? undefined : commandLine
459
+ }
460
+
461
+ /** The parent of process `pid`, or undefined when it cannot be read. */
462
+ export function parentProcess(pid: number): number | undefined {
463
+ try {
464
+ if (process.platform === "linux") {
465
+ // The command name, in parentheses, may hold spaces; the parent is the second field after it.
466
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8")
467
+ const parent = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1])
468
+ return Number.isInteger(parent) && parent > 0 ? parent : undefined
469
+ }
470
+ if (process.platform === "win32") return undefined
471
+ const result = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], {
472
+ encoding: "utf8",
473
+ timeout: 1_000,
474
+ })
475
+ const parent = Number((result.stdout ?? "").trim())
476
+ return result.status === 0 && Number.isInteger(parent) && parent > 0 ? parent : undefined
477
+ } catch {
478
+ return undefined
479
+ }
480
+ }
481
+
482
+ /** How many processes up from the server `claudeCommandLine` looks for Claude Code. */
483
+ const MAX_CLAUDE_DEPTH = 8
484
+
485
+ /**
486
+ * The command line of the Claude Code process that runs this server: the nearest of `pid` and its
487
+ * ancestors whose program is `claude`, since Claude Code may run the server through a shell prefix
488
+ * (`CLAUDE_CODE_SHELL_PREFIX`) or a version manager's `bun` shim. Undefined, read as "cannot tell",
489
+ * when no such process is found or a command line cannot be read.
490
+ */
491
+ export function claudeCommandLine(
492
+ pid: number,
493
+ read: (pid: number) => CommandLine | undefined = processCommandLine,
494
+ parent: (pid: number) => number | undefined = parentProcess,
495
+ ): CommandLine | undefined {
496
+ let current: number | undefined = pid
497
+ for (let depth = 0; current !== undefined && current > 1 && depth < MAX_CLAUDE_DEPTH; depth++) {
498
+ const commandLine = read(current)
499
+ if (commandLine === undefined) return undefined
500
+ if (basename(commandLine.argv[0] ?? "") === "claude") return commandLine
501
+ current = parent(current)
502
+ }
503
+ return undefined
504
+ }
505
+
506
+ /** The segments after `…/plugins/cache/` in a plugin install path, or undefined for any other path. */
507
+ function pluginCacheSegments(bundlePath: string): string[] | undefined {
508
+ const parts = bundlePath.split(sep)
509
+ for (let index = 0; index + 1 < parts.length; index++) {
510
+ if (parts[index] === "plugins" && parts[index + 1] === "cache") return parts.slice(index + 2)
511
+ }
512
+ return undefined
513
+ }
514
+
515
+ /**
516
+ * The marketplace a plugin install path names (`…/plugins/cache/<marketplace>/<plugin>/…`), or
517
+ * undefined for any other path: a source checkout, `--plugin-dir`, or a bare server config.
518
+ */
519
+ export function installedMarketplace(bundlePath: string): string | undefined {
520
+ return pluginCacheSegments(bundlePath)?.[0]
521
+ }
522
+
523
+ /**
524
+ * The version a plugin install path names (`…/plugins/cache/<marketplace>/<plugin>/<version>/`,
525
+ * the published npm version for this plugin, whose marketplace entry is an `npm` source), or
526
+ * undefined for any other path.
527
+ */
528
+ export function installedPluginVersion(bundlePath: string): string | undefined {
529
+ return pluginCacheSegments(bundlePath)?.[2]
530
+ }
531
+
532
+ /** The marketplace Claude Code names a plugin it loads with `--plugin-dir` under. */
533
+ export const INLINE_MARKETPLACE = "inline"
534
+
535
+ /**
536
+ * The marketplace this plugin, rooted at `pluginRoot`, is loaded from: `inline` when a
537
+ * `--plugin-dir` value on the `claude` command line names that directory, the plugin cache's
538
+ * marketplace when `bundlePath` lies in it, and undefined otherwise (a directory-source
539
+ * marketplace, say), which the gate does not check.
540
+ */
541
+ export function loadedMarketplace(
542
+ pluginRoot: string,
543
+ bundlePath: string,
544
+ commandLine: { readonly argv: readonly string[]; readonly cwd: string } | undefined,
545
+ ): string | undefined {
546
+ if (commandLine !== undefined) {
547
+ const { argv, cwd } = commandLine
548
+ const root = resolve(pluginRoot)
549
+ for (let index = 1; index < argv.length; index++) {
550
+ const argument = argv[index] ?? ""
551
+ if (argument === "--") break
552
+ const equals = argument.indexOf("=")
553
+ const flag = equals === -1 ? argument : argument.slice(0, equals)
554
+ if (flag === "--plugin-dir" || flag === "--plugin-dir-no-mcp") {
555
+ const value = equals === -1 ? argv[index + 1] : argument.slice(equals + 1)
556
+ if (value !== undefined && resolve(cwd, value) === root) return INLINE_MARKETPLACE
557
+ }
558
+ if (equals === -1) index = lastValueIndex(argv, index)
559
+ }
560
+ }
561
+ return installedMarketplace(bundlePath)
562
+ }
563
+
564
+ /** One `allowedChannelPlugins` entry as Claude Code accepts it: the documented object, or a
565
+ * `plugin@marketplace` string, which it also accepts with a warning. */
566
+ const AllowedChannelEntry = z.union([
567
+ z.object({ marketplace: z.string(), plugin: z.string() }),
568
+ z
569
+ .string()
570
+ .regex(/^[^@]+@[^@]+$/)
571
+ .transform((value) => {
572
+ const at = value.indexOf("@")
573
+ return { plugin: value.slice(0, at), marketplace: value.slice(at + 1) }
574
+ }),
575
+ ])
576
+
577
+ /** An `allowedChannelPlugins` value's entries, each invalid one dropped on its own as Claude Code
578
+ * drops it; undefined when the value is not a list. */
579
+ function allowedEntries(value: unknown): readonly AllowedChannelPlugin[] | undefined {
580
+ if (!Array.isArray(value)) return undefined
581
+ return value.flatMap((entry: unknown) => {
582
+ const parsed = AllowedChannelEntry.safeParse(entry)
583
+ return parsed.success ? [parsed.data] : []
584
+ })
585
+ }
586
+
587
+ /** Claude Code's managed settings directory on this platform. */
588
+ export function managedSettingsDirectory(): string {
589
+ switch (process.platform) {
590
+ case "darwin":
591
+ return "/Library/Application Support/ClaudeCode"
592
+ case "win32":
593
+ return "C:\\Program Files\\ClaudeCode"
594
+ default:
595
+ return "/etc/claude-code"
596
+ }
597
+ }
598
+
599
+ interface ChannelSettings {
600
+ /** The file's `allowedChannelPlugins`, or undefined when it does not set one. */
601
+ readonly list: readonly AllowedChannelPlugin[] | undefined
602
+ /** The file's `managedSourcesBehavior`, when it sets one. */
603
+ readonly behavior: string | undefined
604
+ }
605
+
606
+ /** Keys that say how sources compose, which make no settings file a source on their own. */
607
+ const COMPOSITION_KEYS: ReadonlySet<string> = new Set([
608
+ "managedSourcesBehavior",
609
+ "wslInheritsWindowsSettings",
610
+ ])
611
+
612
+ /** A settings file's channel settings, or undefined when the file is missing or is not a JSON
613
+ * object, and (with `asSource`) when it holds nothing but `COMPOSITION_KEYS`, as Claude Code does
614
+ * not count such a file as a source. */
615
+ function readChannelSettings(file: string, asSource = false): ChannelSettings | undefined {
616
+ try {
617
+ const value: unknown = JSON.parse(readFileSync(file, "utf8"))
618
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
619
+ if (asSource && Object.keys(value).every((key) => COMPOSITION_KEYS.has(key))) return undefined
620
+ const record = value as Record<string, unknown>
621
+ const behavior = record["managedSourcesBehavior"]
622
+ return {
623
+ list: allowedEntries(record["allowedChannelPlugins"]),
624
+ behavior: typeof behavior === "string" ? behavior : undefined,
625
+ }
626
+ } catch {
627
+ return undefined
628
+ }
629
+ }
630
+
631
+ /**
632
+ * The `allowedChannelPlugins` of the managed settings Claude Code applies, read from files.
633
+ * Server-managed settings (cached as `remote-settings.json` in the config directory) outrank an
634
+ * MDM profile or the Windows registry, which outrank `managed-settings.json` with its
635
+ * `managed-settings.d` drop-ins (`.json` files whose names do not start with a dot). Under
636
+ * `managedSourcesBehavior` "first-wins", the default, the highest source present is the tier
637
+ * alone; under "merge", the highest source that sets the key owns it. Undefined when that source
638
+ * does not set the key, or none is present, which leaves Claude Code's own list. MDM and registry
639
+ * settings are not read; `ENVOY_CHANNEL_LOADED=1` covers an allowlist only they deliver.
640
+ */
641
+ export function channelAllowlist(directories: {
642
+ readonly managedDirectory: string
643
+ readonly configDirectory: string
644
+ }): readonly AllowedChannelPlugin[] | undefined {
645
+ const remote = readChannelSettings(
646
+ join(directories.configDirectory, "remote-settings.json"),
647
+ true,
648
+ )
649
+ const dropIns = join(directories.managedDirectory, "managed-settings.d")
650
+ let dropInFiles: string[] = []
651
+ try {
652
+ dropInFiles = readdirSync(dropIns)
653
+ .filter((name) => name.endsWith(".json") && !name.startsWith("."))
654
+ .sort()
655
+ .map((name) => join(dropIns, name))
656
+ } catch {
657
+ dropInFiles = []
658
+ }
659
+ const files = [join(directories.managedDirectory, "managed-settings.json"), ...dropInFiles]
660
+ .map((file) => readChannelSettings(file))
661
+ .filter((settings) => settings !== undefined)
662
+ const lists = files.map((settings) => settings.list).filter((list) => list !== undefined)
663
+ const fileList = lists.length === 0 ? undefined : lists.flat()
664
+ const behavior = remote?.behavior ?? files.find((settings) => settings.behavior)?.behavior
665
+ if (remote === undefined) return fileList
666
+ return behavior === "merge" ? (remote.list ?? fileList) : remote.list
667
+ }
668
+
669
+ /**
670
+ * Whether this server's channel is loaded, for the `claude` process `claudePid` running the bundle
671
+ * at `bundlePath`: as a plugin when Claude Code set `CLAUDE_PLUGIN_ROOT`, else from a bare MCP
672
+ * config. `ENVOY_CHANNEL_LOADED=1` or `0` overrides the check.
673
+ */
674
+ export function detectClaudeChannel(input: {
675
+ readonly claudePid: number
676
+ readonly bundlePath: string
677
+ readonly environment: NodeJS.ProcessEnv
678
+ }): ChannelGate {
679
+ const override = input.environment["ENVOY_CHANNEL_LOADED"]?.trim()
680
+ if (override === "1") return { loaded: true }
681
+ if (override === "0") return { loaded: false, why: "ENVOY_CHANNEL_LOADED=0" }
682
+ const configDirectory =
683
+ input.environment["CLAUDE_CONFIG_DIR"]?.trim() || join(homedir(), ".claude")
684
+ const commandLine = claudeCommandLine(input.claudePid)
685
+ const pluginRoot = input.environment["CLAUDE_PLUGIN_ROOT"]?.trim()
686
+ const plugin = pluginRoot
687
+ ? {
688
+ name: "claude-envoy",
689
+ // A `--plugin-dir` value is relative to the `claude` process's directory, which only
690
+ // `/proc` gives, with the exact command line.
691
+ marketplace: loadedMarketplace(
692
+ pluginRoot,
693
+ input.bundlePath,
694
+ commandLine?.exact && commandLine.cwd !== undefined
695
+ ? { argv: commandLine.argv, cwd: commandLine.cwd }
696
+ : undefined,
697
+ ),
698
+ }
699
+ : undefined
700
+ return claudeChannelGate({
701
+ commandLine,
702
+ plugin,
703
+ allowlist: channelAllowlist({ managedDirectory: managedSettingsDirectory(), configDirectory }),
704
+ })
705
+ }