@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,113 @@
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 { CliUsageError, parseArgs } 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 { COMMANDS, resolveCommand } from './registry'
16
+
17
+ function printHelp(): void {
18
+ process.stdout.write(
19
+ 'aimux CLI control plane\n\nUsage:\n aimux <group> <verb> [flags] [args]\n\n'
20
+ )
21
+ const byGroup = new Map<string, typeof COMMANDS>()
22
+ for (const command of COMMANDS) {
23
+ const existing = byGroup.get(command.group) ?? []
24
+ byGroup.set(command.group, [...existing, command] as typeof COMMANDS)
25
+ }
26
+ for (const [group, commands] of byGroup) {
27
+ process.stdout.write(` ${group}\n`)
28
+ for (const command of commands) {
29
+ process.stdout.write(` aimux ${group} ${command.verb.padEnd(10)} ${command.summary}\n`)
30
+ }
31
+ process.stdout.write('\n')
32
+ }
33
+ }
34
+
35
+ export async function runCli(argv: readonly string[]): Promise<number> {
36
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
37
+ printHelp()
38
+ return EXIT_OK
39
+ }
40
+
41
+ const group = argv[0] ?? ''
42
+ const verb = argv[1] ?? ''
43
+ const command = resolveCommand(group, verb)
44
+ if (!command) {
45
+ writeError(`unknown command: ${group} ${verb}`)
46
+ printHelp()
47
+ return EXIT_USAGE
48
+ }
49
+
50
+ let parsed
51
+ try {
52
+ parsed = parseArgs(argv.slice(2), command.flags, command.args)
53
+ } catch (error) {
54
+ if (error instanceof CliUsageError) {
55
+ writeError(error.message)
56
+ writeError(`usage: aimux ${command.group} ${command.verb}`)
57
+ return EXIT_USAGE
58
+ }
59
+ throw error
60
+ }
61
+
62
+ // --profile overrides the env var BEFORE anyone touches a runtime path —
63
+ // the daemon socket path, catalog path, and TM socket path all derive from
64
+ // it. After this point all `getIpcDaemonSocketPath()` reads pick it up.
65
+ if (typeof parsed.flags.profile === 'string' && parsed.flags.profile !== '') {
66
+ process.env.AIMUX_PROFILE = parsed.flags.profile
67
+ }
68
+
69
+ const state: {
70
+ daemon: DaemonClient | null
71
+ workspace: ReturnType<typeof resolveWorkspace> | null
72
+ } = {
73
+ daemon: null,
74
+ workspace: null,
75
+ }
76
+ const ctx: CliContext = {
77
+ args: parsed,
78
+ getDaemon: async () => {
79
+ if (state.daemon) return state.daemon
80
+ state.daemon = await connectToDaemon()
81
+ return state.daemon
82
+ },
83
+ getWorkspace: () => {
84
+ if (state.workspace) return state.workspace
85
+ const workspaceFlag =
86
+ typeof parsed.flags.workspace === 'string' ? parsed.flags.workspace : undefined
87
+ state.workspace = resolveWorkspace(workspaceFlag)
88
+ return state.workspace
89
+ },
90
+ }
91
+
92
+ try {
93
+ const code = await command.run(ctx)
94
+ return code
95
+ } catch (error) {
96
+ const message = error instanceof Error ? error.message : String(error)
97
+ // Classify by error type, not by string-sniffing the message: a runtime
98
+ // error whose message happens to include "socket" (e.g. daemon reply
99
+ // "socket write failed for tab X") must not masquerade as
100
+ // daemon-unreachable, or CI that treats exit 4 as a "restart the daemon"
101
+ // signal will retry spuriously.
102
+ if (error instanceof DaemonUnreachableError) {
103
+ writeError(message)
104
+ writeJson({ error: message, kind: 'daemon-unreachable' })
105
+ return EXIT_DAEMON_UNREACHABLE
106
+ }
107
+ writeError(message)
108
+ writeJson({ error: message, kind: 'runtime-error' })
109
+ return EXIT_RUNTIME
110
+ } finally {
111
+ state.daemon?.close()
112
+ }
113
+ }
@@ -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
+ }