@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,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
|
+
}
|