@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
@@ -1,26 +1,61 @@
1
1
  import { resolve as resolvePath } from 'node:path'
2
2
 
3
+ import type { WorktreeRecord } from '../../../state/types'
4
+ import type { CliContext } from '../../context'
3
5
  import type { CliCommand } from '../../registry'
4
6
 
5
7
  import { loadConfig } from '../../../config'
8
+ import { removeGitWorktree } from '../../../git/worktree'
6
9
  import {
7
10
  IPC_CAPABILITY_CREATE_TAB_SIZE_FALLBACK,
11
+ IPC_CAPABILITY_LIST_TABS,
8
12
  IPC_CAPABILITY_THIN_ATTACH,
13
+ IPC_CAPABILITY_WORKER_METADATA,
9
14
  } from '../../../ipc/protocol'
10
15
  import { createPrefixedId } from '../../../platform/id'
16
+ import { pruneEmptyWorktreeParent } from '../../../platform/worktree-paths'
11
17
  import {
12
18
  type AssistantOption,
13
19
  buildAssistantModelArgs,
14
20
  getAllAssistantOptions,
15
21
  parseCommand,
16
22
  } from '../../../pty/command-registry'
17
- import { SHARED_FLAGS } from '../../flags'
23
+ import { CliUsageError, SHARED_FLAGS } from '../../flags'
18
24
  import { EXIT_OK, writeJson } from '../../output'
19
25
  import { createWorkspaceWorktree } from '../worktree/create-core'
20
26
 
21
27
  const FALLBACK_COLS = 200
22
28
  const FALLBACK_ROWS = 60
23
29
 
30
+ export interface CreateCliTabOptions {
31
+ assistantId: string
32
+ base?: string
33
+ branch?: string
34
+ commandOverride?: string
35
+ cwd?: string
36
+ effort?: string
37
+ model?: string
38
+ newWorktree?: boolean | string
39
+ title?: string
40
+ workerName?: string
41
+ worktreeId?: string
42
+ }
43
+
44
+ export interface CreateCliTabResult {
45
+ assistant: string
46
+ branch: string | null
47
+ command: string
48
+ cwd: string | null
49
+ effort: string | null
50
+ model: string | null
51
+ name: string | null
52
+ path: string | null
53
+ tabId: string
54
+ title: string
55
+ workerName: string | null
56
+ worktreeId: string | null
57
+ }
58
+
24
59
  /**
25
60
  * Resolve the cwd a spawned tab should run in. Precedence: explicit `--cwd`
26
61
  * (resolved to absolute) > the resolved worktree's path > undefined (the
@@ -50,44 +85,250 @@ export function resolveAssistantCommand(
50
85
  return commandOverride ?? customCommands[option.id] ?? option.command
51
86
  }
52
87
 
88
+ async function rollbackCreatedWorktree(
89
+ record: WorktreeRecord,
90
+ ctx: CliContext,
91
+ originalError: unknown
92
+ ): Promise<never> {
93
+ const daemon = await ctx.getDaemon()
94
+ const workspace = ctx.getWorkspace()
95
+ let rollbackError: unknown
96
+ try {
97
+ await removeGitWorktree({
98
+ force: true,
99
+ repoPath: record.repoRoot,
100
+ targetPath: record.path,
101
+ })
102
+ await pruneEmptyWorktreeParent(record.path)
103
+ await daemon.expectOk('removeWorktreeRecord', {
104
+ sessionId: workspace.id,
105
+ worktreeId: record.id,
106
+ })
107
+ } catch (error) {
108
+ rollbackError = error
109
+ }
110
+ const original = originalError instanceof Error ? originalError.message : String(originalError)
111
+ if (rollbackError === undefined) throw originalError
112
+ const rollback =
113
+ rollbackError instanceof Error ? rollbackError.message : JSON.stringify(rollbackError)
114
+ throw new Error(`${original}; rollback failed: ${rollback}`)
115
+ }
116
+
117
+ /**
118
+ * Create a tab without writing to stdout. Worker commands compose this helper
119
+ * with prompt dispatch/await while `tab create` remains a thin compatibility
120
+ * adapter. All validation and daemon attachment happen before a worktree is
121
+ * created; any later failure rolls the fresh worktree back.
122
+ */
123
+ export async function createCliTab(
124
+ ctx: CliContext,
125
+ options: CreateCliTabOptions
126
+ ): Promise<CreateCliTabResult> {
127
+ const {
128
+ assistantId,
129
+ base,
130
+ branch,
131
+ commandOverride,
132
+ cwd: cwdRaw,
133
+ effort,
134
+ model,
135
+ newWorktree,
136
+ title: requestedTitle,
137
+ workerName,
138
+ worktreeId: requestedWorktreeId,
139
+ } = options
140
+ if (assistantId.length === 0) throw new CliUsageError('--assistant is required')
141
+ if (workerName !== undefined && workerName.trim().length === 0) {
142
+ throw new CliUsageError('--name must be a non-empty string')
143
+ }
144
+ if (commandOverride !== undefined && (model !== undefined || effort !== undefined)) {
145
+ throw new CliUsageError(
146
+ '--model / --effort cannot be combined with --command (bake them into --command)'
147
+ )
148
+ }
149
+
150
+ const createFreshWorktree = newWorktree !== undefined && newWorktree !== false
151
+ if (createFreshWorktree && requestedWorktreeId !== undefined) {
152
+ throw new CliUsageError(
153
+ '--new-worktree creates its own; use --worktree <id> to co-locate instead'
154
+ )
155
+ }
156
+ if (createFreshWorktree && cwdRaw !== undefined) {
157
+ throw new CliUsageError('--new-worktree sets the cwd to the new worktree; drop --cwd')
158
+ }
159
+ if (!createFreshWorktree && (base !== undefined || branch !== undefined)) {
160
+ throw new CliUsageError(
161
+ '--base / --branch require --new-worktree (use `worktree create` otherwise)'
162
+ )
163
+ }
164
+
165
+ // Resolve and validate the complete assistant invocation before touching git.
166
+ const { customCommands } = loadConfig()
167
+ const option = getAllAssistantOptions(customCommands).find((entry) => entry.id === assistantId)
168
+ if (!option) {
169
+ const known = getAllAssistantOptions(customCommands)
170
+ .map((entry) => entry.id)
171
+ .join(', ')
172
+ throw new CliUsageError(`unknown assistant: ${assistantId} (known: ${known})`)
173
+ }
174
+ const command = resolveAssistantCommand(commandOverride, customCommands, option)
175
+ const { args: baseArgs, executable } = parseCommand(command)
176
+ const args = [...baseArgs, ...buildAssistantModelArgs(option, { effort, model })]
177
+ const title = requestedTitle ?? option.label
178
+ const tabId = createPrefixedId('tab')
179
+
180
+ const workspace = ctx.getWorkspace()
181
+ const daemon = await ctx.getDaemon()
182
+ if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
183
+ throw new Error(
184
+ 'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
185
+ )
186
+ }
187
+ if (workerName !== undefined && !daemon.hasCapability(IPC_CAPABILITY_WORKER_METADATA)) {
188
+ throw new Error(
189
+ 'daemon predates workerMetadata capability — restart aimux to use worker commands'
190
+ )
191
+ }
192
+ if (workerName !== undefined) {
193
+ if (!daemon.hasCapability(IPC_CAPABILITY_LIST_TABS)) {
194
+ throw new Error('daemon cannot validate worker-name uniqueness — restart aimux')
195
+ }
196
+ const existing = await daemon.listTabs(workspace.id)
197
+ if (existing.tabs.some((tab) => tab.workerName === workerName)) {
198
+ throw new Error(`worker name already exists in workspace "${workspace.name}": ${workerName}`)
199
+ }
200
+ }
201
+
202
+ // Attach before creating a worktree so stale daemon/session failures have no
203
+ // git-side effect.
204
+ await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
205
+
206
+ let worktreeId = requestedWorktreeId ?? workspace.activeWorktreeId
207
+ let worktreeRecord =
208
+ worktreeId !== undefined
209
+ ? workspace.worktrees?.find((entry) => entry.id === worktreeId)
210
+ : undefined
211
+ if (requestedWorktreeId !== undefined && worktreeRecord === undefined) {
212
+ const ids = workspace.worktrees?.map((entry) => entry.id).join(', ') ?? '(none)'
213
+ throw new Error(`unknown worktree id: ${requestedWorktreeId} (known: ${ids})`)
214
+ }
215
+
216
+ let createdWorktree: WorktreeRecord | undefined
217
+ if (createFreshWorktree) {
218
+ const worktreeName =
219
+ typeof newWorktree === 'string' && newWorktree !== ''
220
+ ? newWorktree
221
+ : `${assistantId}-${tabId.slice(-6)}`
222
+ createdWorktree = await createWorkspaceWorktree({
223
+ base: base ?? 'HEAD',
224
+ branch: branch ?? `aimux/${worktreeName}`,
225
+ daemon,
226
+ name: worktreeName,
227
+ workspace,
228
+ })
229
+ worktreeId = createdWorktree.id
230
+ worktreeRecord = createdWorktree
231
+ }
232
+
233
+ const cwd = resolveTabCwd(cwdRaw, worktreeRecord)
234
+ const useFallback = daemon.hasCapability(IPC_CAPABILITY_CREATE_TAB_SIZE_FALLBACK)
235
+ try {
236
+ await daemon.expectOk('createTab', {
237
+ args,
238
+ assistant: assistantId,
239
+ autoRenameCandidate: requestedTitle === undefined,
240
+ cols: useFallback ? 0 : FALLBACK_COLS,
241
+ command: executable,
242
+ cwd,
243
+ rows: useFallback ? 0 : FALLBACK_ROWS,
244
+ tabId,
245
+ title,
246
+ workerName,
247
+ worktreeId,
248
+ })
249
+ } catch (error) {
250
+ if (createdWorktree !== undefined) {
251
+ return rollbackCreatedWorktree(createdWorktree, ctx, error)
252
+ }
253
+ throw error
254
+ }
255
+
256
+ return {
257
+ assistant: assistantId,
258
+ branch: worktreeRecord?.branch ?? null,
259
+ command: [executable, ...args].join(' '),
260
+ cwd: cwd ?? null,
261
+ effort: effort ?? null,
262
+ model: model ?? null,
263
+ name: worktreeRecord?.name ?? null,
264
+ path: worktreeRecord?.path ?? null,
265
+ tabId,
266
+ title,
267
+ workerName: workerName ?? null,
268
+ worktreeId: worktreeId ?? null,
269
+ }
270
+ }
271
+
53
272
  export const tabCreate: CliCommand = {
54
273
  args: [],
55
274
  flags: [
56
275
  ...SHARED_FLAGS,
57
276
  {
277
+ complete: { kind: 'dynamic', source: 'assistant' },
58
278
  description: 'assistant id (claude, codex, opencode, grok, kimi, terminal, ...)',
59
279
  kind: 'string',
60
280
  name: 'assistant',
61
281
  },
62
- { description: 'tab title (defaults to assistant label)', kind: 'string', name: 'title' },
63
- { description: 'cwd for the spawned PTY', kind: 'string', name: 'cwd' },
64
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' },
65
296
  description: 'explicit command (overrides the assistant default)',
66
297
  kind: 'string',
67
298
  name: 'command',
68
299
  },
69
300
  {
301
+ complete: { kind: 'none' },
70
302
  description: 'model for the worker (maps to the assistant’s model flag)',
71
303
  kind: 'string',
72
304
  name: 'model',
73
305
  },
74
306
  {
307
+ complete: { kind: 'none' },
75
308
  description: 'reasoning-effort level (maps to the assistant’s effort flag)',
76
309
  kind: 'string',
77
310
  name: 'effort',
78
311
  },
79
312
  {
313
+ complete: { kind: 'dynamic', source: 'worktree' },
80
314
  description: 'worktree id the tab belongs to (defaults to the workspace’s active worktree)',
81
315
  kind: 'string',
82
316
  name: 'worktree',
83
317
  },
84
318
  {
319
+ complete: { kind: 'none' },
85
320
  description: 'create a fresh worktree for this tab (optionally named: --new-worktree=<name>)',
86
321
  kind: 'optional-string',
87
322
  name: 'new-worktree',
88
323
  },
89
- { description: 'base ref for --new-worktree (default HEAD)', kind: 'string', name: 'base' },
90
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' },
91
332
  description: 'branch for --new-worktree (default aimux/<name>)',
92
333
  kind: 'string',
93
334
  name: 'branch',
@@ -97,143 +338,37 @@ export const tabCreate: CliCommand = {
97
338
  run: async (ctx) => {
98
339
  const assistantId = ctx.args.flags.assistant
99
340
  if (typeof assistantId !== 'string' || assistantId.length === 0) {
100
- throw new Error('--assistant is required')
101
- }
102
- // Load the workspace's persisted customCommands so CLI-spawned tabs honor
103
- // the same assistant commands the UI uses (e.g. skip-permissions flags), and
104
- // so purely-custom assistant ids resolve. loadConfig degrades to {} on a
105
- // missing/invalid config — no new failure mode.
106
- const { customCommands } = loadConfig()
107
- const options = getAllAssistantOptions(customCommands)
108
- const option = options.find((o) => o.id === assistantId)
109
- if (!option) {
110
- throw new Error(
111
- `unknown assistant: ${assistantId} (known: ${options.map((o) => o.id).join(', ')})`
112
- )
341
+ throw new CliUsageError('--assistant is required')
113
342
  }
114
343
  const commandOverride =
115
344
  typeof ctx.args.flags.command === 'string' ? ctx.args.flags.command : undefined
116
345
  const model = typeof ctx.args.flags.model === 'string' ? ctx.args.flags.model : undefined
117
346
  const effort = typeof ctx.args.flags.effort === 'string' ? ctx.args.flags.effort : undefined
118
347
 
119
- // A full `--command` override owns the whole invocation, so `--model` /
120
- // `--effort` (which only make sense as additions to the assistant default)
121
- // would be ambiguous alongside it — reject rather than silently drop them.
122
- if (commandOverride !== undefined && (model !== undefined || effort !== undefined)) {
123
- throw new Error(
124
- '--model / --effort cannot be combined with --command (bake them into --command)'
125
- )
126
- }
127
-
128
- const command = resolveAssistantCommand(commandOverride, customCommands, option)
129
- const title = typeof ctx.args.flags.title === 'string' ? ctx.args.flags.title : option.label
348
+ const title = typeof ctx.args.flags.title === 'string' ? ctx.args.flags.title : undefined
130
349
  const cwdRaw = typeof ctx.args.flags.cwd === 'string' ? ctx.args.flags.cwd : undefined
131
- const tabId = createPrefixedId('tab')
132
-
133
- // `--new-worktree[=<name>]`: create a fresh worktree and run the tab in it.
134
- // A bare flag parses as `true` (name derived below); the `=` form names it.
135
- const newWorktreeFlag = ctx.args.flags['new-worktree']
136
- const newWorktree = newWorktreeFlag !== undefined
350
+ const newWorktreeRaw = ctx.args.flags['new-worktree']
351
+ const newWorktreeFlag =
352
+ typeof newWorktreeRaw === 'string' || typeof newWorktreeRaw === 'boolean'
353
+ ? newWorktreeRaw
354
+ : undefined
137
355
  const worktreeFlag =
138
356
  typeof ctx.args.flags.worktree === 'string' ? ctx.args.flags.worktree : undefined
139
357
  const baseFlag = typeof ctx.args.flags.base === 'string' ? ctx.args.flags.base : undefined
140
358
  const branchFlag = typeof ctx.args.flags.branch === 'string' ? ctx.args.flags.branch : undefined
141
- if (newWorktree && worktreeFlag !== undefined) {
142
- throw new Error('--new-worktree creates its own; use --worktree <id> to co-locate instead')
143
- }
144
- if (newWorktree && cwdRaw !== undefined) {
145
- throw new Error('--new-worktree sets the cwd to the new worktree; drop --cwd')
146
- }
147
- if (!newWorktree && (baseFlag !== undefined || branchFlag !== undefined)) {
148
- throw new Error('--base / --branch require --new-worktree (use `worktree create` otherwise)')
149
- }
150
-
151
- const workspace = ctx.getWorkspace()
152
- const daemon = await ctx.getDaemon()
153
-
154
- if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
155
- throw new Error(
156
- 'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
157
- )
158
- }
159
-
160
- // Resolve the worktree the tab belongs to + its record (for the cwd default
161
- // and the output). Three modes: create a fresh one, use an explicit id, or
162
- // fall back to the workspace's active worktree.
163
- let worktreeId: string | undefined
164
- let worktreeRecord: { branch?: string; name?: string; path: string } | undefined
165
- if (newWorktree) {
166
- const worktreeName =
167
- typeof newWorktreeFlag === 'string' && newWorktreeFlag !== ''
168
- ? newWorktreeFlag
169
- : `${assistantId}-${tabId.slice(-6)}`
170
- const record = await createWorkspaceWorktree({
171
- base: baseFlag ?? 'HEAD',
172
- branch: branchFlag ?? `aimux/${worktreeName}`,
173
- daemon,
174
- name: worktreeName,
175
- workspace,
176
- })
177
- worktreeId = record.id
178
- worktreeRecord = record
179
- } else {
180
- worktreeId = worktreeFlag ?? workspace.activeWorktreeId
181
- if (worktreeFlag !== undefined) {
182
- const known = workspace.worktrees?.some((w) => w.id === worktreeFlag) ?? false
183
- if (!known) {
184
- const ids = workspace.worktrees?.map((w) => w.id).join(', ') ?? '(none)'
185
- throw new Error(`unknown worktree id: ${worktreeFlag} (known: ${ids})`)
186
- }
187
- }
188
- worktreeRecord =
189
- worktreeId !== undefined ? workspace.worktrees?.find((w) => w.id === worktreeId) : undefined
190
- }
191
-
192
- // Default the PTY cwd to the resolved worktree's path so a worker spawned
193
- // into a worktree actually runs inside it. An explicit --cwd always wins.
194
- const cwd = resolveTabCwd(cwdRaw, worktreeRecord)
195
-
196
- // Thin-attach so we don't clobber the UI's dimensions on the same session.
197
- await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
198
-
199
- const { args: baseArgs, executable } = parseCommand(command)
200
- // Append the model/effort flags to the assistant default. Throws if the
201
- // assistant has no control for a requested dimension.
202
- const modelArgs = buildAssistantModelArgs(option, { effort, model })
203
- const args = [...baseArgs, ...modelArgs]
204
-
205
- // cols/rows = 0 means "fall back to the session's last attached size" on
206
- // v11 daemons. Without that capability we have nothing reasonable to put
207
- // here (the CLI has no terminal of its own), so use a roomy fallback —
208
- // PTYs are reflowable, so 200×60 won't break anything that adapts.
209
- const useFallback = daemon.hasCapability(IPC_CAPABILITY_CREATE_TAB_SIZE_FALLBACK)
210
- await daemon.expectOk('createTab', {
211
- args,
212
- assistant: assistantId,
213
- autoRenameCandidate: ctx.args.flags.title === undefined,
214
- cols: useFallback ? 0 : FALLBACK_COLS,
215
- command: executable,
216
- cwd,
217
- rows: useFallback ? 0 : FALLBACK_ROWS,
218
- tabId,
219
- title,
220
- worktreeId,
221
- })
222
-
223
- const resolvedCommand = [executable, ...args].join(' ')
224
- writeJson({
225
- assistant: assistantId,
226
- branch: worktreeRecord?.branch ?? null,
227
- command: resolvedCommand,
228
- cwd: cwd ?? null,
229
- effort: effort ?? null,
230
- model: model ?? null,
231
- name: worktreeRecord?.name ?? null,
232
- path: worktreeRecord?.path ?? null,
233
- tabId,
359
+ const result = await createCliTab(ctx, {
360
+ assistantId,
361
+ base: baseFlag,
362
+ branch: branchFlag,
363
+ commandOverride,
364
+ cwd: cwdRaw,
365
+ effort,
366
+ model,
367
+ newWorktree: newWorktreeFlag,
234
368
  title,
235
- worktreeId: worktreeId ?? null,
369
+ worktreeId: worktreeFlag,
236
370
  })
371
+ writeJson(result)
237
372
  return EXIT_OK
238
373
  },
239
374
  summary: 'Create a new tab in the active workspace',
@@ -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) => {
@@ -6,6 +6,7 @@
6
6
  import type { DaemonClient } from '../../client/daemon-client'
7
7
 
8
8
  import { bracketedPaste, notationToBytes } from '../../chord'
9
+ import { CliUsageError } from '../../flags'
9
10
 
10
11
  /**
11
12
  * Gap between the bracketed-paste write and the trailing carriage return when
@@ -33,7 +34,7 @@ export async function resolvePromptText(
33
34
  (present) => present
34
35
  ).length
35
36
  if (sources !== 1) {
36
- throw new Error(
37
+ throw new CliUsageError(
37
38
  'provide exactly one prompt source: --prompt-file <f>, --stdin, or a [text] positional'
38
39
  )
39
40
  }
@@ -91,7 +92,12 @@ export async function writePromptPayload(
91
92
  payload: PromptPayload,
92
93
  appendEnter: boolean
93
94
  ): Promise<number> {
94
- 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
+ }
95
101
  let bytesWritten = Buffer.byteLength(payload.data, 'utf8')
96
102
  if (appendEnter) {
97
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',
@@ -0,0 +1,34 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { SHARED_FLAGS } from '../../flags'
4
+ import { writeJson } from '../../output'
5
+ import { DEFAULT_TIMEOUT_MS } from '../tab/await-turn'
6
+ import {
7
+ awaitExistingWorker,
8
+ resolveWorkerTarget,
9
+ workerEnvelope,
10
+ workerOutcomeExitCode,
11
+ workerView,
12
+ } from './shared'
13
+
14
+ export const workerAwait: CliCommand = {
15
+ args: [{ complete: { kind: 'dynamic', source: 'worker' }, name: 'worker', required: true }],
16
+ flags: [
17
+ ...SHARED_FLAGS,
18
+ { description: 'overall turn cap in milliseconds', kind: 'number', name: 'timeout' },
19
+ ],
20
+ group: 'worker',
21
+ run: async (ctx) => {
22
+ const { tab, workspace } = await resolveWorkerTarget(ctx, ctx.args.positionals[0] ?? '')
23
+ const outcome = await awaitExistingWorker(
24
+ ctx,
25
+ workspace,
26
+ tab.id,
27
+ typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_TIMEOUT_MS
28
+ )
29
+ writeJson(workerEnvelope(workspace, workerView(workspace, tab), outcome))
30
+ return workerOutcomeExitCode(outcome)
31
+ },
32
+ summary: "Await an existing worker's in-flight turn",
33
+ verb: 'await',
34
+ }