@brimveyn/aimux 1.19.7 → 1.20.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 (54) hide show
  1. package/README.md +14 -0
  2. package/package.json +3 -2
  3. package/skills/aimux-orchestrator/SKILL.md +127 -0
  4. package/skills/aimux-orchestrator/assets/ledger.template.md +18 -0
  5. package/skills/aimux-orchestrator/references/prompts.md +68 -0
  6. package/skills/aimux-orchestrator/references/review.md +18 -0
  7. package/src/cli/client/daemon-client.ts +16 -0
  8. package/src/cli/client/workspace-resolver.ts +61 -8
  9. package/src/cli/commands/tab/await.ts +1 -1
  10. package/src/cli/commands/tab/close.ts +1 -1
  11. package/src/cli/commands/tab/create.ts +263 -128
  12. package/src/cli/commands/tab/focus.ts +1 -1
  13. package/src/cli/commands/tab/prompt-io.ts +8 -2
  14. package/src/cli/commands/tab/run.ts +10 -2
  15. package/src/cli/commands/tab/send.ts +12 -7
  16. package/src/cli/commands/tab/snapshot.ts +2 -1
  17. package/src/cli/commands/tab/tail.ts +1 -1
  18. package/src/cli/commands/tab/wait.ts +2 -1
  19. package/src/cli/commands/worker/await.ts +34 -0
  20. package/src/cli/commands/worker/doctor.ts +153 -0
  21. package/src/cli/commands/worker/list.ts +67 -0
  22. package/src/cli/commands/worker/prompt.ts +74 -0
  23. package/src/cli/commands/worker/run.ts +143 -0
  24. package/src/cli/commands/worker/shared.ts +515 -0
  25. package/src/cli/commands/worker/stop.ts +113 -0
  26. package/src/cli/commands/worker/submit.ts +40 -0
  27. package/src/cli/commands/workspace/close.ts +1 -1
  28. package/src/cli/commands/workspace/create.ts +2 -1
  29. package/src/cli/commands/workspace/switch.ts +1 -1
  30. package/src/cli/commands/worktree/create-core.ts +27 -7
  31. package/src/cli/commands/worktree/create.ts +18 -3
  32. package/src/cli/commands/worktree/remove.ts +32 -10
  33. package/src/cli/completion/entry.ts +181 -0
  34. package/src/cli/completion/install.ts +222 -0
  35. package/src/cli/completion/plan.ts +216 -0
  36. package/src/cli/completion/scripts.ts +147 -0
  37. package/src/cli/completion/sources.ts +74 -0
  38. package/src/cli/context.ts +15 -0
  39. package/src/cli/flags.ts +45 -2
  40. package/src/cli/index.ts +40 -18
  41. package/src/cli/output.ts +3 -0
  42. package/src/cli/registry.ts +14 -0
  43. package/src/daemon/daemon.ts +58 -8
  44. package/src/daemon/session-registry.ts +5 -0
  45. package/src/doctor.ts +4 -0
  46. package/src/git/worktree.ts +23 -1
  47. package/src/index.tsx +53 -92
  48. package/src/ipc/manager-protocol.ts +15 -2
  49. package/src/ipc/protocol.ts +26 -3
  50. package/src/platform/worktree-paths.ts +21 -1
  51. package/src/state/session-persistence.ts +2 -0
  52. package/src/state/types.ts +4 -0
  53. package/src/state/validation.ts +1 -0
  54. package/src/terminal-manager/manager-client.ts +18 -3
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Pure completion planner. Given the shell's current words + cursor index it
3
+ * decides WHAT should be completed — never how, and never with I/O. Dynamic
4
+ * sources come back as a plan the caller resolves (see `sources.ts`), which
5
+ * keeps this whole file unit-testable and free of daemon/filesystem access.
6
+ *
7
+ * The candidate lists are derived from `COMMANDS`, the same registry that
8
+ * generates `--help`, so completion cannot drift from the documented CLI.
9
+ */
10
+
11
+ import type { CompletionSource, DynamicCompletionSource, FlagSpec } from '../flags'
12
+
13
+ import { COMMANDS, resolveCommand } from '../registry'
14
+
15
+ export interface CompletionCandidate {
16
+ description?: string
17
+ value: string
18
+ }
19
+
20
+ export type CompletionPlan =
21
+ /** Ready-to-print candidates, already filtered and prefixed. */
22
+ | { candidates: CompletionCandidate[]; kind: 'candidates' }
23
+ /** Needs live state. `prefix` is re-applied to each resolved value. */
24
+ | { kind: 'dynamic'; prefix: string; source: DynamicCompletionSource; word: string }
25
+ /** Hand off to the shell's own filename completion. */
26
+ | { kind: 'files' }
27
+ /** Free text — offer nothing. */
28
+ | { kind: 'none' }
29
+
30
+ const NONE: CompletionPlan = { kind: 'none' }
31
+
32
+ /**
33
+ * Top-level (non-group) commands, mirroring the branches in `src/index.tsx`.
34
+ * `daemon` / `terminal-manager` / `__complete` are internal and stay hidden.
35
+ */
36
+ export const TOP_LEVEL_COMMANDS: readonly CompletionCandidate[] = [
37
+ { description: 'Print setup diagnostics', value: 'doctor' },
38
+ { description: 'Self-update to the latest release', value: 'update' },
39
+ { description: 'Print the package version', value: 'version' },
40
+ { description: 'Restart the IPC daemon (PTYs survive)', value: 'restart-daemon' },
41
+ { description: 'Restart the terminal manager (kills PTYs)', value: 'restart-terminal-manager' },
42
+ { description: 'Print or install a shell completion script', value: 'completion' },
43
+ { description: 'Show CLI help', value: 'help' },
44
+ ]
45
+
46
+ const COMPLETION_SUBCOMMANDS: readonly CompletionCandidate[] = [
47
+ { description: 'Print the bash completion script', value: 'bash' },
48
+ { description: 'Print the fish completion script', value: 'fish' },
49
+ { description: 'Print the zsh completion script', value: 'zsh' },
50
+ { description: 'Install the script for the detected shell', value: 'install' },
51
+ ]
52
+
53
+ const GROUP_DESCRIPTIONS: Record<string, string> = {
54
+ tab: 'Drive individual tabs (create, send, snapshot, await)',
55
+ worker: 'Named agent workers — the preferred orchestration surface',
56
+ workspace: 'Workspaces (sessions) in the profile catalog',
57
+ worktree: 'Git worktrees attached to the active workspace',
58
+ }
59
+
60
+ function groupCandidates(): CompletionCandidate[] {
61
+ const seen = new Set<string>()
62
+ const candidates: CompletionCandidate[] = []
63
+ for (const command of COMMANDS) {
64
+ if (seen.has(command.group)) continue
65
+ seen.add(command.group)
66
+ candidates.push({ description: GROUP_DESCRIPTIONS[command.group], value: command.group })
67
+ }
68
+ return candidates
69
+ }
70
+
71
+ function filtered(candidates: readonly CompletionCandidate[], word: string): CompletionPlan {
72
+ const matches = candidates
73
+ .filter((candidate) => candidate.value.startsWith(word))
74
+ .map((candidate) => ({ ...candidate }))
75
+ return { candidates: matches, kind: 'candidates' }
76
+ }
77
+
78
+ function fromSource(
79
+ source: CompletionSource | undefined,
80
+ word: string,
81
+ prefix: string
82
+ ): CompletionPlan {
83
+ if (!source) return NONE
84
+ switch (source.kind) {
85
+ case 'dynamic':
86
+ return { kind: 'dynamic', prefix, source: source.source, word }
87
+ case 'file':
88
+ return { kind: 'files' }
89
+ case 'values': {
90
+ const matches = source.values
91
+ .filter((value) => value.startsWith(word))
92
+ .map((value) => ({ value: `${prefix}${value}` }))
93
+ return { candidates: matches, kind: 'candidates' }
94
+ }
95
+ // 'none' and any future kind fall through to no candidates.
96
+ default:
97
+ return NONE
98
+ }
99
+ }
100
+
101
+ interface TokenScan {
102
+ /** Set when the last token was a flag still waiting for its value. */
103
+ awaitingValueFor: FlagSpec | null
104
+ positionalCount: number
105
+ stoppedFlags: boolean
106
+ usedFlags: Set<string>
107
+ }
108
+
109
+ /**
110
+ * Walk the tokens BEFORE the cursor the way `parseArgs` would, but tolerant of
111
+ * anything malformed — a half-typed command line is the normal case here.
112
+ */
113
+ function scanTokens(tokens: readonly string[], flags: readonly FlagSpec[]): TokenScan {
114
+ const byName = new Map(flags.map((flag) => [flag.name, flag]))
115
+ const scan: TokenScan = {
116
+ awaitingValueFor: null,
117
+ positionalCount: 0,
118
+ stoppedFlags: false,
119
+ usedFlags: new Set<string>(),
120
+ }
121
+
122
+ for (const token of tokens) {
123
+ if (scan.awaitingValueFor) {
124
+ scan.awaitingValueFor = null
125
+ continue
126
+ }
127
+ if (!scan.stoppedFlags && token === '--') {
128
+ scan.stoppedFlags = true
129
+ continue
130
+ }
131
+ if (!scan.stoppedFlags && token.startsWith('--')) {
132
+ const eq = token.indexOf('=')
133
+ const name = eq === -1 ? token.slice(2) : token.slice(2, eq)
134
+ scan.usedFlags.add(name)
135
+ const spec = byName.get(name)
136
+ // `optional-string` only ever binds via `=`, so a bare one never awaits.
137
+ if (spec && eq === -1 && (spec.kind === 'string' || spec.kind === 'number')) {
138
+ scan.awaitingValueFor = spec
139
+ }
140
+ continue
141
+ }
142
+ scan.positionalCount++
143
+ }
144
+
145
+ return scan
146
+ }
147
+
148
+ function planTopLevel(word: string): CompletionPlan {
149
+ if (word.startsWith('-')) {
150
+ return filtered(
151
+ [
152
+ { description: 'Show CLI help', value: '--help' },
153
+ { description: 'Print the package version', value: '--version' },
154
+ ],
155
+ word
156
+ )
157
+ }
158
+ return filtered([...groupCandidates(), ...TOP_LEVEL_COMMANDS], word)
159
+ }
160
+
161
+ /**
162
+ * @param words Full command line tokens, including the program name at index 0.
163
+ * @param cword Index into `words` of the token being completed.
164
+ */
165
+ export function planCompletion(words: readonly string[], cword: number): CompletionPlan {
166
+ const index = Math.max(0, cword)
167
+ const word = words[index] ?? ''
168
+ const argIndex = index - 1
169
+ if (argIndex < 0) return NONE
170
+
171
+ const args = words.slice(1)
172
+ if (argIndex === 0) return planTopLevel(word)
173
+
174
+ const group = args[0] ?? ''
175
+
176
+ if (group === 'completion') {
177
+ return argIndex === 1 ? filtered(COMPLETION_SUBCOMMANDS, word) : NONE
178
+ }
179
+
180
+ const verbs = COMMANDS.filter((command) => command.group === group)
181
+ if (verbs.length === 0) return NONE
182
+
183
+ if (argIndex === 1) {
184
+ return filtered(
185
+ verbs.map((command) => ({ description: command.summary, value: command.verb })),
186
+ word
187
+ )
188
+ }
189
+
190
+ const command = resolveCommand(group, args[1] ?? '')
191
+ if (!command) return NONE
192
+
193
+ const scan = scanTokens(args.slice(2, argIndex), command.flags)
194
+
195
+ if (scan.awaitingValueFor) {
196
+ return fromSource(scan.awaitingValueFor.complete, word, '')
197
+ }
198
+
199
+ if (!scan.stoppedFlags && word.startsWith('--')) {
200
+ const eq = word.indexOf('=')
201
+ if (eq !== -1) {
202
+ const name = word.slice(2, eq)
203
+ const spec = command.flags.find((flag) => flag.name === name)
204
+ if (!spec || spec.kind === 'boolean') return NONE
205
+ return fromSource(spec.complete, word.slice(eq + 1), `--${name}=`)
206
+ }
207
+ return filtered(
208
+ command.flags
209
+ .filter((flag) => !scan.usedFlags.has(flag.name))
210
+ .map((flag) => ({ description: flag.description, value: `--${flag.name}` })),
211
+ word
212
+ )
213
+ }
214
+
215
+ return fromSource(command.args[scan.positionalCount]?.complete, word, '')
216
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Shell completion scripts.
3
+ *
4
+ * They are deliberately THIN: collect the current words, shell out to
5
+ * `aimux __complete`, render the reply. All real logic lives in TypeScript, so
6
+ * upgrading aimux upgrades completion behaviour without regenerating anything
7
+ * the user has installed.
8
+ *
9
+ * Reply protocol (`__complete` stdout):
10
+ * value<TAB>description … zero or more candidate lines
11
+ * :list | :files | :none … exactly one trailing directive line
12
+ *
13
+ * `:files` means "no candidates of ours — use your own filename completion".
14
+ */
15
+
16
+ import { COMMANDS } from '../registry'
17
+
18
+ export type SupportedShell = 'bash' | 'fish' | 'zsh'
19
+
20
+ export const SUPPORTED_SHELLS: readonly SupportedShell[] = ['bash', 'fish', 'zsh']
21
+
22
+ export const DIRECTIVE_LIST = ':list'
23
+ export const DIRECTIVE_FILES = ':files'
24
+ export const DIRECTIVE_NONE = ':none'
25
+
26
+ export function isSupportedShell(value: string): value is SupportedShell {
27
+ return SUPPORTED_SHELLS.includes(value as SupportedShell)
28
+ }
29
+
30
+ /** Long flags that take a path — fish needs these declared up front. */
31
+ function fileFlagNames(): string[] {
32
+ const names = new Set<string>()
33
+ for (const command of COMMANDS) {
34
+ for (const flag of command.flags) {
35
+ if (flag.complete?.kind === 'file') names.add(flag.name)
36
+ }
37
+ }
38
+ return [...names].sort()
39
+ }
40
+
41
+ function bashScript(command: string): string {
42
+ return `# aimux bash completion. Regenerate with: aimux completion bash
43
+ _aimux_complete() {
44
+ local IFS=$'\\n'
45
+ local directive="${DIRECTIVE_LIST}"
46
+ local -a candidates=()
47
+ local line
48
+
49
+ while IFS= read -r line; do
50
+ case "$line" in
51
+ :*) directive="$line" ;;
52
+ "") ;;
53
+ *) candidates+=("\${line%%$'\\t'*}") ;;
54
+ esac
55
+ done < <(${command} __complete --no-descriptions --cword "$COMP_CWORD" -- "\${COMP_WORDS[@]}" 2>/dev/null)
56
+
57
+ if [[ "$directive" == "${DIRECTIVE_FILES}" ]]; then
58
+ # Let readline do filenames itself — it handles quoting and trailing slashes.
59
+ compopt -o default 2>/dev/null
60
+ COMPREPLY=()
61
+ return 0
62
+ fi
63
+
64
+ compopt +o default 2>/dev/null
65
+ if [[ "$directive" == "${DIRECTIVE_NONE}" ]]; then
66
+ COMPREPLY=()
67
+ return 0
68
+ fi
69
+
70
+ COMPREPLY=($(compgen -W "\${candidates[*]}" -- "\${COMP_WORDS[COMP_CWORD]}"))
71
+ }
72
+
73
+ complete -F _aimux_complete aimux
74
+ `
75
+ }
76
+
77
+ function zshScript(command: string): string {
78
+ return `#compdef aimux
79
+ # aimux zsh completion. Regenerate with: aimux completion zsh
80
+
81
+ _aimux_complete() {
82
+ local -a candidates
83
+ local directive="${DIRECTIVE_LIST}"
84
+ local line
85
+
86
+ local -a reply_lines
87
+ reply_lines=("\${(@f)$(${command} __complete --cword $((CURRENT - 1)) -- "\${words[@]}" 2>/dev/null)}")
88
+
89
+ for line in "\${reply_lines[@]}"; do
90
+ case "$line" in
91
+ :*) directive="$line" ;;
92
+ "") ;;
93
+ # _describe takes "value:description"; the reply is tab separated.
94
+ *) candidates+=("\${line/$'\\t'/:}") ;;
95
+ esac
96
+ done
97
+
98
+ if [[ "$directive" == "${DIRECTIVE_FILES}" ]]; then
99
+ _files
100
+ return
101
+ fi
102
+ [[ "$directive" == "${DIRECTIVE_NONE}" ]] && return
103
+
104
+ _describe -t aimux 'aimux' candidates
105
+ }
106
+
107
+ compdef _aimux_complete aimux
108
+ `
109
+ }
110
+
111
+ function fishScript(command: string): string {
112
+ const fileFlags = fileFlagNames()
113
+ .map((name) => `complete -c aimux -l ${name} -r -F`)
114
+ .join('\n')
115
+ return `# aimux fish completion. Regenerate with: aimux completion fish
116
+
117
+ function __aimux_complete
118
+ # \`commandline -opc\` is every finished token (including "aimux"), so its
119
+ # count IS the 0-based index of the token being typed. An empty current
120
+ # token expands to nothing, which the resolver reads as "".
121
+ set -l tokens (commandline -opc)
122
+ set -l current (commandline -ct)
123
+ ${command} __complete --cword (count $tokens) -- $tokens $current 2>/dev/null |
124
+ string match --invert --regex '^:'
125
+ end
126
+
127
+ complete -c aimux -f -a '(__aimux_complete)'
128
+
129
+ # Flags that take a path get fish's own file completion.
130
+ ${fileFlags}
131
+ `
132
+ }
133
+
134
+ /**
135
+ * @param command How the script should invoke aimux. Override it in dev to
136
+ * point at a checkout (e.g. `bun run /path/to/aimux/src/index.tsx`).
137
+ */
138
+ export function renderCompletionScript(shell: SupportedShell, command = 'aimux'): string {
139
+ switch (shell) {
140
+ case 'bash':
141
+ return bashScript(command)
142
+ case 'fish':
143
+ return fishScript(command)
144
+ case 'zsh':
145
+ return zshScript(command)
146
+ }
147
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Dynamic completion sources — the half of completion that needs live state.
3
+ *
4
+ * Two hard rules, because this code runs on every TAB press:
5
+ * 1. Never block. Anything that could hang (a daemon round-trip) must be
6
+ * wrapped in a deadline by its resolver.
7
+ * 2. Never fail loudly. A source that throws yields zero candidates; the
8
+ * shell must never see a stack trace where a completion list belongs.
9
+ *
10
+ * Phase 1 implements only the sources that read local state (built-in
11
+ * assistants, the workspace catalog). Daemon-backed sources — tabs, workers,
12
+ * worktrees — and git refs return nothing until phase 2 wires them up.
13
+ */
14
+
15
+ import type { DynamicCompletionSource } from '../flags'
16
+ import type { CompletionCandidate } from './plan'
17
+
18
+ import { ASSISTANT_OPTIONS } from '../../pty/command-registry'
19
+
20
+ function assistantCandidates(): CompletionCandidate[] {
21
+ return ASSISTANT_OPTIONS.map((option) => ({
22
+ description: option.description,
23
+ value: option.id,
24
+ }))
25
+ }
26
+
27
+ async function workspaceCandidates(): Promise<CompletionCandidate[]> {
28
+ // Imported lazily: the session catalog pulls in config + state modules that
29
+ // the static-source paths (groups, verbs, flags) have no reason to load.
30
+ const { listWorkspaces } = await import('../client/workspace-resolver')
31
+ const sessions = listWorkspaces()
32
+ const nameCounts = new Map<string, number>()
33
+ for (const session of sessions) {
34
+ nameCounts.set(session.name, (nameCounts.get(session.name) ?? 0) + 1)
35
+ }
36
+ return sessions.map((session) =>
37
+ // Ambiguous names can't be resolved by `--workspace`, so offer the id.
38
+ (nameCounts.get(session.name) ?? 0) > 1
39
+ ? { description: session.name, value: session.id }
40
+ : { description: session.id, value: session.name }
41
+ )
42
+ }
43
+
44
+ async function resolveSource(source: DynamicCompletionSource): Promise<CompletionCandidate[]> {
45
+ switch (source) {
46
+ case 'assistant':
47
+ return assistantCandidates()
48
+ case 'workspace':
49
+ return await workspaceCandidates()
50
+ // Phase 2: 'tab' | 'worker' | 'worktree' need a daemon round-trip under a
51
+ // deadline; 'git-ref' needs a `git for-each-ref` in the workspace repo.
52
+ default:
53
+ return []
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Resolve a dynamic source, filter by the partial word, and re-apply the
59
+ * prefix the planner stripped (e.g. `--workspace=`). Always resolves.
60
+ */
61
+ export async function resolveDynamicCandidates(
62
+ source: DynamicCompletionSource,
63
+ word: string,
64
+ prefix: string
65
+ ): Promise<CompletionCandidate[]> {
66
+ try {
67
+ const candidates = await resolveSource(source)
68
+ return candidates
69
+ .filter((candidate) => candidate.value.startsWith(word))
70
+ .map((candidate) => ({ ...candidate, value: `${prefix}${candidate.value}` }))
71
+ } catch {
72
+ return []
73
+ }
74
+ }
@@ -1,5 +1,6 @@
1
1
  import type { SessionRecord } from '../state/types'
2
2
  import type { DaemonClient } from './client/daemon-client'
3
+ import type { WorkspaceOrigin } from './client/workspace-resolver'
3
4
  import type { ParsedArgs } from './flags'
4
5
 
5
6
  /**
@@ -13,4 +14,18 @@ export interface CliContext {
13
14
  getDaemon: () => Promise<DaemonClient>
14
15
  /** Resolved workspace, populated on first call to `workspace()`. */
15
16
  getWorkspace: () => SessionRecord
17
+ /**
18
+ * Every catalogued workspace. Commands that must answer "is my worker really
19
+ * gone, or did the active workspace move?" need the whole catalog, not just
20
+ * the resolved record. Optional so test fixtures can inject one.
21
+ */
22
+ getWorkspaces?: () => SessionRecord[]
23
+ /**
24
+ * Where `getWorkspace()` came from — `flag` (`--workspace`), `env`
25
+ * (`AIMUX_WORKSPACE`), or `active` (most recently opened session). Only
26
+ * `active` can drift under a command while the UI switches workspaces, so
27
+ * commands that must not follow the UI branch on this. Optional so test
28
+ * fixtures can build a minimal context.
29
+ */
30
+ getWorkspaceOrigin?: () => WorkspaceOrigin
16
31
  }
package/src/cli/flags.ts CHANGED
@@ -4,15 +4,48 @@
4
4
  * and `--` to end flag parsing. Unknown flags produce a usage error.
5
5
  */
6
6
 
7
+ /**
8
+ * A dynamic completion source — resolved at TAB time from live state (catalog
9
+ * file, config, or the daemon) rather than from a fixed list. Resolution is
10
+ * always best-effort: a source that can't be reached yields no candidates
11
+ * instead of blocking or erroring the shell.
12
+ */
13
+ export type DynamicCompletionSource =
14
+ | 'assistant'
15
+ | 'git-ref'
16
+ | 'tab'
17
+ | 'worker'
18
+ | 'workspace'
19
+ | 'worktree'
20
+
21
+ /**
22
+ * Where a flag value or positional draws its shell-completion candidates.
23
+ * Declared on the spec next to `description` so `--help` and completion are
24
+ * generated from one source and can never drift apart.
25
+ *
26
+ * - `values` — fixed vocabulary, zero I/O
27
+ * - `dynamic` — resolved from live state, best-effort
28
+ * - `file` — hand off to the shell's own filename completion
29
+ * - `none` — free text (prompts, models, branch names we can't enumerate)
30
+ */
31
+ export type CompletionSource =
32
+ | { kind: 'dynamic'; source: DynamicCompletionSource }
33
+ | { kind: 'file' }
34
+ | { kind: 'none' }
35
+ | { kind: 'values'; values: readonly string[] }
36
+
7
37
  export interface FlagSpec {
8
38
  name: string
9
39
  kind: 'string' | 'number' | 'boolean' | 'optional-string'
10
40
  description?: string
41
+ /** Completion source for this flag's VALUE. Boolean flags take none. */
42
+ complete?: CompletionSource
11
43
  }
12
44
 
13
45
  export interface ArgSpec {
14
46
  name: string
15
47
  required?: boolean
48
+ complete?: CompletionSource
16
49
  }
17
50
 
18
51
  export interface ParsedArgs {
@@ -102,7 +135,17 @@ export function parseArgs(
102
135
  * consistency with future formats.
103
136
  */
104
137
  export const SHARED_FLAGS: readonly FlagSpec[] = [
105
- { description: 'workspace id or name', kind: 'string', name: 'workspace' },
106
- { description: 'runtime profile override (sets AIMUX_PROFILE)', kind: 'string', name: 'profile' },
138
+ {
139
+ complete: { kind: 'dynamic', source: 'workspace' },
140
+ description: 'workspace id or name',
141
+ kind: 'string',
142
+ name: 'workspace',
143
+ },
144
+ {
145
+ complete: { kind: 'none' },
146
+ description: 'runtime profile override (sets AIMUX_PROFILE)',
147
+ kind: 'string',
148
+ name: 'profile',
149
+ },
107
150
  { description: 'always-on JSON output (kept for consistency)', kind: 'boolean', name: 'json' },
108
151
  ]
package/src/cli/index.ts CHANGED
@@ -2,7 +2,7 @@ import type { DaemonClient } from './client/daemon-client'
2
2
  import type { CliContext } from './context'
3
3
 
4
4
  import { connectToDaemon, DaemonUnreachableError } from './client/bootstrap'
5
- import { resolveWorkspace } from './client/workspace-resolver'
5
+ import { listWorkspaces, resolveWorkspaceWithOrigin } from './client/workspace-resolver'
6
6
  import { type ArgSpec, CliUsageError, type FlagSpec, parseArgs, SHARED_FLAGS } from './flags'
7
7
  import {
8
8
  EXIT_DAEMON_UNREACHABLE,
@@ -21,6 +21,7 @@ const EXIT_CODES_BLOCK = [
21
21
  ' 3 runtime error (server replied with error, command failed)',
22
22
  ' 4 daemon unreachable (socket missing and autostart failed)',
23
23
  ' 10 question (tab run / tab await: worker is blocked on a question/permission)',
24
+ ' 11 pending submit (worker holds an unsubmitted prompt — see `worker submit`)',
24
25
  ' 124 timeout (tab run, tab await, tab wait, tab tail --timeout, workspace switch --wait)',
25
26
  ].join('\n')
26
27
 
@@ -64,9 +65,10 @@ function formatFlagLine(flag: FlagSpec): string {
64
65
  function printHelp(): void {
65
66
  process.stdout.write(
66
67
  [
67
- 'aimux CLI control plane drive workspaces, worktrees, and tabs from scripts.',
68
+ 'aimux — terminal multiplexer and agent-friendly control plane.',
68
69
  '',
69
70
  'Usage:',
71
+ ' aimux Start the interactive TUI',
70
72
  ' aimux <group> <verb> [flags] [args]',
71
73
  ' aimux <group> --help List verbs in a group',
72
74
  ' aimux <group> <verb> --help Show flags/args for a verb',
@@ -98,16 +100,19 @@ function printHelp(): void {
98
100
  '',
99
101
  'Env:',
100
102
  ' AIMUX_PROFILE Runtime profile (state dir, socket paths); --profile overrides.',
103
+ ' AIMUX_WORKSPACE Pin the target workspace (id or name); --workspace overrides.',
101
104
  '',
102
- 'Agent recipes:',
103
- ' # spawn Claude in a new tab, wait until it idles, snapshot the screen',
104
- ' TAB=$(aimux tab create --assistant claude --title fixup | jq -r .tabId)',
105
- ' aimux tab send "$TAB" "explain this repo" --enter',
106
- ' aimux tab wait "$TAB" --status idle --timeout 60000',
107
- ' aimux tab snapshot "$TAB" --tail 40 --format text',
105
+ 'Agent recipe:',
106
+ ' # create an isolated named worker, dispatch, and await one structured outcome',
107
+ ' # --workspace pins the target repo so a UI workspace switch cannot redirect it',
108
+ ' aimux worker run --workspace myrepo --name fixup --assistant claude "explain this repo"',
108
109
  '',
109
- ' # stream renders as NDJSON (one event per line)',
110
- ' aimux tab tail "$TAB" --rate-limit-ms 100 --follow-status',
110
+ 'Maintenance:',
111
+ ' aimux doctor | update | restart-daemon | restart-terminal-manager | version',
112
+ '',
113
+ 'Shell completion:',
114
+ ' Installed automatically on first launch (AIMUX_NO_COMPLETION_INSTALL=1 opts out).',
115
+ ' aimux completion install | aimux completion <bash|zsh|fish>',
111
116
  '',
112
117
  ].join('\n')
113
118
  )
@@ -201,6 +206,11 @@ export async function runCli(argv: readonly string[]): Promise<number> {
201
206
  if (error instanceof CliUsageError) {
202
207
  writeError(error.message)
203
208
  writeError(`usage: aimux ${command.group} ${command.verb}`)
209
+ writeJson({
210
+ command: `${command.group} ${command.verb}`,
211
+ error: error.message,
212
+ kind: 'usage-error',
213
+ })
204
214
  return EXIT_USAGE
205
215
  }
206
216
  throw error
@@ -215,11 +225,18 @@ export async function runCli(argv: readonly string[]): Promise<number> {
215
225
 
216
226
  const state: {
217
227
  daemon: DaemonClient | null
218
- workspace: ReturnType<typeof resolveWorkspace> | null
228
+ workspace: ReturnType<typeof resolveWorkspaceWithOrigin> | null
219
229
  } = {
220
230
  daemon: null,
221
231
  workspace: null,
222
232
  }
233
+ const resolveOnce = (): ReturnType<typeof resolveWorkspaceWithOrigin> => {
234
+ if (state.workspace) return state.workspace
235
+ const workspaceFlag =
236
+ typeof parsed.flags.workspace === 'string' ? parsed.flags.workspace : undefined
237
+ state.workspace = resolveWorkspaceWithOrigin(workspaceFlag)
238
+ return state.workspace
239
+ }
223
240
  const ctx: CliContext = {
224
241
  args: parsed,
225
242
  getDaemon: async () => {
@@ -227,13 +244,9 @@ export async function runCli(argv: readonly string[]): Promise<number> {
227
244
  state.daemon = await connectToDaemon()
228
245
  return state.daemon
229
246
  },
230
- getWorkspace: () => {
231
- if (state.workspace) return state.workspace
232
- const workspaceFlag =
233
- typeof parsed.flags.workspace === 'string' ? parsed.flags.workspace : undefined
234
- state.workspace = resolveWorkspace(workspaceFlag)
235
- return state.workspace
236
- },
247
+ getWorkspace: () => resolveOnce().record,
248
+ getWorkspaceOrigin: () => resolveOnce().origin,
249
+ getWorkspaces: () => listWorkspaces(),
237
250
  }
238
251
 
239
252
  try {
@@ -241,6 +254,15 @@ export async function runCli(argv: readonly string[]): Promise<number> {
241
254
  return code
242
255
  } catch (error) {
243
256
  const message = error instanceof Error ? error.message : String(error)
257
+ if (error instanceof CliUsageError) {
258
+ writeError(message)
259
+ writeJson({
260
+ command: `${command.group} ${command.verb}`,
261
+ error: message,
262
+ kind: 'usage-error',
263
+ })
264
+ return EXIT_USAGE
265
+ }
244
266
  // Classify by error type, not by string-sniffing the message: a runtime
245
267
  // error whose message happens to include "socket" (e.g. daemon reply
246
268
  // "socket write failed for tab X") must not masquerade as
package/src/cli/output.ts CHANGED
@@ -23,10 +23,13 @@ export function writeError(message: string): void {
23
23
  // 3 runtime error (server replied with `error`, command failed)
24
24
  // 4 daemon unreachable (socket missing and autostart failed)
25
25
  // 10 question (`tab run`: worker is blocked on a question/permission)
26
+ // 11 pending submit (`worker run --detach`: prompt is in the composer but no
27
+ // turn started — recoverable with `worker submit`, unlike a real error)
26
28
  // 124 timeout (`tab wait`, `tab tail --timeout`, `workspace switch --wait`)
27
29
  export const EXIT_OK = 0
28
30
  export const EXIT_USAGE = 2
29
31
  export const EXIT_RUNTIME = 3
30
32
  export const EXIT_DAEMON_UNREACHABLE = 4
31
33
  export const EXIT_QUESTION = 10
34
+ export const EXIT_PENDING_SUBMIT = 11
32
35
  export const EXIT_TIMEOUT = 124