@brimveyn/aimux 1.16.2 → 1.18.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 (61) hide show
  1. package/package.json +4 -2
  2. package/src/app-runtime/backend-runtime-events.ts +179 -0
  3. package/src/app-runtime/side-effects.ts +3 -1
  4. package/src/app.tsx +26 -0
  5. package/src/cli/chord.ts +77 -0
  6. package/src/cli/client/bootstrap.ts +76 -0
  7. package/src/cli/client/daemon-client.ts +228 -0
  8. package/src/cli/client/workspace-resolver.ts +57 -0
  9. package/src/cli/commands/tab/close.ts +33 -0
  10. package/src/cli/commands/tab/create.ts +109 -0
  11. package/src/cli/commands/tab/focus.ts +33 -0
  12. package/src/cli/commands/tab/list.ts +52 -0
  13. package/src/cli/commands/tab/send.ts +83 -0
  14. package/src/cli/commands/tab/snapshot.ts +127 -0
  15. package/src/cli/commands/tab/tail.ts +171 -0
  16. package/src/cli/commands/tab/wait.ts +86 -0
  17. package/src/cli/commands/workspace/close.ts +32 -0
  18. package/src/cli/commands/workspace/create.ts +92 -0
  19. package/src/cli/commands/workspace/list.ts +25 -0
  20. package/src/cli/commands/workspace/show.ts +32 -0
  21. package/src/cli/commands/workspace/switch.ts +75 -0
  22. package/src/cli/commands/worktree/create.ts +113 -0
  23. package/src/cli/commands/worktree/list.ts +44 -0
  24. package/src/cli/commands/worktree/remove.ts +59 -0
  25. package/src/cli/context.ts +16 -0
  26. package/src/cli/flags.ts +101 -0
  27. package/src/cli/index.ts +113 -0
  28. package/src/cli/output.ts +30 -0
  29. package/src/cli/registry.ts +54 -0
  30. package/src/cli/snapshot-render.ts +54 -0
  31. package/src/daemon/catalog-writer.ts +123 -0
  32. package/src/daemon/daemon.ts +566 -11
  33. package/src/daemon/reexec-client.ts +147 -0
  34. package/src/daemon/runtime-paths.ts +161 -1
  35. package/src/daemon/session-registry.ts +17 -0
  36. package/src/index.tsx +9 -0
  37. package/src/input/modes/bridge.ts +6 -0
  38. package/src/input/modes/transitions.ts +2 -0
  39. package/src/input/modes/types.ts +1 -0
  40. package/src/ipc/README.md +112 -0
  41. package/src/ipc/manager-protocol.ts +54 -4
  42. package/src/ipc/protocol.ts +466 -25
  43. package/src/platform/daemon-control.ts +14 -0
  44. package/src/restart-daemon.ts +36 -4
  45. package/src/session-backend/bootstrap.ts +110 -0
  46. package/src/session-backend/local-session-backend.ts +6 -0
  47. package/src/session-backend/remote-session-backend.ts +63 -0
  48. package/src/session-backend/types.ts +34 -0
  49. package/src/state/reducers/modal-state.ts +66 -1
  50. package/src/state/types.ts +40 -0
  51. package/src/state/validation.ts +1 -1
  52. package/src/terminal-manager/manager-client.ts +24 -7
  53. package/src/ui/components/flash/flash-label-badge.tsx +38 -0
  54. package/src/ui/components/layout/sidebar/tab-item.tsx +2 -0
  55. package/src/ui/components/layout/sidebar/workspace-list.tsx +4 -0
  56. package/src/ui/components/layout/sidebar/worktree-row.tsx +2 -0
  57. package/src/ui/components/layout/top-tab-bar.tsx +2 -0
  58. package/src/ui/flash/assign-labels.ts +126 -0
  59. package/src/ui/flash/build-labels.ts +78 -0
  60. package/src/ui/hooks/use-flash-label.ts +40 -0
  61. package/src/ui/root.tsx +3 -0
@@ -0,0 +1,86 @@
1
+ import type { TabActivity } from '../../../state/types'
2
+ import type { CliCommand } from '../../registry'
3
+
4
+ import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
5
+ import { SHARED_FLAGS } from '../../flags'
6
+ import { EXIT_OK, EXIT_TIMEOUT, writeNdjson } from '../../output'
7
+
8
+ const DEFAULT_TIMEOUT_MS = 30_000
9
+
10
+ function isTabActivity(value: string): value is TabActivity {
11
+ return value === 'idle' || value === 'working' || value === 'waiting-input'
12
+ }
13
+
14
+ export const tabWait: CliCommand = {
15
+ args: [{ name: 'tabId', required: true }],
16
+ flags: [
17
+ ...SHARED_FLAGS,
18
+ {
19
+ description: 'target activity (idle | working | waiting-input)',
20
+ kind: 'string',
21
+ name: 'status',
22
+ },
23
+ { description: 'timeout in milliseconds (default 30000)', kind: 'number', name: 'timeout' },
24
+ ],
25
+ group: 'tab',
26
+ run: async (ctx) => {
27
+ const tabId = ctx.args.positionals[0]
28
+ if (typeof tabId !== 'string' || tabId.length === 0) {
29
+ throw new Error('tabId is required')
30
+ }
31
+ const statusFlag = ctx.args.flags.status
32
+ if (typeof statusFlag !== 'string' || !isTabActivity(statusFlag)) {
33
+ throw new Error('--status must be one of: idle, working, waiting-input')
34
+ }
35
+ const timeoutMs =
36
+ typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_TIMEOUT_MS
37
+
38
+ const workspace = ctx.getWorkspace()
39
+ const daemon = await ctx.getDaemon()
40
+ if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
41
+ throw new Error(
42
+ 'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
43
+ )
44
+ }
45
+
46
+ const attach = await daemon.attach({
47
+ cols: 0,
48
+ rows: 0,
49
+ sessionId: workspace.id,
50
+ thin: true,
51
+ })
52
+
53
+ // Daemon only emits tabStatus on transitions, so a tab that is already at
54
+ // the target activity would never produce an event and this command would
55
+ // sit until timeout. Short-circuit on the activity we got from the attach
56
+ // replay when it already matches.
57
+ const currentTab = attach.tabs.find((t) => t.id === tabId)
58
+ if (!currentTab) {
59
+ throw new Error(`tab not found: ${tabId}`)
60
+ }
61
+ if (currentTab.activity === statusFlag) {
62
+ writeNdjson({ status: statusFlag, tabId, ts: 0 })
63
+ return EXIT_OK
64
+ }
65
+
66
+ return new Promise<number>((resolve) => {
67
+ const start = Date.now()
68
+ const off = daemon.on('tabStatus', (payload) => {
69
+ if (payload.tabId !== tabId) return
70
+ writeNdjson({ status: payload.status, tabId, ts: Date.now() - start })
71
+ if (payload.status === statusFlag) {
72
+ off()
73
+ clearTimeout(timer)
74
+ resolve(EXIT_OK)
75
+ }
76
+ })
77
+ const timer = setTimeout(() => {
78
+ off()
79
+ writeNdjson({ status: 'timeout', tabId, ts: Date.now() - start })
80
+ resolve(EXIT_TIMEOUT)
81
+ }, timeoutMs)
82
+ })
83
+ },
84
+ summary: 'Stream tabStatus events until the tab reaches the requested state',
85
+ verb: 'wait',
86
+ }
@@ -0,0 +1,32 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { IPC_CAPABILITY_WORKSPACE_LIFECYCLE } from '../../../ipc/protocol'
4
+ import { resolveWorkspace } from '../../client/workspace-resolver'
5
+ import { SHARED_FLAGS } from '../../flags'
6
+ import { EXIT_OK, writeJson } from '../../output'
7
+
8
+ export const workspaceClose: CliCommand = {
9
+ args: [{ name: 'workspace', required: true }],
10
+ flags: SHARED_FLAGS,
11
+ group: 'workspace',
12
+ run: async (ctx) => {
13
+ const target = ctx.args.positionals[0]
14
+ if (typeof target !== 'string' || target.length === 0) {
15
+ throw new Error('target workspace is required (id or name)')
16
+ }
17
+ const session = resolveWorkspace(target)
18
+
19
+ const daemon = await ctx.getDaemon()
20
+ if (!daemon.hasCapability(IPC_CAPABILITY_WORKSPACE_LIFECYCLE)) {
21
+ throw new Error(
22
+ 'daemon predates workspaceLifecycle capability — restart aimux to pick up the new daemon'
23
+ )
24
+ }
25
+
26
+ await daemon.expectOk('closeWorkspace', { targetSessionId: session.id })
27
+ writeJson({ closedSessionId: session.id, name: session.name })
28
+ return EXIT_OK
29
+ },
30
+ summary: 'Close a workspace (via the UI when attached, otherwise the catalog)',
31
+ verb: 'close',
32
+ }
@@ -0,0 +1,92 @@
1
+ import { resolve as resolvePath } from 'node:path'
2
+
3
+ import type { CliCommand } from '../../registry'
4
+
5
+ import { IPC_CAPABILITY_WORKSPACE_LIFECYCLE } from '../../../ipc/protocol'
6
+ import { SHARED_FLAGS } from '../../flags'
7
+ import { EXIT_OK, EXIT_TIMEOUT, writeJson } from '../../output'
8
+
9
+ const DEFAULT_WAIT_TIMEOUT_MS = 30_000
10
+
11
+ export const workspaceCreate: CliCommand = {
12
+ args: [{ name: 'name', required: true }],
13
+ flags: [
14
+ ...SHARED_FLAGS,
15
+ {
16
+ description: 'project path to associate with the workspace',
17
+ kind: 'string',
18
+ name: 'project',
19
+ },
20
+ {
21
+ description: 'immediately switch the running UI to the new workspace',
22
+ kind: 'boolean',
23
+ name: 'switch',
24
+ },
25
+ {
26
+ description: 'with --switch, wait until the UI confirms the switch completed',
27
+ kind: 'boolean',
28
+ name: 'wait',
29
+ },
30
+ {
31
+ description: 'timeout for --wait, in milliseconds (default 30000)',
32
+ kind: 'number',
33
+ name: 'timeout',
34
+ },
35
+ ],
36
+ group: 'workspace',
37
+ run: async (ctx) => {
38
+ const name = ctx.args.positionals[0]
39
+ if (typeof name !== 'string' || name.length === 0) {
40
+ throw new Error('workspace name is required')
41
+ }
42
+ const projectRaw =
43
+ typeof ctx.args.flags.project === 'string' ? ctx.args.flags.project : undefined
44
+ const projectPath = projectRaw === undefined ? undefined : resolvePath(projectRaw)
45
+ const doSwitch = ctx.args.flags.switch === true
46
+ const wait = ctx.args.flags.wait === true
47
+ const timeoutMs =
48
+ typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_WAIT_TIMEOUT_MS
49
+
50
+ if (wait && !doSwitch) {
51
+ // Without --switch, the UI never emits an ack event, so there's
52
+ // nothing meaningful for --wait to wait on.
53
+ throw new Error('--wait requires --switch (nothing to wait for otherwise)')
54
+ }
55
+
56
+ const daemon = await ctx.getDaemon()
57
+ if (!daemon.hasCapability(IPC_CAPABILITY_WORKSPACE_LIFECYCLE)) {
58
+ throw new Error(
59
+ 'daemon predates workspaceLifecycle capability — restart aimux to pick up the new daemon'
60
+ )
61
+ }
62
+
63
+ if (!wait) {
64
+ await daemon.expectOk('createWorkspace', { name, projectPath, switch: doSwitch })
65
+ writeJson({ name, projectPath, switch: doSwitch })
66
+ return EXIT_OK
67
+ }
68
+
69
+ // Subscribe BEFORE sending the request. The UI's create+switch path
70
+ // relays as `workspaceSwitched` once handleCreateSessionEffect has
71
+ // dispatched load-session. Match on name+projectPath since the CLI
72
+ // doesn't know the id the UI will assign to the new session.
73
+ const settled = new Promise<number>((resolve) => {
74
+ const off = daemon.on('workspaceSwitched', (payload) => {
75
+ off()
76
+ clearTimeout(timer)
77
+ writeJson({ name, projectPath, sessionId: payload.sessionId, switch: doSwitch })
78
+ resolve(EXIT_OK)
79
+ })
80
+ const timer = setTimeout(() => {
81
+ off()
82
+ writeJson({ error: 'timed out waiting for workspaceSwitched', name, projectPath })
83
+ resolve(EXIT_TIMEOUT)
84
+ }, timeoutMs)
85
+ })
86
+
87
+ await daemon.expectOk('createWorkspace', { name, projectPath, switch: doSwitch })
88
+ return settled
89
+ },
90
+ summary: 'Create a new workspace (via the UI when attached, otherwise the catalog)',
91
+ verb: 'create',
92
+ }
@@ -0,0 +1,25 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { listWorkspaces } from '../../client/workspace-resolver'
4
+ import { SHARED_FLAGS } from '../../flags'
5
+ import { EXIT_OK, writeJson } from '../../output'
6
+
7
+ export const workspaceList: CliCommand = {
8
+ args: [],
9
+ flags: SHARED_FLAGS,
10
+ group: 'workspace',
11
+ run: async () => {
12
+ const sessions = listWorkspaces()
13
+ writeJson({
14
+ workspaces: sessions.map((session) => ({
15
+ id: session.id,
16
+ lastOpenedAt: session.lastOpenedAt,
17
+ name: session.name,
18
+ projectPath: session.projectPath,
19
+ })),
20
+ })
21
+ return EXIT_OK
22
+ },
23
+ summary: 'List known workspaces (sessions) in the profile catalog',
24
+ verb: 'list',
25
+ }
@@ -0,0 +1,32 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { SHARED_FLAGS } from '../../flags'
4
+ import { EXIT_OK, writeJson } from '../../output'
5
+
6
+ export const workspaceShow: CliCommand = {
7
+ args: [],
8
+ flags: SHARED_FLAGS,
9
+ group: 'workspace',
10
+ run: async (ctx) => {
11
+ const workspace = ctx.getWorkspace()
12
+ writeJson({
13
+ activeWorktreeId: workspace.activeWorktreeId,
14
+ createdAt: workspace.createdAt,
15
+ id: workspace.id,
16
+ lastOpenedAt: workspace.lastOpenedAt,
17
+ name: workspace.name,
18
+ projectPath: workspace.projectPath,
19
+ worktrees:
20
+ workspace.worktrees?.map((w) => ({
21
+ branch: w.branch,
22
+ id: w.id,
23
+ name: w.name,
24
+ path: w.path,
25
+ source: w.source,
26
+ })) ?? [],
27
+ })
28
+ return EXIT_OK
29
+ },
30
+ summary: 'Show the active workspace (or the one named via --workspace)',
31
+ verb: 'show',
32
+ }
@@ -0,0 +1,75 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { IPC_CAPABILITY_WORKSPACE_LIFECYCLE } from '../../../ipc/protocol'
4
+ import { resolveWorkspace } from '../../client/workspace-resolver'
5
+ import { SHARED_FLAGS } from '../../flags'
6
+ import { EXIT_OK, EXIT_TIMEOUT, writeJson } from '../../output'
7
+
8
+ const DEFAULT_WAIT_TIMEOUT_MS = 30_000
9
+
10
+ export const workspaceSwitch: CliCommand = {
11
+ args: [{ name: 'workspace', required: true }],
12
+ flags: [
13
+ ...SHARED_FLAGS,
14
+ {
15
+ description: 'wait until the UI (or daemon) confirms the switch completed',
16
+ kind: 'boolean',
17
+ name: 'wait',
18
+ },
19
+ {
20
+ description: 'timeout for --wait, in milliseconds (default 30000)',
21
+ kind: 'number',
22
+ name: 'timeout',
23
+ },
24
+ ],
25
+ group: 'workspace',
26
+ run: async (ctx) => {
27
+ const target = ctx.args.positionals[0]
28
+ if (typeof target !== 'string' || target.length === 0) {
29
+ throw new Error('target workspace is required (id or name)')
30
+ }
31
+ const session = resolveWorkspace(target)
32
+ const wait = ctx.args.flags.wait === true
33
+ const timeoutMs =
34
+ typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_WAIT_TIMEOUT_MS
35
+
36
+ const daemon = await ctx.getDaemon()
37
+ if (!daemon.hasCapability(IPC_CAPABILITY_WORKSPACE_LIFECYCLE)) {
38
+ throw new Error(
39
+ 'daemon predates workspaceLifecycle capability — restart aimux to pick up the new daemon'
40
+ )
41
+ }
42
+
43
+ if (!wait) {
44
+ await daemon.expectOk('switchWorkspace', { targetSessionId: session.id })
45
+ writeJson({ name: session.name, targetSessionId: session.id })
46
+ return EXIT_OK
47
+ }
48
+
49
+ // Subscribe BEFORE sending the request so we don't miss the broadcast for
50
+ // the no-UI path (the daemon emits `workspaceSwitched` synchronously in
51
+ // that branch).
52
+ const settled = new Promise<number>((resolve) => {
53
+ const off = daemon.on('workspaceSwitched', (payload) => {
54
+ if (payload.sessionId !== session.id) return
55
+ off()
56
+ clearTimeout(timer)
57
+ writeJson({ name: session.name, targetSessionId: session.id })
58
+ resolve(EXIT_OK)
59
+ })
60
+ const timer = setTimeout(() => {
61
+ off()
62
+ writeJson({
63
+ error: 'timed out waiting for workspaceSwitched',
64
+ targetSessionId: session.id,
65
+ })
66
+ resolve(EXIT_TIMEOUT)
67
+ }, timeoutMs)
68
+ })
69
+
70
+ await daemon.expectOk('switchWorkspace', { targetSessionId: session.id })
71
+ return settled
72
+ },
73
+ summary: 'Switch the UI (or catalog when headless) to another workspace',
74
+ verb: 'switch',
75
+ }
@@ -0,0 +1,113 @@
1
+ import type { WorktreeRecord } from '../../../state/types'
2
+ import type { CliCommand } from '../../registry'
3
+
4
+ import { createGitWorktree, removeGitWorktree } from '../../../git/worktree'
5
+ import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
6
+ import { createPrefixedId } from '../../../platform/id'
7
+ import {
8
+ assertSafeAimuxWorktreePath,
9
+ ensureAimuxWorktreeRoot,
10
+ makeWorktreePath,
11
+ } from '../../../platform/worktree-paths'
12
+ import { SHARED_FLAGS } from '../../flags'
13
+ import { EXIT_OK, writeJson } from '../../output'
14
+
15
+ export const worktreeCreate: CliCommand = {
16
+ args: [],
17
+ flags: [
18
+ ...SHARED_FLAGS,
19
+ { description: 'display name for the new worktree', kind: 'string', name: 'name' },
20
+ { description: 'branch name (defaults to aimux/<name>)', kind: 'string', name: 'branch' },
21
+ { description: 'base ref for the branch (defaults to HEAD)', kind: 'string', name: 'base' },
22
+ ],
23
+ group: 'worktree',
24
+ run: async (ctx) => {
25
+ const name = ctx.args.flags.name
26
+ if (typeof name !== 'string' || name.length === 0) {
27
+ throw new Error('--name is required')
28
+ }
29
+ const branch =
30
+ typeof ctx.args.flags.branch === 'string' ? ctx.args.flags.branch : `aimux/${name}`
31
+ const base = typeof ctx.args.flags.base === 'string' ? ctx.args.flags.base : 'HEAD'
32
+
33
+ const workspace = ctx.getWorkspace()
34
+ const primary = workspace.worktrees?.find((w) => w.source === 'primary')
35
+ if (!primary) {
36
+ throw new Error(
37
+ `workspace "${workspace.name}" has no primary worktree — set --project when creating it`
38
+ )
39
+ }
40
+
41
+ // Check the daemon's capability BEFORE mutating disk — otherwise a
42
+ // capability mismatch would leave a git worktree on disk with no
43
+ // catalog record to track it.
44
+ const daemon = await ctx.getDaemon()
45
+ if (!daemon.hasCapability(IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS)) {
46
+ throw new Error(
47
+ 'daemon predates worktreeLifecycleEvents capability — restart aimux to pick up the new daemon'
48
+ )
49
+ }
50
+
51
+ const worktreeId = createPrefixedId('worktree')
52
+ const targetPath = makeWorktreePath({
53
+ repoRoot: primary.repoRoot,
54
+ worktreeId,
55
+ worktreeName: name,
56
+ })
57
+ await ensureAimuxWorktreeRoot()
58
+ await assertSafeAimuxWorktreePath(targetPath)
59
+
60
+ await createGitWorktree({
61
+ baseRef: base,
62
+ branchName: branch,
63
+ repoPath: primary.repoRoot,
64
+ targetPath,
65
+ })
66
+
67
+ const now = new Date().toISOString()
68
+ const record: WorktreeRecord = {
69
+ baseRef: base,
70
+ branch,
71
+ createdAt: now,
72
+ createdByAimux: true,
73
+ id: worktreeId,
74
+ name,
75
+ path: targetPath,
76
+ repoRoot: primary.repoRoot,
77
+ source: 'aimux-temp',
78
+ updatedAt: now,
79
+ }
80
+
81
+ try {
82
+ await daemon.expectOk('addWorktreeRecord', { sessionId: workspace.id, worktree: record })
83
+ } catch (error) {
84
+ // Catalog registration failed — roll back the on-disk worktree so
85
+ // `worktree list` doesn't perpetually surface an orphan. Swallow
86
+ // rollback errors: report the original failure, which is the real
87
+ // problem the operator needs to see.
88
+ try {
89
+ await removeGitWorktree({
90
+ force: true,
91
+ repoPath: primary.repoRoot,
92
+ targetPath,
93
+ })
94
+ } catch {
95
+ // Best-effort rollback; leave the git-side worktree if it can't be
96
+ // removed cleanly. `worktree list --workspace` will flag it as
97
+ // `gitTracked: true, catalog: no` on the next inspection.
98
+ }
99
+ throw error
100
+ }
101
+
102
+ writeJson({
103
+ branch,
104
+ id: worktreeId,
105
+ name,
106
+ path: targetPath,
107
+ repoRoot: primary.repoRoot,
108
+ })
109
+ return EXIT_OK
110
+ },
111
+ summary: 'Create a new worktree in the active workspace',
112
+ verb: 'create',
113
+ }
@@ -0,0 +1,44 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { listGitWorktrees } from '../../../git/worktree'
4
+ import { SHARED_FLAGS } from '../../flags'
5
+ import { EXIT_OK, writeJson } from '../../output'
6
+
7
+ export const worktreeList: CliCommand = {
8
+ args: [],
9
+ flags: SHARED_FLAGS,
10
+ group: 'worktree',
11
+ run: async (ctx) => {
12
+ const workspace = ctx.getWorkspace()
13
+ const records = workspace.worktrees ?? []
14
+
15
+ // Cross-check against git so we can flag catalog entries that git no
16
+ // longer knows about (prunable / vanished) — otherwise the CLI would
17
+ // happily report worktrees that don't exist on disk.
18
+ const primary = records.find((w) => w.source === 'primary')
19
+ const gitPaths = new Set<string>()
20
+ if (primary) {
21
+ for (const w of await listGitWorktrees(primary.repoRoot)) {
22
+ if (w.prunable !== true) gitPaths.add(w.path)
23
+ }
24
+ }
25
+
26
+ writeJson({
27
+ activeWorktreeId: workspace.activeWorktreeId ?? null,
28
+ workspaceId: workspace.id,
29
+ worktrees: records.map((w) => ({
30
+ branch: w.branch,
31
+ createdByAimux: w.createdByAimux,
32
+ gitTracked: primary ? gitPaths.has(w.path) : null,
33
+ id: w.id,
34
+ name: w.name,
35
+ path: w.path,
36
+ repoRoot: w.repoRoot,
37
+ source: w.source,
38
+ })),
39
+ })
40
+ return EXIT_OK
41
+ },
42
+ summary: 'List worktrees for a workspace',
43
+ verb: 'list',
44
+ }
@@ -0,0 +1,59 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { removeGitWorktree } from '../../../git/worktree'
4
+ import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
5
+ import { SHARED_FLAGS } from '../../flags'
6
+ import { EXIT_OK, writeJson } from '../../output'
7
+
8
+ export const worktreeRemove: CliCommand = {
9
+ args: [{ name: 'worktree', required: true }],
10
+ flags: [
11
+ ...SHARED_FLAGS,
12
+ { description: 'pass --force to git worktree remove', kind: 'boolean', name: 'force' },
13
+ ],
14
+ group: 'worktree',
15
+ run: async (ctx) => {
16
+ const target = ctx.args.positionals[0]
17
+ if (typeof target !== 'string' || target.length === 0) {
18
+ throw new Error('worktree id or path is required')
19
+ }
20
+ const force = ctx.args.flags.force === true
21
+
22
+ const workspace = ctx.getWorkspace()
23
+ const worktree =
24
+ workspace.worktrees?.find((w) => w.id === target) ??
25
+ workspace.worktrees?.find((w) => w.path === target)
26
+ if (!worktree) {
27
+ const known = workspace.worktrees?.map((w) => w.id).join(', ') ?? '(none)'
28
+ throw new Error(`unknown worktree: ${target} (known: ${known})`)
29
+ }
30
+ if (worktree.source === 'primary') {
31
+ throw new Error('refusing to remove the primary worktree')
32
+ }
33
+
34
+ const primary = workspace.worktrees?.find((w) => w.source === 'primary')
35
+ if (!primary) {
36
+ throw new Error('workspace has no primary worktree — cannot resolve repoRoot for git remove')
37
+ }
38
+
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
+ const daemon = await ctx.getDaemon()
44
+ if (!daemon.hasCapability(IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS)) {
45
+ throw new Error(
46
+ 'daemon predates worktreeLifecycleEvents capability — restart aimux to pick up the new daemon'
47
+ )
48
+ }
49
+ await daemon.expectOk('removeWorktreeRecord', {
50
+ sessionId: workspace.id,
51
+ worktreeId: worktree.id,
52
+ })
53
+
54
+ writeJson({ id: worktree.id, name: worktree.name, path: worktree.path })
55
+ return EXIT_OK
56
+ },
57
+ summary: 'Remove a worktree from the active workspace',
58
+ verb: 'remove',
59
+ }
@@ -0,0 +1,16 @@
1
+ import type { SessionRecord } from '../state/types'
2
+ import type { DaemonClient } from './client/daemon-client'
3
+ import type { ParsedArgs } from './flags'
4
+
5
+ /**
6
+ * Per-command context handed to `CliCommand.run`. Commands lazily resolve
7
+ * the workspace + daemon via the helpers on this object so a command that
8
+ * only reads flags (e.g. `workspace list`) doesn't have to open a socket.
9
+ */
10
+ export interface CliContext {
11
+ args: ParsedArgs
12
+ /** Already-running daemon client, populated on first call to `daemon()`. */
13
+ getDaemon: () => Promise<DaemonClient>
14
+ /** Resolved workspace, populated on first call to `workspace()`. */
15
+ getWorkspace: () => SessionRecord
16
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Minimal flag/positional parser. ~50 lines as the plan calls for — no
3
+ * external dep. Supports `--flag value`, `--flag=value`, boolean `--flag`,
4
+ * and `--` to end flag parsing. Unknown flags produce a usage error.
5
+ */
6
+
7
+ export interface FlagSpec {
8
+ name: string
9
+ kind: 'string' | 'number' | 'boolean'
10
+ description?: string
11
+ }
12
+
13
+ export interface ArgSpec {
14
+ name: string
15
+ required?: boolean
16
+ }
17
+
18
+ export interface ParsedArgs {
19
+ flags: Record<string, string | number | boolean>
20
+ positionals: string[]
21
+ }
22
+
23
+ export class CliUsageError extends Error {
24
+ constructor(message: string) {
25
+ super(message)
26
+ this.name = 'CliUsageError'
27
+ }
28
+ }
29
+
30
+ export function parseArgs(
31
+ argv: string[],
32
+ flagSpecs: readonly FlagSpec[],
33
+ argSpecs: readonly ArgSpec[]
34
+ ): ParsedArgs {
35
+ const flagByName = new Map<string, FlagSpec>()
36
+ for (const spec of flagSpecs) flagByName.set(spec.name, spec)
37
+
38
+ const flags: Record<string, string | number | boolean> = {}
39
+ const positionals: string[] = []
40
+ let stopFlags = false
41
+
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const token = argv[i] ?? ''
44
+ if (token === '--') {
45
+ stopFlags = true
46
+ continue
47
+ }
48
+ if (!stopFlags && token.startsWith('--')) {
49
+ const eq = token.indexOf('=')
50
+ const name = eq === -1 ? token.slice(2) : token.slice(2, eq)
51
+ const spec = flagByName.get(name)
52
+ if (!spec) {
53
+ throw new CliUsageError(`unknown flag: --${name}`)
54
+ }
55
+ if (spec.kind === 'boolean') {
56
+ if (eq !== -1) {
57
+ throw new CliUsageError(`flag --${name} does not take a value`)
58
+ }
59
+ flags[name] = true
60
+ continue
61
+ }
62
+ const raw = eq === -1 ? argv[++i] : token.slice(eq + 1)
63
+ if (raw === undefined) {
64
+ throw new CliUsageError(`flag --${name} requires a value`)
65
+ }
66
+ if (spec.kind === 'number') {
67
+ const parsed = Number(raw)
68
+ if (!Number.isFinite(parsed)) {
69
+ throw new CliUsageError(`flag --${name} must be a number (got: ${raw})`)
70
+ }
71
+ flags[name] = parsed
72
+ } else {
73
+ flags[name] = raw
74
+ }
75
+ continue
76
+ }
77
+ positionals.push(token)
78
+ }
79
+
80
+ for (let i = 0; i < argSpecs.length; i++) {
81
+ const spec = argSpecs[i]
82
+ if (!spec) continue
83
+ if (spec.required === true && positionals[i] === undefined) {
84
+ throw new CliUsageError(`missing required argument: <${spec.name}>`)
85
+ }
86
+ }
87
+
88
+ return { flags, positionals }
89
+ }
90
+
91
+ /**
92
+ * Shared flag set — every command takes these. `--workspace` selects the
93
+ * target session (by id or name); `--profile` overrides `AIMUX_PROFILE`
94
+ * before any runtime path is resolved; `--json` is a no-op kept for
95
+ * consistency with future formats.
96
+ */
97
+ export const SHARED_FLAGS: readonly FlagSpec[] = [
98
+ { description: 'workspace id or name', kind: 'string', name: 'workspace' },
99
+ { description: 'runtime profile override (sets AIMUX_PROFILE)', kind: 'string', name: 'profile' },
100
+ { description: 'always-on JSON output (kept for consistency)', kind: 'boolean', name: 'json' },
101
+ ]