@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
@@ -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,8 +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'
7
+ import { AutoRenameCoordinator, initialAutoRenameStatus } from '../auto-rename/coordinator'
8
+ import { loadUserConfig } from '../config/loader'
6
9
  import { logDebug } from '../debug/input-log'
7
10
  import { type ClaudeHookServer, startClaudeHookServer } from '../integrations/claude-hook-server'
11
+ import { MANAGER_CAPABILITY_WORKER_METADATA } from '../ipc/manager-protocol'
8
12
  import {
9
13
  type ClientRequest,
10
14
  encodeMessage,
@@ -70,6 +74,8 @@ export interface DaemonTabEntry {
70
74
  title?: string
71
75
  status?: TabStatus
72
76
  worktreeId?: string
77
+ workerName?: string
78
+ autoRenameStatus?: 'eligible' | 'attempted'
73
79
  }
74
80
 
75
81
  /**
@@ -91,24 +97,46 @@ export function mergeTabRegistryEntry(
91
97
  command: string,
92
98
  initialViewport: TerminalSnapshot | undefined,
93
99
  allocateSeq: () => number,
94
- metadata?: { title?: string; status?: TabStatus; worktreeId?: string }
100
+ metadata?: {
101
+ title?: string
102
+ status?: TabStatus
103
+ worktreeId?: string
104
+ workerName?: string
105
+ autoRenameStatus?: 'eligible' | 'attempted'
106
+ }
95
107
  ): DaemonTabEntry {
96
108
  const existing = registry.get(tabId)
97
109
  const viewport = existing?.viewport ?? initialViewport
110
+ const preserveAttemptedMetadata = existing?.autoRenameStatus === 'attempted'
98
111
  const entry: DaemonTabEntry = {
99
112
  assistant,
113
+ autoRenameStatus: preserveAttemptedMetadata
114
+ ? 'attempted'
115
+ : (metadata?.autoRenameStatus ?? existing?.autoRenameStatus),
100
116
  command,
101
117
  sessionId,
102
118
  status: metadata?.status ?? existing?.status,
103
- title: metadata?.title ?? existing?.title,
119
+ title: preserveAttemptedMetadata ? existing.title : (metadata?.title ?? existing?.title),
104
120
  viewport,
105
121
  viewportSeq: existing?.viewportSeq ?? (viewport ? allocateSeq() : 0),
122
+ workerName: metadata?.workerName ?? existing?.workerName,
106
123
  worktreeId: metadata?.worktreeId ?? existing?.worktreeId,
107
124
  }
108
125
  registry.set(tabId, entry)
109
126
  return entry
110
127
  }
111
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
+
112
140
  /**
113
141
  * Turn-complete settle window for the status loop, overridable via
114
142
  * `AIMUX_TURN_SETTLE_MS` for slow/loaded machines. Falls back to the loop's
@@ -193,6 +221,7 @@ async function ensureTerminalManagerReady(manager: TerminalManagerClient): Promi
193
221
  }
194
222
 
195
223
  export async function runDaemon(): Promise<void> {
224
+ const resolvedConfig = await loadUserConfig()
196
225
  const socketPath = getIpcDaemonSocketPath()
197
226
  // A handoff file means we were spawned to take over from a predecessor
198
227
  // daemon that already drained and renamed its socket away. Consume the
@@ -247,8 +276,14 @@ export async function runDaemon(): Promise<void> {
247
276
  assistant: AssistantId,
248
277
  command: string,
249
278
  initialViewport?: TerminalSnapshot,
250
- metadata?: { title?: string; status?: TabStatus; worktreeId?: string }
251
- ): void => {
279
+ metadata?: {
280
+ title?: string
281
+ status?: TabStatus
282
+ worktreeId?: string
283
+ workerName?: string
284
+ autoRenameStatus?: 'eligible' | 'attempted'
285
+ }
286
+ ): DaemonTabEntry => {
252
287
  const before = tabRegistry.get(tabId)
253
288
  const entry = mergeTabRegistryEntry(
254
289
  tabRegistry,
@@ -270,6 +305,7 @@ export async function runDaemon(): Promise<void> {
270
305
  sessionId,
271
306
  tabId,
272
307
  })
308
+ return entry
273
309
  }
274
310
 
275
311
  const broadcastAll = (event: ServerEvent): void => {
@@ -315,6 +351,45 @@ export async function runDaemon(): Promise<void> {
315
351
  }
316
352
  }
317
353
 
354
+ const applyTabMetadata = (
355
+ tabId: string,
356
+ patch: { title?: string; autoRenameStatus?: 'eligible' | 'attempted' }
357
+ ): void => {
358
+ const entry = tabRegistry.get(tabId)
359
+ if (!entry) return
360
+ if (patch.title !== undefined) entry.title = patch.title
361
+ if (patch.autoRenameStatus !== undefined) entry.autoRenameStatus = patch.autoRenameStatus
362
+ void (async () => {
363
+ try {
364
+ await manager.updateTabMetadata(entry.sessionId, tabId, patch)
365
+ } catch (error) {
366
+ logDebug('daemon.tabMetadata.managerFailed', {
367
+ error: error instanceof Error ? error.message : String(error),
368
+ tabId,
369
+ })
370
+ }
371
+ })()
372
+ broadcastForSessionVersioned(entry.sessionId, 14, {
373
+ payload: { ...patch, sessionId: entry.sessionId, tabId },
374
+ type: 'tabMetadataUpdated',
375
+ })
376
+ }
377
+
378
+ const autoRename = new AutoRenameCoordinator({
379
+ config: resolvedConfig.autoRename,
380
+ getTab: (tabId) => {
381
+ const entry = tabRegistry.get(tabId)
382
+ if (!entry) return
383
+ return {
384
+ assistant: entry.assistant,
385
+ autoRenameStatus: entry.autoRenameStatus,
386
+ id: tabId,
387
+ title: entry.title ?? '',
388
+ }
389
+ },
390
+ updateTab: applyTabMetadata,
391
+ })
392
+
318
393
  // Count UI attachers so workspace/worktree handlers can decide whether to
319
394
  // relay via broadcast (UI attached → its reducer owns the write) or mutate
320
395
  // the catalog directly. A "UI attacher" here is any non-thin attach — thin
@@ -352,6 +427,7 @@ export async function runDaemon(): Promise<void> {
352
427
  manager.on('exit', (sessionId, tabId, exitCode) => {
353
428
  logDebug('daemon.manager.exit', { exitCode, sessionId, tabId })
354
429
  tabRegistry.delete(tabId)
430
+ autoRename.unregister(tabId)
355
431
  if (sessionActiveTabIds.get(sessionId) === tabId) {
356
432
  sessionActiveTabIds.set(sessionId, null)
357
433
  }
@@ -530,7 +606,9 @@ export async function runDaemon(): Promise<void> {
530
606
  send(socket, {
531
607
  id: message.id,
532
608
  payload: {
609
+ appVersion: APP_VERSION,
533
610
  capabilities: [...IPC_PROTOCOL_CAPABILITIES],
611
+ managerCapabilities: [...manager.getCapabilities()],
534
612
  ...(managerSelectedVersion !== null && { managerSelectedVersion }),
535
613
  maxVersion: IPC_PROTOCOL_VERSION,
536
614
  minVersion: IPC_PROTOCOL_MIN_VERSION,
@@ -611,14 +689,26 @@ export async function runDaemon(): Promise<void> {
611
689
  })
612
690
  sessionActiveTabIds.set(message.payload.sessionId, attachResult.activeTabId)
613
691
  for (const tab of attachResult.tabs) {
614
- rememberTab(
692
+ const remembered = rememberTab(
615
693
  message.payload.sessionId,
616
694
  tab.id,
617
695
  tab.assistant,
618
696
  tab.command,
619
697
  tab.viewport,
620
- { status: tab.status, title: tab.title, worktreeId: tab.worktreeId }
698
+ {
699
+ autoRenameStatus: tab.autoRenameStatus,
700
+ status: tab.status,
701
+ title: tab.title,
702
+ workerName: tab.workerName,
703
+ worktreeId: tab.worktreeId,
704
+ }
621
705
  )
706
+ autoRename.register({
707
+ assistant: remembered.assistant,
708
+ autoRenameStatus: remembered.autoRenameStatus,
709
+ id: tab.id,
710
+ title: remembered.title ?? tab.title,
711
+ })
622
712
  }
623
713
  // Classify synchronously BEFORE sending attachResult so
624
714
  // each tab's activity and the full session-status snapshot
@@ -637,10 +727,16 @@ export async function runDaemon(): Promise<void> {
637
727
  })),
638
728
  })
639
729
  statusLoop.classifyNow(message.payload.sessionId, tabsForLoop)
640
- const tabsWithActivity = attachResult.tabs.map((tab) => ({
641
- ...tab,
642
- activity: statusLoop.getTabStatus(tab.id) ?? tab.activity,
643
- }))
730
+ const tabsWithActivity = attachResult.tabs.map((tab) => {
731
+ const metadata = tabRegistry.get(tab.id)
732
+ return {
733
+ ...tab,
734
+ activity: statusLoop.getTabStatus(tab.id) ?? tab.activity,
735
+ autoRenameStatus: metadata?.autoRenameStatus ?? tab.autoRenameStatus,
736
+ title: metadata?.title ?? tab.title,
737
+ workerName: metadata?.workerName ?? tab.workerName,
738
+ }
739
+ })
644
740
  const initialSessionStatuses = statusLoop.snapshotSessions()
645
741
  logDebug('daemon.attach.replay', {
646
742
  sessionId: message.payload.sessionId,
@@ -667,6 +763,23 @@ export async function runDaemon(): Promise<void> {
667
763
  case 'createTab': {
668
764
  const sessionId = requireSession(socket, attachedSessions)
669
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
+ }
670
783
  // Capability `createTabSizeFallback`: cols/rows = 0 means
671
784
  // "use the session's last attached dimensions". Headless
672
785
  // CLIs don't have a viewport of their own, so this lets
@@ -699,6 +812,11 @@ export async function runDaemon(): Promise<void> {
699
812
  message.payload.command,
700
813
  ...(message.payload.args ?? []),
701
814
  ].join(' ')
815
+ const autoRenameStatus = initialAutoRenameStatus(
816
+ resolvedConfig.autoRename,
817
+ message.payload.assistant,
818
+ message.payload.autoRenameCandidate === true
819
+ )
702
820
  rememberTab(
703
821
  sessionId,
704
822
  message.payload.tabId,
@@ -706,8 +824,10 @@ export async function runDaemon(): Promise<void> {
706
824
  fullCommand,
707
825
  undefined,
708
826
  {
827
+ autoRenameStatus,
709
828
  status: 'starting',
710
829
  title: message.payload.title,
830
+ workerName: message.payload.workerName,
711
831
  worktreeId: message.payload.worktreeId,
712
832
  }
713
833
  )
@@ -719,7 +839,25 @@ export async function runDaemon(): Promise<void> {
719
839
  // even on PTYs that outlive this daemon process.
720
840
  const env: Record<string, string> = { AIMUX_PANE_ID: message.payload.tabId }
721
841
  if (hookServer) env.AIMUX_HOOK_URL_FILE = hookUrlFilePath
722
- await manager.createTab({ ...message.payload, cols, env, rows, sessionId })
842
+ const { autoRenameCandidate: _autoRenameCandidate, ...managerTabPayload } =
843
+ message.payload
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
+ }
723
861
  sendOk(socket, message.id)
724
862
  // Fan a `tabAdded` event only to peers that negotiated at
725
863
  // least v11 — older parsers throw on unknown message types
@@ -728,18 +866,21 @@ export async function runDaemon(): Promise<void> {
728
866
  const synthesizedTab: TabSession = {
729
867
  activity: 'idle',
730
868
  assistant: message.payload.assistant,
869
+ autoRenameStatus,
731
870
  buffer: '',
732
871
  command: fullCommand,
733
872
  id: message.payload.tabId,
734
873
  status: 'starting',
735
874
  terminalModes: createDefaultTerminalModes(),
736
875
  title: message.payload.title,
876
+ workerName: message.payload.workerName,
737
877
  worktreeId: message.payload.worktreeId,
738
878
  }
739
879
  broadcastForSessionVersioned(sessionId, 11, {
740
880
  payload: { sessionId, tab: synthesizedTab },
741
881
  type: 'tabAdded',
742
882
  })
883
+ autoRename.register(synthesizedTab)
743
884
  logDebug('daemon.request.createTab.success', {
744
885
  sessionId,
745
886
  tabId: message.payload.tabId,
@@ -750,6 +891,21 @@ export async function runDaemon(): Promise<void> {
750
891
  const sessionId = requireSession(socket, attachedSessions)
751
892
  requireNegotiatedVersion(socket, negotiatedVersions)
752
893
  await manager.write(sessionId, message.payload.tabId, message.payload.data)
894
+ autoRename.observeWrite(message.payload.tabId, message.payload.data)
895
+ sendOk(socket, message.id)
896
+ break
897
+ }
898
+ case 'renameTab': {
899
+ const sessionId = requireSession(socket, attachedSessions)
900
+ requireNegotiatedVersion(socket, negotiatedVersions)
901
+ if (tabRegistry.get(message.payload.tabId)?.sessionId !== sessionId) {
902
+ throw new Error(`Tab not found in attached session: ${message.payload.tabId}`)
903
+ }
904
+ autoRename.manualRename(message.payload.tabId)
905
+ applyTabMetadata(message.payload.tabId, {
906
+ autoRenameStatus: 'attempted',
907
+ title: message.payload.title.trim(),
908
+ })
753
909
  sendOk(socket, message.id)
754
910
  break
755
911
  }
@@ -806,6 +962,7 @@ export async function runDaemon(): Promise<void> {
806
962
  const sessionId = requireSession(socket, attachedSessions)
807
963
  requireNegotiatedVersion(socket, negotiatedVersions)
808
964
  tabRegistry.delete(message.payload.tabId)
965
+ autoRename.unregister(message.payload.tabId)
809
966
  if (sessionActiveTabIds.get(sessionId) === message.payload.tabId) {
810
967
  sessionActiveTabIds.set(sessionId, null)
811
968
  }
@@ -832,6 +989,7 @@ export async function runDaemon(): Promise<void> {
832
989
  lastLine: lastNonBlankLine(entry.viewport),
833
990
  status: entry.status ?? 'running',
834
991
  title: entry.title ?? '',
992
+ workerName: entry.workerName,
835
993
  worktreeId: entry.worktreeId,
836
994
  })
837
995
  }
@@ -851,7 +1009,10 @@ export async function runDaemon(): Promise<void> {
851
1009
  requireNegotiatedVersion(socket, negotiatedVersions)
852
1010
  if (sessionId != null && sessionId !== '') {
853
1011
  for (const [tabId, entry] of tabRegistry) {
854
- if (entry.sessionId === sessionId) tabRegistry.delete(tabId)
1012
+ if (entry.sessionId === sessionId) {
1013
+ autoRename.unregister(tabId)
1014
+ tabRegistry.delete(tabId)
1015
+ }
855
1016
  }
856
1017
  sessionActiveTabIds.delete(sessionId)
857
1018
  sessionDimensions.delete(sessionId)
@@ -67,6 +67,14 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
67
67
  this.getOrCreateRegistry(sessionId).write(tabId, data)
68
68
  }
69
69
 
70
+ updateTabMetadata(
71
+ sessionId: string,
72
+ tabId: string,
73
+ patch: { title?: string; autoRenameStatus?: 'eligible' | 'attempted' }
74
+ ): void {
75
+ this.getOrCreateRegistry(sessionId).updateTabMetadata(tabId, patch)
76
+ }
77
+
70
78
  resize(sessionId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
71
79
  this.getOrCreateRegistry(sessionId).resizeAll(cols, rows, options)
72
80
  }
@@ -89,8 +89,12 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
89
89
  for (const persisted of snapshot.tabs) {
90
90
  const existing = this.tabs.get(persisted.id)
91
91
  if (existing) {
92
- existing.title = persisted.title
92
+ if (existing.autoRenameStatus !== 'attempted') {
93
+ existing.title = persisted.title
94
+ existing.autoRenameStatus = persisted.autoRenameStatus
95
+ }
93
96
  existing.worktreeId = persisted.worktreeId
97
+ existing.workerName = persisted.workerName
94
98
  }
95
99
  }
96
100
  if (
@@ -145,12 +149,15 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
145
149
  cwd?: string
146
150
  /** Extra env injected into the spawned shell. Passed through to the PTY. */
147
151
  env?: Record<string, string>
152
+ autoRenameStatus?: 'eligible' | 'attempted'
148
153
  /**
149
154
  * Worktree this tab belongs to (for UI grouping). Stored on the
150
155
  * TabSession so an attach-time replay surfaces it inside the right
151
156
  * worktree column. Optional — tabs not bound to a worktree are valid.
152
157
  */
153
158
  worktreeId?: string
159
+ /** Workspace-scoped orchestration handle; does not affect PTY behavior. */
160
+ workerName?: string
154
161
  }): void {
155
162
  logDebug('daemon.registry.createSession', {
156
163
  args: options.args ?? [],
@@ -164,12 +171,14 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
164
171
  this.tabs.set(options.tabId, {
165
172
  activity: 'idle',
166
173
  assistant: options.assistant,
174
+ autoRenameStatus: options.autoRenameStatus,
167
175
  buffer: '',
168
176
  command: [options.command, ...(options.args ?? [])].join(' '),
169
177
  id: options.tabId,
170
178
  status: 'starting',
171
179
  terminalModes: createDefaultTerminalModes(),
172
180
  title: options.title,
181
+ workerName: options.workerName,
173
182
  worktreeId: options.worktreeId,
174
183
  })
175
184
  } else {
@@ -182,13 +191,25 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
182
191
  existing.assistant = options.assistant
183
192
  existing.title = options.title
184
193
  existing.command = [options.command, ...(options.args ?? [])].join(' ')
194
+ existing.autoRenameStatus = options.autoRenameStatus
185
195
  if (options.worktreeId !== undefined) existing.worktreeId = options.worktreeId
196
+ if (options.workerName !== undefined) existing.workerName = options.workerName
186
197
  }
187
198
 
188
199
  this.activeTabId = options.tabId
189
200
  this.ptyManager.createSession(options)
190
201
  }
191
202
 
203
+ updateTabMetadata(
204
+ tabId: string,
205
+ patch: { title?: string; autoRenameStatus?: 'eligible' | 'attempted' }
206
+ ): void {
207
+ const tab = this.tabs.get(tabId)
208
+ if (!tab) return
209
+ if (patch.title !== undefined) tab.title = patch.title
210
+ if (patch.autoRenameStatus !== undefined) tab.autoRenameStatus = patch.autoRenameStatus
211
+ }
212
+
192
213
  /**
193
214
  * Read the in-memory TabSession for a tab. Returned by reference — used by
194
215
  * the daemon's `tabAdded` broadcast path to publish the same shape the UI
@@ -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[]> {