@brimveyn/aimux 1.16.3 → 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,57 @@
1
+ import type { SessionRecord } from '../../state/types'
2
+
3
+ import { findMostRecentSession, loadSessionCatalog } from '../../state/session-catalog'
4
+
5
+ /**
6
+ * Resolve `--workspace W` to a session record from the catalog. Falls back to
7
+ * the most recently opened session when the flag is absent. Throws when the
8
+ * catalog is empty (no session has ever been created) or when the explicit
9
+ * name/id doesn't match.
10
+ *
11
+ * Matching: exact id wins; otherwise exact name (case-sensitive); otherwise
12
+ * unique case-insensitive name match.
13
+ */
14
+ export function resolveWorkspace(name: string | undefined): SessionRecord {
15
+ const sessions = loadSessionCatalog()
16
+ if (sessions.length === 0) {
17
+ throw new Error(
18
+ 'no sessions found — create one from the aimux UI first (or pass --workspace once created)'
19
+ )
20
+ }
21
+
22
+ if (name === undefined || name === '') {
23
+ const active = findMostRecentSession(sessions)
24
+ if (!active) {
25
+ throw new Error('no active workspace and the catalog is empty')
26
+ }
27
+ return active
28
+ }
29
+
30
+ const byId = sessions.find((session) => session.id === name)
31
+ if (byId) return byId
32
+
33
+ const exactNameMatches = sessions.filter((session) => session.name === name)
34
+ if (exactNameMatches.length > 1) {
35
+ throw new Error(
36
+ `workspace "${name}" matches multiple sessions: ${exactNameMatches.map((s) => s.id).join(', ')}`
37
+ )
38
+ }
39
+ const exactOnly = exactNameMatches[0]
40
+ if (exactOnly) return exactOnly
41
+
42
+ const lower = name.toLowerCase()
43
+ const ciMatches = sessions.filter((session) => session.name.toLowerCase() === lower)
44
+ if (ciMatches.length > 1) {
45
+ throw new Error(
46
+ `workspace "${name}" matches multiple sessions: ${ciMatches.map((s) => s.id).join(', ')}`
47
+ )
48
+ }
49
+ const ciOnly = ciMatches[0]
50
+ if (ciOnly) return ciOnly
51
+
52
+ throw new Error(`workspace not found: ${name}`)
53
+ }
54
+
55
+ export function listWorkspaces(): SessionRecord[] {
56
+ return loadSessionCatalog()
57
+ }
@@ -0,0 +1,33 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
4
+ import { SHARED_FLAGS } from '../../flags'
5
+ import { EXIT_OK, writeJson } from '../../output'
6
+
7
+ export const tabClose: CliCommand = {
8
+ args: [{ name: 'tabId', required: true }],
9
+ flags: SHARED_FLAGS,
10
+ group: 'tab',
11
+ run: async (ctx) => {
12
+ const tabId = ctx.args.positionals[0]
13
+ if (typeof tabId !== 'string' || tabId.length === 0) {
14
+ throw new Error('tabId is required')
15
+ }
16
+
17
+ const workspace = ctx.getWorkspace()
18
+ const daemon = await ctx.getDaemon()
19
+
20
+ if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
21
+ throw new Error(
22
+ 'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
23
+ )
24
+ }
25
+ await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
26
+ await daemon.expectOk('closeTab', { tabId })
27
+
28
+ writeJson({ ok: true })
29
+ return EXIT_OK
30
+ },
31
+ summary: 'Close a tab in the workspace',
32
+ verb: 'close',
33
+ }
@@ -0,0 +1,109 @@
1
+ import { resolve as resolvePath } from 'node:path'
2
+
3
+ import type { CliCommand } from '../../registry'
4
+
5
+ import {
6
+ IPC_CAPABILITY_CREATE_TAB_SIZE_FALLBACK,
7
+ IPC_CAPABILITY_THIN_ATTACH,
8
+ } from '../../../ipc/protocol'
9
+ import { createPrefixedId } from '../../../platform/id'
10
+ import { getAllAssistantOptions, parseCommand } from '../../../pty/command-registry'
11
+ import { SHARED_FLAGS } from '../../flags'
12
+ import { EXIT_OK, writeJson } from '../../output'
13
+
14
+ const FALLBACK_COLS = 200
15
+ const FALLBACK_ROWS = 60
16
+
17
+ export const tabCreate: CliCommand = {
18
+ args: [],
19
+ flags: [
20
+ ...SHARED_FLAGS,
21
+ {
22
+ description: 'assistant id (claude, codex, opencode, terminal, ...)',
23
+ kind: 'string',
24
+ name: 'assistant',
25
+ },
26
+ { description: 'tab title (defaults to assistant label)', kind: 'string', name: 'title' },
27
+ { description: 'cwd for the spawned PTY', kind: 'string', name: 'cwd' },
28
+ {
29
+ description: 'explicit command (overrides the assistant default)',
30
+ kind: 'string',
31
+ name: 'command',
32
+ },
33
+ {
34
+ description: 'worktree id the tab belongs to (defaults to the workspace’s active worktree)',
35
+ kind: 'string',
36
+ name: 'worktree',
37
+ },
38
+ ],
39
+ group: 'tab',
40
+ run: async (ctx) => {
41
+ const assistantId = ctx.args.flags.assistant
42
+ if (typeof assistantId !== 'string' || assistantId.length === 0) {
43
+ throw new Error('--assistant is required')
44
+ }
45
+ const options = getAllAssistantOptions({})
46
+ const option = options.find((o) => o.id === assistantId)
47
+ if (!option) {
48
+ throw new Error(
49
+ `unknown assistant: ${assistantId} (known: ${options.map((o) => o.id).join(', ')})`
50
+ )
51
+ }
52
+ const command =
53
+ typeof ctx.args.flags.command === 'string' ? ctx.args.flags.command : option.command
54
+ const title = typeof ctx.args.flags.title === 'string' ? ctx.args.flags.title : option.label
55
+ const cwdRaw = typeof ctx.args.flags.cwd === 'string' ? ctx.args.flags.cwd : undefined
56
+ const cwd = cwdRaw === undefined ? undefined : resolvePath(cwdRaw)
57
+
58
+ const workspace = ctx.getWorkspace()
59
+ const daemon = await ctx.getDaemon()
60
+
61
+ if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
62
+ throw new Error(
63
+ 'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
64
+ )
65
+ }
66
+
67
+ // Resolve worktreeId: explicit flag wins, otherwise the workspace's
68
+ // currently-active worktree, otherwise undefined (= no grouping).
69
+ const worktreeFlag =
70
+ typeof ctx.args.flags.worktree === 'string' ? ctx.args.flags.worktree : undefined
71
+ let worktreeId: string | undefined = worktreeFlag ?? workspace.activeWorktreeId
72
+ if (worktreeFlag !== undefined) {
73
+ const known = workspace.worktrees?.some((w) => w.id === worktreeFlag) ?? false
74
+ if (!known) {
75
+ const ids = workspace.worktrees?.map((w) => w.id).join(', ') ?? '(none)'
76
+ throw new Error(`unknown worktree id: ${worktreeFlag} (known: ${ids})`)
77
+ }
78
+ worktreeId = worktreeFlag
79
+ }
80
+
81
+ // Thin-attach so we don't clobber the UI's dimensions on the same session.
82
+ await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
83
+
84
+ const { args, executable } = parseCommand(command)
85
+ const tabId = createPrefixedId('tab')
86
+
87
+ // cols/rows = 0 means "fall back to the session's last attached size" on
88
+ // v11 daemons. Without that capability we have nothing reasonable to put
89
+ // here (the CLI has no terminal of its own), so use a roomy fallback —
90
+ // PTYs are reflowable, so 200×60 won't break anything that adapts.
91
+ const useFallback = daemon.hasCapability(IPC_CAPABILITY_CREATE_TAB_SIZE_FALLBACK)
92
+ await daemon.expectOk('createTab', {
93
+ args,
94
+ assistant: assistantId,
95
+ cols: useFallback ? 0 : FALLBACK_COLS,
96
+ command: executable,
97
+ cwd,
98
+ rows: useFallback ? 0 : FALLBACK_ROWS,
99
+ tabId,
100
+ title,
101
+ worktreeId,
102
+ })
103
+
104
+ writeJson({ assistant: assistantId, command, tabId, title, worktreeId })
105
+ return EXIT_OK
106
+ },
107
+ summary: 'Create a new tab in the active workspace',
108
+ verb: 'create',
109
+ }
@@ -0,0 +1,33 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
4
+ import { SHARED_FLAGS } from '../../flags'
5
+ import { EXIT_OK, writeJson } from '../../output'
6
+
7
+ export const tabFocus: CliCommand = {
8
+ args: [{ name: 'tabId', required: true }],
9
+ flags: SHARED_FLAGS,
10
+ group: 'tab',
11
+ run: async (ctx) => {
12
+ const tabId = ctx.args.positionals[0]
13
+ if (typeof tabId !== 'string' || tabId.length === 0) {
14
+ throw new Error('tabId is required')
15
+ }
16
+
17
+ const workspace = ctx.getWorkspace()
18
+ const daemon = await ctx.getDaemon()
19
+
20
+ if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
21
+ throw new Error(
22
+ 'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
23
+ )
24
+ }
25
+ await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
26
+ await daemon.expectOk('setActiveTab', { tabId })
27
+
28
+ writeJson({ ok: true })
29
+ return EXIT_OK
30
+ },
31
+ summary: 'Set the active tab in the workspace',
32
+ verb: 'focus',
33
+ }
@@ -0,0 +1,52 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { IPC_CAPABILITY_LIST_TABS, IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
4
+ import { SHARED_FLAGS } from '../../flags'
5
+ import { EXIT_OK, writeJson } from '../../output'
6
+
7
+ export const tabList: CliCommand = {
8
+ args: [],
9
+ flags: SHARED_FLAGS,
10
+ group: 'tab',
11
+ run: async (ctx) => {
12
+ const workspace = ctx.getWorkspace()
13
+ const daemon = await ctx.getDaemon()
14
+
15
+ if (daemon.hasCapability(IPC_CAPABILITY_LIST_TABS)) {
16
+ const result = await daemon.listTabs(workspace.id)
17
+ writeJson({ activeTabId: result.activeTabId, tabs: result.tabs })
18
+ return EXIT_OK
19
+ }
20
+
21
+ if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
22
+ throw new Error(
23
+ 'daemon predates listTabs and thinAttach capabilities — restart aimux to pick up the new daemon'
24
+ )
25
+ }
26
+
27
+ // Fallback: thin-attach to read the same information without resizing the
28
+ // session. Pre-listTabs daemons that advertise thinAttach would in
29
+ // principle land here, but that combination shouldn't ship.
30
+ const attach = await daemon.attach({
31
+ cols: 0,
32
+ rows: 0,
33
+ sessionId: workspace.id,
34
+ thin: true,
35
+ })
36
+ writeJson({
37
+ activeTabId: attach.activeTabId,
38
+ tabs: attach.tabs.map((tab) => ({
39
+ activity: tab.activity,
40
+ assistant: tab.assistant,
41
+ command: tab.command,
42
+ id: tab.id,
43
+ status: tab.status,
44
+ title: tab.title,
45
+ worktreeId: tab.worktreeId,
46
+ })),
47
+ })
48
+ return EXIT_OK
49
+ },
50
+ summary: 'List tabs in the active workspace',
51
+ verb: 'list',
52
+ }
@@ -0,0 +1,83 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
4
+ import { bracketedPaste, notationToBytes } from '../../chord'
5
+ import { SHARED_FLAGS } from '../../flags'
6
+ import { EXIT_OK, writeJson } from '../../output'
7
+
8
+ /**
9
+ * Lower a chord/paste buffer of bytes into the string the protocol expects.
10
+ * Every byte we emit is < 0x80 (control chars or printable ASCII), so a
11
+ * Latin-1 decode is faithful — the receiving PTY's UTF-8 path treats each
12
+ * single byte as itself.
13
+ */
14
+ function bytesToString(bytes: Buffer): string {
15
+ let out = ''
16
+ for (const byte of bytes) {
17
+ out += String.fromCharCode(byte)
18
+ }
19
+ return out
20
+ }
21
+
22
+ export const tabSend: CliCommand = {
23
+ args: [{ name: 'tabId', required: true }, { name: 'text' }],
24
+ flags: [
25
+ ...SHARED_FLAGS,
26
+ { description: 'append \\r so the receiving CLI submits', kind: 'boolean', name: 'enter' },
27
+ {
28
+ description: 'interpret <text> as a vim-style key chord (e.g. <C-c>, <Esc>, <Up><Up>)',
29
+ kind: 'boolean',
30
+ name: 'keys',
31
+ },
32
+ {
33
+ description: 'read the payload from stdin instead of <text>',
34
+ kind: 'boolean',
35
+ name: 'stdin',
36
+ },
37
+ ],
38
+ group: 'tab',
39
+ run: async (ctx) => {
40
+ const tabId = ctx.args.positionals[0]
41
+ if (typeof tabId !== 'string' || tabId.length === 0) {
42
+ throw new Error('tabId is required')
43
+ }
44
+
45
+ const fromStdin = ctx.args.flags.stdin === true
46
+ const asKeys = ctx.args.flags.keys === true
47
+ const appendEnter = ctx.args.flags.enter === true
48
+
49
+ let data: string
50
+ if (fromStdin) {
51
+ const stdinText = await Bun.stdin.text()
52
+ data = asKeys ? bytesToString(notationToBytes(stdinText)) : bracketedPaste(stdinText)
53
+ } else {
54
+ const text = ctx.args.positionals[1] ?? ''
55
+ if (asKeys) {
56
+ if (text === '') throw new Error('--keys requires the chord notation as <text>')
57
+ data = bytesToString(notationToBytes(text))
58
+ } else {
59
+ data = bracketedPaste(text)
60
+ }
61
+ }
62
+
63
+ if (appendEnter) {
64
+ data = `${data}\r`
65
+ }
66
+
67
+ const workspace = ctx.getWorkspace()
68
+ const daemon = await ctx.getDaemon()
69
+ if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
70
+ throw new Error(
71
+ 'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
72
+ )
73
+ }
74
+
75
+ await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
76
+ await daemon.expectOk('write', { data, tabId })
77
+
78
+ writeJson({ bytesWritten: Buffer.byteLength(data, 'utf8'), ok: true })
79
+ return EXIT_OK
80
+ },
81
+ summary: 'Write text or a key chord to a tab',
82
+ verb: 'send',
83
+ }
@@ -0,0 +1,127 @@
1
+ import type { TerminalSnapshot } 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, writeJson } from '../../output'
7
+ import { snapshotTailLines, snapshotToLines } from '../../snapshot-render'
8
+
9
+ const RENDER_WAIT_MS = 500
10
+
11
+ export const tabSnapshot: CliCommand = {
12
+ args: [{ name: 'tabId', required: true }],
13
+ flags: [
14
+ ...SHARED_FLAGS,
15
+ { description: 'return only the last N non-blank lines', kind: 'number', name: 'tail' },
16
+ {
17
+ description: 'output format: json (default) or text (raw screen dump)',
18
+ kind: 'string',
19
+ name: 'format',
20
+ },
21
+ {
22
+ description: 'do not strip trailing whitespace from each line',
23
+ kind: 'boolean',
24
+ name: 'no-trim',
25
+ },
26
+ ],
27
+ group: 'tab',
28
+ run: async (ctx) => {
29
+ const tabId = ctx.args.positionals[0]
30
+ if (typeof tabId !== 'string' || tabId.length === 0) {
31
+ throw new Error('tabId is required')
32
+ }
33
+ const tail = typeof ctx.args.flags.tail === 'number' ? ctx.args.flags.tail : 0
34
+ const format = ctx.args.flags.format ?? 'json'
35
+ if (format !== 'json' && format !== 'text') {
36
+ throw new Error(`--format must be "json" or "text" (got: ${String(format)})`)
37
+ }
38
+ const renderOptions = { trim: ctx.args.flags['no-trim'] !== true }
39
+
40
+ const workspace = ctx.getWorkspace()
41
+ const daemon = await ctx.getDaemon()
42
+ if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
43
+ throw new Error(
44
+ 'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
45
+ )
46
+ }
47
+
48
+ // Subscribe BEFORE attaching so a render fired between attach completion
49
+ // and our subscription doesn't slip through.
50
+ const renderState: { cancel: (() => void) | null } = { cancel: null }
51
+ const renderPromise = new Promise<TerminalSnapshot | null>((resolve) => {
52
+ const off = daemon.on('tabRender', (payload) => {
53
+ if (payload.tabId !== tabId) return
54
+ off()
55
+ clearTimeout(timer)
56
+ renderState.cancel = null
57
+ resolve(payload.viewport)
58
+ })
59
+ const timer = setTimeout(() => {
60
+ off()
61
+ renderState.cancel = null
62
+ resolve(null)
63
+ }, RENDER_WAIT_MS)
64
+ renderState.cancel = () => {
65
+ clearTimeout(timer)
66
+ off()
67
+ resolve(null)
68
+ }
69
+ })
70
+
71
+ const attach = await daemon.attach({
72
+ cols: 0,
73
+ rows: 0,
74
+ sessionId: workspace.id,
75
+ thin: true,
76
+ })
77
+ const tab = attach.tabs.find((t) => t.id === tabId)
78
+ if (!tab) {
79
+ renderState.cancel?.()
80
+ throw new Error(`tab not found: ${tabId}`)
81
+ }
82
+
83
+ let snapshot = tab.viewport
84
+ if (!snapshot || snapshot.lines.length === 0) {
85
+ const awaited = await renderPromise
86
+ if (awaited) snapshot = awaited
87
+ } else {
88
+ renderState.cancel?.()
89
+ }
90
+
91
+ if (!snapshot) {
92
+ throw new Error('no snapshot available within timeout')
93
+ }
94
+
95
+ const lines =
96
+ tail > 0
97
+ ? snapshotTailLines(snapshot, tail, renderOptions)
98
+ : snapshotToLines(snapshot, renderOptions)
99
+
100
+ if (format === 'text') {
101
+ // Raw screen dump: best fit when piping into an LLM prompt — no JSON
102
+ // escape noise, the model sees the terminal exactly as it appears.
103
+ // The tail-trim and per-line trim already ran above; just join.
104
+ process.stdout.write(`${lines.join('\n')}\n`)
105
+ return EXIT_OK
106
+ }
107
+
108
+ let widest = 0
109
+ for (const line of lines) {
110
+ if (line.length > widest) widest = line.length
111
+ }
112
+ writeJson({
113
+ cols: widest,
114
+ cursor: {
115
+ col: snapshot.cursorCol ?? null,
116
+ row: snapshot.cursorRow ?? null,
117
+ visible: snapshot.cursorVisible,
118
+ },
119
+ lines,
120
+ rows: lines.length,
121
+ tabId,
122
+ })
123
+ return EXIT_OK
124
+ },
125
+ summary: 'Snapshot the visible viewport of a tab as plain text',
126
+ verb: 'snapshot',
127
+ }
@@ -0,0 +1,171 @@
1
+ import type { TerminalSnapshot } 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_RUNTIME, EXIT_TIMEOUT, writeNdjson } from '../../output'
7
+ import { snapshotToLines } from '../../snapshot-render'
8
+
9
+ interface Cursor {
10
+ row: number | null
11
+ col: number | null
12
+ visible: boolean
13
+ }
14
+
15
+ function toCursor(snapshot: TerminalSnapshot): Cursor {
16
+ return {
17
+ col: snapshot.cursorCol ?? null,
18
+ row: snapshot.cursorRow ?? null,
19
+ visible: snapshot.cursorVisible,
20
+ }
21
+ }
22
+
23
+ export const tabTail: CliCommand = {
24
+ args: [{ name: 'tabId', required: true }],
25
+ flags: [
26
+ ...SHARED_FLAGS,
27
+ {
28
+ description: 'emit the raw TerminalSnapshot instead of the trimmed text lines',
29
+ kind: 'boolean',
30
+ name: 'raw',
31
+ },
32
+ {
33
+ description: 'coalesce renders arriving within N ms (default 0 = no coalescing)',
34
+ kind: 'number',
35
+ name: 'rate-limit-ms',
36
+ },
37
+ {
38
+ description: 'also emit tabStatus records interleaved with renders',
39
+ kind: 'boolean',
40
+ name: 'follow-status',
41
+ },
42
+ {
43
+ description: 'exit after N milliseconds even if the tab is still alive',
44
+ kind: 'number',
45
+ name: 'timeout',
46
+ },
47
+ ],
48
+ group: 'tab',
49
+ run: async (ctx) => {
50
+ const tabId = ctx.args.positionals[0]
51
+ if (typeof tabId !== 'string' || tabId.length === 0) {
52
+ throw new Error('tabId is required')
53
+ }
54
+ const raw = ctx.args.flags.raw === true
55
+ const rateLimitMs =
56
+ typeof ctx.args.flags['rate-limit-ms'] === 'number' ? ctx.args.flags['rate-limit-ms'] : 0
57
+ const followStatus = ctx.args.flags['follow-status'] === true
58
+ const timeoutMs = typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : 0
59
+
60
+ const workspace = ctx.getWorkspace()
61
+ const daemon = await ctx.getDaemon()
62
+ if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
63
+ throw new Error(
64
+ 'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
65
+ )
66
+ }
67
+
68
+ const start = Date.now()
69
+ // Attach before wiring subscribers so replay renders arrive after we
70
+ // print the "attached" marker; that keeps NDJSON output deterministic
71
+ // for downstream consumers.
72
+ const attach = await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
73
+
74
+ // Fail fast on a bad tabId — otherwise tail would sit silent until
75
+ // --timeout, since no matching tabRender/tabExit would ever fire.
76
+ if (!attach.tabs.some((t) => t.id === tabId)) {
77
+ throw new Error(`tab not found: ${tabId}`)
78
+ }
79
+
80
+ return new Promise<number>((resolve) => {
81
+ let lastEmitAt = 0
82
+ let pendingViewport: TerminalSnapshot | null = null
83
+ let coalesceTimer: ReturnType<typeof setTimeout> | null = null
84
+
85
+ const emitRender = (viewport: TerminalSnapshot): void => {
86
+ const ts = Date.now() - start
87
+ if (raw) {
88
+ writeNdjson({ tabId, ts, type: 'render', viewport })
89
+ } else {
90
+ writeNdjson({
91
+ cursor: toCursor(viewport),
92
+ lines: snapshotToLines(viewport, { trim: true }),
93
+ tabId,
94
+ ts,
95
+ type: 'render',
96
+ })
97
+ }
98
+ lastEmitAt = Date.now()
99
+ }
100
+
101
+ const scheduleCoalesced = (viewport: TerminalSnapshot): void => {
102
+ pendingViewport = viewport
103
+ if (coalesceTimer) return
104
+ const elapsed = Date.now() - lastEmitAt
105
+ const wait = Math.max(0, rateLimitMs - elapsed)
106
+ coalesceTimer = setTimeout(() => {
107
+ coalesceTimer = null
108
+ const v = pendingViewport
109
+ pendingViewport = null
110
+ if (v) emitRender(v)
111
+ }, wait)
112
+ }
113
+
114
+ const offRender = daemon.on('tabRender', (payload) => {
115
+ if (payload.tabId !== tabId) return
116
+ if (rateLimitMs > 0) {
117
+ scheduleCoalesced(payload.viewport)
118
+ } else {
119
+ emitRender(payload.viewport)
120
+ }
121
+ })
122
+ const offExit = daemon.on('tabExit', (payload) => {
123
+ if (payload.tabId !== tabId) return
124
+ writeNdjson({ exitCode: payload.exitCode, tabId, ts: Date.now() - start, type: 'exit' })
125
+ cleanup()
126
+ resolve(EXIT_OK)
127
+ })
128
+ const offError = daemon.on('tabError', (payload) => {
129
+ if (payload.tabId !== tabId) return
130
+ writeNdjson({ error: payload.message, tabId, ts: Date.now() - start, type: 'error' })
131
+ cleanup()
132
+ resolve(EXIT_RUNTIME)
133
+ })
134
+ const noop = (): void => {}
135
+ const offStatus = followStatus
136
+ ? daemon.on('tabStatus', (payload) => {
137
+ if (payload.tabId !== tabId) return
138
+ writeNdjson({
139
+ status: payload.status,
140
+ tabId,
141
+ ts: Date.now() - start,
142
+ type: 'status',
143
+ })
144
+ })
145
+ : noop
146
+
147
+ const timer =
148
+ timeoutMs > 0
149
+ ? setTimeout(() => {
150
+ writeNdjson({ tabId, ts: Date.now() - start, type: 'timeout' })
151
+ cleanup()
152
+ resolve(EXIT_TIMEOUT)
153
+ }, timeoutMs)
154
+ : null
155
+
156
+ const cleanup = (): void => {
157
+ offRender()
158
+ offExit()
159
+ offError()
160
+ offStatus()
161
+ if (coalesceTimer) {
162
+ clearTimeout(coalesceTimer)
163
+ coalesceTimer = null
164
+ }
165
+ if (timer) clearTimeout(timer)
166
+ }
167
+ })
168
+ },
169
+ summary: 'Stream a tab’s render events as NDJSON',
170
+ verb: 'tail',
171
+ }