@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.
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,97 @@
1
+ // One session's delivery record, in one file per session id under `CLAUDE_PLUGIN_DATA`: the
2
+ // replay engine's (`@legion/envoy-client/replay`: its cursors, the subjects it follows and the
3
+ // frames it was shown in the last 72 hours), and consumer delivery's
4
+ // (`@legion/envoy-client/consumer-delivery`: the resume point and when each topic was first
5
+ // followed). Claude Code can resume a session at any later time, so no sweep prunes these files;
6
+ // each holds only cursors, followed subjects, a resume point and at most 72 hours of ledger,
7
+ // since the engine drops older ledger entries before every write.
8
+ import { readFile } from "node:fs/promises"
9
+ import { join } from "node:path"
10
+ import {
11
+ type ConsumerDeliverySnapshot,
12
+ ConsumerDeliverySnapshotSchema,
13
+ } from "@legion/envoy-client/consumer-delivery"
14
+ import { hasErrnoCode } from "@legion/envoy-client/errors"
15
+ import { type ReplaySnapshot, ReplaySnapshotSchema } from "@legion/envoy-client/replay"
16
+ import type { z } from "zod"
17
+ import { writeAtomicStateFile } from "./session-state"
18
+
19
+ /**
20
+ * A session's file: the replay snapshot, and the consumer delivery snapshot once the session had
21
+ * one. `consumer` is optional, so a file a release before consumer delivery wrote still reads, and
22
+ * such a release reading a newer file passes over it.
23
+ */
24
+ export const ReplayStateFileSchema = ReplaySnapshotSchema.extend({
25
+ consumer: ConsumerDeliverySnapshotSchema.optional(),
26
+ })
27
+
28
+ export type ReplayStateFile = z.infer<typeof ReplayStateFileSchema>
29
+
30
+ /** The `.json` suffix is what keeps a session id of `.` or `..` a file of the directory itself,
31
+ * since `encodeURIComponent` leaves a dot alone. */
32
+ export function replayStateFile(stateDirectory: string, sessionId: string): string {
33
+ return join(stateDirectory, `replay-${encodeURIComponent(sessionId)}.json`)
34
+ }
35
+
36
+ /**
37
+ * The record a session's file holds; an empty one when there is no file, which is a session that
38
+ * never ran this plugin, or a new id (a `/clear`, a fresh start). A file that cannot be read or
39
+ * holds no replay snapshot throws.
40
+ */
41
+ export async function readReplayState(file: string): Promise<ReplayStateFile> {
42
+ let text: string
43
+ try {
44
+ text = await readFile(file, "utf8")
45
+ } catch (error) {
46
+ if (hasErrnoCode(error, "ENOENT")) return { ledger: [] }
47
+ throw error
48
+ }
49
+ let parsed: unknown
50
+ try {
51
+ parsed = JSON.parse(text)
52
+ } catch {
53
+ throw new Error(`${file} is not JSON`)
54
+ }
55
+ const snapshot = ReplayStateFileSchema.safeParse(parsed)
56
+ if (!snapshot.success) throw new Error(`${file} holds no replay state`)
57
+ return snapshot.data
58
+ }
59
+
60
+ /** Replaces the session's file by rename, mode 0600, as every other state file is written. */
61
+ export async function writeReplayState(file: string, state: ReplayStateFile): Promise<void> {
62
+ await writeAtomicStateFile(file, `${JSON.stringify(state)}\n`)
63
+ }
64
+
65
+ /** Writes one session's file from its two parts, each written whenever its owner changes it. */
66
+ export interface ReplayStateWriter {
67
+ /** The replay engine's snapshot changed; resolves once a file holding it is in place. */
68
+ replay(snapshot: ReplaySnapshot): Promise<void>
69
+ /** The consumer delivery snapshot changed; resolves once a file holding it is in place. */
70
+ consumer(snapshot: ConsumerDeliverySnapshot): Promise<void>
71
+ /** Resolves once every write asked for so far has finished, failed ones included. */
72
+ idle(): Promise<void>
73
+ }
74
+
75
+ /**
76
+ * The writer of `file`, starting from what it held (`restored`). Every write replaces the whole
77
+ * file with both parts as they stand when it runs, one write at a time, so a write that finishes
78
+ * last never puts back an older part the other owner has replaced since.
79
+ */
80
+ export function replayStateWriter(file: string, restored: ReplayStateFile): ReplayStateWriter {
81
+ let current: ReplayStateFile = restored
82
+ let writing: Promise<void> = Promise.resolve()
83
+ const write = (next: ReplayStateFile): Promise<void> => {
84
+ current = next
85
+ const run = writing.then(() => writeReplayState(file, current))
86
+ writing = run.catch(() => undefined)
87
+ return run
88
+ }
89
+ return {
90
+ replay: (snapshot) =>
91
+ write(
92
+ current.consumer === undefined ? snapshot : { ...snapshot, consumer: current.consumer },
93
+ ),
94
+ consumer: (snapshot) => write({ ...current, consumer: snapshot }),
95
+ idle: () => writing,
96
+ }
97
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from "zod"
2
+
3
+ const SendArgumentsSchema = z.tuple([z.string().min(1), z.string().min(1)]).rest(z.string().min(1))
4
+
5
+ export type SendArguments = {
6
+ readonly targetSession: string
7
+ readonly message: string
8
+ }
9
+
10
+ export function parseSendArguments(arguments_: readonly string[]): SendArguments {
11
+ const [targetSession, ...words] = SendArgumentsSchema.parse(arguments_)
12
+ return { targetSession, message: words.join(" ") }
13
+ }
@@ -0,0 +1,165 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { basename, join } from "node:path"
3
+ import { hasErrnoCode, messageFor } from "@legion/envoy-client/errors"
4
+ import { z } from "zod"
5
+ import { pruneStateDirectory, writeAtomicStateFile, writtenLongAgo } from "./session-state"
6
+
7
+ const PersistedRole = z.object({ session_id: z.string().min(1), role: z.string().min(1) })
8
+
9
+ /**
10
+ * The session id the channel server acts as, held once and passed by reference.
11
+ *
12
+ * Claude Code mints a new session id on `/clear` but a stdio MCP server keeps the
13
+ * `CLAUDE_CODE_SESSION_ID` it was spawned with, so the server learns the current id
14
+ * from the SessionStart hook's handoff file and rebinds. Every registration, tool
15
+ * call, role claim, and deregistration reads `id` at the moment it runs.
16
+ */
17
+ export class SessionIdentity {
18
+ #id: string
19
+
20
+ constructor(
21
+ id: string,
22
+ /** The Claude project directory Dispatch operations are scoped to. */
23
+ readonly directory: string,
24
+ ) {
25
+ this.#id = id
26
+ }
27
+
28
+ get id(): string {
29
+ return this.#id
30
+ }
31
+
32
+ set(next: string): void {
33
+ this.#id = next
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Per-Claude-process handoff: `${CLAUDE_PLUGIN_DATA}/sessions/<pid>/session-id`, where
39
+ * `<pid>` is the `claude` process both the SessionStart hook and the stdio MCP server are
40
+ * spawned by. Two concurrent Claude sessions therefore never share a file.
41
+ */
42
+ export function sessionHandoffDirectory(stateDirectory: string, claudePid: number): string {
43
+ return join(stateDirectory, "sessions", String(claudePid))
44
+ }
45
+
46
+ export function sessionHandoffFile(stateDirectory: string, claudePid: number): string {
47
+ return join(sessionHandoffDirectory(stateDirectory, claudePid), "session-id")
48
+ }
49
+
50
+ /** The directory the role records live in, named once for the writer and the sweep. */
51
+ const ROLES_DIRECTORY = "roles"
52
+
53
+ /** Role state is keyed by session id so `claude --resume <id>` finds the role it held. The
54
+ * `.json` suffix keeps the name a path segment: `encodeURIComponent` leaves a dot alone, so a
55
+ * session id of `.` or `..` would otherwise name the directory itself or its parent. */
56
+ export function roleStateFile(stateDirectory: string, sessionId: string): string {
57
+ return join(stateDirectory, ROLES_DIRECTORY, `${encodeURIComponent(sessionId)}.json`)
58
+ }
59
+
60
+ /**
61
+ * A role record whose bytes cannot be parsed — truncated, hand-edited, written by a later format.
62
+ * Thrown only by `readPersistedRole`, the one place that knows the bytes came from the file and
63
+ * not from a listener answering a claim: no later read of the same bytes succeeds, so unlike every
64
+ * other failure to restore a role it is not worth retrying.
65
+ */
66
+ export class UnparseableRoleRecordError extends Error {
67
+ readonly name = "UnparseableRoleRecordError"
68
+
69
+ constructor(roleFile: string, cause: unknown) {
70
+ super(`the role record ${roleFile} cannot be parsed (${messageFor(cause)})`, { cause })
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The role a session held, from its role file; undefined when it has none. Bytes that do not
76
+ * parse throw `UnparseableRoleRecordError`; every other failure to read them (`EACCES`, `EISDIR`,
77
+ * any I/O error) propagates unchanged.
78
+ */
79
+ export async function readPersistedRole(
80
+ roleFile: string,
81
+ ): Promise<z.infer<typeof PersistedRole> | undefined> {
82
+ let bytes: string
83
+ try {
84
+ bytes = await readFile(roleFile, "utf8")
85
+ } catch (error) {
86
+ if (hasErrnoCode(error, "ENOENT")) return undefined
87
+ throw error
88
+ }
89
+ try {
90
+ return PersistedRole.parse(JSON.parse(bytes))
91
+ } catch (error) {
92
+ throw new UnparseableRoleRecordError(roleFile, error)
93
+ }
94
+ }
95
+
96
+ export async function writePersistedRole(
97
+ roleFile: string,
98
+ state: z.infer<typeof PersistedRole>,
99
+ ): Promise<void> {
100
+ await writeAtomicStateFile(roleFile, `${JSON.stringify(state)}\n`)
101
+ }
102
+
103
+ export async function writeSessionHandoff(file: string, sessionId: string): Promise<void> {
104
+ await writeAtomicStateFile(file, `${sessionId.trim()}\n`)
105
+ }
106
+
107
+ export async function readSessionHandoff(file: string): Promise<string | undefined> {
108
+ try {
109
+ const id = (await readFile(file, "utf8")).trim()
110
+ return id.length === 0 ? undefined : id
111
+ } catch (error) {
112
+ if (hasErrnoCode(error, "ENOENT")) return undefined
113
+ throw error
114
+ }
115
+ }
116
+
117
+ function processExists(pid: number): boolean {
118
+ try {
119
+ process.kill(pid, 0)
120
+ return true
121
+ } catch (error) {
122
+ return hasErrnoCode(error, "EPERM")
123
+ }
124
+ }
125
+
126
+ /** Drop `sessions/<pid>` directories whose Claude process has exited. Anything else under
127
+ * `sessions/` is left alone: it was never this sweep's to name. */
128
+ export async function pruneStaleSessionHandoffs(stateDirectory: string): Promise<void> {
129
+ await pruneStateDirectory(join(stateDirectory, "sessions"), (file) => {
130
+ const pid = basename(file)
131
+ return /^\d+$/.test(pid) && !processExists(Number(pid))
132
+ })
133
+ }
134
+
135
+ /**
136
+ * Drop `roles/` records nothing has touched in `STATE_FILE_MAX_AGE_MS`, by the age rule the
137
+ * title records follow, except the record of `currentSessionId` — the session running the sweep.
138
+ *
139
+ * A record's mtime is when its session was last seen holding the role: every heartbeat on which
140
+ * the listener still names the session as holder touches the record (`refreshRoleRecord` in the
141
+ * channel server), so this age reaches only a record whose session has not confirmed its role for
142
+ * that long — in practice one that stopped running. Only one path removes a role file by hand —
143
+ * a soft `setRole` another live session holds off (`claimed: false`, the listener's 409), never
144
+ * any answer of the publish route — so a session that holds its role to the end leaves its file
145
+ * behind, and the handoff sweep cannot reach it: that one walks `sessions/<pid>/` and asks
146
+ * the operating system about a pid, while a role file is keyed by session id and has no process
147
+ * left to ask about.
148
+ *
149
+ * The sweep runs at startup, before any heartbeat has touched anything, and a session whose claim
150
+ * the listener refused there has not touched its own record either: age alone would expire the
151
+ * record of the very session that is running, and the retry queued for its next heartbeat would
152
+ * find nothing left to restore. Expiring an abandoned record is this sweep's whole point, and the
153
+ * running session's own record is by construction not abandoned, so it is the one record age does
154
+ * not decide.
155
+ */
156
+ export async function pruneStaleSessionRoles(
157
+ stateDirectory: string,
158
+ currentSessionId: string,
159
+ ): Promise<void> {
160
+ const own = roleStateFile(stateDirectory, currentSessionId)
161
+ await pruneStateDirectory(
162
+ join(stateDirectory, ROLES_DIRECTORY),
163
+ async (file) => file !== own && (await writtenLongAgo(file)),
164
+ )
165
+ }
@@ -0,0 +1,142 @@
1
+ // The plugin's state files under `CLAUDE_PLUGIN_DATA`: how one is written, how a directory of
2
+ // them is swept, and the chosen-title file itself.
3
+ //
4
+ // This module is the title hook's whole dependency graph beyond `node:` builtins, and it must
5
+ // stay that way: `hooks/session-title-hook.ts` runs before every user prompt, with Claude Code
6
+ // blocking the prompt on it, so anything this file pulls in (zod, the Dispatch client, the
7
+ // session-settings reader — all of which `src/session-identity.ts` reaches through its role
8
+ // schema) is parsed and evaluated on every prompt. The role helpers import from here, never the
9
+ // other way round.
10
+ import { readFileSync } from "node:fs"
11
+ import { mkdir, readdir, rename, rm, stat, utimes, writeFile } from "node:fs/promises"
12
+ import { dirname, join } from "node:path"
13
+ import { hasErrnoCode } from "@legion/envoy-client/errors"
14
+ import { configuredValue } from "./claude-session"
15
+
16
+ /**
17
+ * Write a private state file atomically: a 0700 directory, a 0600 temporary file
18
+ * unique to this process, then a rename onto `file`.
19
+ */
20
+ export async function writeAtomicStateFile(file: string, contents: string): Promise<void> {
21
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 })
22
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
23
+ await writeFile(temporary, contents, { mode: 0o600 })
24
+ await rename(temporary, file)
25
+ }
26
+
27
+ /**
28
+ * Mark `file` as current without touching what it holds: the sweeps below read an mtime for
29
+ * "last seen", and a holder that is still holding has nothing new to write. A touch also leaves
30
+ * no temporary file beside it, which an atomic rewrite on every heartbeat would.
31
+ */
32
+ export async function touchStateFile(file: string): Promise<void> {
33
+ const now = new Date()
34
+ await utimes(file, now, now)
35
+ }
36
+
37
+ /** Whether an entry of a state directory may go, given its path. */
38
+ export type StaleStateEntry = (file: string) => boolean | Promise<boolean>
39
+
40
+ /**
41
+ * Remove every entry of `directory` that `isStale` gives up, directories and all. A directory
42
+ * that does not exist holds nothing stale: nothing here has run yet, or the sweep before this one
43
+ * emptied it.
44
+ *
45
+ * The one sweep behind all three state directories, each of which differs only in what it calls
46
+ * stale: a handoff whose Claude process is gone, a title or a role nothing has touched in
47
+ * `STATE_FILE_MAX_AGE_MS`.
48
+ */
49
+ export async function pruneStateDirectory(
50
+ directory: string,
51
+ isStale: StaleStateEntry,
52
+ ): Promise<void> {
53
+ const entries = await readdir(directory).catch((error: unknown) => {
54
+ if (hasErrnoCode(error, "ENOENT")) return []
55
+ throw error
56
+ })
57
+ for (const entry of entries) {
58
+ const file = join(directory, entry)
59
+ if (!(await isStale(file))) continue
60
+ await rm(file, { recursive: true, force: true })
61
+ }
62
+ }
63
+
64
+ /** Thirty days: long enough that a session resumed weeks later still finds the name and the role
65
+ * it held. */
66
+ export const STATE_FILE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000
67
+
68
+ /**
69
+ * Stale by age: last written more than `STATE_FILE_MAX_AGE_MS` ago. A file whose age cannot be
70
+ * read is kept, since a sweep that cannot tell must not delete.
71
+ *
72
+ * Both the title and the role file are keyed by session id, and a session's id dies with it,
73
+ * unmourned, so nothing else ever deletes one: without this each directory grows by a file per
74
+ * session for ever.
75
+ */
76
+ export async function writtenLongAgo(file: string): Promise<boolean> {
77
+ const modified = await stat(file).then(
78
+ (stats) => stats.mtimeMs,
79
+ () => undefined,
80
+ )
81
+ return modified !== undefined && Date.now() - modified > STATE_FILE_MAX_AGE_MS
82
+ }
83
+
84
+ /** The directory the title records live in, named once for the writer, the reader and the
85
+ * sweep. */
86
+ const TITLES_DIRECTORY = "titles"
87
+
88
+ /** Title state is keyed by session id, like role state, so `claude --resume <id>` finds the name
89
+ * the user gave that session and a `/clear`'s new id starts without one. The `.txt` suffix is
90
+ * what keeps the name a path segment: `encodeURIComponent` leaves a dot alone, so a session id
91
+ * of `.` or `..` would otherwise name the directory itself or its parent. */
92
+ export function sessionTitleFile(stateDirectory: string, sessionId: string): string {
93
+ return join(stateDirectory, TITLES_DIRECTORY, `${encodeURIComponent(sessionId)}.txt`)
94
+ }
95
+
96
+ export async function writeChosenTitle(file: string, title: string): Promise<void> {
97
+ await writeAtomicStateFile(file, `${title}\n`)
98
+ }
99
+
100
+ /** The session has no custom title any more: a rename can be undone, and the fallback returns. */
101
+ export async function removeChosenTitle(file: string): Promise<void> {
102
+ await rm(file, { force: true })
103
+ }
104
+
105
+ /**
106
+ * The name a user gave the session, as recorded; undefined when the file cannot be read.
107
+ *
108
+ * Synchronous and never throwing, because the registration path reads it inside a
109
+ * `(sessionId: string) => string`: the title is cosmetic, so a failed read must leave the session
110
+ * on its fallback name rather than fail the registration that carries it.
111
+ */
112
+ export function readChosenTitleSync(file: string): string | undefined {
113
+ try {
114
+ return readFileSync(file, "utf8")
115
+ } catch {
116
+ return undefined
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Records the session's custom title under its session id, or removes the record when the session
122
+ * has none: an absent or blank `session_title` is how Claude Code says a rename was undone. The
123
+ * title is written as Claude Code gave it; `src/session-title.ts` cleans and judges it when it
124
+ * reads the record. A session with no `CLAUDE_PLUGIN_DATA` records nothing, since there is
125
+ * nowhere the channel server would read it from.
126
+ */
127
+ export async function recordChosenTitle(
128
+ sessionId: string,
129
+ title: string | undefined,
130
+ ): Promise<void> {
131
+ const pluginData = configuredValue(process.env["CLAUDE_PLUGIN_DATA"])
132
+ if (pluginData === undefined) return
133
+ const file = sessionTitleFile(pluginData, sessionId)
134
+ const chosen = configuredValue(title)
135
+ if (chosen === undefined) await removeChosenTitle(file)
136
+ else await writeChosenTitle(file, chosen)
137
+ }
138
+
139
+ /** Drop `titles/` records nothing has touched in `STATE_FILE_MAX_AGE_MS`. */
140
+ export async function pruneStaleSessionTitles(stateDirectory: string): Promise<void> {
141
+ await pruneStateDirectory(join(stateDirectory, TITLES_DIRECTORY), writtenLongAgo)
142
+ }
@@ -0,0 +1,251 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { readFileSync } from "node:fs"
3
+ import { basename } from "node:path"
4
+ import { configuredValue } from "./claude-session"
5
+
6
+ export interface SessionTitleEnvironment {
7
+ readonly ENVOY_SESSION_TITLE?: string | undefined
8
+ readonly TMUX?: string | undefined
9
+ readonly TMUX_PANE?: string | undefined
10
+ }
11
+
12
+ /**
13
+ * The title registered for the session's current id. Every registration calls it, so the short
14
+ * id in it follows a `/clear` handoff.
15
+ */
16
+ export type SessionTitle = (sessionId: string) => string
17
+
18
+ /** What `tmux -u -S <socket> display-message -p` prints for `pane` and `format`; undefined when
19
+ * tmux cannot say. */
20
+ export type TmuxQuery = (
21
+ socket: string,
22
+ pane: string,
23
+ format: string,
24
+ ) => Promise<string | undefined>
25
+
26
+ // `automatic-rename` is the window option, which tmux expands as a format variable (1 or 0). A
27
+ // window left on it is named after the running command, `claude` in every pane, which tells
28
+ // sessions apart no better than nothing, so only a window with it off lends its name. A window
29
+ // index is never used: with `renumber-windows` on, tmux moves it after the title is worked out.
30
+ // A pane of grouped sessions answers with whichever session of the group tmux picks, so the
31
+ // group's name stands in for it, which tmux keeps even after the sessions are renamed or end.
32
+ // `pid` is the server's: a pane id names a pane only on the server that gave it out, and a server
33
+ // started again on the same socket gives out the same ids. `pane_pid` is the pane's first process:
34
+ // tmux hands a popup, `run-shell` job or hook a `TMUX_PANE` left over from wherever the server was
35
+ // started, so a pane is this session's only when its process is one of this session's ancestors.
36
+ const TMUX_PANE_FORMAT = [
37
+ "#{session_name}",
38
+ "#{window_name}",
39
+ "#{automatic-rename}",
40
+ "#{session_group}",
41
+ "#{pid}",
42
+ "#{pane_pid}",
43
+ ].join("\t")
44
+
45
+ /** Bounds the one tmux call the server makes as it starts. A timer bounds it, not a synchronous
46
+ * spawn's `timeout`: Bun 1.3.14's `spawnSync` and `execFileSync` can let a child that outlives the
47
+ * timeout run to its end, holding the server's start as long as tmux hangs. */
48
+ const TMUX_TIMEOUT_MS = 1_000
49
+ const SHORT_ID_LENGTH = 8
50
+
51
+ /** How many parents `isAncestor` follows before it stops. */
52
+ const MAX_ANCESTORS = 64
53
+
54
+ /** A terminal control sequence (a colour code: ESC [ parameters final byte). Built from the ESC
55
+ * code, since a control character in a regular expression literal is a lint error. */
56
+ const CSI_SEQUENCE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-?]*[ -/]*[@-~]`, "gu")
57
+
58
+ // Bidirectional overrides and isolates would reorder the short id after the name on the Agents
59
+ // page. Other format characters stay: a zero-width joiner holds an emoji sequence together.
60
+ function oneLine(value: string): string {
61
+ return (
62
+ value
63
+ // A colour code goes whole; any other escape loses only its ESC, below.
64
+ .replace(CSI_SEQUENCE, "")
65
+ .replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
66
+ .replace(/[\s\p{Cc}]+/gu, " ")
67
+ .trim()
68
+ )
69
+ }
70
+
71
+ /** Whether `value` holds a character that shows: not whitespace, a control, format, surrogate or
72
+ * unassigned code point, a combining mark with nothing to sit on, an ignorable code point, or
73
+ * the blank braille pattern. A private-use glyph (a patched font's icon) counts. */
74
+ function visible(value: string): boolean {
75
+ return (
76
+ value.replace(
77
+ /[\s\p{Cc}\p{Cf}\p{Cs}\p{Cn}\p{M}\p{Default_Ignorable_Code_Point}\u2800]/gu,
78
+ "",
79
+ ) !== ""
80
+ )
81
+ }
82
+
83
+ /** The parent of process `pid`, or undefined when it cannot be read. */
84
+ function parentPid(pid: number): number | undefined {
85
+ try {
86
+ if (process.platform === "linux") {
87
+ // The command name, in parentheses, may hold spaces; the parent is the second field after it.
88
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8")
89
+ const parent = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1])
90
+ return Number.isInteger(parent) ? parent : undefined
91
+ }
92
+ const parent = Number(
93
+ execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
94
+ encoding: "utf8",
95
+ timeout: TMUX_TIMEOUT_MS,
96
+ killSignal: "SIGKILL",
97
+ }).trim(),
98
+ )
99
+ return Number.isInteger(parent) ? parent : undefined
100
+ } catch {
101
+ return undefined
102
+ }
103
+ }
104
+
105
+ /** Whether process `candidate` is `pid` or one of its ancestors, walking up from `pid` and
106
+ * stopping at the first match, at init, or after `MAX_ANCESTORS` steps. */
107
+ export function isAncestor(candidate: string, pid: number = process.pid): boolean {
108
+ let current: number | undefined = pid
109
+ for (let step = 0; current !== undefined && current > 1 && step < MAX_ANCESTORS; step++) {
110
+ if (String(current) === candidate) return true
111
+ current = parentPid(current)
112
+ }
113
+ return false
114
+ }
115
+
116
+ /**
117
+ * `tmux -u -S <socket> display-message -p -t <pane> <format>`; undefined when tmux is missing, slow
118
+ * or refuses, or prints nothing: a running tmux server answers a pane it does not have with an
119
+ * empty line and exit status 0 (tmux 3.7b), not an error. `-u`, because the format's fields are
120
+ * tab-separated, and tmux prints a tab as `_` to a client that has neither `TMUX` nor a UTF-8
121
+ * locale in its environment; the daemon's `utf8Argv` passes it for the same reason.
122
+ */
123
+ export async function queryTmux(
124
+ socket: string,
125
+ pane: string,
126
+ format: string,
127
+ ): Promise<string | undefined> {
128
+ let child: Bun.Subprocess<"ignore", "pipe", "ignore">
129
+ try {
130
+ child = Bun.spawn(["tmux", "-u", "-S", socket, "display-message", "-p", "-t", pane, format], {
131
+ stdin: "ignore",
132
+ stdout: "pipe",
133
+ stderr: "ignore",
134
+ })
135
+ } catch {
136
+ // No `tmux` on the PATH.
137
+ return undefined
138
+ }
139
+ const reader = child.stdout.getReader()
140
+ const expired = Promise.withResolvers<undefined>()
141
+ const timer = setTimeout(() => {
142
+ // A `tmux` that ignores SIGTERM (a wrapper script) would hold the server's start past the
143
+ // bound; SIGKILL cannot be ignored. A grandchild it leaves behind (the wrapper's own command)
144
+ // can still hold the pipe open, so the pipe is not waited for, and the read is cancelled: a
145
+ // read left pending would keep this process alive until that grandchild ends.
146
+ child.kill("SIGKILL")
147
+ reader.cancel().catch(() => undefined)
148
+ expired.resolve(undefined)
149
+ }, TMUX_TIMEOUT_MS)
150
+ const answered = (async () => {
151
+ const decoder = new TextDecoder()
152
+ let answer = ""
153
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
154
+ answer += decoder.decode(chunk.value, { stream: true })
155
+ }
156
+ answer += decoder.decode()
157
+ return (await child.exited) === 0 && answer.trim() !== "" ? answer : undefined
158
+ })().catch(() => undefined)
159
+ try {
160
+ return await Promise.race([answered, expired.promise])
161
+ } finally {
162
+ clearTimeout(timer)
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The tmux name of `pane` on the server at `socket` whose process is `serverPid`: `session:window`
168
+ * for a window whose `automatic-rename` is off, else the tmux session's name. Undefined when tmux
169
+ * cannot say, when another server now answers on that socket, when `ownPane` refuses the pane's
170
+ * first process (the pane is not this session's), and for a session named only by digits (plain
171
+ * `tmux` names its first one `0`) in a window nobody named, since that says nothing on any machine.
172
+ */
173
+ export async function tmuxPaneTitle(
174
+ socket: string,
175
+ serverPid: string,
176
+ pane: string,
177
+ ownPane: (panePid: string) => boolean,
178
+ query: TmuxQuery = queryTmux,
179
+ ): Promise<string | undefined> {
180
+ const fields = (await query(socket, pane, TMUX_PANE_FORMAT))?.replace(/\r?\n$/, "").split("\t")
181
+ if (fields === undefined || fields.length < 6) return undefined
182
+ const [name = "", window = "", automaticRename, group = "", pid, panePid = ""] =
183
+ fields.map(oneLine)
184
+ if (pid !== serverPid || !ownPane(panePid)) return undefined
185
+ const session = group === "" ? name : group
186
+ // A tmux too old to expand a format prints it as it was sent; a name with nothing that shows
187
+ // would be a blank label.
188
+ if (!visible(session) || session.startsWith("#{")) return undefined
189
+ if (visible(window) && (automaticRename === "0" || automaticRename === "off")) {
190
+ return `${session}:${window}`
191
+ }
192
+ return /^[0-9]+$/.test(session) ? undefined : session
193
+ }
194
+
195
+ /** A name the user gave the session (`claude -n`, `--name`, `/rename`), as recorded for
196
+ * `sessionId`; undefined when that session has none. */
197
+ export type ChosenTitle = (sessionId: string) => string | undefined
198
+
199
+ /** The seams a caller may inject; each defaults to what the running session uses. */
200
+ export interface SessionTitleSources {
201
+ /** How a tmux server is asked for a pane's fields; by default a bounded `tmux` run. */
202
+ readonly query?: TmuxQuery
203
+ /** Whether a pane's first process is ours; by default one of this process's ancestors. */
204
+ readonly ownPane?: (panePid: string) => boolean
205
+ /** The name the user gave a session; by default none is recorded. */
206
+ readonly chosen?: ChosenTitle
207
+ }
208
+
209
+ /**
210
+ * The title this session registers with the Envoy listener, which Dispatch's Agents page shows in
211
+ * place of the session id: `ENVOY_SESSION_TITLE`; else the name the user gave the session, alone,
212
+ * since a name a person chose needs nothing to tell it apart; else a name and the current id's
213
+ * first eight characters, the name being the pane's tmux name inside tmux (`TMUX` and
214
+ * `TMUX_PANE`, asked of the server `TMUX` names, for a pane whose first process `ownPane`
215
+ * accepts, by default one of this process's ancestors) and the project directory's name
216
+ * otherwise. The short id keeps two sessions that share a tmux window or session apart. The tmux
217
+ * name is worked out once, as the server starts, which is the one thing the returned promise waits
218
+ * for: a tmux rename after that shows on the next start. `chosen` is asked on every call instead,
219
+ * so a name the user chose after the server started reaches the listener on the next registration.
220
+ */
221
+ export async function sessionTitle(
222
+ environment: SessionTitleEnvironment,
223
+ directory: string,
224
+ sources: SessionTitleSources = {},
225
+ ): Promise<SessionTitle> {
226
+ const {
227
+ query = queryTmux,
228
+ ownPane = (panePid: string) => isAncestor(panePid),
229
+ chosen = () => undefined,
230
+ } = sources
231
+ const explicit = oneLine(configuredValue(environment.ENVOY_SESSION_TITLE) ?? "")
232
+ // A title with nothing that shows once cleaned (a zero-width space, a colour code alone) would
233
+ // be a blank row.
234
+ if (visible(explicit)) return () => explicit
235
+ // `TMUX` is `<socket>,<server pid>,<session>`, and a socket path may hold a comma.
236
+ const parts = configuredValue(environment.TMUX)?.split(",") ?? []
237
+ const socket = parts.slice(0, -2).join(",")
238
+ const serverPid = parts.at(-2) ?? ""
239
+ const pane = configuredValue(environment.TMUX_PANE)?.trim()
240
+ // A directory name holds whatever the filesystem allows, newlines and overrides among them.
241
+ const directoryName = oneLine(basename(directory) || directory)
242
+ const name =
243
+ (socket === "" || serverPid === "" || pane === undefined
244
+ ? undefined
245
+ : await tmuxPaneTitle(socket, serverPid, pane, ownPane, query)) ??
246
+ (visible(directoryName) ? directoryName : "session")
247
+ return (sessionId) => {
248
+ const own = oneLine(chosen(sessionId) ?? "")
249
+ return visible(own) ? own : `${name} ${sessionId.slice(0, SHORT_ID_LENGTH)}`
250
+ }
251
+ }