@brimveyn/aimux 1.16.2 → 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.
- package/package.json +4 -2
- package/src/app-runtime/backend-runtime-events.ts +179 -0
- package/src/app-runtime/side-effects.ts +3 -1
- package/src/app.tsx +26 -0
- package/src/cli/chord.ts +77 -0
- package/src/cli/client/bootstrap.ts +76 -0
- package/src/cli/client/daemon-client.ts +228 -0
- package/src/cli/client/workspace-resolver.ts +57 -0
- package/src/cli/commands/tab/close.ts +33 -0
- package/src/cli/commands/tab/create.ts +109 -0
- package/src/cli/commands/tab/focus.ts +33 -0
- package/src/cli/commands/tab/list.ts +52 -0
- package/src/cli/commands/tab/send.ts +83 -0
- package/src/cli/commands/tab/snapshot.ts +127 -0
- package/src/cli/commands/tab/tail.ts +171 -0
- package/src/cli/commands/tab/wait.ts +86 -0
- package/src/cli/commands/workspace/close.ts +32 -0
- package/src/cli/commands/workspace/create.ts +92 -0
- package/src/cli/commands/workspace/list.ts +25 -0
- package/src/cli/commands/workspace/show.ts +32 -0
- package/src/cli/commands/workspace/switch.ts +75 -0
- package/src/cli/commands/worktree/create.ts +113 -0
- package/src/cli/commands/worktree/list.ts +44 -0
- package/src/cli/commands/worktree/remove.ts +59 -0
- package/src/cli/context.ts +16 -0
- package/src/cli/flags.ts +101 -0
- package/src/cli/index.ts +113 -0
- package/src/cli/output.ts +30 -0
- package/src/cli/registry.ts +54 -0
- package/src/cli/snapshot-render.ts +54 -0
- package/src/daemon/catalog-writer.ts +123 -0
- package/src/daemon/daemon.ts +566 -11
- package/src/daemon/reexec-client.ts +147 -0
- package/src/daemon/runtime-paths.ts +161 -1
- package/src/daemon/session-registry.ts +17 -0
- package/src/index.tsx +9 -0
- package/src/input/modes/bridge.ts +6 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +1 -0
- package/src/ipc/README.md +112 -0
- package/src/ipc/manager-protocol.ts +54 -4
- package/src/ipc/protocol.ts +466 -25
- package/src/platform/daemon-control.ts +14 -0
- package/src/restart-daemon.ts +36 -4
- package/src/session-backend/bootstrap.ts +110 -0
- package/src/session-backend/local-session-backend.ts +6 -0
- package/src/session-backend/remote-session-backend.ts +63 -0
- package/src/session-backend/types.ts +34 -0
- package/src/state/reducers/modal-state.ts +66 -1
- package/src/state/types.ts +40 -0
- package/src/state/validation.ts +1 -1
- package/src/terminal-manager/manager-client.ts +24 -7
- package/src/ui/components/flash/flash-label-badge.tsx +38 -0
- package/src/ui/components/layout/sidebar/tab-item.tsx +2 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +4 -0
- package/src/ui/components/layout/sidebar/worktree-row.tsx +2 -0
- package/src/ui/components/layout/top-tab-bar.tsx +2 -0
- package/src/ui/flash/assign-labels.ts +126 -0
- package/src/ui/flash/build-labels.ts +78 -0
- package/src/ui/hooks/use-flash-label.ts +40 -0
- 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 {
|
|
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'],
|
package/src/input/modes/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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')
|