@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,515 @@
1
+ import type { SessionRecord, TerminalSnapshot, WorktreeRecord } from '../../../state/types'
2
+ import type { CliContext } from '../../context'
3
+
4
+ import {
5
+ IPC_CAPABILITY_LIST_TABS,
6
+ IPC_CAPABILITY_QUESTION_EVENTS,
7
+ IPC_CAPABILITY_THIN_ATTACH,
8
+ IPC_CAPABILITY_TURN_LIFECYCLE,
9
+ IPC_CAPABILITY_WORKER_METADATA,
10
+ type TabSessionSummary,
11
+ } from '../../../ipc/protocol'
12
+ import {
13
+ listWorkspaces,
14
+ workspaceIdentity,
15
+ workspaceRepoRoot,
16
+ } from '../../client/workspace-resolver'
17
+ import { CliUsageError } from '../../flags'
18
+ import {
19
+ EXIT_OK,
20
+ EXIT_PENDING_SUBMIT,
21
+ EXIT_QUESTION,
22
+ EXIT_RUNTIME,
23
+ EXIT_TIMEOUT,
24
+ } from '../../output'
25
+ import { snapshotTailLines, snapshotToLines } from '../../snapshot-render'
26
+ import { awaitTurn, type TurnOutcome } from '../tab/await-turn'
27
+ import { buildPromptPayload, writePromptPayload } from '../tab/prompt-io'
28
+
29
+ export const WORKER_SCHEMA_VERSION = 1
30
+ export const WORKER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
31
+ const QUESTION_TAIL_LINES = 25
32
+
33
+ /**
34
+ * Default window for the `--detach` submit→`working` confirmation. Overridable
35
+ * per call with `--uptake-timeout`: a cold assistant on a loaded machine can
36
+ * take longer than this to accept its first keystroke, and the old hard ceiling
37
+ * (min(--timeout, 15s)) made that unfixable from the outside.
38
+ */
39
+ export const DETACH_UPTAKE_TIMEOUT_MS = 15_000
40
+ /** Second, shorter window used after the idempotent submit retry. */
41
+ const UPTAKE_RESUBMIT_WINDOW_MS = 5_000
42
+ /** One retry is enough: the payload is already in the composer either way. */
43
+ const MAX_UPTAKE_RESUBMITS = 1
44
+ /**
45
+ * How long to wait for a freshly spawned assistant to paint anything before
46
+ * writing its prompt. A booting TUI has a pty that buffers the payload (which
47
+ * is why the text shows up intact) but is not yet reading keystrokes, so the
48
+ * submitting `\r` is consumed before the composer exists. First paint is a
49
+ * generic, assistant-agnostic "it is reading input now" signal.
50
+ */
51
+ const TAB_FIRST_PAINT_TIMEOUT_MS = 10_000
52
+ /** Settle after a *just observed* first paint, before writing. */
53
+ const TAB_FIRST_PAINT_SETTLE_MS = 250
54
+ /** Chord that clears the composer line (readline kill-line). */
55
+ const CLEAR_COMPOSER_CHORD = '<C-u>'
56
+
57
+ export interface WorkerView {
58
+ activity?: TabSessionSummary['activity']
59
+ assistant: string
60
+ branch: string | null
61
+ command: string
62
+ lastLine?: string
63
+ name: string
64
+ path: string | null
65
+ /** Repository the worker's worktree was cut from — see `workspaceRepoRoot`. */
66
+ repoRoot: string | null
67
+ status: TabSessionSummary['status']
68
+ tabId: string
69
+ title: string
70
+ worktreeId: string | null
71
+ }
72
+
73
+ export interface WorkerOutcome {
74
+ durationMs: number
75
+ error?: string
76
+ kind?: string
77
+ options?: string[]
78
+ question?: string
79
+ status: 'completed' | 'dispatched' | 'pending-submit' | 'question' | 'timeout' | 'error'
80
+ uptake?: {
81
+ confirmed: boolean
82
+ activity?: TabSessionSummary['activity']
83
+ ms?: number
84
+ resubmits?: number
85
+ }
86
+ }
87
+
88
+ /** A worker plus the workspace it is actually bound to. */
89
+ export interface WorkerTarget {
90
+ tab: TabSessionSummary
91
+ workspace: SessionRecord
92
+ }
93
+
94
+ export function validateWorkerName(name: string): void {
95
+ if (!WORKER_NAME_PATTERN.test(name)) {
96
+ throw new CliUsageError(
97
+ 'worker name must be 1-64 characters: letters, numbers, dot, underscore, or hyphen'
98
+ )
99
+ }
100
+ }
101
+
102
+ function worktreeFor(
103
+ workspace: SessionRecord,
104
+ worktreeId: string | undefined
105
+ ): WorktreeRecord | undefined {
106
+ if (worktreeId === undefined) return undefined
107
+ return workspace.worktrees?.find((worktree) => worktree.id === worktreeId)
108
+ }
109
+
110
+ export function workerView(workspace: SessionRecord, tab: TabSessionSummary): WorkerView {
111
+ if (tab.workerName === undefined) {
112
+ throw new Error(`tab is not a named worker: ${tab.id}`)
113
+ }
114
+ const worktree = worktreeFor(workspace, tab.worktreeId)
115
+ return {
116
+ activity: tab.activity,
117
+ assistant: tab.assistant,
118
+ branch: worktree?.branch ?? null,
119
+ command: tab.command,
120
+ lastLine: tab.lastLine,
121
+ name: tab.workerName,
122
+ path: worktree?.path ?? null,
123
+ repoRoot: worktree?.repoRoot ?? workspaceRepoRoot(workspace),
124
+ status: tab.status,
125
+ tabId: tab.id,
126
+ title: tab.title,
127
+ worktreeId: tab.worktreeId ?? null,
128
+ }
129
+ }
130
+
131
+ async function requireWorkerCapabilities(
132
+ ctx: CliContext
133
+ ): Promise<Awaited<ReturnType<CliContext['getDaemon']>>> {
134
+ const daemon = await ctx.getDaemon()
135
+ if (
136
+ !daemon.hasCapability(IPC_CAPABILITY_LIST_TABS) ||
137
+ !daemon.hasCapability(IPC_CAPABILITY_WORKER_METADATA)
138
+ ) {
139
+ throw new Error('daemon predates worker commands — restart aimux')
140
+ }
141
+ return daemon
142
+ }
143
+
144
+ export async function listNamedWorkerTabs(
145
+ ctx: CliContext,
146
+ workspace: SessionRecord = ctx.getWorkspace()
147
+ ): Promise<TabSessionSummary[]> {
148
+ const daemon = await requireWorkerCapabilities(ctx)
149
+ return (await daemon.listTabs(workspace.id)).tabs.filter((tab) => tab.workerName !== undefined)
150
+ }
151
+
152
+ /** Every named worker the daemon knows about, across every catalogued workspace. */
153
+ export async function listWorkerTargets(ctx: CliContext): Promise<WorkerTarget[]> {
154
+ const daemon = await requireWorkerCapabilities(ctx)
155
+ const targets: WorkerTarget[] = []
156
+ for (const workspace of ctx.getWorkspaces?.() ?? listWorkspaces()) {
157
+ const { tabs } = await daemon.listTabs(workspace.id)
158
+ for (const tab of tabs) {
159
+ if (tab.workerName !== undefined) targets.push({ tab, workspace })
160
+ }
161
+ }
162
+ return targets
163
+ }
164
+
165
+ function matches(tab: TabSessionSummary, selector: string): boolean {
166
+ return tab.id === selector || tab.workerName === selector
167
+ }
168
+
169
+ /**
170
+ * Resolve a worker selector to the tab AND the workspace that owns it.
171
+ *
172
+ * A worker is addressed for its whole lifetime, but the *active* workspace can
173
+ * change under it (the UI switching projects is enough). Resolving only against
174
+ * the active workspace is what made a live fleet look dead — `worker list`
175
+ * returning `[]` and `worker await` saying "worker not found" while all twelve
176
+ * workers were healthy. So when the workspace was merely inferred, fall back to
177
+ * a catalog-wide search and bind to wherever the worker actually lives; when it
178
+ * was pinned (`--workspace` / `AIMUX_WORKSPACE`), keep the boundary but name the
179
+ * workspace that does hold it instead of a bare "not found".
180
+ */
181
+ export async function resolveWorkerTarget(
182
+ ctx: CliContext,
183
+ selector: string
184
+ ): Promise<WorkerTarget> {
185
+ const workspace = ctx.getWorkspace()
186
+ const local = (await listNamedWorkerTabs(ctx, workspace)).filter((tab) => matches(tab, selector))
187
+ if (local.length > 1) {
188
+ throw new Error(
189
+ `worker selector is ambiguous in workspace "${workspace.name}": ${selector} (${local
190
+ .map((tab) => tab.id)
191
+ .join(', ')})`
192
+ )
193
+ }
194
+ const only = local[0]
195
+ if (only) return { tab: only, workspace }
196
+
197
+ const elsewhere = (await listWorkerTargets(ctx)).filter(
198
+ (target) => target.workspace.id !== workspace.id && matches(target.tab, selector)
199
+ )
200
+ const pinned = (ctx.getWorkspaceOrigin?.() ?? 'active') !== 'active'
201
+ if (elsewhere.length === 1 && elsewhere[0] && !pinned) return elsewhere[0]
202
+ if (elsewhere.length > 0) {
203
+ const where = elsewhere
204
+ .map((target) => `${target.workspace.name} (${target.tab.id})`)
205
+ .join(', ')
206
+ throw new Error(
207
+ `worker not found in workspace "${workspace.name}": ${selector} — it lives in ${where}; re-run with --workspace`
208
+ )
209
+ }
210
+ throw new Error(`worker not found in workspace "${workspace.name}": ${selector}`)
211
+ }
212
+
213
+ export function normalizeTurnOutcome(outcome: TurnOutcome): WorkerOutcome {
214
+ switch (outcome.outcome) {
215
+ case 'completed':
216
+ return { durationMs: outcome.durationMs, status: 'completed' }
217
+ case 'question':
218
+ return {
219
+ durationMs: outcome.durationMs,
220
+ kind: outcome.kind,
221
+ options: outcome.options,
222
+ question: outcome.question,
223
+ status: 'question',
224
+ }
225
+ case 'timeout':
226
+ return { durationMs: outcome.durationMs, status: 'timeout' }
227
+ case 'error':
228
+ return { durationMs: outcome.durationMs, error: outcome.error, status: 'error' }
229
+ }
230
+ }
231
+
232
+ export function workerOutcomeExitCode(outcome: WorkerOutcome): number {
233
+ switch (outcome.status) {
234
+ case 'completed':
235
+ case 'dispatched':
236
+ return EXIT_OK
237
+ case 'question':
238
+ return EXIT_QUESTION
239
+ case 'pending-submit':
240
+ return EXIT_PENDING_SUBMIT
241
+ case 'timeout':
242
+ return EXIT_TIMEOUT
243
+ case 'error':
244
+ return EXIT_RUNTIME
245
+ }
246
+ }
247
+
248
+ async function requireTurnCapabilities(ctx: CliContext): Promise<{
249
+ daemon: Awaited<ReturnType<CliContext['getDaemon']>>
250
+ }> {
251
+ const daemon = await ctx.getDaemon()
252
+ if (
253
+ !daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH) ||
254
+ !daemon.hasCapability(IPC_CAPABILITY_TURN_LIFECYCLE) ||
255
+ !daemon.hasCapability(IPC_CAPABILITY_QUESTION_EVENTS)
256
+ ) {
257
+ throw new Error('daemon predates worker turn lifecycle — restart aimux')
258
+ }
259
+ return { daemon }
260
+ }
261
+
262
+ function hasPaintedContent(viewport: TerminalSnapshot | undefined): boolean {
263
+ if (!viewport) return false
264
+ return snapshotToLines(viewport).some((line) => line.trim() !== '')
265
+ }
266
+
267
+ /**
268
+ * Resolve once the tab has painted something, i.e. the assistant is up far
269
+ * enough to be reading keystrokes. Returns immediately for a tab that already
270
+ * painted, and gives up (rather than blocking the dispatch) if nothing paints
271
+ * within the window.
272
+ */
273
+ async function waitForFirstPaint(
274
+ daemon: Awaited<ReturnType<CliContext['getDaemon']>>,
275
+ tabId: string,
276
+ seed: TerminalSnapshot | undefined
277
+ ): Promise<void> {
278
+ if (hasPaintedContent(seed)) return
279
+ const painted = await new Promise<boolean>((resolve) => {
280
+ const off = daemon.on('tabRender', (event) => {
281
+ if (event.tabId !== tabId || !hasPaintedContent(event.viewport)) return
282
+ off()
283
+ clearTimeout(timer)
284
+ resolve(true)
285
+ })
286
+ const timer = setTimeout(() => {
287
+ off()
288
+ resolve(false)
289
+ }, TAB_FIRST_PAINT_TIMEOUT_MS)
290
+ })
291
+ // Even a painted TUI needs a beat between "first frame" and "composer is
292
+ // interactive". Skip the settle when first paint never arrived — waiting
293
+ // longer on a tab that produced nothing buys nothing.
294
+ if (painted) await Bun.sleep(TAB_FIRST_PAINT_SETTLE_MS)
295
+ }
296
+
297
+ interface UptakeResult {
298
+ activity?: TabSessionSummary['activity']
299
+ confirmed: boolean
300
+ ms?: number
301
+ }
302
+
303
+ // `async` only to satisfy promise-function-async; the body has no `await`, so
304
+ // the executor below still runs synchronously and the `tabStatus` subscription
305
+ // is armed before this returns — callers write to the tab right after calling.
306
+ async function armUptake(
307
+ daemon: Awaited<ReturnType<CliContext['getDaemon']>>,
308
+ tabId: string,
309
+ windowMs: number,
310
+ startedAt: number
311
+ ): Promise<UptakeResult> {
312
+ return new Promise<UptakeResult>((resolve) => {
313
+ const off = daemon.on('tabStatus', (event) => {
314
+ if (event.tabId !== tabId || event.status !== 'working') return
315
+ off()
316
+ clearTimeout(timer)
317
+ resolve({ activity: 'working', confirmed: true, ms: Date.now() - startedAt })
318
+ })
319
+ const timer = setTimeout(() => {
320
+ off()
321
+ resolve({ confirmed: false })
322
+ }, windowMs)
323
+ })
324
+ }
325
+
326
+ /**
327
+ * Uptake read from the tab's *current* activity rather than from a transition.
328
+ * `tabStatus` is edge-triggered, so a transition can be missed; and a worker that
329
+ * answered with a question never emitted `working` at all. Both mean the prompt
330
+ * was taken up. Returns undefined when the tab is genuinely idle.
331
+ */
332
+ async function liveUptake(
333
+ daemon: Awaited<ReturnType<CliContext['getDaemon']>>,
334
+ sessionId: string,
335
+ tabId: string,
336
+ startedAt: number
337
+ ): Promise<UptakeResult | undefined> {
338
+ const live = (await daemon.listTabs(sessionId)).tabs.find((tab) => tab.id === tabId)
339
+ if (live?.activity !== 'working' && live?.activity !== 'waiting-input') return undefined
340
+ return { activity: live.activity, confirmed: true, ms: Date.now() - startedAt }
341
+ }
342
+
343
+ export interface DispatchOptions {
344
+ /**
345
+ * Gate the first write on the tab painting. Set only for a tab this call just
346
+ * spawned: an already-running worker has painted long ago, so waiting on a
347
+ * fresh render event would just stall on a quiet screen.
348
+ */
349
+ awaitFirstPaint?: boolean
350
+ detach: boolean
351
+ /** Clear the composer (`<C-u>`) before writing, so stray text can't merge in. */
352
+ replace?: boolean
353
+ timeoutMs: number
354
+ /** Window for the detached submit→`working` confirmation. */
355
+ uptakeTimeoutMs?: number
356
+ }
357
+
358
+ export async function dispatchWorkerPrompt(
359
+ ctx: CliContext,
360
+ workspace: SessionRecord,
361
+ tabId: string,
362
+ text: string,
363
+ options: DispatchOptions
364
+ ): Promise<WorkerOutcome> {
365
+ const { daemon } = await requireTurnCapabilities(ctx)
366
+ const attach = await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
367
+ const attached = attach.tabs.find((tab) => tab.id === tabId)
368
+ if (!attached) throw new Error(`tab not found: ${tabId}`)
369
+ const payload = buildPromptPayload(text, false)
370
+ const write = async (): Promise<void> => {
371
+ if (options.replace === true) {
372
+ await writePromptPayload(daemon, tabId, buildPromptPayload(CLEAR_COMPOSER_CHORD, true), false)
373
+ }
374
+ await writePromptPayload(daemon, tabId, payload, true)
375
+ }
376
+
377
+ if (options.awaitFirstPaint === true) {
378
+ await waitForFirstPaint(daemon, tabId, attached.viewport)
379
+ }
380
+
381
+ if (!options.detach) {
382
+ const outcome = await awaitTurn({
383
+ assumeWorking: false,
384
+ daemon,
385
+ onArmed: write,
386
+ tabId,
387
+ timeoutMs: options.timeoutMs,
388
+ })
389
+ return normalizeTurnOutcome(outcome)
390
+ }
391
+
392
+ const startedAt = Date.now()
393
+ const window = options.uptakeTimeoutMs ?? DETACH_UPTAKE_TIMEOUT_MS
394
+ const first = armUptake(daemon, tabId, window, startedAt)
395
+ await write()
396
+ let result = await first
397
+ let resubmits = 0
398
+
399
+ while (!result.confirmed && resubmits < MAX_UPTAKE_RESUBMITS) {
400
+ const live = await liveUptake(daemon, workspace.id, tabId, startedAt)
401
+ if (live) {
402
+ result = live
403
+ break
404
+ }
405
+ // The payload is already in the composer; a bare `\r` just submits what is
406
+ // sitting there. Idempotent, and it is exactly the manual recovery this
407
+ // used to force on the caller.
408
+ resubmits++
409
+ const retry = armUptake(daemon, tabId, UPTAKE_RESUBMIT_WINDOW_MS, startedAt)
410
+ await daemon.expectOk('write', { data: '\r', tabId })
411
+ result = await retry
412
+ }
413
+ // Last look before declaring failure: never report a healthy, working worker
414
+ // as pending just because an edge-triggered event was missed.
415
+ if (!result.confirmed) {
416
+ result = (await liveUptake(daemon, workspace.id, tabId, startedAt)) ?? result
417
+ }
418
+
419
+ if (!result.confirmed) {
420
+ return {
421
+ durationMs: Date.now() - startedAt,
422
+ error: `prompt is in the composer but no turn started after ${resubmits + 1} submit attempt(s); recover with \`aimux worker submit\``,
423
+ status: 'pending-submit',
424
+ uptake: { ...result, resubmits },
425
+ }
426
+ }
427
+ return {
428
+ durationMs: Date.now() - startedAt,
429
+ status: 'dispatched',
430
+ uptake: { ...result, resubmits },
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Submit whatever is already sitting in a worker's composer and confirm uptake.
436
+ * The recovery verb for a `pending-submit` outcome — awaitable, unlike the
437
+ * `tab send <tab> "<CR>" --keys` dance it replaces.
438
+ */
439
+ export async function submitWorkerPrompt(
440
+ ctx: CliContext,
441
+ workspace: SessionRecord,
442
+ tabId: string,
443
+ uptakeTimeoutMs: number
444
+ ): Promise<WorkerOutcome> {
445
+ const { daemon } = await requireTurnCapabilities(ctx)
446
+ const attach = await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
447
+ if (!attach.tabs.some((tab) => tab.id === tabId)) throw new Error(`tab not found: ${tabId}`)
448
+ const startedAt = Date.now()
449
+ const uptake = armUptake(daemon, tabId, uptakeTimeoutMs, startedAt)
450
+ await daemon.expectOk('write', { data: '\r', tabId })
451
+ const result = await uptake
452
+ if (!result.confirmed) {
453
+ const live = await liveUptake(daemon, workspace.id, tabId, startedAt)
454
+ if (live) {
455
+ return { durationMs: Date.now() - startedAt, status: 'dispatched', uptake: live }
456
+ }
457
+ return {
458
+ durationMs: Date.now() - startedAt,
459
+ error: 'submitted but no turn started — the composer may be empty',
460
+ status: 'pending-submit',
461
+ uptake: result,
462
+ }
463
+ }
464
+ return { durationMs: Date.now() - startedAt, status: 'dispatched', uptake: result }
465
+ }
466
+
467
+ export async function awaitExistingWorker(
468
+ ctx: CliContext,
469
+ workspace: SessionRecord,
470
+ tabId: string,
471
+ timeoutMs: number
472
+ ): Promise<WorkerOutcome> {
473
+ const { daemon } = await requireTurnCapabilities(ctx)
474
+ const attach = await daemon.attach({
475
+ cols: 0,
476
+ rows: 0,
477
+ sessionId: workspace.id,
478
+ thin: true,
479
+ })
480
+ const tab = attach.tabs.find((entry) => entry.id === tabId)
481
+ if (!tab) throw new Error(`tab not found: ${tabId}`)
482
+ if (tab.activity === 'waiting-input') {
483
+ const question =
484
+ tab.viewport && tab.viewport.lines.length > 0
485
+ ? snapshotTailLines(tab.viewport, QUESTION_TAIL_LINES, { trim: true }).join('\n')
486
+ : ''
487
+ return { durationMs: 0, kind: 'question', question, status: 'question' }
488
+ }
489
+ return normalizeTurnOutcome(
490
+ await awaitTurn({
491
+ assumeWorking: tab.activity === 'working',
492
+ daemon,
493
+ tabId,
494
+ timeoutMs,
495
+ })
496
+ )
497
+ }
498
+
499
+ /**
500
+ * Versioned envelope. `workspace` is not decoration: without it a caller cannot
501
+ * tell which project a worker belongs to, which is how a whole orchestration
502
+ * can run against the wrong repository unnoticed.
503
+ */
504
+ export function workerEnvelope(
505
+ workspace: SessionRecord,
506
+ worker: WorkerView,
507
+ outcome?: WorkerOutcome
508
+ ): Record<string, unknown> {
509
+ return {
510
+ schemaVersion: WORKER_SCHEMA_VERSION,
511
+ worker,
512
+ workspace: workspaceIdentity(workspace),
513
+ ...(outcome === undefined ? {} : { outcome }),
514
+ }
515
+ }
@@ -0,0 +1,113 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { isGitWorktreeDirty, removeGitWorktree } from '../../../git/worktree'
4
+ import {
5
+ IPC_CAPABILITY_THIN_ATTACH,
6
+ IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS,
7
+ } from '../../../ipc/protocol'
8
+ import { pruneEmptyWorktreeParent } from '../../../platform/worktree-paths'
9
+ import { workspaceIdentity } from '../../client/workspace-resolver'
10
+ import { SHARED_FLAGS } from '../../flags'
11
+ import { EXIT_OK, EXIT_RUNTIME, writeJson } from '../../output'
12
+ import { resolveWorkerTarget, WORKER_SCHEMA_VERSION, workerView } from './shared'
13
+
14
+ export const workerStop: CliCommand = {
15
+ args: [{ complete: { kind: 'dynamic', source: 'worker' }, name: 'worker', required: true }],
16
+ flags: [
17
+ ...SHARED_FLAGS,
18
+ {
19
+ description: 'remove its aimux-created worktree after closing the tab',
20
+ kind: 'boolean',
21
+ name: 'cleanup-worktree',
22
+ },
23
+ { description: 'force removal of a dirty worktree', kind: 'boolean', name: 'force' },
24
+ ],
25
+ group: 'worker',
26
+ run: async (ctx) => {
27
+ const { tab, workspace } = await resolveWorkerTarget(ctx, ctx.args.positionals[0] ?? '')
28
+ const worker = workerView(workspace, tab)
29
+ const daemon = await ctx.getDaemon()
30
+ const cleanup = ctx.args.flags['cleanup-worktree'] === true
31
+ const record =
32
+ tab.worktreeId === undefined
33
+ ? undefined
34
+ : workspace.worktrees?.find((worktree) => worktree.id === tab.worktreeId)
35
+
36
+ if (cleanup) {
37
+ if (record === undefined) throw new Error('worker has no registered worktree to clean up')
38
+ if (record.source !== 'aimux-temp' || !record.createdByAimux) {
39
+ throw new Error('refusing to clean up a primary or externally managed worktree')
40
+ }
41
+ const siblings = (await daemon.listTabs(workspace.id)).tabs.filter(
42
+ (entry) => entry.id !== tab.id && entry.worktreeId === record.id
43
+ )
44
+ if (siblings.length > 0) {
45
+ throw new Error(
46
+ `refusing to clean up shared worktree; live tabs: ${siblings.map((entry) => entry.id).join(', ')}`
47
+ )
48
+ }
49
+ if (!daemon.hasCapability(IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS)) {
50
+ throw new Error('daemon predates safe worktree cleanup — restart aimux')
51
+ }
52
+ if (ctx.args.flags.force !== true && (await isGitWorktreeDirty(record.path))) {
53
+ throw new Error('refusing to clean up a dirty worktree without --force')
54
+ }
55
+ }
56
+
57
+ // `closeTab` is session-scoped on the daemon, so it fails with "No session
58
+ // attached" unless this connection has attached first. Every other worker
59
+ // verb thin-attaches; this one did not, which made teardown the one step of
60
+ // the documented lifecycle a headless orchestrator could not complete.
61
+ if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
62
+ throw new Error(
63
+ 'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
64
+ )
65
+ }
66
+ await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
67
+
68
+ // Teardown is two independent effects. Report them independently: a failed
69
+ // tab close must not strand a merged worktree on disk with no way to remove
70
+ // it through aimux (the alternative — a bare `git worktree remove` — desyncs
71
+ // the catalog from disk).
72
+ let closeError: string | undefined
73
+ try {
74
+ await daemon.expectOk('closeTab', { tabId: tab.id })
75
+ } catch (error) {
76
+ closeError = error instanceof Error ? error.message : String(error)
77
+ if (!cleanup) throw error
78
+ }
79
+
80
+ let worktreeRemoved = false
81
+ if (cleanup && record !== undefined) {
82
+ await removeGitWorktree({
83
+ force: ctx.args.flags.force === true,
84
+ repoPath: record.repoRoot,
85
+ targetPath: record.path,
86
+ })
87
+ await pruneEmptyWorktreeParent(record.path)
88
+ try {
89
+ await daemon.expectOk('removeWorktreeRecord', {
90
+ sessionId: workspace.id,
91
+ worktreeId: record.id,
92
+ })
93
+ } catch (error) {
94
+ const message = error instanceof Error ? error.message : String(error)
95
+ throw new Error(
96
+ `worker stopped and git worktree removed, but catalog reconciliation failed: ${message}`
97
+ )
98
+ }
99
+ worktreeRemoved = true
100
+ }
101
+ writeJson({
102
+ closed: closeError === undefined,
103
+ ...(closeError === undefined ? {} : { closeError }),
104
+ schemaVersion: WORKER_SCHEMA_VERSION,
105
+ worker,
106
+ workspace: workspaceIdentity(workspace),
107
+ worktreeRemoved,
108
+ })
109
+ return closeError === undefined ? EXIT_OK : EXIT_RUNTIME
110
+ },
111
+ summary: 'Stop a named worker and optionally clean up its worktree',
112
+ verb: 'stop',
113
+ }
@@ -0,0 +1,40 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { SHARED_FLAGS } from '../../flags'
4
+ import { writeJson } from '../../output'
5
+ import {
6
+ DETACH_UPTAKE_TIMEOUT_MS,
7
+ resolveWorkerTarget,
8
+ submitWorkerPrompt,
9
+ workerEnvelope,
10
+ workerOutcomeExitCode,
11
+ workerView,
12
+ } from './shared'
13
+
14
+ export const workerSubmit: CliCommand = {
15
+ args: [{ complete: { kind: 'dynamic', source: 'worker' }, name: 'worker', required: true }],
16
+ flags: [
17
+ ...SHARED_FLAGS,
18
+ {
19
+ description: 'milliseconds to wait for the submit→working confirmation (default 15000)',
20
+ kind: 'number',
21
+ name: 'uptake-timeout',
22
+ },
23
+ ],
24
+ group: 'worker',
25
+ run: async (ctx) => {
26
+ const { tab, workspace } = await resolveWorkerTarget(ctx, ctx.args.positionals[0] ?? '')
27
+ const outcome = await submitWorkerPrompt(
28
+ ctx,
29
+ workspace,
30
+ tab.id,
31
+ typeof ctx.args.flags['uptake-timeout'] === 'number'
32
+ ? ctx.args.flags['uptake-timeout']
33
+ : DETACH_UPTAKE_TIMEOUT_MS
34
+ )
35
+ writeJson(workerEnvelope(workspace, workerView(workspace, tab), outcome))
36
+ return workerOutcomeExitCode(outcome)
37
+ },
38
+ summary: 'Submit a prompt already sitting in a worker composer and confirm uptake',
39
+ verb: 'submit',
40
+ }
@@ -6,7 +6,7 @@ import { SHARED_FLAGS } from '../../flags'
6
6
  import { EXIT_OK, writeJson } from '../../output'
7
7
 
8
8
  export const workspaceClose: CliCommand = {
9
- args: [{ name: 'workspace', required: true }],
9
+ args: [{ complete: { kind: 'dynamic', source: 'workspace' }, name: 'workspace', required: true }],
10
10
  flags: SHARED_FLAGS,
11
11
  group: 'workspace',
12
12
  run: async (ctx) => {
@@ -9,10 +9,11 @@ import { EXIT_OK, EXIT_TIMEOUT, writeJson } from '../../output'
9
9
  const DEFAULT_WAIT_TIMEOUT_MS = 30_000
10
10
 
11
11
  export const workspaceCreate: CliCommand = {
12
- args: [{ name: 'name', required: true }],
12
+ args: [{ complete: { kind: 'none' }, name: 'name', required: true }],
13
13
  flags: [
14
14
  ...SHARED_FLAGS,
15
15
  {
16
+ complete: { kind: 'file' },
16
17
  description: 'project path to associate with the workspace',
17
18
  kind: 'string',
18
19
  name: 'project',
@@ -8,7 +8,7 @@ import { EXIT_OK, EXIT_TIMEOUT, writeJson } from '../../output'
8
8
  const DEFAULT_WAIT_TIMEOUT_MS = 30_000
9
9
 
10
10
  export const workspaceSwitch: CliCommand = {
11
- args: [{ name: 'workspace', required: true }],
11
+ args: [{ complete: { kind: 'dynamic', source: 'workspace' }, name: 'workspace', required: true }],
12
12
  flags: [
13
13
  ...SHARED_FLAGS,
14
14
  {