@brimveyn/aimux 1.19.7 → 1.20.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.
@@ -1,7 +1,10 @@
1
1
  import type { CliCommand } from '../../registry'
2
2
 
3
3
  import { removeGitWorktree } from '../../../git/worktree'
4
- import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
4
+ import {
5
+ IPC_CAPABILITY_LIST_TABS,
6
+ IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS,
7
+ } from '../../../ipc/protocol'
5
8
  import { SHARED_FLAGS } from '../../flags'
6
9
  import { EXIT_OK, writeJson } from '../../output'
7
10
 
@@ -36,20 +39,37 @@ export const worktreeRemove: CliCommand = {
36
39
  throw new Error('workspace has no primary worktree — cannot resolve repoRoot for git remove')
37
40
  }
38
41
 
39
- // Git side first — matches the UI's discipline in side-effects.ts. If
40
- // git refuses (dirty, uncommitted changes) the catalog stays intact.
41
- await removeGitWorktree({ force, repoPath: primary.repoRoot, targetPath: worktree.path })
42
-
43
42
  const daemon = await ctx.getDaemon()
44
43
  if (!daemon.hasCapability(IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS)) {
45
44
  throw new Error(
46
45
  'daemon predates worktreeLifecycleEvents capability — restart aimux to pick up the new daemon'
47
46
  )
48
47
  }
49
- await daemon.expectOk('removeWorktreeRecord', {
50
- sessionId: workspace.id,
51
- worktreeId: worktree.id,
52
- })
48
+ if (daemon.hasCapability(IPC_CAPABILITY_LIST_TABS)) {
49
+ const live = (await daemon.listTabs(workspace.id)).tabs.filter(
50
+ (tab) => tab.worktreeId === worktree.id
51
+ )
52
+ if (live.length > 0) {
53
+ throw new Error(
54
+ `refusing to remove worktree with live tabs: ${live.map((tab) => tab.id).join(', ')}`
55
+ )
56
+ }
57
+ }
58
+
59
+ // All capability and liveness checks happen before touching git. If git
60
+ // refuses a dirty worktree the catalog remains unchanged.
61
+ await removeGitWorktree({ force, repoPath: primary.repoRoot, targetPath: worktree.path })
62
+ try {
63
+ await daemon.expectOk('removeWorktreeRecord', {
64
+ sessionId: workspace.id,
65
+ worktreeId: worktree.id,
66
+ })
67
+ } catch (error) {
68
+ const message = error instanceof Error ? error.message : String(error)
69
+ throw new Error(
70
+ `worktree removed from git but catalog reconciliation failed for ${worktree.id}: ${message}`
71
+ )
72
+ }
53
73
 
54
74
  writeJson({ id: worktree.id, name: worktree.name, path: worktree.path })
55
75
  return EXIT_OK
package/src/cli/index.ts CHANGED
@@ -64,9 +64,10 @@ function formatFlagLine(flag: FlagSpec): string {
64
64
  function printHelp(): void {
65
65
  process.stdout.write(
66
66
  [
67
- 'aimux CLI control plane drive workspaces, worktrees, and tabs from scripts.',
67
+ 'aimux — terminal multiplexer and agent-friendly control plane.',
68
68
  '',
69
69
  'Usage:',
70
+ ' aimux Start the interactive TUI',
70
71
  ' aimux <group> <verb> [flags] [args]',
71
72
  ' aimux <group> --help List verbs in a group',
72
73
  ' aimux <group> <verb> --help Show flags/args for a verb',
@@ -99,15 +100,12 @@ function printHelp(): void {
99
100
  'Env:',
100
101
  ' AIMUX_PROFILE Runtime profile (state dir, socket paths); --profile overrides.',
101
102
  '',
102
- 'Agent recipes:',
103
- ' # spawn Claude in a new tab, wait until it idles, snapshot the screen',
104
- ' TAB=$(aimux tab create --assistant claude --title fixup | jq -r .tabId)',
105
- ' aimux tab send "$TAB" "explain this repo" --enter',
106
- ' aimux tab wait "$TAB" --status idle --timeout 60000',
107
- ' aimux tab snapshot "$TAB" --tail 40 --format text',
103
+ 'Agent recipe:',
104
+ ' # create an isolated named worker, dispatch, and await one structured outcome',
105
+ ' aimux worker run --name fixup --assistant claude "explain this repo"',
108
106
  '',
109
- ' # stream renders as NDJSON (one event per line)',
110
- ' aimux tab tail "$TAB" --rate-limit-ms 100 --follow-status',
107
+ 'Maintenance:',
108
+ ' aimux doctor | update | restart-daemon | restart-terminal-manager | version',
111
109
  '',
112
110
  ].join('\n')
113
111
  )
@@ -201,6 +199,11 @@ export async function runCli(argv: readonly string[]): Promise<number> {
201
199
  if (error instanceof CliUsageError) {
202
200
  writeError(error.message)
203
201
  writeError(`usage: aimux ${command.group} ${command.verb}`)
202
+ writeJson({
203
+ command: `${command.group} ${command.verb}`,
204
+ error: error.message,
205
+ kind: 'usage-error',
206
+ })
204
207
  return EXIT_USAGE
205
208
  }
206
209
  throw error
@@ -241,6 +244,15 @@ export async function runCli(argv: readonly string[]): Promise<number> {
241
244
  return code
242
245
  } catch (error) {
243
246
  const message = error instanceof Error ? error.message : String(error)
247
+ if (error instanceof CliUsageError) {
248
+ writeError(message)
249
+ writeJson({
250
+ command: `${command.group} ${command.verb}`,
251
+ error: message,
252
+ kind: 'usage-error',
253
+ })
254
+ return EXIT_USAGE
255
+ }
244
256
  // Classify by error type, not by string-sniffing the message: a runtime
245
257
  // error whose message happens to include "socket" (e.g. daemon reply
246
258
  // "socket write failed for tab X") must not masquerade as
@@ -11,6 +11,12 @@ import { tabSend } from './commands/tab/send'
11
11
  import { tabSnapshot } from './commands/tab/snapshot'
12
12
  import { tabTail } from './commands/tab/tail'
13
13
  import { tabWait } from './commands/tab/wait'
14
+ import { workerAwait } from './commands/worker/await'
15
+ import { workerDoctor } from './commands/worker/doctor'
16
+ import { workerList } from './commands/worker/list'
17
+ import { workerPrompt } from './commands/worker/prompt'
18
+ import { workerRun } from './commands/worker/run'
19
+ import { workerStop } from './commands/worker/stop'
14
20
  import { workspaceClose } from './commands/workspace/close'
15
21
  import { workspaceCreate } from './commands/workspace/create'
16
22
  import { workspaceList } from './commands/workspace/list'
@@ -48,6 +54,12 @@ export const COMMANDS: readonly CliCommand[] = [
48
54
  worktreeList,
49
55
  worktreeCreate,
50
56
  worktreeRemove,
57
+ workerRun,
58
+ workerPrompt,
59
+ workerAwait,
60
+ workerList,
61
+ workerStop,
62
+ workerDoctor,
51
63
  ]
52
64
 
53
65
  export function resolveCommand(group: string, verb: string): CliCommand | null {
@@ -3,10 +3,12 @@ import { connect, createServer, type Socket } from 'node:net'
3
3
 
4
4
  import type { AssistantId, TabSession, TabStatus, TerminalSnapshot } from '../state/types'
5
5
 
6
+ import { version as APP_VERSION } from '../../package.json'
6
7
  import { AutoRenameCoordinator, initialAutoRenameStatus } from '../auto-rename/coordinator'
7
8
  import { loadUserConfig } from '../config/loader'
8
9
  import { logDebug } from '../debug/input-log'
9
10
  import { type ClaudeHookServer, startClaudeHookServer } from '../integrations/claude-hook-server'
11
+ import { MANAGER_CAPABILITY_WORKER_METADATA } from '../ipc/manager-protocol'
10
12
  import {
11
13
  type ClientRequest,
12
14
  encodeMessage,
@@ -72,6 +74,7 @@ export interface DaemonTabEntry {
72
74
  title?: string
73
75
  status?: TabStatus
74
76
  worktreeId?: string
77
+ workerName?: string
75
78
  autoRenameStatus?: 'eligible' | 'attempted'
76
79
  }
77
80
 
@@ -98,6 +101,7 @@ export function mergeTabRegistryEntry(
98
101
  title?: string
99
102
  status?: TabStatus
100
103
  worktreeId?: string
104
+ workerName?: string
101
105
  autoRenameStatus?: 'eligible' | 'attempted'
102
106
  }
103
107
  ): DaemonTabEntry {
@@ -115,12 +119,24 @@ export function mergeTabRegistryEntry(
115
119
  title: preserveAttemptedMetadata ? existing.title : (metadata?.title ?? existing?.title),
116
120
  viewport,
117
121
  viewportSeq: existing?.viewportSeq ?? (viewport ? allocateSeq() : 0),
122
+ workerName: metadata?.workerName ?? existing?.workerName,
118
123
  worktreeId: metadata?.worktreeId ?? existing?.worktreeId,
119
124
  }
120
125
  registry.set(tabId, entry)
121
126
  return entry
122
127
  }
123
128
 
129
+ export function findWorkerNameConflict(
130
+ registry: ReadonlyMap<string, DaemonTabEntry>,
131
+ sessionId: string,
132
+ workerName: string
133
+ ): string | undefined {
134
+ for (const [tabId, entry] of registry) {
135
+ if (entry.sessionId === sessionId && entry.workerName === workerName) return tabId
136
+ }
137
+ return undefined
138
+ }
139
+
124
140
  /**
125
141
  * Turn-complete settle window for the status loop, overridable via
126
142
  * `AIMUX_TURN_SETTLE_MS` for slow/loaded machines. Falls back to the loop's
@@ -264,6 +280,7 @@ export async function runDaemon(): Promise<void> {
264
280
  title?: string
265
281
  status?: TabStatus
266
282
  worktreeId?: string
283
+ workerName?: string
267
284
  autoRenameStatus?: 'eligible' | 'attempted'
268
285
  }
269
286
  ): DaemonTabEntry => {
@@ -589,7 +606,9 @@ export async function runDaemon(): Promise<void> {
589
606
  send(socket, {
590
607
  id: message.id,
591
608
  payload: {
609
+ appVersion: APP_VERSION,
592
610
  capabilities: [...IPC_PROTOCOL_CAPABILITIES],
611
+ managerCapabilities: [...manager.getCapabilities()],
593
612
  ...(managerSelectedVersion !== null && { managerSelectedVersion }),
594
613
  maxVersion: IPC_PROTOCOL_VERSION,
595
614
  minVersion: IPC_PROTOCOL_MIN_VERSION,
@@ -680,6 +699,7 @@ export async function runDaemon(): Promise<void> {
680
699
  autoRenameStatus: tab.autoRenameStatus,
681
700
  status: tab.status,
682
701
  title: tab.title,
702
+ workerName: tab.workerName,
683
703
  worktreeId: tab.worktreeId,
684
704
  }
685
705
  )
@@ -714,6 +734,7 @@ export async function runDaemon(): Promise<void> {
714
734
  activity: statusLoop.getTabStatus(tab.id) ?? tab.activity,
715
735
  autoRenameStatus: metadata?.autoRenameStatus ?? tab.autoRenameStatus,
716
736
  title: metadata?.title ?? tab.title,
737
+ workerName: metadata?.workerName ?? tab.workerName,
717
738
  }
718
739
  })
719
740
  const initialSessionStatuses = statusLoop.snapshotSessions()
@@ -742,6 +763,23 @@ export async function runDaemon(): Promise<void> {
742
763
  case 'createTab': {
743
764
  const sessionId = requireSession(socket, attachedSessions)
744
765
  requireNegotiatedVersion(socket, negotiatedVersions)
766
+ if (message.payload.workerName !== undefined) {
767
+ if (!manager.hasCapability(MANAGER_CAPABILITY_WORKER_METADATA)) {
768
+ throw new Error(
769
+ 'the running terminal manager does not support worker metadata; restart aimux before creating named workers'
770
+ )
771
+ }
772
+ const conflictTabId = findWorkerNameConflict(
773
+ tabRegistry,
774
+ sessionId,
775
+ message.payload.workerName
776
+ )
777
+ if (conflictTabId !== undefined) {
778
+ throw new Error(
779
+ `worker name already exists in this workspace: ${message.payload.workerName} (${conflictTabId})`
780
+ )
781
+ }
782
+ }
745
783
  // Capability `createTabSizeFallback`: cols/rows = 0 means
746
784
  // "use the session's last attached dimensions". Headless
747
785
  // CLIs don't have a viewport of their own, so this lets
@@ -789,6 +827,7 @@ export async function runDaemon(): Promise<void> {
789
827
  autoRenameStatus,
790
828
  status: 'starting',
791
829
  title: message.payload.title,
830
+ workerName: message.payload.workerName,
792
831
  worktreeId: message.payload.worktreeId,
793
832
  }
794
833
  )
@@ -802,14 +841,23 @@ export async function runDaemon(): Promise<void> {
802
841
  if (hookServer) env.AIMUX_HOOK_URL_FILE = hookUrlFilePath
803
842
  const { autoRenameCandidate: _autoRenameCandidate, ...managerTabPayload } =
804
843
  message.payload
805
- await manager.createTab({
806
- ...managerTabPayload,
807
- autoRenameStatus,
808
- cols,
809
- env,
810
- rows,
811
- sessionId,
812
- })
844
+ try {
845
+ await manager.createTab({
846
+ ...managerTabPayload,
847
+ autoRenameStatus,
848
+ cols,
849
+ env,
850
+ rows,
851
+ sessionId,
852
+ })
853
+ } catch (error) {
854
+ // `rememberTab` must happen before the manager call so
855
+ // early render events have metadata to merge into. Undo
856
+ // that optimistic entry when creation fails, otherwise a
857
+ // ghost worker name would block a clean retry.
858
+ tabRegistry.delete(message.payload.tabId)
859
+ throw error
860
+ }
813
861
  sendOk(socket, message.id)
814
862
  // Fan a `tabAdded` event only to peers that negotiated at
815
863
  // least v11 — older parsers throw on unknown message types
@@ -825,6 +873,7 @@ export async function runDaemon(): Promise<void> {
825
873
  status: 'starting',
826
874
  terminalModes: createDefaultTerminalModes(),
827
875
  title: message.payload.title,
876
+ workerName: message.payload.workerName,
828
877
  worktreeId: message.payload.worktreeId,
829
878
  }
830
879
  broadcastForSessionVersioned(sessionId, 11, {
@@ -940,6 +989,7 @@ export async function runDaemon(): Promise<void> {
940
989
  lastLine: lastNonBlankLine(entry.viewport),
941
990
  status: entry.status ?? 'running',
942
991
  title: entry.title ?? '',
992
+ workerName: entry.workerName,
943
993
  worktreeId: entry.worktreeId,
944
994
  })
945
995
  }
@@ -94,6 +94,7 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
94
94
  existing.autoRenameStatus = persisted.autoRenameStatus
95
95
  }
96
96
  existing.worktreeId = persisted.worktreeId
97
+ existing.workerName = persisted.workerName
97
98
  }
98
99
  }
99
100
  if (
@@ -155,6 +156,8 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
155
156
  * worktree column. Optional — tabs not bound to a worktree are valid.
156
157
  */
157
158
  worktreeId?: string
159
+ /** Workspace-scoped orchestration handle; does not affect PTY behavior. */
160
+ workerName?: string
158
161
  }): void {
159
162
  logDebug('daemon.registry.createSession', {
160
163
  args: options.args ?? [],
@@ -175,6 +178,7 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
175
178
  status: 'starting',
176
179
  terminalModes: createDefaultTerminalModes(),
177
180
  title: options.title,
181
+ workerName: options.workerName,
178
182
  worktreeId: options.worktreeId,
179
183
  })
180
184
  } else {
@@ -189,6 +193,7 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
189
193
  existing.command = [options.command, ...(options.args ?? [])].join(' ')
190
194
  existing.autoRenameStatus = options.autoRenameStatus
191
195
  if (options.worktreeId !== undefined) existing.worktreeId = options.worktreeId
196
+ if (options.workerName !== undefined) existing.workerName = options.workerName
192
197
  }
193
198
 
194
199
  this.activeTabId = options.tabId
@@ -33,6 +33,14 @@ export async function getHeadSha(cwd: string): Promise<string | undefined> {
33
33
  return result.text().trim() || undefined
34
34
  }
35
35
 
36
+ export async function isGitWorktreeDirty(cwd: string): Promise<boolean> {
37
+ const result = await $`git -C ${cwd} status --porcelain`.quiet().nothrow()
38
+ if (result.exitCode !== 0) {
39
+ throw new Error(result.stderr.toString().trim() || `failed to inspect worktree: ${cwd}`)
40
+ }
41
+ return result.text().trim().length > 0
42
+ }
43
+
36
44
  // Local branch names, ordered most-recently-committed first so the likely base
37
45
  // surfaces near the top of the picker.
38
46
  export async function listLocalBranches(cwd: string): Promise<string[]> {
package/src/index.tsx CHANGED
@@ -1,9 +1,4 @@
1
1
  #!/usr/bin/env bun
2
- import { createCliRenderer } from '@opentui/core'
3
- import { createRoot } from '@opentui/react'
4
-
5
- import { App } from './app'
6
- import { loadUserConfig } from './config/loader'
7
2
  import { runDaemon } from './daemon/daemon'
8
3
  import { getRuntimeProfile } from './daemon/runtime-paths'
9
4
  import { logDebug } from './debug/input-log'
@@ -12,8 +7,6 @@ import { runRestartDaemon } from './restart-daemon'
12
7
  import { runRestartTerminalManager } from './restart-terminal-manager'
13
8
  import { createSessionBackend } from './session-backend/bootstrap'
14
9
  import { runTerminalManager } from './terminal-manager/terminal-manager'
15
- import { BreakingUpdateScreen } from './ui/breaking-update-screen'
16
- import { setHostPalette } from './ui/host-palette'
17
10
  import { runUpdate } from './update'
18
11
 
19
12
  const command = process.argv[2]
@@ -22,7 +15,7 @@ const runtimeProfile = getRuntimeProfile()
22
15
  // CLI control plane (docs/reference/cli.md). Branch BEFORE the UI bootstrap so
23
16
  // `aimux tab list` from a non-TTY shell never spins up the React renderer.
24
17
  // Dynamic import keeps the CLI code out of the UI's cold-start cost.
25
- const CLI_GROUPS = new Set(['tab', 'workspace', 'worktree'])
18
+ const CLI_GROUPS = new Set(['tab', 'workspace', 'worktree', 'worker'])
26
19
  if (typeof command === 'string' && CLI_GROUPS.has(command)) {
27
20
  const { runCli } = await import('./cli')
28
21
  process.exit(await runCli(process.argv.slice(2)))
@@ -61,81 +54,25 @@ if (command === 'terminal-manager') {
61
54
  }
62
55
 
63
56
  if (command === '--help' || command === '-h' || command === 'help') {
64
- process.stdout.write(
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 / grok / kimi / 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')
135
- )
136
- process.exit(0)
57
+ const { runCli } = await import('./cli')
58
+ process.exit(await runCli([]))
137
59
  }
138
60
 
61
+ const [
62
+ { createCliRenderer },
63
+ { createRoot },
64
+ { App },
65
+ { loadUserConfig },
66
+ { BreakingUpdateScreen },
67
+ { setHostPalette },
68
+ ] = await Promise.all([
69
+ import('@opentui/core'),
70
+ import('@opentui/react'),
71
+ import('./app'),
72
+ import('./config/loader'),
73
+ import('./ui/breaking-update-screen'),
74
+ import('./ui/host-palette'),
75
+ ])
139
76
  const resolvedConfig = await loadUserConfig()
140
77
 
141
78
  const renderer = await createCliRenderer({
@@ -35,8 +35,14 @@ import {
35
35
  //
36
36
  // v9: additive — `tabMetadata` updates titles and auto-rename state without
37
37
  // restarting PTYs. Capability-gated; MIN remains at 8.
38
- export const MANAGER_PROTOCOL_MIN_VERSION = 8
39
- export const MANAGER_PROTOCOL_VERSION = 9
38
+ //
39
+ // v10: additive `workerName` persists a workspace-scoped orchestration
40
+ // handle on tabs created through the headless worker facade.
41
+ //
42
+ // v11: breaking release boundary — force a fresh terminal-manager so named
43
+ // worker metadata is guaranteed to survive daemon reattach and process swaps.
44
+ export const MANAGER_PROTOCOL_MIN_VERSION = 11
45
+ export const MANAGER_PROTOCOL_VERSION = 11
40
46
 
41
47
  /**
42
48
  * Capability strings advertised by *this* process in its `helloResult`. New
@@ -48,9 +54,11 @@ export const MANAGER_PROTOCOL_CAPABILITIES: readonly string[] = [
48
54
  'setBroadcastEnabled',
49
55
  'createTabWorktreeId',
50
56
  'tabMetadata',
57
+ 'workerMetadata',
51
58
  ]
52
59
 
53
60
  export const MANAGER_CAPABILITY_TAB_METADATA = 'tabMetadata'
61
+ export const MANAGER_CAPABILITY_WORKER_METADATA = 'workerMetadata'
54
62
 
55
63
  /**
56
64
  * Capability name a daemon must observe on the TM's helloResult before
@@ -125,6 +133,7 @@ export type ManagerRequest =
125
133
  * `worktreeId = undefined`.
126
134
  */
127
135
  worktreeId?: string
136
+ workerName?: string
128
137
  autoRenameStatus?: 'eligible' | 'attempted'
129
138
  }
130
139
  }
@@ -381,6 +390,10 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
381
390
  value.payload.worktreeId === undefined || isString(value.payload.worktreeId),
382
391
  'createTab.worktreeId must be a string when present'
383
392
  )
393
+ assert(
394
+ value.payload.workerName === undefined || isString(value.payload.workerName),
395
+ 'createTab.workerName must be a string when present'
396
+ )
384
397
  assert(
385
398
  value.payload.autoRenameStatus === undefined ||
386
399
  value.payload.autoRenameStatus === 'eligible' ||
@@ -40,8 +40,14 @@ import { isWorkspaceSnapshotV1, isWorktreeRecord } from '../state/validation'
40
40
  //
41
41
  // v14: additive — tab metadata synchronization for manual and automatic
42
42
  // renames. The new request/event are capability-gated; MIN stays at 10.
43
- export const IPC_PROTOCOL_MIN_VERSION = 10
44
- export const IPC_PROTOCOL_VERSION = 14
43
+ //
44
+ // v15: additive workspace-scoped `workerName` metadata on create, attach,
45
+ // and list results. This powers stable name-or-id orchestration selectors.
46
+ //
47
+ // v16: breaking release boundary — the agent-first worker control plane and
48
+ // its metadata guarantees are now required. Old app/daemon pairs must not mix.
49
+ export const IPC_PROTOCOL_MIN_VERSION = 16
50
+ export const IPC_PROTOCOL_VERSION = 16
45
51
 
46
52
  /**
47
53
  * Capability advertised by a daemon that knows how to drain + handoff its
@@ -106,6 +112,7 @@ export const IPC_CAPABILITY_WORKSPACE_LIFECYCLE = 'workspaceLifecycle'
106
112
  * fanout as workspaceLifecycle.
107
113
  */
108
114
  export const IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS = 'worktreeLifecycleEvents'
115
+ export const IPC_CAPABILITY_WORKER_METADATA = 'workerMetadata'
109
116
 
110
117
  /**
111
118
  * v12 — capability marker for `aimux tab tail`. Functionally it's just a
@@ -167,6 +174,7 @@ export const IPC_PROTOCOL_CAPABILITIES: readonly string[] = [
167
174
  IPC_CAPABILITY_QUESTION_EVENTS,
168
175
  IPC_CAPABILITY_LIST_TABS_LAST_LINE,
169
176
  IPC_CAPABILITY_TAB_METADATA,
177
+ IPC_CAPABILITY_WORKER_METADATA,
170
178
  ]
171
179
 
172
180
  export interface ProtocolHelloRequest {
@@ -175,6 +183,8 @@ export interface ProtocolHelloRequest {
175
183
  }
176
184
 
177
185
  export interface ProtocolHelloResult {
186
+ /** aimux package version; absent on older daemons. */
187
+ appVersion?: string
178
188
  minVersion: number
179
189
  maxVersion: number
180
190
  processVersion: string
@@ -184,6 +194,8 @@ export interface ProtocolHelloResult {
184
194
  * not yet advertise capabilities are normalised to `[]` at parse time.
185
195
  */
186
196
  capabilities: string[]
197
+ /** Capabilities negotiated with the terminal-manager; absent on older daemons. */
198
+ managerCapabilities?: string[]
187
199
  /**
188
200
  * Manager-protocol version currently negotiated between the daemon and
189
201
  * its terminal-manager. `undefined` when the daemon has not yet connected
@@ -227,6 +239,7 @@ export interface TabSessionSummary {
227
239
  activity?: TabActivity
228
240
  command: string
229
241
  worktreeId?: string
242
+ workerName?: string
230
243
  /**
231
244
  * v13 / capability `listTabsLastLine`. The tab's last non-blank rendered
232
245
  * line, trimmed. Present only when the daemon advertises the capability;
@@ -276,6 +289,8 @@ export type ClientRequest =
276
289
  * only forwards it to the TM when its own capability is in play.
277
290
  */
278
291
  worktreeId?: string
292
+ /** v15 / capability `workerMetadata`: stable workspace-scoped handle. */
293
+ workerName?: string
279
294
  /** True only when the creator did not provide an explicit title. */
280
295
  autoRenameCandidate?: boolean
281
296
  }
@@ -566,12 +581,14 @@ function isProtocolHelloResult(value: unknown): value is ProtocolHelloResult {
566
581
  isFiniteNumber(value.maxVersion) &&
567
582
  isFiniteNumber(value.selectedVersion) &&
568
583
  isString(value.processVersion) &&
584
+ (value.appVersion === undefined || isString(value.appVersion)) &&
569
585
  // Wire-back-compat: peers that predate the capabilities field omit it
570
586
  // entirely. parseServerMessage normalises that to `[]` before the cast
571
587
  // so the typed shape stays non-optional.
572
588
  (value.capabilities === undefined || isStringArray(value.capabilities)) &&
573
589
  // Additive: peers that predate managerSelectedVersion omit it.
574
- (value.managerSelectedVersion === undefined || isFiniteNumber(value.managerSelectedVersion))
590
+ (value.managerSelectedVersion === undefined || isFiniteNumber(value.managerSelectedVersion)) &&
591
+ (value.managerCapabilities === undefined || isStringArray(value.managerCapabilities))
575
592
  )
576
593
  }
577
594
 
@@ -592,6 +609,7 @@ function isTabSessionSummary(value: unknown): value is TabSessionSummary {
592
609
  value.activity === 'idle') &&
593
610
  isString(value.command) &&
594
611
  (value.worktreeId === undefined || isString(value.worktreeId)) &&
612
+ (value.workerName === undefined || isString(value.workerName)) &&
595
613
  (value.lastLine === undefined || isString(value.lastLine))
596
614
  )
597
615
  }
@@ -627,6 +645,7 @@ function isTabSession(value: unknown): value is TabSession {
627
645
  isString(value.buffer) &&
628
646
  isTerminalModeState(value.terminalModes) &&
629
647
  isString(value.command) &&
648
+ (value.workerName === undefined || isString(value.workerName)) &&
630
649
  (value.autoRenameStatus === undefined ||
631
650
  value.autoRenameStatus === 'eligible' ||
632
651
  value.autoRenameStatus === 'attempted') &&
@@ -736,6 +755,10 @@ export function parseClientRequest(value: unknown): ClientRequest {
736
755
  value.payload.worktreeId === undefined || isString(value.payload.worktreeId),
737
756
  'createTab.worktreeId must be a string when present'
738
757
  )
758
+ assert(
759
+ value.payload.workerName === undefined || isString(value.payload.workerName),
760
+ 'createTab.workerName must be a string when present'
761
+ )
739
762
  assert(
740
763
  value.payload.autoRenameCandidate === undefined ||
741
764
  typeof value.payload.autoRenameCandidate === 'boolean',
@@ -59,6 +59,7 @@ export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
59
59
  terminalModes: tab.terminalModes,
60
60
  title: tab.title,
61
61
  viewport: tab.viewport,
62
+ workerName: tab.workerName,
62
63
  worktreeId: tab.worktreeId,
63
64
  })),
64
65
  version: 1,
@@ -124,6 +125,7 @@ export function restoreTabsFromWorkspace(
124
125
  terminalModes: tab.terminalModes,
125
126
  title: tab.title,
126
127
  viewport: tab.viewport,
128
+ workerName: tab.workerName,
127
129
  worktreeId: tab.worktreeId,
128
130
  }))
129
131
  return pruneOrphanedTabs(restored, options.validWorktreeIds)