@brimveyn/aimux 1.20.1 → 1.20.3

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 (50) 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/auto-rename/coordinator.ts +206 -32
  6. package/src/auto-rename/heuristic-title.ts +57 -0
  7. package/src/auto-rename/prompt-capture.ts +19 -0
  8. package/src/auto-rename/prompt-gate.ts +82 -0
  9. package/src/auto-rename/title-format.ts +38 -0
  10. package/src/auto-rename/title-runner.ts +32 -21
  11. package/src/cli/client/workspace-resolver.ts +61 -8
  12. package/src/cli/commands/tab/await.ts +1 -1
  13. package/src/cli/commands/tab/close.ts +1 -1
  14. package/src/cli/commands/tab/create.ts +27 -3
  15. package/src/cli/commands/tab/focus.ts +1 -1
  16. package/src/cli/commands/tab/prompt-io.ts +6 -1
  17. package/src/cli/commands/tab/run.ts +10 -2
  18. package/src/cli/commands/tab/send.ts +12 -7
  19. package/src/cli/commands/tab/snapshot.ts +2 -1
  20. package/src/cli/commands/tab/tail.ts +1 -1
  21. package/src/cli/commands/tab/wait.ts +2 -1
  22. package/src/cli/commands/worker/await.ts +5 -4
  23. package/src/cli/commands/worker/doctor.ts +25 -1
  24. package/src/cli/commands/worker/list.ts +50 -8
  25. package/src/cli/commands/worker/prompt.ts +31 -6
  26. package/src/cli/commands/worker/run.ts +56 -10
  27. package/src/cli/commands/worker/shared.ts +312 -52
  28. package/src/cli/commands/worker/stop.ts +39 -10
  29. package/src/cli/commands/worker/submit.ts +40 -0
  30. package/src/cli/commands/workspace/close.ts +1 -1
  31. package/src/cli/commands/workspace/create.ts +2 -1
  32. package/src/cli/commands/workspace/switch.ts +1 -1
  33. package/src/cli/commands/worktree/create-core.ts +27 -7
  34. package/src/cli/commands/worktree/create.ts +18 -3
  35. package/src/cli/commands/worktree/remove.ts +3 -1
  36. package/src/cli/completion/entry.ts +181 -0
  37. package/src/cli/completion/install.ts +222 -0
  38. package/src/cli/completion/plan.ts +216 -0
  39. package/src/cli/completion/scripts.ts +147 -0
  40. package/src/cli/completion/sources.ts +74 -0
  41. package/src/cli/context.ts +15 -0
  42. package/src/cli/flags.ts +45 -2
  43. package/src/cli/index.ts +20 -10
  44. package/src/cli/output.ts +3 -0
  45. package/src/cli/registry.ts +2 -0
  46. package/src/daemon/daemon.ts +11 -0
  47. package/src/doctor.ts +4 -0
  48. package/src/git/worktree.ts +15 -1
  49. package/src/index.tsx +35 -11
  50. package/src/platform/worktree-paths.ts +21 -1
@@ -14,22 +14,39 @@ import {
14
14
  } from './shared'
15
15
 
16
16
  export const workerRun: CliCommand = {
17
- args: [{ name: 'text' }],
17
+ args: [{ complete: { kind: 'none' }, name: 'text' }],
18
18
  flags: [
19
19
  ...SHARED_FLAGS,
20
- { description: 'unique workspace-scoped worker name', kind: 'string', name: 'name' },
21
20
  {
21
+ complete: { kind: 'none' },
22
+ description: 'unique workspace-scoped worker name',
23
+ kind: 'string',
24
+ name: 'name',
25
+ },
26
+ {
27
+ complete: { kind: 'dynamic', source: 'assistant' },
22
28
  description: 'assistant id (claude, codex, opencode, ...)',
23
29
  kind: 'string',
24
30
  name: 'assistant',
25
31
  },
26
- { description: 'model passed through to the assistant', kind: 'string', name: 'model' },
27
32
  {
33
+ complete: { kind: 'none' },
34
+ description: 'model passed through to the assistant',
35
+ kind: 'string',
36
+ name: 'model',
37
+ },
38
+ {
39
+ complete: { kind: 'none' },
28
40
  description: 'reasoning effort passed through to the assistant',
29
41
  kind: 'string',
30
42
  name: 'effort',
31
43
  },
32
- { description: 'read the prompt from this file', kind: 'string', name: 'prompt-file' },
44
+ {
45
+ complete: { kind: 'file' },
46
+ description: 'read the prompt from this file',
47
+ kind: 'string',
48
+ name: 'prompt-file',
49
+ },
33
50
  { description: 'read the prompt from stdin', kind: 'boolean', name: 'stdin' },
34
51
  {
35
52
  description: 'return after prompt uptake instead of turn completion',
@@ -37,14 +54,31 @@ export const workerRun: CliCommand = {
37
54
  name: 'detach',
38
55
  },
39
56
  { description: 'overall turn cap in milliseconds', kind: 'number', name: 'timeout' },
40
- { description: 'co-locate in an existing worktree id', kind: 'string', name: 'worktree' },
57
+ {
58
+ description:
59
+ 'milliseconds to wait for the detached submit→working confirmation (default 15000)',
60
+ kind: 'number',
61
+ name: 'uptake-timeout',
62
+ },
63
+ {
64
+ complete: { kind: 'dynamic', source: 'worktree' },
65
+ description: 'co-locate in an existing worktree id',
66
+ kind: 'string',
67
+ name: 'worktree',
68
+ },
41
69
  {
42
70
  description: 'run in the workspace active worktree instead of creating one',
43
71
  kind: 'boolean',
44
72
  name: 'no-worktree',
45
73
  },
46
- { description: 'base ref for the fresh worktree (default HEAD)', kind: 'string', name: 'base' },
47
74
  {
75
+ complete: { kind: 'dynamic', source: 'git-ref' },
76
+ description: 'base ref for the fresh worktree (default HEAD)',
77
+ kind: 'string',
78
+ name: 'base',
79
+ },
80
+ {
81
+ complete: { kind: 'none' },
48
82
  description: 'branch for the fresh worktree (default aimux/<name>)',
49
83
  kind: 'string',
50
84
  name: 'branch',
@@ -69,6 +103,11 @@ export const workerRun: CliCommand = {
69
103
  ctx.args.flags.stdin === true,
70
104
  ctx.args.positionals[0]
71
105
  )
106
+ // Resolve the target workspace ONCE, up front, and use that record for
107
+ // every later step. The repo a worktree is cut from comes from this record,
108
+ // so an orchestrator that omits --workspace at least gets the resolved
109
+ // identity echoed back in the response instead of having to infer it.
110
+ const workspace = ctx.getWorkspace()
72
111
  const result = await createCliTab(ctx, {
73
112
  assistantId: assistant,
74
113
  base: typeof ctx.args.flags.base === 'string' ? ctx.args.flags.base : undefined,
@@ -80,16 +119,23 @@ export const workerRun: CliCommand = {
80
119
  workerName: name,
81
120
  worktreeId: worktree,
82
121
  })
83
- const tabs = await (await ctx.getDaemon()).listTabs(ctx.getWorkspace().id)
122
+ const tabs = await (await ctx.getDaemon()).listTabs(workspace.id)
84
123
  const tab = tabs.tabs.find((entry) => entry.id === result.tabId)
85
124
  if (!tab) throw new Error(`created worker disappeared: ${result.tabId}`)
86
- const worker = workerView(ctx, tab)
87
- const outcome = await dispatchWorkerPrompt(ctx, result.tabId, text, {
125
+ const worker = workerView(workspace, tab)
126
+ const outcome = await dispatchWorkerPrompt(ctx, workspace, result.tabId, text, {
127
+ // The tab was spawned microseconds ago: its assistant is still booting, so
128
+ // the prompt must not be written until the TUI is reading keystrokes.
129
+ awaitFirstPaint: true,
88
130
  detach: ctx.args.flags.detach === true,
89
131
  timeoutMs:
90
132
  typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_TIMEOUT_MS,
133
+ uptakeTimeoutMs:
134
+ typeof ctx.args.flags['uptake-timeout'] === 'number'
135
+ ? ctx.args.flags['uptake-timeout']
136
+ : undefined,
91
137
  })
92
- writeJson(workerEnvelope(worker, outcome))
138
+ writeJson(workerEnvelope(workspace, worker, outcome))
93
139
  return workerOutcomeExitCode(outcome)
94
140
  },
95
141
  summary: 'Create a named worker, dispatch a prompt, and await its outcome',
@@ -1,4 +1,4 @@
1
- import type { WorktreeRecord } from '../../../state/types'
1
+ import type { SessionRecord, TerminalSnapshot, WorktreeRecord } from '../../../state/types'
2
2
  import type { CliContext } from '../../context'
3
3
 
4
4
  import {
@@ -9,16 +9,50 @@ import {
9
9
  IPC_CAPABILITY_WORKER_METADATA,
10
10
  type TabSessionSummary,
11
11
  } from '../../../ipc/protocol'
12
+ import {
13
+ listWorkspaces,
14
+ workspaceIdentity,
15
+ workspaceRepoRoot,
16
+ } from '../../client/workspace-resolver'
12
17
  import { CliUsageError } from '../../flags'
13
- import { EXIT_OK, EXIT_QUESTION, EXIT_RUNTIME, EXIT_TIMEOUT } from '../../output'
14
- import { snapshotTailLines } from '../../snapshot-render'
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'
15
26
  import { awaitTurn, type TurnOutcome } from '../tab/await-turn'
16
27
  import { buildPromptPayload, writePromptPayload } from '../tab/prompt-io'
17
28
 
18
29
  export const WORKER_SCHEMA_VERSION = 1
19
30
  export const WORKER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
20
31
  const QUESTION_TAIL_LINES = 25
21
- const DETACH_UPTAKE_TIMEOUT_MS = 15_000
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>'
22
56
 
23
57
  export interface WorkerView {
24
58
  activity?: TabSessionSummary['activity']
@@ -28,6 +62,8 @@ export interface WorkerView {
28
62
  lastLine?: string
29
63
  name: string
30
64
  path: string | null
65
+ /** Repository the worker's worktree was cut from — see `workspaceRepoRoot`. */
66
+ repoRoot: string | null
31
67
  status: TabSessionSummary['status']
32
68
  tabId: string
33
69
  title: string
@@ -40,8 +76,19 @@ export interface WorkerOutcome {
40
76
  kind?: string
41
77
  options?: string[]
42
78
  question?: string
43
- status: 'completed' | 'dispatched' | 'question' | 'timeout' | 'error'
44
- uptake?: { confirmed: boolean; ms?: number }
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
45
92
  }
46
93
 
47
94
  export function validateWorkerName(name: string): void {
@@ -52,16 +99,19 @@ export function validateWorkerName(name: string): void {
52
99
  }
53
100
  }
54
101
 
55
- function worktreeFor(ctx: CliContext, worktreeId: string | undefined): WorktreeRecord | undefined {
102
+ function worktreeFor(
103
+ workspace: SessionRecord,
104
+ worktreeId: string | undefined
105
+ ): WorktreeRecord | undefined {
56
106
  if (worktreeId === undefined) return undefined
57
- return ctx.getWorkspace().worktrees?.find((worktree) => worktree.id === worktreeId)
107
+ return workspace.worktrees?.find((worktree) => worktree.id === worktreeId)
58
108
  }
59
109
 
60
- export function workerView(ctx: CliContext, tab: TabSessionSummary): WorkerView {
110
+ export function workerView(workspace: SessionRecord, tab: TabSessionSummary): WorkerView {
61
111
  if (tab.workerName === undefined) {
62
112
  throw new Error(`tab is not a named worker: ${tab.id}`)
63
113
  }
64
- const worktree = worktreeFor(ctx, tab.worktreeId)
114
+ const worktree = worktreeFor(workspace, tab.worktreeId)
65
115
  return {
66
116
  activity: tab.activity,
67
117
  assistant: tab.assistant,
@@ -70,6 +120,7 @@ export function workerView(ctx: CliContext, tab: TabSessionSummary): WorkerView
70
120
  lastLine: tab.lastLine,
71
121
  name: tab.workerName,
72
122
  path: worktree?.path ?? null,
123
+ repoRoot: worktree?.repoRoot ?? workspaceRepoRoot(workspace),
73
124
  status: tab.status,
74
125
  tabId: tab.id,
75
126
  title: tab.title,
@@ -77,7 +128,9 @@ export function workerView(ctx: CliContext, tab: TabSessionSummary): WorkerView
77
128
  }
78
129
  }
79
130
 
80
- export async function listNamedWorkerTabs(ctx: CliContext): Promise<TabSessionSummary[]> {
131
+ async function requireWorkerCapabilities(
132
+ ctx: CliContext
133
+ ): Promise<Awaited<ReturnType<CliContext['getDaemon']>>> {
81
134
  const daemon = await ctx.getDaemon()
82
135
  if (
83
136
  !daemon.hasCapability(IPC_CAPABILITY_LIST_TABS) ||
@@ -85,26 +138,76 @@ export async function listNamedWorkerTabs(ctx: CliContext): Promise<TabSessionSu
85
138
  ) {
86
139
  throw new Error('daemon predates worker commands — restart aimux')
87
140
  }
88
- return (await daemon.listTabs(ctx.getWorkspace().id)).tabs.filter(
89
- (tab) => tab.workerName !== undefined
90
- )
141
+ return daemon
91
142
  }
92
143
 
93
- export async function resolveWorkerTab(
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(
94
182
  ctx: CliContext,
95
183
  selector: string
96
- ): Promise<TabSessionSummary> {
97
- const workers = await listNamedWorkerTabs(ctx)
98
- const byId = workers.find((tab) => tab.id === selector)
99
- if (byId) return byId
100
- const matches = workers.filter((tab) => tab.workerName === selector)
101
- if (matches.length === 1 && matches[0]) return matches[0]
102
- if (matches.length > 1) {
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) {
103
188
  throw new Error(
104
- `worker selector is ambiguous: ${selector} (${matches.map((tab) => tab.id).join(', ')})`
189
+ `worker selector is ambiguous in workspace "${workspace.name}": ${selector} (${local
190
+ .map((tab) => tab.id)
191
+ .join(', ')})`
105
192
  )
106
193
  }
107
- throw new Error(`worker not found: ${selector}`)
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}`)
108
211
  }
109
212
 
110
213
  export function normalizeTurnOutcome(outcome: TurnOutcome): WorkerOutcome {
@@ -133,6 +236,8 @@ export function workerOutcomeExitCode(outcome: WorkerOutcome): number {
133
236
  return EXIT_OK
134
237
  case 'question':
135
238
  return EXIT_QUESTION
239
+ case 'pending-submit':
240
+ return EXIT_PENDING_SUBMIT
136
241
  case 'timeout':
137
242
  return EXIT_TIMEOUT
138
243
  case 'error':
@@ -154,25 +259,130 @@ async function requireTurnCapabilities(ctx: CliContext): Promise<{
154
259
  return { daemon }
155
260
  }
156
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
+
157
358
  export async function dispatchWorkerPrompt(
158
359
  ctx: CliContext,
360
+ workspace: SessionRecord,
159
361
  tabId: string,
160
362
  text: string,
161
- options: { detach: boolean; timeoutMs: number }
363
+ options: DispatchOptions
162
364
  ): Promise<WorkerOutcome> {
163
365
  const { daemon } = await requireTurnCapabilities(ctx)
164
- const workspace = ctx.getWorkspace()
165
366
  const attach = await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
166
- if (!attach.tabs.some((tab) => tab.id === tabId)) throw new Error(`tab not found: ${tabId}`)
367
+ const attached = attach.tabs.find((tab) => tab.id === tabId)
368
+ if (!attached) throw new Error(`tab not found: ${tabId}`)
167
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
+ }
168
380
 
169
381
  if (!options.detach) {
170
382
  const outcome = await awaitTurn({
171
383
  assumeWorking: false,
172
384
  daemon,
173
- onArmed: async () => {
174
- await writePromptPayload(daemon, tabId, payload, true)
175
- },
385
+ onArmed: write,
176
386
  tabId,
177
387
  timeoutMs: options.timeoutMs,
178
388
  })
@@ -180,40 +390,83 @@ export async function dispatchWorkerPrompt(
180
390
  }
181
391
 
182
392
  const startedAt = Date.now()
183
- const uptake = new Promise<{ confirmed: true; ms: number } | { confirmed: false }>((resolve) => {
184
- const off = daemon.on('tabStatus', (event) => {
185
- if (event.tabId !== tabId || event.status !== 'working') return
186
- off()
187
- clearTimeout(timer)
188
- resolve({ confirmed: true, ms: Date.now() - startedAt })
189
- })
190
- const timer = setTimeout(
191
- () => {
192
- off()
193
- resolve({ confirmed: false })
194
- },
195
- Math.min(options.timeoutMs, DETACH_UPTAKE_TIMEOUT_MS)
196
- )
197
- })
198
- await writePromptPayload(daemon, tabId, payload, true)
199
- const result = await uptake
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
+
200
419
  if (!result.confirmed) {
201
420
  return {
202
421
  durationMs: Date.now() - startedAt,
203
- error: 'prompt was written but worker uptake was not confirmed',
204
- status: 'error',
205
- uptake: result,
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 },
206
425
  }
207
426
  }
208
427
  return {
209
428
  durationMs: Date.now() - startedAt,
210
429
  status: 'dispatched',
211
- uptake: result,
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
+ }
212
463
  }
464
+ return { durationMs: Date.now() - startedAt, status: 'dispatched', uptake: result }
213
465
  }
214
466
 
215
467
  export async function awaitExistingWorker(
216
468
  ctx: CliContext,
469
+ workspace: SessionRecord,
217
470
  tabId: string,
218
471
  timeoutMs: number
219
472
  ): Promise<WorkerOutcome> {
@@ -221,7 +474,7 @@ export async function awaitExistingWorker(
221
474
  const attach = await daemon.attach({
222
475
  cols: 0,
223
476
  rows: 0,
224
- sessionId: ctx.getWorkspace().id,
477
+ sessionId: workspace.id,
225
478
  thin: true,
226
479
  })
227
480
  const tab = attach.tabs.find((entry) => entry.id === tabId)
@@ -243,13 +496,20 @@ export async function awaitExistingWorker(
243
496
  )
244
497
  }
245
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
+ */
246
504
  export function workerEnvelope(
505
+ workspace: SessionRecord,
247
506
  worker: WorkerView,
248
507
  outcome?: WorkerOutcome
249
508
  ): Record<string, unknown> {
250
509
  return {
251
510
  schemaVersion: WORKER_SCHEMA_VERSION,
252
511
  worker,
512
+ workspace: workspaceIdentity(workspace),
253
513
  ...(outcome === undefined ? {} : { outcome }),
254
514
  }
255
515
  }