@brimveyn/aimux 1.16.3 → 1.18.2

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 +258 -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 +80 -2
  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,258 @@
1
+ import type { DaemonClient } from './client/daemon-client'
2
+ import type { CliContext } from './context'
3
+
4
+ import { connectToDaemon, DaemonUnreachableError } from './client/bootstrap'
5
+ import { resolveWorkspace } from './client/workspace-resolver'
6
+ import { type ArgSpec, CliUsageError, type FlagSpec, parseArgs, SHARED_FLAGS } from './flags'
7
+ import {
8
+ EXIT_DAEMON_UNREACHABLE,
9
+ EXIT_OK,
10
+ EXIT_RUNTIME,
11
+ EXIT_USAGE,
12
+ writeError,
13
+ writeJson,
14
+ } from './output'
15
+ import { type CliCommand, COMMANDS, resolveCommand } from './registry'
16
+
17
+ const EXIT_CODES_BLOCK = [
18
+ 'Exit codes:',
19
+ ' 0 success',
20
+ ' 2 usage error (bad flags, unknown command, missing argument)',
21
+ ' 3 runtime error (server replied with error, command failed)',
22
+ ' 4 daemon unreachable (socket missing and autostart failed)',
23
+ ' 124 timeout (tab wait, tab tail --timeout, workspace switch --wait)',
24
+ ].join('\n')
25
+
26
+ const OUTPUT_CONTRACT_BLOCK = [
27
+ 'Output contract:',
28
+ ' stdout one JSON object per command (NDJSON for `tab tail`, `tab wait`)',
29
+ ' stderr human-readable error lines, prefixed with `aimux:`',
30
+ ].join('\n')
31
+
32
+ function groupByCommand(commands: readonly CliCommand[]): Map<string, CliCommand[]> {
33
+ const byGroup = new Map<string, CliCommand[]>()
34
+ for (const command of commands) {
35
+ const existing = byGroup.get(command.group) ?? []
36
+ existing.push(command)
37
+ byGroup.set(command.group, existing)
38
+ }
39
+ return byGroup
40
+ }
41
+
42
+ function isSharedFlag(name: string): boolean {
43
+ return SHARED_FLAGS.some((f) => f.name === name)
44
+ }
45
+
46
+ function formatArgs(args: readonly ArgSpec[]): string {
47
+ if (args.length === 0) return ''
48
+ return args.map((a) => (a.required === true ? `<${a.name}>` : `[${a.name}]`)).join(' ')
49
+ }
50
+
51
+ function flagValueHint(flag: FlagSpec): string {
52
+ if (flag.kind === 'boolean') return ''
53
+ if (flag.kind === 'number') return ' <n>'
54
+ return ` <${flag.name}>`
55
+ }
56
+
57
+ function formatFlagLine(flag: FlagSpec): string {
58
+ const head = ` --${flag.name}${flagValueHint(flag)}`.padEnd(32)
59
+ return `${head}${flag.description ?? ''}`
60
+ }
61
+
62
+ function printHelp(): void {
63
+ process.stdout.write(
64
+ [
65
+ 'aimux CLI control plane — drive workspaces, worktrees, and tabs from scripts.',
66
+ '',
67
+ 'Usage:',
68
+ ' aimux <group> <verb> [flags] [args]',
69
+ ' aimux <group> --help List verbs in a group',
70
+ ' aimux <group> <verb> --help Show flags/args for a verb',
71
+ '',
72
+ 'Groups:',
73
+ '',
74
+ ].join('\n')
75
+ )
76
+
77
+ const byGroup = groupByCommand(COMMANDS)
78
+ for (const [group, commands] of byGroup) {
79
+ process.stdout.write(` ${group}\n`)
80
+ for (const command of commands) {
81
+ const args = formatArgs(command.args)
82
+ const signature = `aimux ${group} ${command.verb}${args === '' ? '' : ` ${args}`}`
83
+ process.stdout.write(` ${signature.padEnd(46)} ${command.summary}\n`)
84
+ }
85
+ process.stdout.write('\n')
86
+ }
87
+
88
+ process.stdout.write(
89
+ [
90
+ 'Shared flags (accepted by every command):',
91
+ ...SHARED_FLAGS.map(formatFlagLine),
92
+ '',
93
+ OUTPUT_CONTRACT_BLOCK,
94
+ '',
95
+ EXIT_CODES_BLOCK,
96
+ '',
97
+ 'Env:',
98
+ ' AIMUX_PROFILE Runtime profile (state dir, socket paths); --profile overrides.',
99
+ '',
100
+ 'Agent recipes:',
101
+ ' # spawn Claude in a new tab, wait until it idles, snapshot the screen',
102
+ ' TAB=$(aimux tab create --assistant claude --title fixup | jq -r .tabId)',
103
+ ' aimux tab send "$TAB" "explain this repo" --enter',
104
+ ' aimux tab wait "$TAB" --status idle --timeout 60000',
105
+ ' aimux tab snapshot "$TAB" --tail 40 --format text',
106
+ '',
107
+ ' # stream renders as NDJSON (one event per line)',
108
+ ' aimux tab tail "$TAB" --rate-limit-ms 100 --follow-status',
109
+ '',
110
+ ].join('\n')
111
+ )
112
+ }
113
+
114
+ function printGroupHelp(group: string): void {
115
+ const commands = COMMANDS.filter((c) => c.group === group)
116
+ if (commands.length === 0) {
117
+ printHelp()
118
+ return
119
+ }
120
+ process.stdout.write(
121
+ [
122
+ `aimux ${group} — ${commands.length} verb${commands.length === 1 ? '' : 's'}`,
123
+ '',
124
+ 'Usage:',
125
+ ` aimux ${group} <verb> [flags] [args]`,
126
+ ` aimux ${group} <verb> --help Show flags/args for a verb`,
127
+ '',
128
+ 'Verbs:',
129
+ '',
130
+ ].join('\n')
131
+ )
132
+ for (const command of commands) {
133
+ const args = formatArgs(command.args)
134
+ const signature = `aimux ${group} ${command.verb}${args === '' ? '' : ` ${args}`}`
135
+ process.stdout.write(` ${signature.padEnd(46)} ${command.summary}\n`)
136
+ }
137
+ process.stdout.write('\n')
138
+ }
139
+
140
+ function printCommandHelp(command: CliCommand): void {
141
+ const args = formatArgs(command.args)
142
+ const signature = `aimux ${command.group} ${command.verb}${args === '' ? '' : ` ${args}`}`
143
+ process.stdout.write([signature, '', ` ${command.summary}`, '', 'Flags:', ''].join('\n'))
144
+ const own = command.flags.filter((f) => !isSharedFlag(f.name))
145
+ if (own.length === 0) {
146
+ process.stdout.write(' (this command takes only the shared flags)\n')
147
+ } else {
148
+ for (const flag of own) process.stdout.write(`${formatFlagLine(flag)}\n`)
149
+ }
150
+ process.stdout.write('\nShared flags:\n')
151
+ for (const flag of SHARED_FLAGS) process.stdout.write(`${formatFlagLine(flag)}\n`)
152
+ process.stdout.write(`\n${OUTPUT_CONTRACT_BLOCK}\n\n${EXIT_CODES_BLOCK}\n`)
153
+ }
154
+
155
+ function argvRequestsHelp(argv: readonly string[]): boolean {
156
+ return argv.includes('--help') || argv.includes('-h')
157
+ }
158
+
159
+ export async function runCli(argv: readonly string[]): Promise<number> {
160
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
161
+ printHelp()
162
+ return EXIT_OK
163
+ }
164
+
165
+ const group = argv[0] ?? ''
166
+ const verb = argv[1] ?? ''
167
+
168
+ // `aimux <group> --help` (or bare `aimux <group>`) — group-scoped help. Print
169
+ // the verb list without erroring on the missing verb, so agents can drill
170
+ // down step by step.
171
+ if (verb === '' || verb === '--help' || verb === '-h') {
172
+ if (COMMANDS.some((c) => c.group === group)) {
173
+ printGroupHelp(group)
174
+ return EXIT_OK
175
+ }
176
+ writeError(`unknown group: ${group}`)
177
+ printHelp()
178
+ return EXIT_USAGE
179
+ }
180
+
181
+ const command = resolveCommand(group, verb)
182
+ if (!command) {
183
+ writeError(`unknown command: ${group} ${verb}`)
184
+ printHelp()
185
+ return EXIT_USAGE
186
+ }
187
+
188
+ // `aimux <group> <verb> --help` — verb-scoped help. Handle BEFORE parseArgs
189
+ // so verbs that don't declare a `--help` flag don't reject it as unknown.
190
+ if (argvRequestsHelp(argv.slice(2))) {
191
+ printCommandHelp(command)
192
+ return EXIT_OK
193
+ }
194
+
195
+ let parsed
196
+ try {
197
+ parsed = parseArgs(argv.slice(2), command.flags, command.args)
198
+ } catch (error) {
199
+ if (error instanceof CliUsageError) {
200
+ writeError(error.message)
201
+ writeError(`usage: aimux ${command.group} ${command.verb}`)
202
+ return EXIT_USAGE
203
+ }
204
+ throw error
205
+ }
206
+
207
+ // --profile overrides the env var BEFORE anyone touches a runtime path —
208
+ // the daemon socket path, catalog path, and TM socket path all derive from
209
+ // it. After this point all `getIpcDaemonSocketPath()` reads pick it up.
210
+ if (typeof parsed.flags.profile === 'string' && parsed.flags.profile !== '') {
211
+ process.env.AIMUX_PROFILE = parsed.flags.profile
212
+ }
213
+
214
+ const state: {
215
+ daemon: DaemonClient | null
216
+ workspace: ReturnType<typeof resolveWorkspace> | null
217
+ } = {
218
+ daemon: null,
219
+ workspace: null,
220
+ }
221
+ const ctx: CliContext = {
222
+ args: parsed,
223
+ getDaemon: async () => {
224
+ if (state.daemon) return state.daemon
225
+ state.daemon = await connectToDaemon()
226
+ return state.daemon
227
+ },
228
+ getWorkspace: () => {
229
+ if (state.workspace) return state.workspace
230
+ const workspaceFlag =
231
+ typeof parsed.flags.workspace === 'string' ? parsed.flags.workspace : undefined
232
+ state.workspace = resolveWorkspace(workspaceFlag)
233
+ return state.workspace
234
+ },
235
+ }
236
+
237
+ try {
238
+ const code = await command.run(ctx)
239
+ return code
240
+ } catch (error) {
241
+ const message = error instanceof Error ? error.message : String(error)
242
+ // Classify by error type, not by string-sniffing the message: a runtime
243
+ // error whose message happens to include "socket" (e.g. daemon reply
244
+ // "socket write failed for tab X") must not masquerade as
245
+ // daemon-unreachable, or CI that treats exit 4 as a "restart the daemon"
246
+ // signal will retry spuriously.
247
+ if (error instanceof DaemonUnreachableError) {
248
+ writeError(message)
249
+ writeJson({ error: message, kind: 'daemon-unreachable' })
250
+ return EXIT_DAEMON_UNREACHABLE
251
+ }
252
+ writeError(message)
253
+ writeJson({ error: message, kind: 'runtime-error' })
254
+ return EXIT_RUNTIME
255
+ } finally {
256
+ state.daemon?.close()
257
+ }
258
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * JSON-pure stdio for CLI commands. Every command writes one JSON object to
3
+ * stdout (or an NDJSON stream for `wait`/`tail`) and one human-readable line
4
+ * to stderr on failure. No ANSI, no prose interleaved with JSON — agents
5
+ * consume stdout, humans read stderr.
6
+ */
7
+
8
+ export function writeJson(value: unknown): void {
9
+ process.stdout.write(`${JSON.stringify(value)}\n`)
10
+ }
11
+
12
+ export function writeNdjson(value: unknown): void {
13
+ process.stdout.write(`${JSON.stringify(value)}\n`)
14
+ }
15
+
16
+ export function writeError(message: string): void {
17
+ process.stderr.write(`aimux: ${message}\n`)
18
+ }
19
+
20
+ // Exit code contract (docs/reference/cli.md):
21
+ // 0 success
22
+ // 2 usage error (bad flags, unknown command, missing argument)
23
+ // 3 runtime error (server replied with `error`, command failed)
24
+ // 4 daemon unreachable (socket missing and autostart failed)
25
+ // 124 timeout (`tab wait`, `tab tail --timeout`, `workspace switch --wait`)
26
+ export const EXIT_OK = 0
27
+ export const EXIT_USAGE = 2
28
+ export const EXIT_RUNTIME = 3
29
+ export const EXIT_DAEMON_UNREACHABLE = 4
30
+ export const EXIT_TIMEOUT = 124
@@ -0,0 +1,54 @@
1
+ import type { CliContext } from './context'
2
+ import type { ArgSpec, FlagSpec } from './flags'
3
+
4
+ import { tabClose } from './commands/tab/close'
5
+ import { tabCreate } from './commands/tab/create'
6
+ import { tabFocus } from './commands/tab/focus'
7
+ import { tabList } from './commands/tab/list'
8
+ import { tabSend } from './commands/tab/send'
9
+ import { tabSnapshot } from './commands/tab/snapshot'
10
+ import { tabTail } from './commands/tab/tail'
11
+ import { tabWait } from './commands/tab/wait'
12
+ import { workspaceClose } from './commands/workspace/close'
13
+ import { workspaceCreate } from './commands/workspace/create'
14
+ import { workspaceList } from './commands/workspace/list'
15
+ import { workspaceShow } from './commands/workspace/show'
16
+ import { workspaceSwitch } from './commands/workspace/switch'
17
+ import { worktreeCreate } from './commands/worktree/create'
18
+ import { worktreeList } from './commands/worktree/list'
19
+ import { worktreeRemove } from './commands/worktree/remove'
20
+
21
+ export interface CliCommand {
22
+ group: string
23
+ verb: string
24
+ summary: string
25
+ flags: readonly FlagSpec[]
26
+ args: readonly ArgSpec[]
27
+ run: (ctx: CliContext) => Promise<number>
28
+ }
29
+
30
+ export const COMMANDS: readonly CliCommand[] = [
31
+ tabList,
32
+ tabCreate,
33
+ tabSend,
34
+ tabFocus,
35
+ tabClose,
36
+ tabSnapshot,
37
+ tabTail,
38
+ tabWait,
39
+ workspaceList,
40
+ workspaceShow,
41
+ workspaceCreate,
42
+ workspaceSwitch,
43
+ workspaceClose,
44
+ worktreeList,
45
+ worktreeCreate,
46
+ worktreeRemove,
47
+ ]
48
+
49
+ export function resolveCommand(group: string, verb: string): CliCommand | null {
50
+ for (const command of COMMANDS) {
51
+ if (command.group === group && command.verb === verb) return command
52
+ }
53
+ return null
54
+ }
@@ -0,0 +1,54 @@
1
+ import type { TerminalSnapshot } from '../state/types'
2
+
3
+ export interface SnapshotLineOptions {
4
+ /** When true (default), strip trailing whitespace from each line so the
5
+ * serialised output isn't a sea of padding cells the LLM has to chew
6
+ * through. Pass false when you need the exact alignment of the terminal
7
+ * (e.g. ASCII art / fixed-column TUIs). */
8
+ trim?: boolean
9
+ }
10
+
11
+ /**
12
+ * Flatten a TerminalSnapshot into plain text — concatenate every span's
13
+ * `text` per line. No ANSI, no palette resolution. Agents consume strings,
14
+ * not pixels.
15
+ */
16
+ export function snapshotToLines(
17
+ snapshot: TerminalSnapshot,
18
+ options: SnapshotLineOptions = {}
19
+ ): string[] {
20
+ const trim = options.trim !== false
21
+ return snapshot.lines.map((line) => {
22
+ const joined = line.spans.map((span) => span.text).join('')
23
+ return trim ? joined.replace(/\s+$/u, '') : joined
24
+ })
25
+ }
26
+
27
+ /**
28
+ * Like `snapshotToLines` but trims trailing blank lines and slices to the
29
+ * last `n` non-blank lines. Mirrors the `--tail N` flag.
30
+ */
31
+ export function snapshotTailLines(
32
+ snapshot: TerminalSnapshot,
33
+ n: number,
34
+ options: SnapshotLineOptions = {}
35
+ ): string[] {
36
+ const all = snapshotToLines(snapshot, options)
37
+ let end = all.length
38
+ while (end > 0 && (all[end - 1] ?? '').trim() === '') end--
39
+ const trimmed = all.slice(0, end)
40
+ if (n <= 0 || trimmed.length <= n) return trimmed
41
+ return trimmed.slice(trimmed.length - n)
42
+ }
43
+
44
+ /**
45
+ * Plain-text dump: lines joined by `\n`, single trailing newline. Best shape
46
+ * for piping into an LLM since it preserves the screen's visual layout
47
+ * without JSON-escape noise.
48
+ */
49
+ export function snapshotToText(
50
+ snapshot: TerminalSnapshot,
51
+ options: SnapshotLineOptions = {}
52
+ ): string {
53
+ return `${snapshotToLines(snapshot, options).join('\n')}\n`
54
+ }
@@ -0,0 +1,123 @@
1
+ import type { SessionRecord, WorktreeRecord } from '../state/types'
2
+
3
+ import { logDebug } from '../debug/input-log'
4
+ import { createPrefixedId } from '../platform/id'
5
+ import { loadSessionCatalog, saveSessionCatalog } from '../state/session-catalog'
6
+ import { createEmptyWorkspaceSnapshot } from '../state/session-persistence'
7
+ import { createPrimaryWorktree, ensureSessionWorktrees } from '../state/session-worktrees'
8
+
9
+ /**
10
+ * Catalog mutations invoked by the daemon when NO UI is attached. When a UI
11
+ * is attached the daemon relays the request as an event and the UI's reducer
12
+ * owns the write (so the live workspace snapshot is preserved).
13
+ *
14
+ * Each helper is a pure read-modify-write against `aimux-sessions.json` —
15
+ * safe to call from the daemon process, no React / dispatcher involved.
16
+ */
17
+
18
+ export function createWorkspaceInCatalog(name: string, projectPath?: string): SessionRecord {
19
+ const sessions = loadSessionCatalog()
20
+ const now = new Date().toISOString()
21
+ const session: SessionRecord = {
22
+ activeWorktreeId: undefined,
23
+ createdAt: now,
24
+ id: createPrefixedId('session'),
25
+ lastOpenedAt: now,
26
+ name,
27
+ projectPath,
28
+ updatedAt: now,
29
+ workspaceSnapshot: createEmptyWorkspaceSnapshot(),
30
+ worktrees: undefined,
31
+ }
32
+ if (projectPath != null && projectPath !== '') {
33
+ const worktree = createPrimaryWorktree(projectPath, now)
34
+ session.activeWorktreeId = worktree.id
35
+ session.worktrees = [worktree]
36
+ }
37
+ saveSessionCatalog([...sessions, ensureSessionWorktrees(session)])
38
+ logDebug('daemon.catalog.createWorkspace', { name, projectPath, sessionId: session.id })
39
+ return session
40
+ }
41
+
42
+ /**
43
+ * Throws when the target session isn't in the catalog. Used by the daemon
44
+ * before broadcasting a workspace-lifecycle request so the CLI's `expectOk`
45
+ * fails fast (with a meaningful message) instead of the `--wait` path
46
+ * hanging out for its timeout while the UI silently ignores an unknown id.
47
+ */
48
+ export function assertSessionInCatalog(sessionId: string): void {
49
+ const sessions = loadSessionCatalog()
50
+ if (!sessions.some((s) => s.id === sessionId)) {
51
+ throw new Error(`workspace not found: ${sessionId}`)
52
+ }
53
+ }
54
+
55
+ export function bumpLastOpenedInCatalog(sessionId: string): void {
56
+ const sessions = loadSessionCatalog()
57
+ const now = new Date().toISOString()
58
+ const target = sessions.find((s) => s.id === sessionId)
59
+ if (!target) {
60
+ throw new Error(`workspace not found: ${sessionId}`)
61
+ }
62
+ const updated = sessions.map((s) => (s.id === sessionId ? { ...s, lastOpenedAt: now } : s))
63
+ saveSessionCatalog(updated)
64
+ logDebug('daemon.catalog.switchWorkspace', { sessionId })
65
+ }
66
+
67
+ export function deleteFromCatalog(sessionId: string): void {
68
+ const sessions = loadSessionCatalog()
69
+ const remaining = sessions.filter((s) => s.id !== sessionId)
70
+ if (remaining.length === sessions.length) {
71
+ throw new Error(`workspace not found: ${sessionId}`)
72
+ }
73
+ saveSessionCatalog(remaining)
74
+ logDebug('daemon.catalog.closeWorkspace', { sessionId })
75
+ }
76
+
77
+ export function addWorktreeToCatalog(sessionId: string, worktree: WorktreeRecord): void {
78
+ const sessions = loadSessionCatalog()
79
+ const target = sessions.find((s) => s.id === sessionId)
80
+ if (!target) {
81
+ throw new Error(`workspace not found: ${sessionId}`)
82
+ }
83
+ const existing = target.worktrees ?? []
84
+ if (existing.some((w) => w.id === worktree.id)) {
85
+ throw new Error(`worktree already exists: ${worktree.id}`)
86
+ }
87
+ const updated = sessions.map((s) =>
88
+ s.id === sessionId
89
+ ? { ...s, updatedAt: new Date().toISOString(), worktrees: [...existing, worktree] }
90
+ : s
91
+ )
92
+ saveSessionCatalog(updated)
93
+ logDebug('daemon.catalog.addWorktree', {
94
+ sessionId,
95
+ worktreeId: worktree.id,
96
+ worktreeName: worktree.name,
97
+ })
98
+ }
99
+
100
+ export function removeWorktreeFromCatalog(sessionId: string, worktreeId: string): void {
101
+ const sessions = loadSessionCatalog()
102
+ const target = sessions.find((s) => s.id === sessionId)
103
+ if (!target) {
104
+ throw new Error(`workspace not found: ${sessionId}`)
105
+ }
106
+ const existing = target.worktrees ?? []
107
+ const nextWorktrees = existing.filter((w) => w.id !== worktreeId)
108
+ if (nextWorktrees.length === existing.length) {
109
+ throw new Error(`worktree not found: ${worktreeId}`)
110
+ }
111
+ const updated = sessions.map((s) =>
112
+ s.id === sessionId
113
+ ? {
114
+ ...s,
115
+ activeWorktreeId: s.activeWorktreeId === worktreeId ? undefined : s.activeWorktreeId,
116
+ updatedAt: new Date().toISOString(),
117
+ worktrees: nextWorktrees,
118
+ }
119
+ : s
120
+ )
121
+ saveSessionCatalog(updated)
122
+ logDebug('daemon.catalog.removeWorktree', { sessionId, worktreeId })
123
+ }