@brimveyn/aimux 1.20.1 → 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 (43) hide show
  1. package/README.md +9 -1
  2. package/package.json +1 -1
  3. package/skills/aimux-orchestrator/SKILL.md +39 -5
  4. package/skills/aimux-orchestrator/references/prompts.md +11 -0
  5. package/src/cli/client/workspace-resolver.ts +61 -8
  6. package/src/cli/commands/tab/await.ts +1 -1
  7. package/src/cli/commands/tab/close.ts +1 -1
  8. package/src/cli/commands/tab/create.ts +27 -3
  9. package/src/cli/commands/tab/focus.ts +1 -1
  10. package/src/cli/commands/tab/prompt-io.ts +6 -1
  11. package/src/cli/commands/tab/run.ts +10 -2
  12. package/src/cli/commands/tab/send.ts +12 -7
  13. package/src/cli/commands/tab/snapshot.ts +2 -1
  14. package/src/cli/commands/tab/tail.ts +1 -1
  15. package/src/cli/commands/tab/wait.ts +2 -1
  16. package/src/cli/commands/worker/await.ts +5 -4
  17. package/src/cli/commands/worker/doctor.ts +25 -1
  18. package/src/cli/commands/worker/list.ts +50 -8
  19. package/src/cli/commands/worker/prompt.ts +31 -6
  20. package/src/cli/commands/worker/run.ts +56 -10
  21. package/src/cli/commands/worker/shared.ts +312 -52
  22. package/src/cli/commands/worker/stop.ts +39 -10
  23. package/src/cli/commands/worker/submit.ts +40 -0
  24. package/src/cli/commands/workspace/close.ts +1 -1
  25. package/src/cli/commands/workspace/create.ts +2 -1
  26. package/src/cli/commands/workspace/switch.ts +1 -1
  27. package/src/cli/commands/worktree/create-core.ts +27 -7
  28. package/src/cli/commands/worktree/create.ts +18 -3
  29. package/src/cli/commands/worktree/remove.ts +3 -1
  30. package/src/cli/completion/entry.ts +181 -0
  31. package/src/cli/completion/install.ts +222 -0
  32. package/src/cli/completion/plan.ts +216 -0
  33. package/src/cli/completion/scripts.ts +147 -0
  34. package/src/cli/completion/sources.ts +74 -0
  35. package/src/cli/context.ts +15 -0
  36. package/src/cli/flags.ts +45 -2
  37. package/src/cli/index.ts +20 -10
  38. package/src/cli/output.ts +3 -0
  39. package/src/cli/registry.ts +2 -0
  40. package/src/doctor.ts +4 -0
  41. package/src/git/worktree.ts +15 -1
  42. package/src/index.tsx +35 -11
  43. package/src/platform/worktree-paths.ts +21 -1
@@ -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
 
@@ -99,14 +100,20 @@ function printHelp(): void {
99
100
  '',
100
101
  'Env:',
101
102
  ' AIMUX_PROFILE Runtime profile (state dir, socket paths); --profile overrides.',
103
+ ' AIMUX_WORKSPACE Pin the target workspace (id or name); --workspace overrides.',
102
104
  '',
103
105
  'Agent recipe:',
104
106
  ' # create an isolated named worker, dispatch, and await one structured outcome',
105
- ' aimux worker run --name fixup --assistant claude "explain this repo"',
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"',
106
109
  '',
107
110
  'Maintenance:',
108
111
  ' aimux doctor | update | restart-daemon | restart-terminal-manager | version',
109
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>',
116
+ '',
110
117
  ].join('\n')
111
118
  )
112
119
  }
@@ -218,11 +225,18 @@ export async function runCli(argv: readonly string[]): Promise<number> {
218
225
 
219
226
  const state: {
220
227
  daemon: DaemonClient | null
221
- workspace: ReturnType<typeof resolveWorkspace> | null
228
+ workspace: ReturnType<typeof resolveWorkspaceWithOrigin> | null
222
229
  } = {
223
230
  daemon: null,
224
231
  workspace: null,
225
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
+ }
226
240
  const ctx: CliContext = {
227
241
  args: parsed,
228
242
  getDaemon: async () => {
@@ -230,13 +244,9 @@ export async function runCli(argv: readonly string[]): Promise<number> {
230
244
  state.daemon = await connectToDaemon()
231
245
  return state.daemon
232
246
  },
233
- getWorkspace: () => {
234
- if (state.workspace) return state.workspace
235
- const workspaceFlag =
236
- typeof parsed.flags.workspace === 'string' ? parsed.flags.workspace : undefined
237
- state.workspace = resolveWorkspace(workspaceFlag)
238
- return state.workspace
239
- },
247
+ getWorkspace: () => resolveOnce().record,
248
+ getWorkspaceOrigin: () => resolveOnce().origin,
249
+ getWorkspaces: () => listWorkspaces(),
240
250
  }
241
251
 
242
252
  try {
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
@@ -17,6 +17,7 @@ import { workerList } from './commands/worker/list'
17
17
  import { workerPrompt } from './commands/worker/prompt'
18
18
  import { workerRun } from './commands/worker/run'
19
19
  import { workerStop } from './commands/worker/stop'
20
+ import { workerSubmit } from './commands/worker/submit'
20
21
  import { workspaceClose } from './commands/workspace/close'
21
22
  import { workspaceCreate } from './commands/workspace/create'
22
23
  import { workspaceList } from './commands/workspace/list'
@@ -56,6 +57,7 @@ export const COMMANDS: readonly CliCommand[] = [
56
57
  worktreeRemove,
57
58
  workerRun,
58
59
  workerPrompt,
60
+ workerSubmit,
59
61
  workerAwait,
60
62
  workerList,
61
63
  workerStop,
package/src/doctor.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
 
3
+ import { completionStatus } from './cli/completion/install'
3
4
  import { getConfigPath, loadConfigResult } from './config'
4
5
  import { ASSISTANT_OPTIONS, isCommandAvailable, parseCommand } from './pty/command-registry'
5
6
 
@@ -73,6 +74,9 @@ export function buildDoctorReport(): DoctorReport {
73
74
  ok: configResult.issues.length === 0,
74
75
  })
75
76
 
77
+ const completion = completionStatus()
78
+ checks.push({ details: completion.detail, name: 'completion', ok: completion.ok })
79
+
76
80
  for (const option of ASSISTANT_OPTIONS) {
77
81
  const configuredCommand = config.customCommands[option.id] ?? option.command
78
82
  const { args, executable } = parseCommand(configuredCommand)
@@ -59,6 +59,16 @@ export async function listLocalBranches(cwd: string): Promise<string[]> {
59
59
  .filter((line) => line !== '')
60
60
  }
61
61
 
62
+ // Resolve a ref to its SHA in a specific repository, or undefined when it does
63
+ // not exist there. The repo argument matters more than it looks: the same ref
64
+ // name ("main", "HEAD") resolves in almost every repo, so a caller that picked
65
+ // the wrong repository gets a *successful* resolution and no signal at all.
66
+ export async function resolveGitRef(repoPath: string, ref: string): Promise<string | undefined> {
67
+ const result = await $`git -C ${repoPath} rev-parse --verify --quiet ${ref}`.quiet().nothrow()
68
+ if (result.exitCode !== 0) return undefined
69
+ return result.text().trim() || undefined
70
+ }
71
+
62
72
  export async function createGitWorktree({
63
73
  baseRef,
64
74
  branchName,
@@ -74,7 +84,11 @@ export async function createGitWorktree({
74
84
  .quiet()
75
85
  .nothrow()
76
86
  if (result.exitCode !== 0) {
77
- throw new Error(result.stderr.toString().trim() || 'failed to create git worktree')
87
+ // Always name the repository. A bare "fatal: not a valid object name: 'X'"
88
+ // reads as a ref-resolution bug when the real cause is that git ran in a
89
+ // repository the caller did not mean to target.
90
+ const stderr = result.stderr.toString().trim()
91
+ throw new Error(`${stderr || 'failed to create git worktree'} (in ${repoPath})`)
78
92
  }
79
93
  }
80
94