@brimveyn/aimux 1.19.6 → 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.
Files changed (42) hide show
  1. package/README.md +6 -0
  2. package/package.json +3 -2
  3. package/skills/aimux-orchestrator/SKILL.md +93 -0
  4. package/skills/aimux-orchestrator/assets/ledger.template.md +18 -0
  5. package/skills/aimux-orchestrator/references/prompts.md +57 -0
  6. package/skills/aimux-orchestrator/references/review.md +18 -0
  7. package/src/app-runtime/backend-runtime-events.ts +16 -0
  8. package/src/app-runtime/side-effects.ts +15 -2
  9. package/src/auto-rename/coordinator.ts +97 -0
  10. package/src/auto-rename/prompt-capture.ts +211 -0
  11. package/src/auto-rename/title-runner.ts +96 -0
  12. package/src/cli/client/daemon-client.ts +16 -0
  13. package/src/cli/commands/tab/create.ts +236 -124
  14. package/src/cli/commands/tab/prompt-io.ts +2 -1
  15. package/src/cli/commands/worker/await.ts +33 -0
  16. package/src/cli/commands/worker/doctor.ts +129 -0
  17. package/src/cli/commands/worker/list.ts +25 -0
  18. package/src/cli/commands/worker/prompt.ts +49 -0
  19. package/src/cli/commands/worker/run.ts +97 -0
  20. package/src/cli/commands/worker/shared.ts +255 -0
  21. package/src/cli/commands/worker/stop.ts +84 -0
  22. package/src/cli/commands/worktree/remove.ts +29 -9
  23. package/src/cli/index.ts +21 -9
  24. package/src/cli/registry.ts +12 -0
  25. package/src/daemon/daemon.ts +173 -12
  26. package/src/daemon/session-manager.ts +8 -0
  27. package/src/daemon/session-registry.ts +22 -1
  28. package/src/git/worktree.ts +8 -0
  29. package/src/index.tsx +21 -82
  30. package/src/input/modes/types.ts +1 -0
  31. package/src/ipc/manager-protocol.ts +52 -2
  32. package/src/ipc/protocol.ts +74 -3
  33. package/src/session-backend/bootstrap.ts +3 -1
  34. package/src/session-backend/local-session-backend.ts +63 -2
  35. package/src/session-backend/remote-session-backend.ts +17 -0
  36. package/src/session-backend/types.ts +7 -0
  37. package/src/state/reducers/tab-state.ts +14 -1
  38. package/src/state/session-persistence.ts +4 -0
  39. package/src/state/types.ts +18 -1
  40. package/src/state/validation.ts +5 -1
  41. package/src/terminal-manager/manager-client.ts +33 -1
  42. package/src/terminal-manager/terminal-manager.ts +9 -0
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,27 @@ 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
+ ])
76
+ const resolvedConfig = await loadUserConfig()
77
+
139
78
  const renderer = await createCliRenderer({
140
79
  autoFocus: true,
141
80
  // Transparent clear color so cells untouched by BoxRenderable paints (e.g.
@@ -164,7 +103,6 @@ try {
164
103
 
165
104
  const root = createRoot(renderer)
166
105
 
167
- const resolvedConfig = await loadUserConfig()
168
106
  logDebug('index.userConfigLoaded', {
169
107
  leader: resolvedConfig.keymaps.leader,
170
108
  modeCount: resolvedConfig.keymaps.modes.size,
@@ -179,6 +117,7 @@ if (resolvedConfig.theme?.beta?.experimentalSyntaxHighlight === true) {
179
117
  }
180
118
 
181
119
  const backend = await createSessionBackend({
120
+ autoRenameConfig: resolvedConfig.autoRename,
182
121
  onBreakingUpdateRequired: () =>
183
122
  new Promise<void>((resolve) => {
184
123
  root.render(<BreakingUpdateScreen onConfirm={resolve} />)
@@ -52,6 +52,7 @@ export type SideEffect =
52
52
  | { type: 'apply-theme'; action: 'confirm' }
53
53
  | { type: 'apply-theme'; action: 'preview'; delta: 1 | -1 }
54
54
  | { type: 'rename-session'; sessionId: string; name: string }
55
+ | { type: 'rename-tab'; tabId: string; title: string }
55
56
  | {
56
57
  type: 'split-pane'
57
58
  direction: SplitDirection
@@ -32,8 +32,17 @@ import {
32
32
  // either way (pre-v7 TM → new client never sees the indices; new TM →
33
33
  // pre-v7 client ignores them and falls back to the theme default), so Min
34
34
  // is raised in lockstep to force matching binaries.
35
- export const MANAGER_PROTOCOL_MIN_VERSION = 8
36
- export const MANAGER_PROTOCOL_VERSION = 8
35
+ //
36
+ // v9: additive `tabMetadata` updates titles and auto-rename state without
37
+ // restarting PTYs. Capability-gated; MIN remains at 8.
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
37
46
 
38
47
  /**
39
48
  * Capability strings advertised by *this* process in its `helloResult`. New
@@ -44,8 +53,13 @@ export const MANAGER_PROTOCOL_VERSION = 8
44
53
  export const MANAGER_PROTOCOL_CAPABILITIES: readonly string[] = [
45
54
  'setBroadcastEnabled',
46
55
  'createTabWorktreeId',
56
+ 'tabMetadata',
57
+ 'workerMetadata',
47
58
  ]
48
59
 
60
+ export const MANAGER_CAPABILITY_TAB_METADATA = 'tabMetadata'
61
+ export const MANAGER_CAPABILITY_WORKER_METADATA = 'workerMetadata'
62
+
49
63
  /**
50
64
  * Capability name a daemon must observe on the TM's helloResult before
51
65
  * sending `setBroadcastEnabled`.
@@ -119,9 +133,21 @@ export type ManagerRequest =
119
133
  * `worktreeId = undefined`.
120
134
  */
121
135
  worktreeId?: string
136
+ workerName?: string
137
+ autoRenameStatus?: 'eligible' | 'attempted'
122
138
  }
123
139
  }
124
140
  | { id: string; type: 'write'; payload: { sessionId: string; tabId: string; data: string } }
141
+ | {
142
+ id: string
143
+ type: 'updateTabMetadata'
144
+ payload: {
145
+ sessionId: string
146
+ tabId: string
147
+ title?: string
148
+ autoRenameStatus?: 'eligible' | 'attempted'
149
+ }
150
+ }
125
151
  | {
126
152
  id: string
127
153
  type: 'resizeClient'
@@ -364,12 +390,36 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
364
390
  value.payload.worktreeId === undefined || isString(value.payload.worktreeId),
365
391
  'createTab.worktreeId must be a string when present'
366
392
  )
393
+ assert(
394
+ value.payload.workerName === undefined || isString(value.payload.workerName),
395
+ 'createTab.workerName must be a string when present'
396
+ )
397
+ assert(
398
+ value.payload.autoRenameStatus === undefined ||
399
+ value.payload.autoRenameStatus === 'eligible' ||
400
+ value.payload.autoRenameStatus === 'attempted',
401
+ 'createTab.autoRenameStatus is invalid'
402
+ )
367
403
  return value as ManagerRequest
368
404
  case 'write':
369
405
  assert(isString(value.payload.sessionId), 'write.sessionId must be a string')
370
406
  assert(isString(value.payload.tabId), 'write.tabId must be a string')
371
407
  assert(isString(value.payload.data), 'write.data must be a string')
372
408
  return value as ManagerRequest
409
+ case 'updateTabMetadata':
410
+ assert(isString(value.payload.sessionId), 'updateTabMetadata.sessionId must be a string')
411
+ assert(isString(value.payload.tabId), 'updateTabMetadata.tabId must be a string')
412
+ assert(
413
+ value.payload.title === undefined || isString(value.payload.title),
414
+ 'updateTabMetadata.title must be a string when present'
415
+ )
416
+ assert(
417
+ value.payload.autoRenameStatus === undefined ||
418
+ value.payload.autoRenameStatus === 'eligible' ||
419
+ value.payload.autoRenameStatus === 'attempted',
420
+ 'updateTabMetadata.autoRenameStatus is invalid'
421
+ )
422
+ return value as ManagerRequest
373
423
  case 'resizeClient':
374
424
  assert(isString(value.payload.sessionId), 'resizeClient.sessionId must be a string')
375
425
  assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
@@ -37,8 +37,17 @@ import { isWorkspaceSnapshotV1, isWorktreeRecord } from '../state/validation'
37
37
  // plus best-effort parsed options), and an additive `lastLine` field on
38
38
  // `TabSessionSummary`. Gated behind `turnLifecycle`, `questionEvents`, and
39
39
  // `listTabsLastLine` respectively; MIN stays at 10.
40
- export const IPC_PROTOCOL_MIN_VERSION = 10
41
- export const IPC_PROTOCOL_VERSION = 13
40
+ //
41
+ // v14: additive tab metadata synchronization for manual and automatic
42
+ // renames. The new request/event are capability-gated; MIN stays at 10.
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
42
51
 
43
52
  /**
44
53
  * Capability advertised by a daemon that knows how to drain + handoff its
@@ -103,6 +112,7 @@ export const IPC_CAPABILITY_WORKSPACE_LIFECYCLE = 'workspaceLifecycle'
103
112
  * fanout as workspaceLifecycle.
104
113
  */
105
114
  export const IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS = 'worktreeLifecycleEvents'
115
+ export const IPC_CAPABILITY_WORKER_METADATA = 'workerMetadata'
106
116
 
107
117
  /**
108
118
  * v12 — capability marker for `aimux tab tail`. Functionally it's just a
@@ -137,6 +147,9 @@ export const IPC_CAPABILITY_QUESTION_EVENTS = 'questionEvents'
137
147
  */
138
148
  export const IPC_CAPABILITY_LIST_TABS_LAST_LINE = 'listTabsLastLine'
139
149
 
150
+ /** Additive tab-title and auto-rename metadata synchronization. */
151
+ export const IPC_CAPABILITY_TAB_METADATA = 'tabMetadata'
152
+
140
153
  /**
141
154
  * Capabilities advertised by *this* process in its `helloResult`. Additive
142
155
  * features should be introduced as new capability strings here rather than
@@ -160,6 +173,8 @@ export const IPC_PROTOCOL_CAPABILITIES: readonly string[] = [
160
173
  IPC_CAPABILITY_TURN_LIFECYCLE,
161
174
  IPC_CAPABILITY_QUESTION_EVENTS,
162
175
  IPC_CAPABILITY_LIST_TABS_LAST_LINE,
176
+ IPC_CAPABILITY_TAB_METADATA,
177
+ IPC_CAPABILITY_WORKER_METADATA,
163
178
  ]
164
179
 
165
180
  export interface ProtocolHelloRequest {
@@ -168,6 +183,8 @@ export interface ProtocolHelloRequest {
168
183
  }
169
184
 
170
185
  export interface ProtocolHelloResult {
186
+ /** aimux package version; absent on older daemons. */
187
+ appVersion?: string
171
188
  minVersion: number
172
189
  maxVersion: number
173
190
  processVersion: string
@@ -177,6 +194,8 @@ export interface ProtocolHelloResult {
177
194
  * not yet advertise capabilities are normalised to `[]` at parse time.
178
195
  */
179
196
  capabilities: string[]
197
+ /** Capabilities negotiated with the terminal-manager; absent on older daemons. */
198
+ managerCapabilities?: string[]
180
199
  /**
181
200
  * Manager-protocol version currently negotiated between the daemon and
182
201
  * its terminal-manager. `undefined` when the daemon has not yet connected
@@ -220,6 +239,7 @@ export interface TabSessionSummary {
220
239
  activity?: TabActivity
221
240
  command: string
222
241
  worktreeId?: string
242
+ workerName?: string
223
243
  /**
224
244
  * v13 / capability `listTabsLastLine`. The tab's last non-blank rendered
225
245
  * line, trimmed. Present only when the daemon advertises the capability;
@@ -269,9 +289,14 @@ export type ClientRequest =
269
289
  * only forwards it to the TM when its own capability is in play.
270
290
  */
271
291
  worktreeId?: string
292
+ /** v15 / capability `workerMetadata`: stable workspace-scoped handle. */
293
+ workerName?: string
294
+ /** True only when the creator did not provide an explicit title. */
295
+ autoRenameCandidate?: boolean
272
296
  }
273
297
  }
274
298
  | { id: string; type: 'write'; payload: { tabId: string; data: string } }
299
+ | { id: string; type: 'renameTab'; payload: { tabId: string; title: string } }
275
300
  | {
276
301
  id: string
277
302
  type: 'resizeClient'
@@ -395,6 +420,15 @@ export type ServerEvent =
395
420
  type: 'tabAdded'
396
421
  payload: { sessionId: string; tab: TabSession }
397
422
  }
423
+ | {
424
+ type: 'tabMetadataUpdated'
425
+ payload: {
426
+ sessionId: string
427
+ tabId: string
428
+ title?: string
429
+ autoRenameStatus?: 'eligible' | 'attempted'
430
+ }
431
+ }
398
432
  // v12 / capability `workspaceLifecycle`. Broadcast to every socket when a
399
433
  // CLI issues `createWorkspace` while a UI is attached — the UI runs its
400
434
  // create-session handler so the live workspace snapshot is preserved.
@@ -547,12 +581,14 @@ function isProtocolHelloResult(value: unknown): value is ProtocolHelloResult {
547
581
  isFiniteNumber(value.maxVersion) &&
548
582
  isFiniteNumber(value.selectedVersion) &&
549
583
  isString(value.processVersion) &&
584
+ (value.appVersion === undefined || isString(value.appVersion)) &&
550
585
  // Wire-back-compat: peers that predate the capabilities field omit it
551
586
  // entirely. parseServerMessage normalises that to `[]` before the cast
552
587
  // so the typed shape stays non-optional.
553
588
  (value.capabilities === undefined || isStringArray(value.capabilities)) &&
554
589
  // Additive: peers that predate managerSelectedVersion omit it.
555
- (value.managerSelectedVersion === undefined || isFiniteNumber(value.managerSelectedVersion))
590
+ (value.managerSelectedVersion === undefined || isFiniteNumber(value.managerSelectedVersion)) &&
591
+ (value.managerCapabilities === undefined || isStringArray(value.managerCapabilities))
556
592
  )
557
593
  }
558
594
 
@@ -573,6 +609,7 @@ function isTabSessionSummary(value: unknown): value is TabSessionSummary {
573
609
  value.activity === 'idle') &&
574
610
  isString(value.command) &&
575
611
  (value.worktreeId === undefined || isString(value.worktreeId)) &&
612
+ (value.workerName === undefined || isString(value.workerName)) &&
576
613
  (value.lastLine === undefined || isString(value.lastLine))
577
614
  )
578
615
  }
@@ -608,6 +645,10 @@ function isTabSession(value: unknown): value is TabSession {
608
645
  isString(value.buffer) &&
609
646
  isTerminalModeState(value.terminalModes) &&
610
647
  isString(value.command) &&
648
+ (value.workerName === undefined || isString(value.workerName)) &&
649
+ (value.autoRenameStatus === undefined ||
650
+ value.autoRenameStatus === 'eligible' ||
651
+ value.autoRenameStatus === 'attempted') &&
611
652
  (value.viewport === undefined || isTerminalSnapshot(value.viewport)) &&
612
653
  (value.errorMessage === undefined || isString(value.errorMessage)) &&
613
654
  (value.exitCode === undefined || isFiniteNumber(value.exitCode)) &&
@@ -714,11 +755,27 @@ export function parseClientRequest(value: unknown): ClientRequest {
714
755
  value.payload.worktreeId === undefined || isString(value.payload.worktreeId),
715
756
  'createTab.worktreeId must be a string when present'
716
757
  )
758
+ assert(
759
+ value.payload.workerName === undefined || isString(value.payload.workerName),
760
+ 'createTab.workerName must be a string when present'
761
+ )
762
+ assert(
763
+ value.payload.autoRenameCandidate === undefined ||
764
+ typeof value.payload.autoRenameCandidate === 'boolean',
765
+ 'createTab.autoRenameCandidate must be a boolean when present'
766
+ )
717
767
  return value as ClientRequest
718
768
  case 'write':
719
769
  assert(isString(value.payload.tabId), 'write.tabId must be a string')
720
770
  assert(isString(value.payload.data), 'write.data must be a string')
721
771
  return value as ClientRequest
772
+ case 'renameTab':
773
+ assert(isString(value.payload.tabId), 'renameTab.tabId must be a string')
774
+ assert(
775
+ isString(value.payload.title) && value.payload.title.trim().length > 0,
776
+ 'renameTab.title must be a non-empty string'
777
+ )
778
+ return value as ClientRequest
722
779
  case 'resizeClient':
723
780
  assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
724
781
  assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
@@ -872,6 +929,20 @@ export function parseServerMessage(value: unknown): ServerResponse | ServerEvent
872
929
  assert(isString(value.payload.sessionId), 'tabAdded.sessionId must be a string')
873
930
  assert(isTabSession(value.payload.tab), 'tabAdded.tab is invalid')
874
931
  return value as ServerEvent
932
+ case 'tabMetadataUpdated':
933
+ assert(isString(value.payload.sessionId), 'tabMetadataUpdated.sessionId must be a string')
934
+ assert(isString(value.payload.tabId), 'tabMetadataUpdated.tabId must be a string')
935
+ assert(
936
+ value.payload.title === undefined || isString(value.payload.title),
937
+ 'tabMetadataUpdated.title must be a string when present'
938
+ )
939
+ assert(
940
+ value.payload.autoRenameStatus === undefined ||
941
+ value.payload.autoRenameStatus === 'eligible' ||
942
+ value.payload.autoRenameStatus === 'attempted',
943
+ 'tabMetadataUpdated.autoRenameStatus is invalid'
944
+ )
945
+ return value as ServerEvent
875
946
  case 'sessionStatus':
876
947
  assert(isString(value.payload.sessionId), 'sessionStatus.sessionId must be a string')
877
948
  assert(isSessionStatus(value.payload.status), 'sessionStatus.status is invalid')
@@ -1,5 +1,6 @@
1
1
  import { connect } from 'node:net'
2
2
 
3
+ import type { AutoRenameConfigSnapshot } from '../auto-rename/coordinator'
3
4
  import type { SessionBackend } from './types'
4
5
 
5
6
  import { negotiateDaemonReexec, waitForSocketRemoval } from '../daemon/reexec-client'
@@ -273,10 +274,11 @@ async function stopTerminalManager(): Promise<void> {
273
274
 
274
275
  export async function createSessionBackend(opts?: {
275
276
  onBreakingUpdateRequired?: () => Promise<void>
277
+ autoRenameConfig?: AutoRenameConfigSnapshot
276
278
  }): Promise<SessionBackend> {
277
279
  if (process.env.AIMUX_LOCAL_BACKEND === '1') {
278
280
  logDebug('backend.create.localExplicit')
279
- return new LocalSessionBackend()
281
+ return new LocalSessionBackend(opts?.autoRenameConfig)
280
282
  }
281
283
 
282
284
  const socketPath = getIpcDaemonSocketPath()
@@ -8,6 +8,11 @@ import type {
8
8
  } from '../state/types'
9
9
  import type { ResizeOptions, SessionBackend, SessionBackendEvents } from './types'
10
10
 
11
+ import {
12
+ type AutoRenameConfigSnapshot,
13
+ AutoRenameCoordinator,
14
+ initialAutoRenameStatus,
15
+ } from '../auto-rename/coordinator'
11
16
  import { SessionManager } from '../daemon/session-manager'
12
17
  import { logDebug } from '../debug/input-log'
13
18
  import { runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
@@ -40,9 +45,26 @@ export class LocalSessionBackend
40
45
  string,
41
46
  { viewport: TerminalSnapshot; terminalModes: TerminalModeState }
42
47
  >()
48
+ private readonly autoRename: AutoRenameCoordinator
49
+ private readonly autoRenameConfig: AutoRenameConfigSnapshot
43
50
 
44
- constructor() {
51
+ constructor(
52
+ autoRenameConfig: AutoRenameConfigSnapshot = { enabled: false, models: {}, timeoutMs: 15_000 }
53
+ ) {
45
54
  super()
55
+ this.autoRenameConfig = autoRenameConfig
56
+ this.autoRename = new AutoRenameCoordinator({
57
+ config: autoRenameConfig,
58
+ getTab: (tabId) => this.findTab(tabId)?.tab,
59
+ updateTab: (tabId, patch) => {
60
+ const found = this.findTab(tabId)
61
+ if (!found) return
62
+ this.sessionManager.updateTabMetadata(found.sessionId, tabId, patch)
63
+ if (found.sessionId === this.currentSessionId) {
64
+ this.emit('tabMetadataUpdated', found.sessionId, tabId, patch)
65
+ }
66
+ },
67
+ })
46
68
  this.sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
47
69
  if (sessionId !== this.currentSessionId) return
48
70
  if (this.paneReady.get(tabId) === false) {
@@ -106,6 +128,7 @@ export class LocalSessionBackend
106
128
  )
107
129
  for (const tab of attachResult.tabs) {
108
130
  this.gatePaneRender(tab.id)
131
+ this.autoRename.register(tab)
109
132
  }
110
133
  // Run a synchronous classification pass so every tab's activity and the
111
134
  // session-status snapshot are available to embed in the reply — mirrors
@@ -133,6 +156,7 @@ export class LocalSessionBackend
133
156
  rows: number
134
157
  cwd?: string
135
158
  worktreeId?: string
159
+ autoRenameCandidate?: boolean
136
160
  }): void {
137
161
  if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
138
162
  logDebug('backend.local.skipCreateWithoutSession', { tabId: options.tabId })
@@ -145,7 +169,14 @@ export class LocalSessionBackend
145
169
  worktreeId: options.worktreeId ?? null,
146
170
  })
147
171
  this.gatePaneRender(options.tabId)
148
- this.sessionManager.createTab(this.currentSessionId, options)
172
+ const autoRenameStatus = initialAutoRenameStatus(
173
+ this.getAutoRenameConfig(),
174
+ options.assistant,
175
+ options.autoRenameCandidate === true
176
+ )
177
+ this.sessionManager.createTab(this.currentSessionId, { ...options, autoRenameStatus })
178
+ const tab = this.findTab(options.tabId)?.tab
179
+ if (tab) this.autoRename.register(tab)
149
180
  }
150
181
 
151
182
  /** Suppress render emission for this tab until the frontend acknowledges its
@@ -175,9 +206,21 @@ export class LocalSessionBackend
175
206
  sessionId: this.currentSessionId,
176
207
  tabId,
177
208
  })
209
+ this.autoRename.observeWrite(tabId, input)
178
210
  this.sessionManager.write(this.currentSessionId, tabId, input)
179
211
  }
180
212
 
213
+ renameTab(tabId: string, title: string): void {
214
+ const found = this.findTab(tabId)
215
+ if (!found) return
216
+ this.autoRename.manualRename(tabId)
217
+ const patch = { autoRenameStatus: 'attempted' as const, title }
218
+ this.sessionManager.updateTabMetadata(found.sessionId, tabId, patch)
219
+ if (found.sessionId === this.currentSessionId) {
220
+ this.emit('tabMetadataUpdated', found.sessionId, tabId, patch)
221
+ }
222
+ }
223
+
181
224
  scrollViewport(tabId: string, deltaLines: number): void {
182
225
  if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
183
226
  this.sessionManager.scroll(this.currentSessionId, tabId, deltaLines)
@@ -212,6 +255,7 @@ export class LocalSessionBackend
212
255
  logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
213
256
  this.paneReady.delete(tabId)
214
257
  this.pendingRender.delete(tabId)
258
+ this.autoRename.unregister(tabId)
215
259
  this.sessionManager.closeTab(this.currentSessionId, tabId)
216
260
  }
217
261
 
@@ -220,6 +264,9 @@ export class LocalSessionBackend
220
264
  logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
221
265
  this.paneReady.clear()
222
266
  this.pendingRender.clear()
267
+ for (const tab of this.sessionManager.listTabs(this.currentSessionId)) {
268
+ this.autoRename.unregister(tab.id)
269
+ }
223
270
  this.sessionManager.disposeSession(this.currentSessionId)
224
271
  }
225
272
 
@@ -235,4 +282,18 @@ export class LocalSessionBackend
235
282
  announceWorkspaceSwitched(_sessionId: string): void {
236
283
  // No daemon on the local backend, so there's no CLI to notify.
237
284
  }
285
+
286
+ private findTab(
287
+ tabId: string
288
+ ): { sessionId: string; tab: ReturnType<SessionManager['listTabs']>[number] } | null {
289
+ for (const sessionId of this.sessionManager.listSessionIds()) {
290
+ const tab = this.sessionManager.listTabs(sessionId).find((entry) => entry.id === tabId)
291
+ if (tab) return { sessionId, tab }
292
+ }
293
+ return null
294
+ }
295
+
296
+ private getAutoRenameConfig(): AutoRenameConfigSnapshot {
297
+ return this.autoRenameConfig
298
+ }
238
299
  }
@@ -10,6 +10,7 @@ import {
10
10
  type AttachResult,
11
11
  type ClientRequest,
12
12
  encodeMessage,
13
+ IPC_CAPABILITY_TAB_METADATA,
13
14
  IPC_PROTOCOL_MIN_VERSION,
14
15
  IPC_PROTOCOL_VERSION,
15
16
  MessageDecoder,
@@ -211,6 +212,12 @@ export class RemoteSessionBackend
211
212
  })
212
213
  this.emit('tabAdded', message.payload.sessionId, message.payload.tab)
213
214
  break
215
+ case 'tabMetadataUpdated':
216
+ this.emit('tabMetadataUpdated', message.payload.sessionId, message.payload.tabId, {
217
+ autoRenameStatus: message.payload.autoRenameStatus,
218
+ title: message.payload.title,
219
+ })
220
+ break
214
221
  case 'workspaceCreateRequested':
215
222
  this.emit(
216
223
  'workspaceCreateRequested',
@@ -411,6 +418,7 @@ export class RemoteSessionBackend
411
418
  rows: number
412
419
  cwd?: string
413
420
  worktreeId?: string
421
+ autoRenameCandidate?: boolean
414
422
  }): void {
415
423
  if (!this.attached) {
416
424
  logDebug('backend.remote.skipCreateBeforeAttach', { tabId: options.tabId })
@@ -436,6 +444,15 @@ export class RemoteSessionBackend
436
444
  )
437
445
  }
438
446
 
447
+ renameTab(tabId: string, title: string): void {
448
+ if (!this.attached || !this.daemonAdvertises(IPC_CAPABILITY_TAB_METADATA)) return
449
+ this.dispatchCommand(
450
+ { id: crypto.randomUUID(), payload: { tabId, title }, type: 'renameTab' },
451
+ 'renameTab',
452
+ tabId
453
+ )
454
+ }
455
+
439
456
  scrollViewport(tabId: string, deltaLines: number): void {
440
457
  if (!this.attached) {
441
458
  return
@@ -23,6 +23,11 @@ export interface SessionBackendEvents {
23
23
  * `tabRender` event lands.
24
24
  */
25
25
  tabAdded: [sessionId: string, tab: TabSession]
26
+ tabMetadataUpdated: [
27
+ sessionId: string,
28
+ tabId: string,
29
+ patch: { title?: string; autoRenameStatus?: 'eligible' | 'attempted' },
30
+ ]
26
31
  /**
27
32
  * v12 workspace-lifecycle events. Fired when a CLI issued
28
33
  * `createWorkspace` / `switchWorkspace` / `closeWorkspace` and the daemon
@@ -86,8 +91,10 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
86
91
  * registry surfaces the right grouping in `listTabs` for headless
87
92
  * consumers (CLI control plane). */
88
93
  worktreeId?: string
94
+ autoRenameCandidate?: boolean
89
95
  }): void
90
96
  write(tabId: string, input: string): void
97
+ renameTab(tabId: string, title: string): void
91
98
  scrollViewport(tabId: string, deltaLines: number): void
92
99
  scrollViewportToBottom(tabId: string): void
93
100
  setActiveTab(tabId: string | null): void
@@ -543,7 +543,20 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
543
543
  case 'rename-tab':
544
544
  return {
545
545
  ...state,
546
- tabs: updateTab(state.tabs, action.tabId, (tab) => ({ ...tab, title: action.title })),
546
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
547
+ ...tab,
548
+ autoRenameStatus: action.autoRenameStatus ?? tab.autoRenameStatus,
549
+ title: action.title,
550
+ })),
551
+ }
552
+ case 'update-tab-metadata':
553
+ return {
554
+ ...state,
555
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
556
+ ...tab,
557
+ autoRenameStatus: action.autoRenameStatus ?? tab.autoRenameStatus,
558
+ title: action.title ?? tab.title,
559
+ })),
547
560
  }
548
561
  case 'split-pane': {
549
562
  if (!(state.activeTabId != null && state.activeTabId !== '')) {