@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.
- package/README.md +6 -0
- package/package.json +3 -2
- package/skills/aimux-orchestrator/SKILL.md +93 -0
- package/skills/aimux-orchestrator/assets/ledger.template.md +18 -0
- package/skills/aimux-orchestrator/references/prompts.md +57 -0
- package/skills/aimux-orchestrator/references/review.md +18 -0
- package/src/app-runtime/backend-runtime-events.ts +16 -0
- package/src/app-runtime/side-effects.ts +15 -2
- package/src/auto-rename/coordinator.ts +97 -0
- package/src/auto-rename/prompt-capture.ts +211 -0
- package/src/auto-rename/title-runner.ts +96 -0
- package/src/cli/client/daemon-client.ts +16 -0
- package/src/cli/commands/tab/create.ts +236 -124
- package/src/cli/commands/tab/prompt-io.ts +2 -1
- package/src/cli/commands/worker/await.ts +33 -0
- package/src/cli/commands/worker/doctor.ts +129 -0
- package/src/cli/commands/worker/list.ts +25 -0
- package/src/cli/commands/worker/prompt.ts +49 -0
- package/src/cli/commands/worker/run.ts +97 -0
- package/src/cli/commands/worker/shared.ts +255 -0
- package/src/cli/commands/worker/stop.ts +84 -0
- package/src/cli/commands/worktree/remove.ts +29 -9
- package/src/cli/index.ts +21 -9
- package/src/cli/registry.ts +12 -0
- package/src/daemon/daemon.ts +173 -12
- package/src/daemon/session-manager.ts +8 -0
- package/src/daemon/session-registry.ts +22 -1
- package/src/git/worktree.ts +8 -0
- package/src/index.tsx +21 -82
- package/src/input/modes/types.ts +1 -0
- package/src/ipc/manager-protocol.ts +52 -2
- package/src/ipc/protocol.ts +74 -3
- package/src/session-backend/bootstrap.ts +3 -1
- package/src/session-backend/local-session-backend.ts +63 -2
- package/src/session-backend/remote-session-backend.ts +17 -0
- package/src/session-backend/types.ts +7 -0
- package/src/state/reducers/tab-state.ts +14 -1
- package/src/state/session-persistence.ts +4 -0
- package/src/state/types.ts +18 -1
- package/src/state/validation.ts +5 -1
- package/src/terminal-manager/manager-client.ts +33 -1
- 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 {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
103
|
-
' #
|
|
104
|
-
'
|
|
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
|
-
'
|
|
110
|
-
' aimux
|
|
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
|
package/src/cli/registry.ts
CHANGED
|
@@ -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 {
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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?: {
|
|
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?: {
|
|
251
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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
|
package/src/git/worktree.ts
CHANGED
|
@@ -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[]> {
|