@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
package/README.md CHANGED
@@ -36,6 +36,12 @@ bun install -g @brimveyn/aimux
36
36
 
37
37
  Requires [Bun](https://bun.sh).
38
38
 
39
+ Shell completion (bash, zsh, fish) installs itself the first time you launch
40
+ the TUI — one file in your shell's completions directory, no dotfile edits.
41
+ Run `aimux doctor` to see where it landed, or
42
+ `aimux completion install --shell zsh` to place it yourself. Opt out with
43
+ `AIMUX_NO_COMPLETION_INSTALL=1`. See [docs/reference/cli.md](docs/reference/cli.md#aimux-completion).
44
+
39
45
  ## Quick Start
40
46
 
41
47
  Create the default profile and install the typed config package into it:
@@ -170,7 +176,9 @@ See [`docs/reference/cli.md`](docs/reference/cli.md) for behavior details.
170
176
 
171
177
  For agent orchestration, prefer the named `aimux worker` commands. They combine
172
178
  isolated worktree creation, prompt dispatch, authoritative turn waiting, fleet
173
- inspection, and guarded cleanup without shell wrappers or `jq`.
179
+ inspection, and guarded cleanup without shell wrappers or `jq`. Pin the target
180
+ with `--workspace` (or `AIMUX_WORKSPACE`) for anything long-running: the default
181
+ follows whichever workspace the UI opened last.
174
182
 
175
183
  ## Runtime Model
176
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.20.1",
3
+ "version": "1.20.2",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -12,7 +12,7 @@ Own the plan and quality gate. Let aimux own worker transport and lifecycle.
12
12
  Run once:
13
13
 
14
14
  ```bash
15
- aimux worker doctor
15
+ aimux worker doctor --workspace <target>
16
16
  aimux worker --help
17
17
  ```
18
18
 
@@ -20,6 +20,14 @@ Stop if the doctor reports missing worker capabilities, the wrong workspace, or
20
20
  an unavailable assistant. Restart/update aimux as instructed; do not fall back
21
21
  to screen scraping or the legacy shell wrappers.
22
22
 
23
+ **Pin the workspace and verify the repo before dispatching anything.** Without
24
+ `--workspace` (or `AIMUX_WORKSPACE`) aimux targets whichever workspace the UI
25
+ opened last, and that can change under you mid-run — including to a different
26
+ project. Check `checks.workspace.repoRoot` in the doctor output against the
27
+ repository you mean, then pass `--workspace <id|name>` on **every** call (all
28
+ examples below do). Every worker response echoes `workspace.repoRoot`; read it on
29
+ the first dispatch instead of assuming.
30
+
23
31
  ## Orchestration loop
24
32
 
25
33
  1. Parse the plan into independently reviewable units and dependencies.
@@ -29,19 +37,20 @@ to screen scraping or the legacy shell wrappers.
29
37
 
30
38
  ```bash
31
39
  aimux worker run \
40
+ --workspace <target> \
32
41
  --name feat-auth \
33
42
  --assistant claude \
34
43
  --prompt-file /tmp/feat-auth.md \
35
44
  --detach
36
45
  ```
37
46
 
38
- 5. Inspect the fleet with `aimux worker list`.
39
- 6. Await a detached worker with `aimux worker await feat-auth`.
47
+ 5. Inspect the fleet with `aimux worker list --workspace <target>`.
48
+ 6. Await a detached worker with `aimux worker await feat-auth --workspace <target>`.
40
49
  7. If a worker asks a question, answer only when the plan already determines the
41
50
  answer:
42
51
 
43
52
  ```bash
44
- aimux worker prompt feat-auth --prompt-file /tmp/answer.md
53
+ aimux worker prompt feat-auth --workspace <target> --replace --prompt-file /tmp/answer.md
45
54
  ```
46
55
 
47
56
  Escalate design choices, irreversible operations, deployments, pushes,
@@ -54,9 +63,32 @@ migrations, spending, and external communication to the human.
54
63
  10. After integration, close and clean up:
55
64
 
56
65
  ```bash
57
- aimux worker stop feat-auth --cleanup-worktree
66
+ aimux worker stop feat-auth --workspace <target> --cleanup-worktree
58
67
  ```
59
68
 
69
+ ## Dispatch failure modes
70
+
71
+ Read these before the first dispatch; each one otherwise reads as a lost fleet.
72
+
73
+ - **`status: "pending-submit"` (exit 11)** — the prompt is sitting unsubmitted in
74
+ the worker's composer. The worker is alive and healthy. Recover with
75
+ `aimux worker submit <name> --workspace <target>`; never re-dispatch, which
76
+ would double the fleet onto the same branches. Widen the confirmation window
77
+ with `--uptake-timeout <ms>` for slow-booting assistants.
78
+ - **An empty `worker list`** — read the `workspace` field in the response. An
79
+ empty fleet in the wrong workspace is not a dead fleet. Add
80
+ `--all-workspaces` to answer "are they really gone?" in one call;
81
+ `git worktree list` in the target repo is the on-disk cross-check.
82
+ - **Composer contamination** — a human typing in a worker's tab leaves text that
83
+ `worker prompt` would append to, merging both into one incoherent instruction.
84
+ Use `--replace` (clears with `<C-u>`) for every correction, and
85
+ `aimux tab snapshot <tabId>` when a worker's behaviour doesn't match the
86
+ prompt you believe you sent.
87
+ - **Fresh worktrees are not provisioned** — a new worktree has no installed
88
+ dependencies and no generated files. Every dispatch prompt must tell the worker
89
+ to bootstrap before running any gate (see `references/prompts.md`), or it will
90
+ report environment failures as its own.
91
+
60
92
  ## Isolation
61
93
 
62
94
  `worker run` creates a fresh worktree by default. Keep that default when writes
@@ -78,6 +110,8 @@ may overlap or scope is uncertain.
78
110
  - Prefer `--prompt-file` for multiline prompts; it avoids shell quoting hazards.
79
111
  - Use `--detach` for parallel launch. Without it, `worker run` waits and returns
80
112
  a completed/question/timeout/error outcome.
113
+ - Pass `--workspace` on every call, and confirm the `repoRoot` the first response
114
+ reports is the repository you intend to change.
81
115
 
82
116
  ## Quality and safety
83
117
 
@@ -23,6 +23,12 @@
23
23
 
24
24
  - <machine-checkable criterion>
25
25
 
26
+ ## Environment
27
+
28
+ This is a fresh worktree: it has no installed dependencies and no generated
29
+ files. Run <the repo's install + codegen commands> before any verification gate,
30
+ and report a bootstrap failure as a bootstrap failure — never as a failing gate.
31
+
26
32
  ## Verification
27
33
 
28
34
  Run the repository's tests, typecheck, lint, and build commands that apply.
@@ -32,6 +38,11 @@ Report changed files, commands run, results, and remaining risks.
32
38
  Tell the worker to read repository instructions before editing. Do not ask it to
33
39
  merge, push, deploy, or clean up its worktree.
34
40
 
41
+ Keep the **Environment** block. A fresh `git worktree` shares history, not build
42
+ state: no `node_modules`, no generated clients or route files. Without it workers
43
+ run the gates in a half-provisioned tree, fail for environment reasons, and report
44
+ those failures as if they were their own.
45
+
35
46
  ## Correction
36
47
 
37
48
  ```markdown
@@ -1,17 +1,55 @@
1
- import type { SessionRecord } from '../../state/types'
1
+ import type { SessionRecord, WorktreeRecord } from '../../state/types'
2
2
 
3
3
  import { findMostRecentSession, loadSessionCatalog } from '../../state/session-catalog'
4
4
 
5
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.
6
+ * Where a command's target workspace came from. An orchestrator needs this to
7
+ * tell "I asked for pragma-once" from "aimux guessed, and the UI had moved on":
8
+ * `active` is the only origin that can silently follow the UI to another repo.
9
+ */
10
+ export type WorkspaceOrigin = 'flag' | 'env' | 'active'
11
+
12
+ export interface ResolvedWorkspace {
13
+ origin: WorkspaceOrigin
14
+ record: SessionRecord
15
+ }
16
+
17
+ /** Env pin for headless orchestrators — `--workspace` still wins over it. */
18
+ export const WORKSPACE_ENV_VAR = 'AIMUX_WORKSPACE'
19
+
20
+ /** The workspace's primary (root) worktree, i.e. the repository it is about. */
21
+ export function findPrimaryWorktree(session: SessionRecord): WorktreeRecord | undefined {
22
+ return session.worktrees?.find((worktree) => worktree.source === 'primary')
23
+ }
24
+
25
+ /**
26
+ * The repo every fresh worktree for this workspace is cut from. Surfaced in
27
+ * every worker envelope so an agent can see *which project* it just acted on
28
+ * instead of inferring it from a worktree path hash.
29
+ */
30
+ export function workspaceRepoRoot(session: SessionRecord): string | null {
31
+ return findPrimaryWorktree(session)?.repoRoot ?? session.projectPath ?? null
32
+ }
33
+
34
+ /** Stable workspace identity block embedded in command output. */
35
+ export function workspaceIdentity(session: SessionRecord): {
36
+ id: string
37
+ name: string
38
+ repoRoot: string | null
39
+ } {
40
+ return { id: session.id, name: session.name, repoRoot: workspaceRepoRoot(session) }
41
+ }
42
+
43
+ /**
44
+ * Resolve `--workspace W` to a session record from the catalog, reporting where
45
+ * the choice came from. Precedence: the explicit flag, then `AIMUX_WORKSPACE`,
46
+ * then the most recently opened session. Throws when the catalog is empty (no
47
+ * session has ever been created) or when the explicit name/id doesn't match.
10
48
  *
11
49
  * Matching: exact id wins; otherwise exact name (case-sensitive); otherwise
12
50
  * unique case-insensitive name match.
13
51
  */
14
- export function resolveWorkspace(name: string | undefined): SessionRecord {
52
+ export function resolveWorkspaceWithOrigin(name: string | undefined): ResolvedWorkspace {
15
53
  const sessions = loadSessionCatalog()
16
54
  if (sessions.length === 0) {
17
55
  throw new Error(
@@ -19,14 +57,29 @@ export function resolveWorkspace(name: string | undefined): SessionRecord {
19
57
  )
20
58
  }
21
59
 
22
- if (name === undefined || name === '') {
60
+ const flag = name !== undefined && name !== '' ? name : undefined
61
+ const env = process.env[WORKSPACE_ENV_VAR]
62
+ const fromEnv = env != null && env !== '' ? env : undefined
63
+ const selector = flag ?? fromEnv
64
+ if (selector === undefined) {
23
65
  const active = findMostRecentSession(sessions)
24
66
  if (!active) {
25
67
  throw new Error('no active workspace and the catalog is empty')
26
68
  }
27
- return active
69
+ return { origin: 'active', record: active }
70
+ }
71
+
72
+ return {
73
+ origin: flag !== undefined ? 'flag' : 'env',
74
+ record: matchWorkspace(sessions, selector),
28
75
  }
76
+ }
77
+
78
+ export function resolveWorkspace(name: string | undefined): SessionRecord {
79
+ return resolveWorkspaceWithOrigin(name).record
80
+ }
29
81
 
82
+ function matchWorkspace(sessions: SessionRecord[], name: string): SessionRecord {
30
83
  const byId = sessions.find((session) => session.id === name)
31
84
  if (byId) return byId
32
85
 
@@ -27,7 +27,7 @@ import { awaitTurn, DEFAULT_TIMEOUT_MS, type TurnOutcome, turnOutcomeExitCode }
27
27
  const QUESTION_TAIL_LINES = 25
28
28
 
29
29
  export const tabAwait: CliCommand = {
30
- args: [{ name: 'tabId', required: true }],
30
+ args: [{ complete: { kind: 'dynamic', source: 'tab' }, name: 'tabId', required: true }],
31
31
  flags: [
32
32
  ...SHARED_FLAGS,
33
33
  {
@@ -5,7 +5,7 @@ import { SHARED_FLAGS } from '../../flags'
5
5
  import { EXIT_OK, writeJson } from '../../output'
6
6
 
7
7
  export const tabClose: CliCommand = {
8
- args: [{ name: 'tabId', required: true }],
8
+ args: [{ complete: { kind: 'dynamic', source: 'tab' }, name: 'tabId', required: true }],
9
9
  flags: SHARED_FLAGS,
10
10
  group: 'tab',
11
11
  run: async (ctx) => {
@@ -13,6 +13,7 @@ import {
13
13
  IPC_CAPABILITY_WORKER_METADATA,
14
14
  } from '../../../ipc/protocol'
15
15
  import { createPrefixedId } from '../../../platform/id'
16
+ import { pruneEmptyWorktreeParent } from '../../../platform/worktree-paths'
16
17
  import {
17
18
  type AssistantOption,
18
19
  buildAssistantModelArgs,
@@ -98,6 +99,7 @@ async function rollbackCreatedWorktree(
98
99
  repoPath: record.repoRoot,
99
100
  targetPath: record.path,
100
101
  })
102
+ await pruneEmptyWorktreeParent(record.path)
101
103
  await daemon.expectOk('removeWorktreeRecord', {
102
104
  sessionId: workspace.id,
103
105
  worktreeId: record.id,
@@ -272,39 +274,61 @@ export const tabCreate: CliCommand = {
272
274
  flags: [
273
275
  ...SHARED_FLAGS,
274
276
  {
277
+ complete: { kind: 'dynamic', source: 'assistant' },
275
278
  description: 'assistant id (claude, codex, opencode, grok, kimi, terminal, ...)',
276
279
  kind: 'string',
277
280
  name: 'assistant',
278
281
  },
279
- { description: 'tab title (defaults to assistant label)', kind: 'string', name: 'title' },
280
- { description: 'cwd for the spawned PTY', kind: 'string', name: 'cwd' },
281
282
  {
283
+ complete: { kind: 'none' },
284
+ description: 'tab title (defaults to assistant label)',
285
+ kind: 'string',
286
+ name: 'title',
287
+ },
288
+ {
289
+ complete: { kind: 'file' },
290
+ description: 'cwd for the spawned PTY',
291
+ kind: 'string',
292
+ name: 'cwd',
293
+ },
294
+ {
295
+ complete: { kind: 'none' },
282
296
  description: 'explicit command (overrides the assistant default)',
283
297
  kind: 'string',
284
298
  name: 'command',
285
299
  },
286
300
  {
301
+ complete: { kind: 'none' },
287
302
  description: 'model for the worker (maps to the assistant’s model flag)',
288
303
  kind: 'string',
289
304
  name: 'model',
290
305
  },
291
306
  {
307
+ complete: { kind: 'none' },
292
308
  description: 'reasoning-effort level (maps to the assistant’s effort flag)',
293
309
  kind: 'string',
294
310
  name: 'effort',
295
311
  },
296
312
  {
313
+ complete: { kind: 'dynamic', source: 'worktree' },
297
314
  description: 'worktree id the tab belongs to (defaults to the workspace’s active worktree)',
298
315
  kind: 'string',
299
316
  name: 'worktree',
300
317
  },
301
318
  {
319
+ complete: { kind: 'none' },
302
320
  description: 'create a fresh worktree for this tab (optionally named: --new-worktree=<name>)',
303
321
  kind: 'optional-string',
304
322
  name: 'new-worktree',
305
323
  },
306
- { description: 'base ref for --new-worktree (default HEAD)', kind: 'string', name: 'base' },
307
324
  {
325
+ complete: { kind: 'dynamic', source: 'git-ref' },
326
+ description: 'base ref for --new-worktree (default HEAD)',
327
+ kind: 'string',
328
+ name: 'base',
329
+ },
330
+ {
331
+ complete: { kind: 'none' },
308
332
  description: 'branch for --new-worktree (default aimux/<name>)',
309
333
  kind: 'string',
310
334
  name: 'branch',
@@ -5,7 +5,7 @@ import { SHARED_FLAGS } from '../../flags'
5
5
  import { EXIT_OK, writeJson } from '../../output'
6
6
 
7
7
  export const tabFocus: CliCommand = {
8
- args: [{ name: 'tabId', required: true }],
8
+ args: [{ complete: { kind: 'dynamic', source: 'tab' }, name: 'tabId', required: true }],
9
9
  flags: SHARED_FLAGS,
10
10
  group: 'tab',
11
11
  run: async (ctx) => {
@@ -92,7 +92,12 @@ export async function writePromptPayload(
92
92
  payload: PromptPayload,
93
93
  appendEnter: boolean
94
94
  ): Promise<number> {
95
- await daemon.expectOk('write', { data: payload.data, tabId })
95
+ // Skip a zero-length write: "submit only" (empty payload + --enter) is a
96
+ // legitimate operation, but a 0-byte write reaches the pty as an empty
97
+ // ArrayBufferView and surfaces a raw Bun ERR_INVALID_ARG_TYPE to the user.
98
+ if (payload.data !== '') {
99
+ await daemon.expectOk('write', { data: payload.data, tabId })
100
+ }
96
101
  let bytesWritten = Buffer.byteLength(payload.data, 'utf8')
97
102
  if (appendEnter) {
98
103
  // A bracketed paste swallows a same-burst `\r`, so settle first, then
@@ -20,10 +20,18 @@ import { awaitTurn, DEFAULT_TIMEOUT_MS, turnOutcomeExitCode } from './await-turn
20
20
  import { buildPromptPayload, resolvePromptText, writePromptPayload } from './prompt-io'
21
21
 
22
22
  export const tabRun: CliCommand = {
23
- args: [{ name: 'tabId', required: true }, { name: 'text' }],
23
+ args: [
24
+ { complete: { kind: 'dynamic', source: 'tab' }, name: 'tabId', required: true },
25
+ { complete: { kind: 'none' }, name: 'text' },
26
+ ],
24
27
  flags: [
25
28
  ...SHARED_FLAGS,
26
- { description: 'read the prompt from this file', kind: 'string', name: 'prompt-file' },
29
+ {
30
+ complete: { kind: 'file' },
31
+ description: 'read the prompt from this file',
32
+ kind: 'string',
33
+ name: 'prompt-file',
34
+ },
27
35
  { description: 'read the prompt from stdin', kind: 'boolean', name: 'stdin' },
28
36
  {
29
37
  description: 'overall turn cap in milliseconds (default 900000 = 15 min)',
@@ -9,7 +9,10 @@ import { buildPromptPayload, writePromptPayload } from './prompt-io'
9
9
  const DEFAULT_AWAIT_TIMEOUT_MS = 15_000
10
10
 
11
11
  export const tabSend: CliCommand = {
12
- args: [{ name: 'tabId', required: true }, { name: 'text' }],
12
+ args: [
13
+ { complete: { kind: 'dynamic', source: 'tab' }, name: 'tabId', required: true },
14
+ { complete: { kind: 'none' }, name: 'text' },
15
+ ],
13
16
  flags: [
14
17
  ...SHARED_FLAGS,
15
18
  { description: 'append \\r so the receiving CLI submits', kind: 'boolean', name: 'enter' },
@@ -24,6 +27,7 @@ export const tabSend: CliCommand = {
24
27
  name: 'stdin',
25
28
  },
26
29
  {
30
+ complete: { kind: 'file' },
27
31
  description: 'read the payload from this file instead of <text>',
28
32
  kind: 'string',
29
33
  name: 'prompt-file',
@@ -59,12 +63,13 @@ export const tabSend: CliCommand = {
59
63
  ? ctx.args.flags['await-timeout']
60
64
  : DEFAULT_AWAIT_TIMEOUT_MS
61
65
 
62
- // Uptake only means something once we actually submit the prompt: the
63
- // working transition is the receiving CLI accepting the Enter. Without
64
- // --enter there is nothing to confirm, so fail loudly rather than block
65
- // forever on a transition that can't come.
66
- if (awaitSubmit && !appendEnter) {
67
- throw new Error('--await-submit requires --enter')
66
+ // Uptake only means something once something submits: either the appended
67
+ // `\r` (--enter) or a chord that carries its own submit (--keys "<CR>",
68
+ // which is the recovery path for a prompt already sitting in a composer).
69
+ // Without one of those there is nothing to confirm, so fail loudly rather
70
+ // than block forever on a transition that can't come.
71
+ if (awaitSubmit && !appendEnter && !asKeys) {
72
+ throw new Error('--await-submit requires --enter or --keys (the chord carries the submit)')
68
73
  }
69
74
 
70
75
  // At most one payload source. Unlike `tab run`, zero sources is valid here
@@ -9,11 +9,12 @@ import { snapshotTailLines, snapshotToLines } from '../../snapshot-render'
9
9
  const RENDER_WAIT_MS = 500
10
10
 
11
11
  export const tabSnapshot: CliCommand = {
12
- args: [{ name: 'tabId', required: true }],
12
+ args: [{ complete: { kind: 'dynamic', source: 'tab' }, name: 'tabId', required: true }],
13
13
  flags: [
14
14
  ...SHARED_FLAGS,
15
15
  { description: 'return only the last N non-blank lines', kind: 'number', name: 'tail' },
16
16
  {
17
+ complete: { kind: 'values', values: ['json', 'text'] },
17
18
  description: 'output format: json (default) or text (raw screen dump)',
18
19
  kind: 'string',
19
20
  name: 'format',
@@ -21,7 +21,7 @@ function toCursor(snapshot: TerminalSnapshot): Cursor {
21
21
  }
22
22
 
23
23
  export const tabTail: CliCommand = {
24
- args: [{ name: 'tabId', required: true }],
24
+ args: [{ complete: { kind: 'dynamic', source: 'tab' }, name: 'tabId', required: true }],
25
25
  flags: [
26
26
  ...SHARED_FLAGS,
27
27
  {
@@ -12,10 +12,11 @@ function isTabActivity(value: string): value is TabActivity {
12
12
  }
13
13
 
14
14
  export const tabWait: CliCommand = {
15
- args: [{ name: 'tabId', required: true }],
15
+ args: [{ complete: { kind: 'dynamic', source: 'tab' }, name: 'tabId', required: true }],
16
16
  flags: [
17
17
  ...SHARED_FLAGS,
18
18
  {
19
+ complete: { kind: 'values', values: ['idle', 'waiting-input', 'working'] },
19
20
  description: 'target activity (idle | working | waiting-input)',
20
21
  kind: 'string',
21
22
  name: 'status',
@@ -5,27 +5,28 @@ import { writeJson } from '../../output'
5
5
  import { DEFAULT_TIMEOUT_MS } from '../tab/await-turn'
6
6
  import {
7
7
  awaitExistingWorker,
8
- resolveWorkerTab,
8
+ resolveWorkerTarget,
9
9
  workerEnvelope,
10
10
  workerOutcomeExitCode,
11
11
  workerView,
12
12
  } from './shared'
13
13
 
14
14
  export const workerAwait: CliCommand = {
15
- args: [{ name: 'worker', required: true }],
15
+ args: [{ complete: { kind: 'dynamic', source: 'worker' }, name: 'worker', required: true }],
16
16
  flags: [
17
17
  ...SHARED_FLAGS,
18
18
  { description: 'overall turn cap in milliseconds', kind: 'number', name: 'timeout' },
19
19
  ],
20
20
  group: 'worker',
21
21
  run: async (ctx) => {
22
- const tab = await resolveWorkerTab(ctx, ctx.args.positionals[0] ?? '')
22
+ const { tab, workspace } = await resolveWorkerTarget(ctx, ctx.args.positionals[0] ?? '')
23
23
  const outcome = await awaitExistingWorker(
24
24
  ctx,
25
+ workspace,
25
26
  tab.id,
26
27
  typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_TIMEOUT_MS
27
28
  )
28
- writeJson(workerEnvelope(workerView(ctx, tab), outcome))
29
+ writeJson(workerEnvelope(workspace, workerView(workspace, tab), outcome))
29
30
  return workerOutcomeExitCode(outcome)
30
31
  },
31
32
  summary: "Await an existing worker's in-flight turn",
@@ -18,6 +18,11 @@ import {
18
18
  isCommandAvailable,
19
19
  parseCommand,
20
20
  } from '../../../pty/command-registry'
21
+ import {
22
+ findPrimaryWorktree,
23
+ WORKSPACE_ENV_VAR,
24
+ workspaceRepoRoot,
25
+ } from '../../client/workspace-resolver'
21
26
  import { SHARED_FLAGS } from '../../flags'
22
27
  import { EXIT_OK, EXIT_RUNTIME, writeJson } from '../../output'
23
28
  import { WORKER_SCHEMA_VERSION } from './shared'
@@ -56,7 +61,8 @@ export const workerDoctor: CliCommand = {
56
61
  const missingManagerCapabilities = [MANAGER_CAPABILITY_WORKER_METADATA].filter(
57
62
  (capability) => !managerCapabilities.includes(capability)
58
63
  )
59
- const primaryWorktree = workspace.worktrees?.find((worktree) => worktree.source === 'primary')
64
+ const primaryWorktree = findPrimaryWorktree(workspace)
65
+ const workspaceOrigin = ctx.getWorkspaceOrigin?.() ?? 'active'
60
66
  const availableAssistants = assistants.filter((assistant) => assistant.available)
61
67
  const skillPath = fileURLToPath(
62
68
  new URL('../../../../skills/aimux-orchestrator/', import.meta.url)
@@ -81,6 +87,16 @@ export const workerDoctor: CliCommand = {
81
87
  if (!existsSync(skillPath)) {
82
88
  issues.push(`packaged orchestrator skill is missing: ${skillPath}`)
83
89
  }
90
+ // Not an issue — an inferred workspace is the normal interactive case — but
91
+ // it IS the one resolution mode that can follow the UI to another project
92
+ // between two calls. An orchestrator dispatching a multi-hour fleet wants to
93
+ // see this before it starts, not after it reviews diffs from the wrong repo.
94
+ const warnings: string[] = []
95
+ if (workspaceOrigin === 'active') {
96
+ warnings.push(
97
+ `workspace "${workspace.name}" was inferred from the most recently opened session and follows the UI; pin it with --workspace or ${WORKSPACE_ENV_VAR}`
98
+ )
99
+ }
84
100
  const ready = issues.length === 0
85
101
  writeJson({
86
102
  assistants,
@@ -100,7 +116,12 @@ export const workerDoctor: CliCommand = {
100
116
  skill: { ok: existsSync(skillPath), path: skillPath },
101
117
  workspace: {
102
118
  hasPrimaryWorktree: primaryWorktree !== undefined,
119
+ name: workspace.name,
103
120
  ok: primaryWorktree !== undefined,
121
+ /** Repo every fresh worker worktree is cut from — confirm before dispatching. */
122
+ repoRoot: workspaceRepoRoot(workspace),
123
+ /** 'flag' | 'env' | 'active'; only 'active' can follow the UI. */
124
+ source: workspaceOrigin,
104
125
  },
105
126
  },
106
127
  cliVersion: version,
@@ -116,10 +137,13 @@ export const workerDoctor: CliCommand = {
116
137
  ready,
117
138
  schemaVersion: WORKER_SCHEMA_VERSION,
118
139
  skillPath,
140
+ warnings,
119
141
  workspace: {
120
142
  id: workspace.id,
121
143
  name: workspace.name,
122
144
  projectPath: workspace.projectPath ?? null,
145
+ repoRoot: workspaceRepoRoot(workspace),
146
+ source: workspaceOrigin,
123
147
  },
124
148
  })
125
149
  return ready ? EXIT_OK : EXIT_RUNTIME
@@ -1,22 +1,64 @@
1
1
  import type { CliCommand } from '../../registry'
2
2
 
3
+ import { workspaceIdentity } from '../../client/workspace-resolver'
3
4
  import { SHARED_FLAGS } from '../../flags'
4
5
  import { EXIT_OK, writeJson } from '../../output'
5
- import { listNamedWorkerTabs, resolveWorkerTab, WORKER_SCHEMA_VERSION, workerView } from './shared'
6
+ import {
7
+ listNamedWorkerTabs,
8
+ listWorkerTargets,
9
+ resolveWorkerTarget,
10
+ WORKER_SCHEMA_VERSION,
11
+ workerView,
12
+ } from './shared'
6
13
 
7
14
  export const workerList: CliCommand = {
8
- args: [{ name: 'worker' }],
9
- flags: SHARED_FLAGS,
15
+ args: [{ complete: { kind: 'dynamic', source: 'worker' }, name: 'worker' }],
16
+ flags: [
17
+ ...SHARED_FLAGS,
18
+ {
19
+ description: 'list workers in every catalogued workspace, not just the target one',
20
+ kind: 'boolean',
21
+ name: 'all-workspaces',
22
+ },
23
+ ],
10
24
  group: 'worker',
11
25
  run: async (ctx) => {
12
26
  const selector = ctx.args.positionals[0]
13
- const tabs =
14
- selector === undefined
15
- ? await listNamedWorkerTabs(ctx)
16
- : [await resolveWorkerTab(ctx, selector)]
27
+
28
+ // `{"workers":[]}` alone is indistinguishable from "every worker died", and
29
+ // the natural recovery from that reading is a destructive re-dispatch. Name
30
+ // the workspace that was queried so an empty fleet is legible as "not here"
31
+ // rather than "gone", and offer one call that answers "are they really gone?"
32
+ if (ctx.args.flags['all-workspaces'] === true) {
33
+ const targets = await listWorkerTargets(ctx)
34
+ writeJson({
35
+ schemaVersion: WORKER_SCHEMA_VERSION,
36
+ workers: targets
37
+ .filter((target) => selector === undefined || target.tab.workerName === selector)
38
+ .map((target) => ({
39
+ ...workerView(target.workspace, target.tab),
40
+ workspace: workspaceIdentity(target.workspace),
41
+ })),
42
+ })
43
+ return EXIT_OK
44
+ }
45
+
46
+ if (selector !== undefined) {
47
+ const { tab, workspace } = await resolveWorkerTarget(ctx, selector)
48
+ writeJson({
49
+ schemaVersion: WORKER_SCHEMA_VERSION,
50
+ workers: [workerView(workspace, tab)],
51
+ workspace: workspaceIdentity(workspace),
52
+ })
53
+ return EXIT_OK
54
+ }
55
+
56
+ const workspace = ctx.getWorkspace()
57
+ const tabs = await listNamedWorkerTabs(ctx, workspace)
17
58
  writeJson({
18
59
  schemaVersion: WORKER_SCHEMA_VERSION,
19
- workers: tabs.map((tab) => workerView(ctx, tab)),
60
+ workers: tabs.map((tab) => workerView(workspace, tab)),
61
+ workspace: workspaceIdentity(workspace),
20
62
  })
21
63
  return EXIT_OK
22
64
  },