@sjawhar/claude-envoy 0.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +6 -0
- package/.mcp.json +11 -0
- package/.omp-plugin/plugin.json +6 -0
- package/AGENTS.md +343 -0
- package/README.md +639 -0
- package/bin/dispatch +6 -0
- package/bin/envoy-channel.ts +9 -0
- package/bin/envoy-send.ts +33 -0
- package/dist/THIRD_PARTY_NOTICES +816 -0
- package/dist/dispatch.js +27707 -0
- package/dist/envoy-channel.js +57695 -0
- package/dist/session-hook.js +26328 -0
- package/dist/session-title-hook.js +116 -0
- package/hooks/hooks.json +60 -0
- package/hooks/session-hook.ts +512 -0
- package/hooks/session-title-hook.ts +46 -0
- package/legion/.claude-plugin/plugin.json +4 -0
- package/legion/agents/deep-worker.md +68 -0
- package/legion/agents/oracle.md +35 -0
- package/legion/agents/plan-gap-analyst.md +65 -0
- package/legion/agents/plan-reviewer.md +104 -0
- package/legion/agents/thermonuclear-code-quality.md +30 -0
- package/legion/agents/thermonuclear-deep-review.md +30 -0
- package/legion/hooks/hooks.json +89 -0
- package/legion/skills/ce-simplify-code/LICENSE +21 -0
- package/legion/skills/ce-simplify-code/SKILL.md +66 -0
- package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
- package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
- package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
- package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
- package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
- package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
- package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
- package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
- package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
- package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
- package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
- package/legion/skills/legion-architect/SKILL.md +86 -0
- package/legion/skills/legion-architect/references/acceptance.md +208 -0
- package/legion/skills/legion-architect/references/decomposition.md +129 -0
- package/legion/skills/legion-architect/references/design-gate.md +206 -0
- package/legion/skills/legion-architect/references/merge-notices.md +91 -0
- package/legion/skills/legion-architect/references/wake-routing.md +41 -0
- package/legion/skills/legion-controller/SKILL.md +151 -0
- package/legion/skills/legion-controller/references/admission.md +22 -0
- package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
- package/legion/skills/legion-controller/references/daily-report.md +75 -0
- package/legion/skills/legion-controller/references/escalation.md +82 -0
- package/legion/skills/legion-controller/references/finding-asks.md +181 -0
- package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
- package/legion/skills/legion-controller/references/session.md +100 -0
- package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
- package/legion/skills/legion-controller/references/triage.md +96 -0
- package/legion/skills/legion-controller/references/wakes.md +132 -0
- package/legion/skills/legion-oracle/SKILL.md +48 -0
- package/legion/skills/legion-retro/SKILL.md +112 -0
- package/legion/skills/legion-retro/references/learnings.md +60 -0
- package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
- package/legion/skills/legion-worker/SKILL.md +69 -0
- package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
- package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
- package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
- package/legion/skills/legion-worker/references/findings.md +199 -0
- package/legion/skills/legion-worker/references/merge-gate.md +353 -0
- package/legion/skills/legion-worker/references/pr-body.md +218 -0
- package/legion/skills/legion-worker/references/review-threads.md +77 -0
- package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
- package/legion/skills/legion-worker/references/tools.md +708 -0
- package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
- package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
- package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
- package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
- package/package.json +49 -1
- package/scripts/build-inputs.txt +84 -0
- package/scripts/build.ts +415 -0
- package/scripts/check-packed-skills.sh +50 -0
- package/scripts/pack-skills.sh +146 -0
- package/scripts/smoke-channel.sh +178 -0
- package/scripts/smoke-clear-rebind.sh +164 -0
- package/scripts/smoke-session-title.sh +245 -0
- package/scripts/verify-build.sh +88 -0
- package/skills/agent-secrets/SKILL.md +180 -0
- package/skills/asd-ste100/LICENSE +21 -0
- package/skills/asd-ste100/SKILL.md +140 -0
- package/skills/asd-ste100/examples/before-after.md +92 -0
- package/skills/asd-ste100/references/writing-rules.md +59 -0
- package/skills/dispatch/SKILL.md +89 -0
- package/skills/dispatch-first/SKILL.md +46 -0
- package/skills/dispatch-writing-specs/SKILL.md +80 -0
- package/skills/envoy/SKILL.md +82 -0
- package/skills/envoy/references/check-settlement.md +62 -0
- package/skills/envoy/references/deliveries.md +67 -0
- package/skills/envoy/references/legion.md +14 -0
- package/skills/envoy/references/roles.md +12 -0
- package/skills/envoy/references/sources.md +41 -0
- package/skills/outside-message/SKILL.md +111 -0
- package/skills/plain-words/SKILL.md +88 -0
- package/src/channel-forwarder.ts +262 -0
- package/src/claude-channel.ts +705 -0
- package/src/claude-session.ts +38 -0
- package/src/envoy-channel-server.ts +2115 -0
- package/src/envoy-client.ts +26 -0
- package/src/envoy-off.ts +62 -0
- package/src/legion-claim.ts +211 -0
- package/src/legion-inbox.ts +110 -0
- package/src/legion-phase-hook.ts +554 -0
- package/src/legion-tool.ts +69 -0
- package/src/replay-state.ts +97 -0
- package/src/send-arguments.ts +13 -0
- package/src/session-identity.ts +165 -0
- package/src/session-state.ts +142 -0
- package/src/session-title.ts +251 -0
- 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
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -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)
|