@brimveyn/aimux 1.16.3 → 1.18.2
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 +258 -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 +80 -2
- 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`)
|
|
@@ -51,9 +60,78 @@ if (command === 'terminal-manager') {
|
|
|
51
60
|
await runTerminalManager()
|
|
52
61
|
}
|
|
53
62
|
|
|
54
|
-
if (command === '--help' || command === '-h') {
|
|
63
|
+
if (command === '--help' || command === '-h' || command === 'help') {
|
|
55
64
|
process.stdout.write(
|
|
56
|
-
|
|
65
|
+
[
|
|
66
|
+
'aimux — terminal multiplexer for AI CLIs',
|
|
67
|
+
'',
|
|
68
|
+
'Two surfaces:',
|
|
69
|
+
' • Interactive TUI (no args): drive assistants side-by-side in one window.',
|
|
70
|
+
' • CLI control plane (`aimux <group> <verb>`): script-friendly, JSON output,',
|
|
71
|
+
' designed to be driven by another agent or a shell pipeline.',
|
|
72
|
+
'',
|
|
73
|
+
'Interactive',
|
|
74
|
+
' aimux Start the TUI in the current profile',
|
|
75
|
+
'',
|
|
76
|
+
'CLI control plane (JSON on stdout, human-readable errors on stderr)',
|
|
77
|
+
' aimux tab list | create | send | focus | close | snapshot | tail | wait',
|
|
78
|
+
' aimux workspace list | show | create | switch | close',
|
|
79
|
+
' aimux worktree list | create | remove',
|
|
80
|
+
'',
|
|
81
|
+
' aimux <group> --help List verbs in that group',
|
|
82
|
+
' aimux <group> <verb> --help Show flags, args, and exit codes',
|
|
83
|
+
'',
|
|
84
|
+
'Common CLI verbs at a glance',
|
|
85
|
+
' aimux tab list Enumerate tabs (+ activeTabId)',
|
|
86
|
+
' aimux tab create --assistant <id> [--title …] Spawn claude / codex / opencode / terminal / …',
|
|
87
|
+
' aimux tab send <tabId> [text] [--enter|--keys|--stdin] Type, chord, or paste into a tab',
|
|
88
|
+
' aimux tab focus <tabId> Bring a tab to the foreground',
|
|
89
|
+
' aimux tab close <tabId> Terminate a tab',
|
|
90
|
+
' aimux tab snapshot <tabId> [--tail N] [--format …] Capture the screen (json | text)',
|
|
91
|
+
' aimux tab tail <tabId> [--follow-status] […] NDJSON stream of renders / status',
|
|
92
|
+
' aimux tab wait <tabId> --status <idle|working|waiting-input> Block on an activity state',
|
|
93
|
+
' aimux workspace show Dump the active workspace + worktrees',
|
|
94
|
+
' aimux workspace create <name> [--project P] [--switch [--wait]]',
|
|
95
|
+
' aimux workspace switch <ws> [--wait --timeout N] Move the running UI to another workspace',
|
|
96
|
+
' aimux worktree create --name <n> [--branch B --base R]',
|
|
97
|
+
' aimux worktree remove <id|path> [--force]',
|
|
98
|
+
'',
|
|
99
|
+
'Shared flags (accepted by every CLI verb)',
|
|
100
|
+
' --workspace <id|name> Target a specific workspace (default: active)',
|
|
101
|
+
' --profile <name> Runtime profile (also settable via AIMUX_PROFILE)',
|
|
102
|
+
' --json Reserved; JSON is always on',
|
|
103
|
+
'',
|
|
104
|
+
'Maintenance',
|
|
105
|
+
' aimux update Update aimux to the latest published version',
|
|
106
|
+
' aimux doctor Diagnose setup (paths, sockets, PTY, integrations)',
|
|
107
|
+
' aimux restart-daemon Restart the IPC daemon (keeps live workspaces)',
|
|
108
|
+
' aimux restart-terminal-manager Restart terminal-manager (kills live workspaces)',
|
|
109
|
+
' aimux --version Print the installed version',
|
|
110
|
+
'',
|
|
111
|
+
'Exit codes',
|
|
112
|
+
' 0 success',
|
|
113
|
+
' 2 usage error (bad flags, unknown command, missing argument)',
|
|
114
|
+
' 3 runtime error (server replied with error, command failed)',
|
|
115
|
+
' 4 daemon unreachable (socket missing and autostart failed)',
|
|
116
|
+
' 124 timeout (tab wait, tab tail --timeout, workspace switch --wait)',
|
|
117
|
+
'',
|
|
118
|
+
'Env',
|
|
119
|
+
' AIMUX_PROFILE Runtime profile (state dir, socket paths)',
|
|
120
|
+
'',
|
|
121
|
+
'Recipes for agents',
|
|
122
|
+
' # spawn Claude, wait for idle, dump the last 40 non-blank lines',
|
|
123
|
+
' TAB=$(aimux tab create --assistant claude --title fixup | jq -r .tabId)',
|
|
124
|
+
' aimux tab send "$TAB" "explain this repo" --enter',
|
|
125
|
+
' aimux tab wait "$TAB" --status idle --timeout 60000',
|
|
126
|
+
' aimux tab snapshot "$TAB" --tail 40 --format text',
|
|
127
|
+
'',
|
|
128
|
+
' # send a control chord (Ctrl-C then Esc) using vim-style notation',
|
|
129
|
+
' aimux tab send "$TAB" "<C-c><Esc>" --keys',
|
|
130
|
+
'',
|
|
131
|
+
' # follow a tab as NDJSON, one render (or tabStatus) per line',
|
|
132
|
+
' aimux tab tail "$TAB" --rate-limit-ms 100 --follow-status',
|
|
133
|
+
'',
|
|
134
|
+
].join('\n')
|
|
57
135
|
)
|
|
58
136
|
process.exit(0)
|
|
59
137
|
}
|
|
@@ -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.
|