@brimveyn/aimux 1.16.3 → 1.18.1

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 (61) hide show
  1. package/package.json +4 -2
  2. package/src/app-runtime/backend-runtime-events.ts +179 -0
  3. package/src/app-runtime/side-effects.ts +3 -1
  4. package/src/app.tsx +26 -0
  5. package/src/cli/chord.ts +77 -0
  6. package/src/cli/client/bootstrap.ts +76 -0
  7. package/src/cli/client/daemon-client.ts +228 -0
  8. package/src/cli/client/workspace-resolver.ts +57 -0
  9. package/src/cli/commands/tab/close.ts +33 -0
  10. package/src/cli/commands/tab/create.ts +109 -0
  11. package/src/cli/commands/tab/focus.ts +33 -0
  12. package/src/cli/commands/tab/list.ts +52 -0
  13. package/src/cli/commands/tab/send.ts +83 -0
  14. package/src/cli/commands/tab/snapshot.ts +127 -0
  15. package/src/cli/commands/tab/tail.ts +171 -0
  16. package/src/cli/commands/tab/wait.ts +86 -0
  17. package/src/cli/commands/workspace/close.ts +32 -0
  18. package/src/cli/commands/workspace/create.ts +92 -0
  19. package/src/cli/commands/workspace/list.ts +25 -0
  20. package/src/cli/commands/workspace/show.ts +32 -0
  21. package/src/cli/commands/workspace/switch.ts +75 -0
  22. package/src/cli/commands/worktree/create.ts +113 -0
  23. package/src/cli/commands/worktree/list.ts +44 -0
  24. package/src/cli/commands/worktree/remove.ts +59 -0
  25. package/src/cli/context.ts +16 -0
  26. package/src/cli/flags.ts +101 -0
  27. package/src/cli/index.ts +113 -0
  28. package/src/cli/output.ts +30 -0
  29. package/src/cli/registry.ts +54 -0
  30. package/src/cli/snapshot-render.ts +54 -0
  31. package/src/daemon/catalog-writer.ts +123 -0
  32. package/src/daemon/daemon.ts +566 -11
  33. package/src/daemon/reexec-client.ts +147 -0
  34. package/src/daemon/runtime-paths.ts +161 -1
  35. package/src/daemon/session-registry.ts +17 -0
  36. package/src/index.tsx +9 -0
  37. package/src/input/modes/bridge.ts +6 -0
  38. package/src/input/modes/transitions.ts +2 -0
  39. package/src/input/modes/types.ts +1 -0
  40. package/src/ipc/README.md +112 -0
  41. package/src/ipc/manager-protocol.ts +54 -4
  42. package/src/ipc/protocol.ts +466 -25
  43. package/src/platform/daemon-control.ts +14 -0
  44. package/src/restart-daemon.ts +36 -4
  45. package/src/session-backend/bootstrap.ts +110 -0
  46. package/src/session-backend/local-session-backend.ts +6 -0
  47. package/src/session-backend/remote-session-backend.ts +63 -0
  48. package/src/session-backend/types.ts +34 -0
  49. package/src/state/reducers/modal-state.ts +66 -1
  50. package/src/state/types.ts +40 -0
  51. package/src/state/validation.ts +1 -1
  52. package/src/terminal-manager/manager-client.ts +24 -7
  53. package/src/ui/components/flash/flash-label-badge.tsx +38 -0
  54. package/src/ui/components/layout/sidebar/tab-item.tsx +2 -0
  55. package/src/ui/components/layout/sidebar/workspace-list.tsx +4 -0
  56. package/src/ui/components/layout/sidebar/worktree-row.tsx +2 -0
  57. package/src/ui/components/layout/top-tab-bar.tsx +2 -0
  58. package/src/ui/flash/assign-labels.ts +126 -0
  59. package/src/ui/flash/build-labels.ts +78 -0
  60. package/src/ui/hooks/use-flash-label.ts +40 -0
  61. package/src/ui/root.tsx +3 -0
@@ -0,0 +1,147 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { connect } from 'node:net'
3
+
4
+ import { MANAGER_PROTOCOL_MIN_VERSION } from '../ipc/manager-protocol'
5
+ import {
6
+ encodeMessage,
7
+ IPC_CAPABILITY_HOT_REEXEC,
8
+ IPC_PROTOCOL_MIN_VERSION,
9
+ IPC_PROTOCOL_VERSION,
10
+ MessageDecoder,
11
+ parseServerMessage,
12
+ } from '../ipc/protocol'
13
+
14
+ export interface NegotiateReexecOptions {
15
+ /**
16
+ * Free-form label sent as `prepareReexec.reason`. Shows up in daemon
17
+ * debug logs so post-mortems can distinguish bootstrap-driven from
18
+ * CLI-driven reexecs.
19
+ */
20
+ reason: string
21
+ /**
22
+ * Overall deadline for the round-trip (connect → hello → prepareReexec
23
+ * → ack). Defaults to 5s, matching the pre-consolidation ad-hoc value.
24
+ */
25
+ timeoutMs?: number
26
+ }
27
+
28
+ export type NegotiateReexecResult =
29
+ | { handoffPath: string; ok: true; renamedSocketPath: string }
30
+ | { ok: false; reason: string }
31
+
32
+ /**
33
+ * Negotiate a hot-reexec with the daemon at `socketPath`. Resolves once
34
+ * the daemon has acked the drain (it will then exit ~250ms later on its
35
+ * own and free the canonical socket for a successor binary) or a failure
36
+ * mode is detected — the caller falls back to a full restart.
37
+ *
38
+ * Consolidates the previously-duplicated negotiation in bootstrap.ts,
39
+ * restart-daemon.ts, and the manual-reexec-test harness.
40
+ */
41
+ export async function negotiateDaemonReexec(
42
+ socketPath: string,
43
+ opts: NegotiateReexecOptions
44
+ ): Promise<NegotiateReexecResult> {
45
+ const { reason, timeoutMs = 5_000 } = opts
46
+ return new Promise<NegotiateReexecResult>((resolve) => {
47
+ const socket = connect(socketPath)
48
+ const decoder = new MessageDecoder(parseServerMessage)
49
+ const helloId = crypto.randomUUID()
50
+ const reexecId = crypto.randomUUID()
51
+ let settled = false
52
+
53
+ const finish = (result: NegotiateReexecResult) => {
54
+ if (settled) return
55
+ settled = true
56
+ clearTimeout(timer)
57
+ socket.removeAllListeners()
58
+ socket.destroy()
59
+ resolve(result)
60
+ }
61
+
62
+ const timer = setTimeout(() => finish({ ok: false, reason: 'timeout' }), timeoutMs)
63
+
64
+ socket.once('error', (error: NodeJS.ErrnoException) => {
65
+ // Once we've observed the ack we've already resolved; ECONNRESET /
66
+ // EPIPE during the daemon's drain is expected. Guard on `settled`.
67
+ if (!settled) finish({ ok: false, reason: error.message })
68
+ })
69
+ socket.once('connect', () => {
70
+ socket.write(
71
+ encodeMessage({
72
+ id: helloId,
73
+ payload: { maxVersion: IPC_PROTOCOL_VERSION, minVersion: IPC_PROTOCOL_MIN_VERSION },
74
+ type: 'hello',
75
+ })
76
+ )
77
+ })
78
+ socket.on('data', (chunk) => {
79
+ try {
80
+ for (const message of decoder.push(chunk)) {
81
+ if (!('id' in message)) continue
82
+ if (message.id === helloId) {
83
+ if (message.type !== 'helloResult') {
84
+ finish({ ok: false, reason: `hello: ${message.type}` })
85
+ return
86
+ }
87
+ if (!message.payload.capabilities.includes(IPC_CAPABILITY_HOT_REEXEC)) {
88
+ finish({ ok: false, reason: 'daemon does not advertise hotReexec' })
89
+ return
90
+ }
91
+ // Successor would fail to speak to the running TM if it needs a
92
+ // newer manager protocol than the TM negotiated. Legacy daemons
93
+ // that predate managerSelectedVersion omit the field — treat
94
+ // that as unknown-and-bail so we don't drain into a doomed
95
+ // reexec.
96
+ const managerSelected = message.payload.managerSelectedVersion
97
+ if (managerSelected === undefined || managerSelected < MANAGER_PROTOCOL_MIN_VERSION) {
98
+ finish({ ok: false, reason: 'manager-protocol mismatch' })
99
+ return
100
+ }
101
+ socket.write(
102
+ encodeMessage({
103
+ id: reexecId,
104
+ payload: { reason },
105
+ type: 'prepareReexec',
106
+ })
107
+ )
108
+ continue
109
+ }
110
+ if (message.id === reexecId) {
111
+ if (message.type === 'reexecAck') {
112
+ finish({
113
+ handoffPath: message.payload.handoffPath,
114
+ ok: true,
115
+ renamedSocketPath: message.payload.renamedSocketPath,
116
+ })
117
+ return
118
+ }
119
+ finish({ ok: false, reason: `prepareReexec: ${message.type}` })
120
+ return
121
+ }
122
+ }
123
+ } catch (error) {
124
+ finish({ ok: false, reason: error instanceof Error ? error.message : String(error) })
125
+ }
126
+ })
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Poll until the canonical daemon socket path no longer exists (the
132
+ * predecessor renamed it away as part of its drain). Returns true once
133
+ * the dirent is gone, false if `deadlineMs` elapses first. Consolidates
134
+ * the poll loop that bootstrap.ts, restart-daemon.ts, and the manual
135
+ * harness each carried a copy of.
136
+ */
137
+ export async function waitForSocketRemoval(
138
+ socketPath: string,
139
+ deadlineMs = 2_000
140
+ ): Promise<boolean> {
141
+ const stop = Date.now() + deadlineMs
142
+ while (Date.now() < stop) {
143
+ if (!existsSync(socketPath)) return true
144
+ await new Promise((r) => setTimeout(r, 25))
145
+ }
146
+ return false
147
+ }
@@ -1,4 +1,13 @@
1
- import { chmodSync, constants, existsSync, lstatSync, mkdirSync, unlinkSync } from 'node:fs'
1
+ import {
2
+ chmodSync,
3
+ constants,
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
2
11
  import { dirname, join } from 'node:path'
3
12
 
4
13
  import { getProfileName } from '../profile-paths'
@@ -108,3 +117,154 @@ export function removeSocketIfExists(socketPath: string): void {
108
117
  export function removeTerminalManagerSocketIfExists(): void {
109
118
  removeSocketIfExists(getTerminalManagerSocketPath())
110
119
  }
120
+
121
+ // --- Hot-reexec sidecars ---
122
+ //
123
+ // Three small files coordinate the daemon-hot-reexec handoff (see
124
+ // docs/developer/hot-reexec.md):
125
+ //
126
+ // daemon.pid // current daemon's pid, written on bind
127
+ // daemon.version // current daemon's process version (Bun version)
128
+ // daemon.handoff.json // present iff the running daemon was spawned as a
129
+ // // reexec successor. Consumed (deleted) on read so
130
+ // // a subsequent fresh boot doesn't trip the path.
131
+ //
132
+ // The renamed-away socket lives at daemon.old.sock until the predecessor
133
+ // daemon exits and the dirent is unlinked.
134
+
135
+ export function getDaemonPidFilePath(): string {
136
+ return join(ensureRuntimeDir(), 'daemon.pid')
137
+ }
138
+
139
+ export function getDaemonVersionFilePath(): string {
140
+ return join(ensureRuntimeDir(), 'daemon.version')
141
+ }
142
+
143
+ export function getDaemonHandoffFilePath(): string {
144
+ return join(ensureRuntimeDir(), 'daemon.handoff.json')
145
+ }
146
+
147
+ export function getDaemonOldSocketPath(): string {
148
+ return join(ensureRuntimeDir(), 'daemon.old.sock')
149
+ }
150
+
151
+ export interface DaemonHandoffV1 {
152
+ version: 1
153
+ fromPid: number
154
+ fromProcessVersion: string
155
+ /** Epoch ms when the handoff was written. Diagnostic only. */
156
+ writtenAt: number
157
+ /** The renamed-away socket the predecessor is about to release. */
158
+ renamedSocketPath: string
159
+ }
160
+
161
+ export function writeDaemonHandoff(data: DaemonHandoffV1): string {
162
+ const path = getDaemonHandoffFilePath()
163
+ writeFileSync(path, JSON.stringify(data), { mode: 0o600 })
164
+ return path
165
+ }
166
+
167
+ /**
168
+ * Reads the handoff file if present and deletes it in the same call. The
169
+ * file is single-use: a daemon that finds it knows it was spawned to take
170
+ * over from a predecessor.
171
+ */
172
+ export function consumeDaemonHandoff(): DaemonHandoffV1 | null {
173
+ const path = getDaemonHandoffFilePath()
174
+ if (!existsSync(path)) {
175
+ return null
176
+ }
177
+ let raw: string
178
+ try {
179
+ raw = readFileSync(path, 'utf8')
180
+ } catch {
181
+ return null
182
+ }
183
+ try {
184
+ unlinkSync(path)
185
+ } catch {
186
+ // best-effort — the next fresh boot would re-consume it, which is fine.
187
+ }
188
+ try {
189
+ const parsed = JSON.parse(raw) as Partial<DaemonHandoffV1>
190
+ if (
191
+ parsed.version === 1 &&
192
+ typeof parsed.fromPid === 'number' &&
193
+ typeof parsed.fromProcessVersion === 'string' &&
194
+ typeof parsed.writtenAt === 'number' &&
195
+ typeof parsed.renamedSocketPath === 'string'
196
+ ) {
197
+ return parsed as DaemonHandoffV1
198
+ }
199
+ return null
200
+ } catch {
201
+ return null
202
+ }
203
+ }
204
+
205
+ export function writeDaemonPidFile(pid: number): void {
206
+ writeFileSync(getDaemonPidFilePath(), `${pid}\n`, { mode: 0o600 })
207
+ }
208
+
209
+ export function writeDaemonVersionFile(version: string): void {
210
+ writeFileSync(getDaemonVersionFilePath(), `${version}\n`, { mode: 0o600 })
211
+ }
212
+
213
+ export function readDaemonPidFile(): number | null {
214
+ const path = getDaemonPidFilePath()
215
+ if (!existsSync(path)) return null
216
+ try {
217
+ const pid = Number.parseInt(readFileSync(path, 'utf8').trim(), 10)
218
+ return Number.isFinite(pid) && pid > 0 ? pid : null
219
+ } catch {
220
+ return null
221
+ }
222
+ }
223
+
224
+ export function readDaemonVersionFile(): string | null {
225
+ const path = getDaemonVersionFilePath()
226
+ if (!existsSync(path)) return null
227
+ try {
228
+ const v = readFileSync(path, 'utf8').trim()
229
+ return v.length > 0 ? v : null
230
+ } catch {
231
+ return null
232
+ }
233
+ }
234
+
235
+ export function removeDaemonSidecars(): void {
236
+ for (const path of [
237
+ getDaemonPidFilePath(),
238
+ getDaemonVersionFilePath(),
239
+ getDaemonHandoffFilePath(),
240
+ getDaemonOldSocketPath(),
241
+ ]) {
242
+ if (existsSync(path)) {
243
+ try {
244
+ unlinkSync(path)
245
+ } catch {
246
+ // best-effort cleanup
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Reexec-only variant: strip pid/version so no bystander (harness, CLI,
254
+ * anything that prefers the pidfile over `lsof`) reads a dead PID during
255
+ * the swap window. The handoff file is intentionally preserved — the
256
+ * successor consumes it on boot to log that it took over from the
257
+ * predecessor. The renamed socket dirent is handled by the shutdown path
258
+ * (which unlinks it after `server.close()` releases the inode).
259
+ */
260
+ export function removeDaemonSidecarsForReexec(): void {
261
+ for (const path of [getDaemonPidFilePath(), getDaemonVersionFilePath()]) {
262
+ if (existsSync(path)) {
263
+ try {
264
+ unlinkSync(path)
265
+ } catch {
266
+ // best-effort cleanup
267
+ }
268
+ }
269
+ }
270
+ }
@@ -131,6 +131,12 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
131
131
  cwd?: string
132
132
  /** Extra env injected into the spawned shell. Passed through to the PTY. */
133
133
  env?: Record<string, string>
134
+ /**
135
+ * Worktree this tab belongs to (for UI grouping). Stored on the
136
+ * TabSession so an attach-time replay surfaces it inside the right
137
+ * worktree column. Optional — tabs not bound to a worktree are valid.
138
+ */
139
+ worktreeId?: string
134
140
  }): void {
135
141
  logDebug('daemon.registry.createSession', {
136
142
  args: options.args ?? [],
@@ -150,6 +156,7 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
150
156
  status: 'starting',
151
157
  terminalModes: createDefaultTerminalModes(),
152
158
  title: options.title,
159
+ worktreeId: options.worktreeId,
153
160
  })
154
161
  } else {
155
162
  existing.status = 'starting'
@@ -161,12 +168,22 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
161
168
  existing.assistant = options.assistant
162
169
  existing.title = options.title
163
170
  existing.command = [options.command, ...(options.args ?? [])].join(' ')
171
+ if (options.worktreeId !== undefined) existing.worktreeId = options.worktreeId
164
172
  }
165
173
 
166
174
  this.activeTabId = options.tabId
167
175
  this.ptyManager.createSession(options)
168
176
  }
169
177
 
178
+ /**
179
+ * Read the in-memory TabSession for a tab. Returned by reference — used by
180
+ * the daemon's `tabAdded` broadcast path to publish the same shape the UI
181
+ * already knows how to ingest from `attachResult.tabs[]`.
182
+ */
183
+ getTab(tabId: string): TabSession | undefined {
184
+ return this.tabs.get(tabId)
185
+ }
186
+
170
187
  write(tabId: string, data: string): void {
171
188
  this.ptyManager.write(tabId, data)
172
189
  }
package/src/index.tsx CHANGED
@@ -19,6 +19,15 @@ import { runUpdate } from './update'
19
19
  const command = process.argv[2]
20
20
  const runtimeProfile = getRuntimeProfile()
21
21
 
22
+ // CLI control plane (docs/reference/cli.md). Branch BEFORE the UI bootstrap so
23
+ // `aimux tab list` from a non-TTY shell never spins up the React renderer.
24
+ // Dynamic import keeps the CLI code out of the UI's cold-start cost.
25
+ const CLI_GROUPS = new Set(['tab', 'workspace', 'worktree'])
26
+ if (typeof command === 'string' && CLI_GROUPS.has(command)) {
27
+ const { runCli } = await import('./cli')
28
+ process.exit(await runCli(process.argv.slice(2)))
29
+ }
30
+
22
31
  if (command === '--version' || command === '-v' || command === 'version') {
23
32
  const { version } = await import('../package.json')
24
33
  process.stdout.write(`aimux ${version}\n`)
@@ -45,6 +45,12 @@ export function deriveModeId(state: AppState): ModeId {
45
45
  return 'modal.worktree-move'
46
46
  }
47
47
 
48
+ // Flash-jump overlay: same pattern — renders on top of navigation, all
49
+ // letter keys are passthrough'd to update-command-edit for buffer matching.
50
+ if (state.modal.type === 'flash-jump') {
51
+ return 'modal.flash-jump'
52
+ }
53
+
48
54
  const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
49
55
  if (directMode) {
50
56
  return directMode
@@ -4,6 +4,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
4
  'git-mode': ['navigation', 'modal.git-commit', 'modal.worktree-move'],
5
5
  'modal.ai-usage': ['navigation', 'terminal-input'],
6
6
  'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
7
+ 'modal.flash-jump': ['navigation'],
7
8
  'modal.git-commit': ['git-mode', 'modal.git-commit.confirm', 'modal.git-commit.generating'],
8
9
  'modal.git-commit.confirm': ['modal.git-commit', 'git-mode'],
9
10
  'modal.git-commit.generating': ['modal.git-commit', 'modal.git-commit.confirm', 'git-mode'],
@@ -41,6 +42,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
41
42
  'modal.ai-usage',
42
43
  'modal.worktree-delete-confirm',
43
44
  'modal.worktree-move-confirm',
45
+ 'modal.flash-jump',
44
46
  'git-mode',
45
47
  ],
46
48
  'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage'],
@@ -28,6 +28,7 @@ export type ModeId =
28
28
  | 'modal.worktree-move'
29
29
  | 'modal.worktree-move-confirm'
30
30
  | 'modal.ai-usage'
31
+ | 'modal.flash-jump'
31
32
 
32
33
  export type SideEffect =
33
34
  | { type: 'quit'; state: AppState }
@@ -0,0 +1,112 @@
1
+ # IPC protocols — additive contract
2
+
3
+ Two wire protocols live in this directory:
4
+
5
+ | Link | File | Negotiated by | Breaking it kills... |
6
+ | ------------------------- | --------------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
7
+ | UI / CLI ↔ daemon | `protocol.ts` | `DaemonClient.hello()` | Client connections. PTYs survive (they live in the TM). |
8
+ | daemon ↔ terminal-manager | `manager-protocol.ts` | `TerminalManagerClient.connect()` | **Every PTY.** A fresh TM is spawned, the old one dies, every session dies with it. |
9
+
10
+ The cost asymmetry is the whole reason this document exists. **Touch
11
+ `manager-protocol.ts` and you are about to drop every running Claude/Codex
12
+ session.** Make sure that's what you want.
13
+
14
+ ## The rule
15
+
16
+ > A wire change is **additive** by default. `MIN_VERSION` only rises when the
17
+ > new daemon literally cannot parse a message a pre-bump peer would send.
18
+
19
+ `MAX_VERSION` advertises "I know about new things." `MIN_VERSION` declares
20
+ "old peers are now unrunnable." These are different operations. The old
21
+ `bump-protocol.ts` script bumps both in lockstep — that's correct only for
22
+ true semantic breaks, and most wire changes aren't that.
23
+
24
+ ### Decision tree before bumping MIN
25
+
26
+ ```
27
+ Are you adding a field / event / request type?
28
+
29
+ ┌───────────────┴───────────────┐
30
+ yes no (removing / repurposing)
31
+ │ │
32
+ Is the new field required for │
33
+ correctness on the OLD peer? Can the old peer still
34
+ │ decode the bytes the new
35
+ ┌──────┴──────┐ peer puts on the wire?
36
+ yes no │
37
+ │ │ ┌──────┴──────┐
38
+ bump MIN keep MIN. yes no
39
+ Advertise as a │ │
40
+ capability. keep MIN. bump MIN.
41
+ Gate the new Deprecate Write
42
+ wire on the old `BREAKING:` in
43
+ `capabilities. behaviour the commit body.
44
+ includes(...)`. and stop
45
+ reading it
46
+ long before
47
+ raising MIN.
48
+ ```
49
+
50
+ If the answer to "are you certain old peers cannot decode this?" is "I
51
+ think so", treat it as additive. The cost of being wrong (PTYs die) is
52
+ much higher than the cost of one stale capability flag.
53
+
54
+ ## Capabilities
55
+
56
+ Both `helloResult` payloads carry a `capabilities: string[]`. A client
57
+ checks `capabilities.includes("featureName")` before sending a request or
58
+ relying on an event that the peer might not understand. New features are
59
+ introduced as new capability strings, **not** as a MIN bump.
60
+
61
+ Current capability registries live next to each protocol:
62
+
63
+ - `src/ipc/protocol.ts` → `IPC_PROTOCOL_CAPABILITIES`
64
+ - `src/ipc/manager-protocol.ts` → `MANAGER_PROTOCOL_CAPABILITIES`
65
+
66
+ To add a new capability:
67
+
68
+ 1. Pick a stable string (kebab/camel — pick one and stay consistent per
69
+ protocol; `manager-protocol` uses camelCase).
70
+ 2. Add it to the capabilities constant the producing side advertises.
71
+ 3. On the consuming side, gate calls / event handlers behind
72
+ `helloResult.capabilities.includes("yourCap")`.
73
+ 4. **Do not** bump MIN. Old peers without the capability will simply not
74
+ see the new behaviour, which is the entire point of the gate.
75
+
76
+ For unknown request types received by an old peer: the peer replies
77
+ `error: "unknown request"`. New clients must treat that as the gate having
78
+ been wrong (capability advertised but no handler) and surface a clear
79
+ error, not retry forever.
80
+
81
+ ## When MIN must bump anyway
82
+
83
+ Examples of genuine semantic breaks where MIN must rise in lockstep with
84
+ MAX (and `BREAKING:` belongs in the commit body):
85
+
86
+ - A field changed type (e.g. `cols: number` → `cols: { value: number; cells: number }`).
87
+ - A request was removed and its absence is load-bearing (the peer would
88
+ hang waiting for a response).
89
+ - Per-line wire framing changed.
90
+
91
+ The previous lockstep bumps in `protocol.ts` (v9 dropped scroll intent
92
+ from resize messages) and `manager-protocol.ts` (v7 added palette indices
93
+ to TerminalSpan) qualify because they change the meaning of existing
94
+ fields. Most other changes don't.
95
+
96
+ ## TM bumps that aren't really TM bumps
97
+
98
+ A historical bad pattern: a UI-only feature lands, `manager-protocol.ts`
99
+ gets bumped "to force a fresh TM and clear caches." This kills PTYs for
100
+ zero benefit. Don't do this. If the TM behaviour didn't change,
101
+ `manager-protocol.ts` doesn't get touched.
102
+
103
+ The lint check in `scripts/check-protocol-discipline.ts` enforces both
104
+ rules: MIN bumps require an explicit `BREAKING:` marker, and new callers
105
+ of `stopTerminalManager` need explicit approval.
106
+
107
+ ## See also
108
+
109
+ - `docs/developer/hot-reexec.md` — the additive-contract discipline and the
110
+ daemon hot-reexec swap that keeps PTYs alive across upgrades.
111
+ - `docs/reference/cli.md` — the headless control-plane surface that the
112
+ capability mechanism was originally introduced to serve.
@@ -34,10 +34,32 @@ import {
34
34
  // is raised in lockstep to force matching binaries.
35
35
  export const MANAGER_PROTOCOL_MIN_VERSION = 8
36
36
  export const MANAGER_PROTOCOL_VERSION = 8
37
+
38
+ /**
39
+ * Capability strings advertised by *this* process in its `helloResult`. New
40
+ * additive TM features should be introduced here rather than via a MIN bump
41
+ * — bumping MIN forces a fresh TM and kills every PTY. See
42
+ * `src/ipc/README.md`.
43
+ */
44
+ export const MANAGER_PROTOCOL_CAPABILITIES: readonly string[] = [
45
+ 'setBroadcastEnabled',
46
+ 'createTabWorktreeId',
47
+ ]
48
+
49
+ /**
50
+ * Capability name a daemon must observe on the TM's helloResult before
51
+ * sending `setBroadcastEnabled`.
52
+ */
53
+ export const MANAGER_CAPABILITY_SET_BROADCAST_ENABLED = 'setBroadcastEnabled'
54
+
37
55
  /**
38
- * Minimum version required to send `setBroadcastEnabled`. Older TMs (v3) will
39
- * not understand the message; the daemon must check the negotiated version
40
- * before sending and fall back to always-on broadcast.
56
+ * Version-based fallback for `setBroadcastEnabled`. TM binaries that predate
57
+ * the capability field (built before the additive-contract migration) don't
58
+ * advertise capabilities, but any TM at v4+ speaks the request. Callers gate
59
+ * on `capabilities.has('setBroadcastEnabled') || selectedVersion >= this`.
60
+ *
61
+ * Rolling-upgrade window: new daemon + old TM (still v4+) would otherwise
62
+ * silently regress to always-broadcasting, spiking CPU and IPC traffic.
41
63
  */
42
64
  export const MANAGER_PROTOCOL_BROADCAST_GATE_VERSION = 4
43
65
 
@@ -51,6 +73,11 @@ export interface ManagerHelloResult {
51
73
  maxVersion: number
52
74
  processVersion: string
53
75
  selectedVersion: number
76
+ /**
77
+ * Feature flags. Always present on the typed shape; older peers that did
78
+ * not yet advertise capabilities are normalised to `[]` at parse time.
79
+ */
80
+ capabilities: string[]
54
81
  }
55
82
 
56
83
  export interface ManagerAttachRequest {
@@ -85,6 +112,13 @@ export type ManagerRequest =
85
112
  cwd?: string
86
113
  /** Extra env vars merged into the spawned PTY's environment. */
87
114
  env?: Record<string, string>
115
+ /**
116
+ * Worktree this tab belongs to (UI grouping). Capability-gated on
117
+ * `createTabWorktreeId`. Pre-cap TMs silently drop the field, which
118
+ * matches the previous behaviour where every new tab had
119
+ * `worktreeId = undefined`.
120
+ */
121
+ worktreeId?: string
88
122
  }
89
123
  }
90
124
  | { id: string; type: 'write'; payload: { sessionId: string; tabId: string; data: string } }
@@ -240,7 +274,10 @@ function isHelloResult(value: unknown): value is ManagerHelloResult {
240
274
  isFiniteNumber(value.minVersion) &&
241
275
  isFiniteNumber(value.maxVersion) &&
242
276
  isFiniteNumber(value.selectedVersion) &&
243
- isString(value.processVersion)
277
+ isString(value.processVersion) &&
278
+ // Wire-back-compat: legacy peers omit capabilities entirely. The parser
279
+ // normalises that to `[]` before returning the typed shape.
280
+ (value.capabilities === undefined || isStringArray(value.capabilities))
244
281
  )
245
282
  }
246
283
 
@@ -250,6 +287,12 @@ function assert(condition: boolean, message: string): asserts condition {
250
287
  }
251
288
  }
252
289
 
290
+ function normaliseManagerCapabilities(payload: Record<string, unknown>): void {
291
+ if (!Array.isArray(payload.capabilities)) {
292
+ payload.capabilities = []
293
+ }
294
+ }
295
+
253
296
  export function selectManagerProtocolVersion(payload: ManagerHelloRequest): number | null {
254
297
  return negotiateProtocolVersion(
255
298
  payload.minVersion,
@@ -261,6 +304,7 @@ export function selectManagerProtocolVersion(payload: ManagerHelloRequest): numb
261
304
 
262
305
  export function createManagerHelloResult(selectedVersion: number): ManagerHelloResult {
263
306
  return {
307
+ capabilities: [...MANAGER_PROTOCOL_CAPABILITIES],
264
308
  maxVersion: MANAGER_PROTOCOL_VERSION,
265
309
  minVersion: MANAGER_PROTOCOL_MIN_VERSION,
266
310
  processVersion: getProcessVersion(),
@@ -316,6 +360,10 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
316
360
  value.payload.env === undefined || isStringRecord(value.payload.env),
317
361
  'createTab.env must be a string-keyed string record'
318
362
  )
363
+ assert(
364
+ value.payload.worktreeId === undefined || isString(value.payload.worktreeId),
365
+ 'createTab.worktreeId must be a string when present'
366
+ )
319
367
  return value as ManagerRequest
320
368
  case 'write':
321
369
  assert(isString(value.payload.sessionId), 'write.sessionId must be a string')
@@ -375,6 +423,8 @@ export function parseManagerMessage(value: unknown): ManagerResponse | ManagerEv
375
423
  case 'helloResult':
376
424
  assert(isString(value.id), 'helloResult.id must be a string')
377
425
  assert(isHelloResult(value.payload), 'helloResult.payload is invalid')
426
+ // Normalise wire-back-compat: pre-capability TMs omit the field.
427
+ normaliseManagerCapabilities(value.payload)
378
428
  return value as ManagerResponse
379
429
  case 'ok':
380
430
  assert(isString(value.id), 'ok.id must be a string')