@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,116 @@
1
+ // @bun
2
+ // ../envoy-client/src/errors.ts
3
+ function messageFor(error) {
4
+ return error instanceof Error ? error.message : String(error);
5
+ }
6
+ function hasErrnoCode(error, code) {
7
+ return error instanceof Error && "code" in error && error.code === code;
8
+ }
9
+
10
+ // src/session-state.ts
11
+ import { readFileSync } from "fs";
12
+ import { mkdir, readdir, rename, rm, stat, utimes, writeFile } from "fs/promises";
13
+ import { dirname, join } from "path";
14
+
15
+ // src/claude-session.ts
16
+ function configuredValue(value) {
17
+ return value !== undefined && value.trim().length > 0 ? value : undefined;
18
+ }
19
+ function configuredSessionId(environment) {
20
+ return configuredValue(environment.ENVOY_SESSION_ID) ?? configuredValue(environment.CLAUDE_CODE_SESSION_ID);
21
+ }
22
+ function claudeSessionId(environment) {
23
+ const id = configuredSessionId(environment);
24
+ if (id !== undefined)
25
+ return id;
26
+ throw new Error("Envoy channel requires ENVOY_SESSION_ID or CLAUDE_CODE_SESSION_ID; Claude Code did not provide a session identity");
27
+ }
28
+ function claudeProjectDirectory(environment, fallback) {
29
+ return configuredValue(environment.CLAUDE_PROJECT_DIR) ?? fallback;
30
+ }
31
+
32
+ // src/session-state.ts
33
+ async function writeAtomicStateFile(file, contents) {
34
+ await mkdir(dirname(file), { recursive: true, mode: 448 });
35
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
36
+ await writeFile(temporary, contents, { mode: 384 });
37
+ await rename(temporary, file);
38
+ }
39
+ async function touchStateFile(file) {
40
+ const now = new Date;
41
+ await utimes(file, now, now);
42
+ }
43
+ async function pruneStateDirectory(directory, isStale) {
44
+ const entries = await readdir(directory).catch((error) => {
45
+ if (hasErrnoCode(error, "ENOENT"))
46
+ return [];
47
+ throw error;
48
+ });
49
+ for (const entry of entries) {
50
+ const file = join(directory, entry);
51
+ if (!await isStale(file))
52
+ continue;
53
+ await rm(file, { recursive: true, force: true });
54
+ }
55
+ }
56
+ var STATE_FILE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
57
+ async function writtenLongAgo(file) {
58
+ const modified = await stat(file).then((stats) => stats.mtimeMs, () => {
59
+ return;
60
+ });
61
+ return modified !== undefined && Date.now() - modified > STATE_FILE_MAX_AGE_MS;
62
+ }
63
+ var TITLES_DIRECTORY = "titles";
64
+ function sessionTitleFile(stateDirectory, sessionId) {
65
+ return join(stateDirectory, TITLES_DIRECTORY, `${encodeURIComponent(sessionId)}.txt`);
66
+ }
67
+ async function writeChosenTitle(file, title) {
68
+ await writeAtomicStateFile(file, `${title}
69
+ `);
70
+ }
71
+ async function removeChosenTitle(file) {
72
+ await rm(file, { force: true });
73
+ }
74
+ function readChosenTitleSync(file) {
75
+ try {
76
+ return readFileSync(file, "utf8");
77
+ } catch {
78
+ return;
79
+ }
80
+ }
81
+ async function recordChosenTitle(sessionId, title) {
82
+ const pluginData = configuredValue(process.env["CLAUDE_PLUGIN_DATA"]);
83
+ if (pluginData === undefined)
84
+ return;
85
+ const file = sessionTitleFile(pluginData, sessionId);
86
+ const chosen = configuredValue(title);
87
+ if (chosen === undefined)
88
+ await removeChosenTitle(file);
89
+ else
90
+ await writeChosenTitle(file, chosen);
91
+ }
92
+ async function pruneStaleSessionTitles(stateDirectory) {
93
+ await pruneStateDirectory(join(stateDirectory, TITLES_DIRECTORY), writtenLongAgo);
94
+ }
95
+
96
+ // hooks/session-title-hook.ts
97
+ function chosenTitlePayload(raw) {
98
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
99
+ throw new Error(`expected a JSON object, received ${JSON.stringify(raw)}`);
100
+ }
101
+ const { session_id: sessionId, session_title: title } = raw;
102
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
103
+ throw new Error(`session_id must be a non-empty string, received ${JSON.stringify(sessionId)}`);
104
+ }
105
+ if (title !== undefined && typeof title !== "string") {
106
+ throw new Error(`session_title must be a string, received ${JSON.stringify(title)}`);
107
+ }
108
+ return { sessionId, title };
109
+ }
110
+ try {
111
+ const { sessionId, title } = chosenTitlePayload(JSON.parse(await Bun.stdin.text()));
112
+ await recordChosenTitle(sessionId, title);
113
+ } catch (error) {
114
+ process.stderr.write(`envoy: could not record the session title \u2014 ${messageFor(error)}
115
+ `);
116
+ }
@@ -0,0 +1,60 @@
1
+ {
2
+ "description": "Envoy: summarise this session's open Dispatch asks when a session starts, load the dispatch-first skill and what people wrote to the session in the last 72 hours into each session and subagent when Dispatch is configured, and record the session's chosen title for the channel server.",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "matcher": "startup|resume|clear|compact|fork",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "bun",
11
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/session-hook.js", "open-asks"],
12
+ "timeout": 20
13
+ },
14
+ {
15
+ "type": "command",
16
+ "command": "bun",
17
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/session-hook.js", "dispatch-first"],
18
+ "timeout": 20
19
+ },
20
+ {
21
+ "type": "command",
22
+ "command": "bun",
23
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/session-hook.js", "answers"],
24
+ "timeout": 5
25
+ }
26
+ ]
27
+ }
28
+ ],
29
+ "SubagentStart": [
30
+ {
31
+ "hooks": [
32
+ {
33
+ "type": "command",
34
+ "command": "bun",
35
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/session-hook.js", "dispatch-first"],
36
+ "timeout": 20
37
+ },
38
+ {
39
+ "type": "command",
40
+ "command": "bun",
41
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/session-hook.js", "answers"],
42
+ "timeout": 5
43
+ }
44
+ ]
45
+ }
46
+ ],
47
+ "UserPromptSubmit": [
48
+ {
49
+ "hooks": [
50
+ {
51
+ "type": "command",
52
+ "command": "bun",
53
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/session-title-hook.js"],
54
+ "timeout": 5
55
+ }
56
+ ]
57
+ }
58
+ ]
59
+ }
60
+ }
@@ -0,0 +1,512 @@
1
+ // Claude Code hook commands, one bundle for all three; hooks.json names the mode as the argument.
2
+ // Each mode is a hook command of its own because Claude Code caps each hook's output at 10,000
3
+ // characters, so a long open-asks summary or answers list sharing a command with the skill could
4
+ // push it out.
5
+ //
6
+ // open-asks (every SessionStart: startup, resume, clear, compact, fork). Three jobs:
7
+ // 1. Name Claude Code as the `dispatch` command's host and hand on the session's envoy.json:
8
+ // append `export DISPATCH_HOST=claude` and `export ENVOY_CONFIG_FILE='<path>'` (the file
9
+ // Claude Code's rule picks, `$CLAUDE_CONFIG_DIR/envoy.json` when that is set) to
10
+ // `CLAUDE_ENV_FILE`, which Claude Code sources into every later Bash command of the session,
11
+ // each unless an earlier SessionStart already wrote that line there. The `dispatch` command
12
+ // reads the file ENVOY_CONFIG_FILE names whenever DISPATCH_HOST is set. A subagent's Bash can
13
+ // lack what that file holds (anthropics/claude-code#46696); the `dispatch` command it runs then
14
+ // takes host `claude` from `CLAUDE_CODE_CHILD_SESSION=1` and reads `CLAUDE_CONFIG_DIR` itself.
15
+ // The path is single-quoted, the one form Claude Code's PowerShell
16
+ // bridge also reads. The command reads the session id from `CLAUDE_CODE_SESSION_ID`, which
17
+ // Claude Code sets fresh in each Bash command, so a `/clear` needs nothing more here.
18
+ // 2. Record the CURRENT session id for this Claude process so the channel
19
+ // server, which keeps the id it was spawned with, can follow a `/clear`.
20
+ // 3. Put the session's open Dispatch asks into the model's context, after one `envoy: <warning>`
21
+ // line for each file the read found and did not read (a checkout's `.opencode/envoy.json`,
22
+ // naming its rename), as the other plugins' start notices and the `dispatch` command name it.
23
+ // Plain stdout becomes context; it always exits 0 so a Dispatch outage never
24
+ // blocks a session from starting.
25
+ //
26
+ // dispatch-first (every SessionStart, and SubagentStart): puts the dispatch-first skill into the
27
+ // model's context when Dispatch is configured for the project, as
28
+ // `hookSpecificOutput.additionalContext` under the event that ran it. A subagent runs the main
29
+ // conversation's `dispatch` command and starts with no SessionStart of its own. On resume and fork
30
+ // Claude Code adds the context only when the transcript does not already hold the same text, so a
31
+ // session that started with the skill keeps one copy, and one that started without it (opened
32
+ // before this plugin version, or before Dispatch was configured) gets it. A plugin installed
33
+ // without the skill file exits non-zero naming it, which Claude Code shows the user.
34
+ //
35
+ // answers (every SessionStart, and SubagentStart): puts what people wrote to the session in the
36
+ // last 72 hours into the model's context, as
37
+ // `hookSpecificOutput.additionalContext` under the event that ran it: `readSessionAnswers`' list,
38
+ // or its failure line naming why when a read failed and no list read before has an item to show,
39
+ // and nothing when Dispatch answered that it has no answers route (`source: "unavailable"`): the
40
+ // read's notice naming that Dispatch goes to stderr instead, one line per hook process.
41
+ // The session is the hook input's `session_id`, which at SubagentStart is the parent's, the
42
+ // session a subagent's `dispatch` command writes as.
43
+ // The hook prints by `ANSWERS_BY_MS` after its process started (bun's start-up included), or
44
+ // `ANSWERS_LEAST_WAIT_MS` after its code began when the start-up took longer, carrying the last
45
+ // list read, from `answers.json` in the session's Dispatch state directory, when Dispatch has not
46
+ // answered by then; it then gives a read still in flight `ANSWERS_SETTLE_MS` more to write that
47
+ // file for the next hook, and exits 0 whatever happened. Dispatch is resolved without running a
48
+ // `!` token command first (`commands: "kept"`). A fresh process has kept none, so when that
49
+ // resolution is off with an error the hook starts this bundle again, detached, in mode
50
+ // `answers-refresh` (never named by hooks.json): that process resolves Dispatch with commands run,
51
+ // reads the list with a long deadline, which writes `answers.json`, and leaves what it read in
52
+ // `answers-refresh.json` beside it, with the Dispatch URL it targeted (`sessionDispatchUrl`, which
53
+ // runs no command), its failure line included when the command failed and the read's notice when
54
+ // it gave one. The hook carries that when
55
+ // it lands by the print deadline and names the Dispatch URL envoy.json names to the hook now, and
56
+ // otherwise the last list read from that URL (resolved the same way; a list another Dispatch
57
+ // answered never stands in) or the failure line: the refresher is in its own
58
+ // session with no stdio of the hook's, so the command always runs to its end or its 10 s timeout
59
+ // inside `envoy-config`'s own run, which kills its process group and removes its temporary output,
60
+ // and the next hook carries the list it read. One refresher runs per session at a time
61
+ // (`answers-refresh.lock`, holding its pid), so a burst of SubagentStart hooks runs the command
62
+ // once; a refresher started while envoy.json named another Dispatch answers no hook.
63
+ //
64
+ // The name a person gave the session is recorded by a bundle of its own,
65
+ // `hooks/session-title-hook.ts`: it runs before every user prompt, with the prompt blocked on it,
66
+ // so it must not pay for this bundle's zod and Dispatch client. `open-asks` records it too, from
67
+ // the same `session_title` field, so a session starts under its name before its first prompt.
68
+
69
+ import { appendFileSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
70
+ import { join } from "node:path"
71
+ import { formatOpenAsksSummary } from "@legion/envoy-client/dispatch-execute"
72
+ import {
73
+ dispatchFirstSkillFile,
74
+ readDispatchFirstContext,
75
+ } from "@legion/envoy-client/dispatch-first"
76
+ import { DispatchClient } from "@legion/envoy-client/dispatch-http"
77
+ import { sessionDirectory, writeAtomically } from "@legion/envoy-client/dispatch-session-directory"
78
+ import { offWithError } from "@legion/envoy-client/envoy-config"
79
+ import { hasErrnoCode, messageFor } from "@legion/envoy-client/errors"
80
+ import {
81
+ readSessionAnswers,
82
+ SESSION_ANSWERS_DEADLINE_MS,
83
+ sessionAnswersSettled,
84
+ } from "@legion/envoy-client/session-answers"
85
+ import {
86
+ sessionConfigFile,
87
+ sessionDispatchSettings,
88
+ sessionDispatchUrl,
89
+ } from "@legion/envoy-client/session-settings"
90
+ import { z } from "zod"
91
+ import { claudeProjectDirectory, configuredValue } from "../src/claude-session"
92
+ import { sessionHandoffFile, writeSessionHandoff } from "../src/session-identity"
93
+ import { recordChosenTitle } from "../src/session-state"
94
+
95
+ const OPEN_ASKS_TIMEOUT_MS = 3_000
96
+ const OpenAsksInput = z.object({
97
+ session_id: z.string().min(1),
98
+ cwd: z.string().optional(),
99
+ session_title: z.string().optional(),
100
+ })
101
+ const DispatchFirstInput = z.object({
102
+ hook_event_name: z.enum(["SessionStart", "SubagentStart"]),
103
+ cwd: z.string().optional(),
104
+ })
105
+ // session_id first, as the schema always had it, so a ZodError lists its issues in the same order.
106
+ const AnswersInput = z.object({ session_id: z.string().min(1), ...DispatchFirstInput.shape })
107
+
108
+ /** When the answers hook prints, in ms after its process started: the per-request deadline, with
109
+ * bun's start-up and Dispatch's resolution inside it, so the session waits no longer on it than a
110
+ * model request on Oh My Pi waits on the same read. */
111
+ const ANSWERS_BY_MS = SESSION_ANSWERS_DEADLINE_MS
112
+ /** The least the hook waits on Dispatch once its own code runs, whatever the start-up spent:
113
+ * Claude Code starts every hook beside itself, so on a loaded host bun's start-up alone can pass
114
+ * `ANSWERS_BY_MS`, and a budget already spent would carry the failure line or a stale list while
115
+ * Dispatch answers at once. A read that answers in this time is carried; one that does not costs
116
+ * the session at most this much past the start-up. */
117
+ const ANSWERS_LEAST_WAIT_MS = 150
118
+ /** How long after the hook's print deadline a read that answered past it may still write
119
+ * `answers.json` for the next hook before the process exits. */
120
+ const ANSWERS_SETTLE_MS = 50
121
+ /** The refresher's lock, holding its pid, and what it read for the hooks, in the session's
122
+ * Dispatch state directory. */
123
+ const REFRESH_LOCK = "answers-refresh.lock"
124
+ const REFRESH_OUTCOME = "answers-refresh.json"
125
+ /** How often a hook waiting on the refresher looks for what it read. */
126
+ const REFRESH_POLL_MS = 20
127
+ /** How long the refresher waits on its read: past the read's own 10 s timeout, which ends it. */
128
+ const REFRESH_READ_MS = 12_000
129
+ /** The age past which a lock is no refresher's, whatever pid it holds: past a refresher's longest
130
+ * run, a 10 s token command and a 10 s read after bun's start-up. */
131
+ const REFRESH_LOCK_STALE_MS = 30_000
132
+ const RefreshOutcome = z.object({
133
+ /** When the refresher finished (epoch ms): a hook carries only an outcome from after it started. */
134
+ finished_at: z.number(),
135
+ /** The Dispatch URL the refresher targeted, as `sessionDispatchUrl` resolves it, the URL the
136
+ * hook compares with, whether or not the token command then failed; null when envoy.json names
137
+ * none. A hook carries only an outcome read for the URL envoy.json names to it. */
138
+ dispatch_url: z.string().nullable(),
139
+ /** `readSessionAnswers`' message; null when there was nothing to show. */
140
+ text: z.string().nullable(),
141
+ /** `readSessionAnswers`' notice that the Dispatch has no answers route, which the hook writes to
142
+ * stderr; null or absent when the read gave none. */
143
+ notice: z.string().nullable().optional(),
144
+ })
145
+ /** Tests only: the file the answers hook writes its own timing to, `{ began, printed, ended }` in
146
+ * ms after its process started (when its code began, when it printed or found nothing to print,
147
+ * and when it exits), so a test can hold the hook to its deadline rather than to bun's start-up.
148
+ * Unset, as Claude Code runs it, the hook writes nothing. */
149
+ const ANSWERS_TIMING_FILE = "ENVOY_ANSWERS_TIMING_FILE"
150
+
151
+ const DISPATCH_HOST_LINE = "export DISPATCH_HOST=claude"
152
+
153
+ /** Appends `line` to the env file, once: every SessionStart of the session runs this. */
154
+ function appendOnce(envFile: string, line: string): void {
155
+ let written = ""
156
+ try {
157
+ written = readFileSync(envFile, "utf8")
158
+ } catch (error) {
159
+ if (!hasErrnoCode(error, "ENOENT")) throw error
160
+ }
161
+ if (written.split("\n").includes(line)) return
162
+ const separator = written === "" || written.endsWith("\n") ? "" : "\n"
163
+ appendFileSync(envFile, `${separator}${line}\n`)
164
+ }
165
+
166
+ async function openAsks(raw: unknown): Promise<void> {
167
+ const input = OpenAsksInput.parse(raw)
168
+ const envFile = process.env["CLAUDE_ENV_FILE"]
169
+ // A file location that cannot be worked out hands nothing on: the open-asks summary below says
170
+ // why Dispatch is unavailable.
171
+ const chosen = sessionConfigFile("claude", process.env)
172
+ if (envFile) {
173
+ appendOnce(envFile, DISPATCH_HOST_LINE)
174
+ if (chosen.path !== null) {
175
+ appendOnce(envFile, `export ENVOY_CONFIG_FILE='${chosen.path.replaceAll("'", "'\\''")}'`)
176
+ }
177
+ }
178
+ const pluginData = process.env["CLAUDE_PLUGIN_DATA"]
179
+ if (pluginData !== undefined && pluginData.trim().length > 0) {
180
+ try {
181
+ await writeSessionHandoff(sessionHandoffFile(pluginData, process.ppid), input.session_id)
182
+ } catch (error) {
183
+ process.stderr.write(`envoy: could not record the session id — ${messageFor(error)}\n`)
184
+ }
185
+ }
186
+ try {
187
+ await recordChosenTitle(input.session_id, input.session_title)
188
+ } catch (error) {
189
+ process.stderr.write(`envoy: could not record the session title — ${messageFor(error)}\n`)
190
+ }
191
+
192
+ const directory = claudeProjectDirectory(
193
+ { CLAUDE_PROJECT_DIR: process.env["CLAUDE_PROJECT_DIR"] },
194
+ input.cwd ?? process.cwd(),
195
+ )
196
+ const { dispatch, warnings, missing } = sessionDispatchSettings(chosen, process.env, {
197
+ cwd: directory,
198
+ session: input.session_id,
199
+ })
200
+ for (const warning of warnings) process.stdout.write(`envoy: ${warning}\n`)
201
+ if (dispatch.enabled) {
202
+ try {
203
+ const snapshot = await new DispatchClient(
204
+ dispatch.url,
205
+ dispatch.credential,
206
+ fetch,
207
+ AbortSignal.timeout(OPEN_ASKS_TIMEOUT_MS),
208
+ ).openAsks(input.session_id)
209
+ process.stdout.write(
210
+ `Dispatch authored-ask summary:\n${formatOpenAsksSummary(snapshot, dispatch.publicUrl ?? dispatch.url)}\n`,
211
+ )
212
+ } catch (error) {
213
+ process.stdout.write(`Dispatch authored-ask summary unavailable: ${messageFor(error)}\n`)
214
+ }
215
+ } else if (dispatch.error !== null) {
216
+ process.stdout.write(`Dispatch authored-ask summary unavailable: ${dispatch.error}\n`)
217
+ } else if (
218
+ missing?.oldUserFileNotice !== undefined &&
219
+ dispatch.notice.endsWith(missing.oldUserFileNotice)
220
+ ) {
221
+ // The missing file is why Dispatch is off, and that notice names the OpenCode-era file this
222
+ // machine still holds: say so and what to do. Off for any other reason says nothing.
223
+ process.stdout.write(`Dispatch authored-ask summary unavailable: ${dispatch.notice}\n`)
224
+ }
225
+ }
226
+
227
+ function dispatchFirst(raw: unknown): void {
228
+ const input = DispatchFirstInput.parse(raw)
229
+ const directory = claudeProjectDirectory(
230
+ { CLAUDE_PROJECT_DIR: process.env["CLAUDE_PROJECT_DIR"] },
231
+ input.cwd ?? process.cwd(),
232
+ )
233
+ const chosen = sessionConfigFile("claude", process.env)
234
+ if (!sessionDispatchSettings(chosen, process.env, { cwd: directory }).dispatch.enabled) return
235
+ const pluginRoot = configuredValue(process.env["CLAUDE_PLUGIN_ROOT"])
236
+ if (pluginRoot === undefined) {
237
+ throw new Error("CLAUDE_PLUGIN_ROOT is unset; Claude Code sets it for every plugin hook")
238
+ }
239
+ process.stdout.write(
240
+ JSON.stringify({
241
+ hookSpecificOutput: {
242
+ hookEventName: input.hook_event_name,
243
+ additionalContext: readDispatchFirstContext(
244
+ dispatchFirstSkillFile(join(pluginRoot, "skills")),
245
+ ),
246
+ },
247
+ }),
248
+ )
249
+ }
250
+
251
+ /** The ms left until `at`, a `performance.now()` reading, in whole ms because the failure line
252
+ * names the wait. */
253
+ function remainingUntil(at: number): number {
254
+ return Math.max(0, Math.round(at - performance.now()))
255
+ }
256
+
257
+ /** Whether a refresher holds `lock`: it exists, is younger than `REFRESH_LOCK_STALE_MS`, and the
258
+ * pid in it is alive or not yet written. A lock its refresher died holding is stale at once. */
259
+ function refreshLockHeld(lock: string): boolean {
260
+ let age: number
261
+ let text: string
262
+ try {
263
+ age = Date.now() - statSync(lock).mtimeMs
264
+ text = readFileSync(lock, "utf8")
265
+ } catch (error) {
266
+ if (hasErrnoCode(error, "ENOENT")) return false
267
+ throw error
268
+ }
269
+ if (age > REFRESH_LOCK_STALE_MS) return false
270
+ const pid = Number(text)
271
+ if (!Number.isSafeInteger(pid) || pid <= 0) return true
272
+ try {
273
+ process.kill(pid, 0)
274
+ return true
275
+ } catch (error) {
276
+ return !hasErrnoCode(error, "ESRCH")
277
+ }
278
+ }
279
+
280
+ /** Takes `lock` for this process, replacing a stale one; false when another refresher holds it. */
281
+ function takeRefreshLock(lock: string): boolean {
282
+ for (let attempt = 0; attempt < 2; attempt++) {
283
+ try {
284
+ writeFileSync(lock, String(process.pid), { flag: "wx", mode: 0o600 })
285
+ return true
286
+ } catch (error) {
287
+ if (!hasErrnoCode(error, "EEXIST")) throw error
288
+ }
289
+ if (refreshLockHeld(lock)) return false
290
+ rmSync(lock, { force: true })
291
+ }
292
+ return false
293
+ }
294
+
295
+ /** What the refresher read, when it finished after `since` (epoch ms), whichever Dispatch it read. */
296
+ function refreshOutcome(file: string, since: number): z.infer<typeof RefreshOutcome> | undefined {
297
+ let value: unknown
298
+ try {
299
+ value = JSON.parse(readFileSync(file, "utf8"))
300
+ } catch {
301
+ // None written yet: nothing to carry from it now.
302
+ return undefined
303
+ }
304
+ const outcome = RefreshOutcome.safeParse(value)
305
+ return outcome.success && outcome.data.finished_at >= since ? outcome.data : undefined
306
+ }
307
+
308
+ /** What the refresher gave the hook: its message (undefined: nothing to show) and the notice the
309
+ * hook reports, or why none. */
310
+ type Refreshed =
311
+ | { readonly text: string | undefined; readonly notice: string | undefined }
312
+ | { readonly reason: string }
313
+
314
+ /** Starts the session's refresher unless one is running, and waits until `by` for what it reads.
315
+ * `startedAt` is when this process started (epoch ms): an outcome from before then is not this
316
+ * hook's. The hook carries an outcome only when the Dispatch URL its refresher targeted is
317
+ * `dispatchUrl`, the one envoy.json names to this hook, a failed token command's error included;
318
+ * any other is a refresher started while envoy.json named another, and the hook carries none of
319
+ * it: a hook after the refresher this one started carries the list of the Dispatch named now. */
320
+ async function refreshedAnswers(
321
+ session: string,
322
+ cwd: string,
323
+ dispatchUrl: string,
324
+ startedAt: number,
325
+ by: number,
326
+ ): Promise<Refreshed> {
327
+ let directory: string
328
+ try {
329
+ directory = sessionDirectory(process.env, session)
330
+ } catch (error) {
331
+ // An id that is not one plain path segment has no directory for the lock and the outcome.
332
+ return { reason: messageFor(error) }
333
+ }
334
+ const lock = join(directory, REFRESH_LOCK)
335
+ if (!refreshLockHeld(lock)) {
336
+ Bun.spawn([process.execPath, Bun.main, "answers-refresh", session, cwd], {
337
+ stdin: "ignore",
338
+ stdout: "ignore",
339
+ stderr: "ignore",
340
+ detached: true,
341
+ }).unref()
342
+ }
343
+ const file = join(directory, REFRESH_OUTCOME)
344
+ for (;;) {
345
+ const outcome = refreshOutcome(file, startedAt)
346
+ if (outcome !== undefined) {
347
+ if (outcome.dispatch_url === dispatchUrl) {
348
+ return { text: outcome.text ?? undefined, notice: outcome.notice ?? undefined }
349
+ }
350
+ return {
351
+ reason: `the refresher read ${outcome.dispatch_url ?? "no Dispatch"}, not the Dispatch envoy.json names now`,
352
+ }
353
+ }
354
+ const left = remainingUntil(by)
355
+ if (left === 0) {
356
+ return {
357
+ reason: `a "!" command in envoy.json had not finished ${Math.round(by)} ms after the hook started`,
358
+ }
359
+ }
360
+ await Bun.sleep(Math.min(REFRESH_POLL_MS, left))
361
+ }
362
+ }
363
+
364
+ /** The answers hook's message for `session`, and the notice it reports on stderr when the Dispatch
365
+ * has no answers route. Dispatch is read first without running any `!` command; when that is off
366
+ * with an error, the refresher's message stands, or, when it has not landed by `by`, the last list
367
+ * read from the Dispatch URL envoy.json names now (resolved without the token), never another's,
368
+ * or the failure line naming why. */
369
+ async function answersMessage(
370
+ session: string,
371
+ cwd: string,
372
+ startedAt: number,
373
+ by: number,
374
+ ): Promise<{ readonly text: string | undefined; readonly notice?: string | undefined }> {
375
+ const file = sessionConfigFile("claude", process.env)
376
+ let { dispatch } = sessionDispatchSettings(file, process.env, {
377
+ cwd,
378
+ commands: "kept",
379
+ session,
380
+ })
381
+ let dispatchUrl: string | undefined
382
+ if (!dispatch.enabled && dispatch.error !== null) {
383
+ // The configured URL alone, no command run. With none (envoy.json unreadable, Dispatch off or
384
+ // no URL), no token command can name one either: the hook starts no refresher and carries the
385
+ // failure line naming why, never a list read for some URL.
386
+ dispatchUrl = sessionDispatchUrl(file, process.env, { cwd })
387
+ if (dispatchUrl !== undefined) {
388
+ const refreshed = await refreshedAnswers(session, cwd, dispatchUrl, startedAt, by)
389
+ if ("text" in refreshed) return refreshed
390
+ dispatch = offWithError(refreshed.reason)
391
+ }
392
+ }
393
+ return readSessionAnswers({
394
+ session,
395
+ dispatch,
396
+ dispatchUrl,
397
+ env: process.env,
398
+ // The read itself always gets the least wait, so a start-up and a resolution that spent the
399
+ // budget on a loaded host never cost a list Dispatch answers at once.
400
+ deadlineMs: Math.max(ANSWERS_LEAST_WAIT_MS, remainingUntil(by)),
401
+ })
402
+ }
403
+
404
+ /** The answers mode: never throws, and ends the process with status 0. */
405
+ async function answers(text: string): Promise<never> {
406
+ // The print deadline: `ANSWERS_BY_MS` after the process started, or `ANSWERS_LEAST_WAIT_MS`
407
+ // from now when the start-up already spent that.
408
+ const began = performance.now()
409
+ const by = Math.max(ANSWERS_BY_MS, began + ANSWERS_LEAST_WAIT_MS)
410
+ const startedAt = Date.now() - performance.now()
411
+ let printed: number | null = null
412
+ try {
413
+ const input = AnswersInput.parse(JSON.parse(text))
414
+ const directory = claudeProjectDirectory(
415
+ { CLAUDE_PROJECT_DIR: process.env["CLAUDE_PROJECT_DIR"] },
416
+ input.cwd ?? process.cwd(),
417
+ )
418
+ const { text: message, notice } = await answersMessage(
419
+ input.session_id,
420
+ directory,
421
+ startedAt,
422
+ by,
423
+ )
424
+ // Never to the model: a Dispatch with no answers route is the operator's to know of.
425
+ if (notice !== undefined) process.stderr.write(`envoy: ${notice}\n`)
426
+ if (message !== undefined) {
427
+ await Bun.write(
428
+ Bun.stdout,
429
+ JSON.stringify({
430
+ hookSpecificOutput: {
431
+ hookEventName: input.hook_event_name,
432
+ additionalContext: message,
433
+ },
434
+ }),
435
+ )
436
+ }
437
+ printed = performance.now()
438
+ await Promise.race([
439
+ sessionAnswersSettled(input.session_id),
440
+ Bun.sleep(remainingUntil(by + ANSWERS_SETTLE_MS)),
441
+ ])
442
+ } catch (error) {
443
+ process.stderr.write(`envoy: people's answers were not added — ${messageFor(error)}\n`)
444
+ }
445
+ const timingFile = process.env[ANSWERS_TIMING_FILE]
446
+ if (timingFile !== undefined) {
447
+ try {
448
+ writeFileSync(timingFile, JSON.stringify({ began, printed, ended: performance.now() }))
449
+ } catch {
450
+ // The tests' seam only: a path it cannot write never changes what the hook printed or its exit.
451
+ }
452
+ }
453
+ // Exits rather than returns: a read still running would hold the process open.
454
+ process.exit(0)
455
+ }
456
+
457
+ /** The answers-refresh mode, which the answers hook starts detached with the session id and the
458
+ * project directory: resolves Dispatch with `!` commands run, reads the session's list (which
459
+ * writes `answers.json`) and writes what it read, and for which Dispatch URL, to
460
+ * `answers-refresh.json` for the hooks waiting on it. A second refresher for the session exits at
461
+ * once. Nothing reads its output. */
462
+ async function answersRefresh(session: string, cwd: string): Promise<never> {
463
+ const directory = sessionDirectory(process.env, session)
464
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
465
+ const lock = join(directory, REFRESH_LOCK)
466
+ if (takeRefreshLock(lock)) {
467
+ try {
468
+ const file = sessionConfigFile("claude", process.env)
469
+ // The URL this refresher targets, resolved as the hook resolves the one it compares with and
470
+ // before any command runs: when envoy.json moved on since the hook started this refresher, no
471
+ // hook carries what it read, and when the token command fails, the hook for the same URL
472
+ // carries this refresher's failure line naming why.
473
+ const dispatchUrl = sessionDispatchUrl(file, process.env, { cwd }) ?? null
474
+ const { dispatch } = sessionDispatchSettings(file, process.env, { cwd, session })
475
+ const read = await readSessionAnswers({
476
+ session,
477
+ dispatch,
478
+ env: process.env,
479
+ deadlineMs: REFRESH_READ_MS,
480
+ })
481
+ const outcome: z.infer<typeof RefreshOutcome> = {
482
+ finished_at: Date.now(),
483
+ dispatch_url: dispatchUrl,
484
+ text: read.text ?? null,
485
+ notice: read.notice ?? null,
486
+ }
487
+ writeAtomically(join(directory, REFRESH_OUTCOME), JSON.stringify(outcome))
488
+ } finally {
489
+ rmSync(lock, { force: true })
490
+ }
491
+ }
492
+ process.exit(0)
493
+ }
494
+
495
+ const [mode, ...modeArguments] = process.argv.slice(2)
496
+ if (mode === "answers-refresh") {
497
+ const [session, cwd] = modeArguments
498
+ if (session === undefined || cwd === undefined) {
499
+ throw new Error("session-hook: answers-refresh takes a session id and a directory")
500
+ }
501
+ await answersRefresh(session, cwd)
502
+ }
503
+ if (mode !== "open-asks" && mode !== "dispatch-first" && mode !== "answers") {
504
+ throw new Error(
505
+ `session-hook: unknown mode ${JSON.stringify(mode)}; hooks.json names open-asks, dispatch-first or answers`,
506
+ )
507
+ }
508
+ const text = await Bun.stdin.text()
509
+ if (mode === "answers") await answers(text)
510
+ const input: unknown = JSON.parse(text)
511
+ if (mode === "open-asks") await openAsks(input)
512
+ else dispatchFirst(input)