@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.
- package/README.md +14 -0
- package/package.json +3 -2
- package/skills/aimux-orchestrator/SKILL.md +127 -0
- package/skills/aimux-orchestrator/assets/ledger.template.md +18 -0
- package/skills/aimux-orchestrator/references/prompts.md +68 -0
- package/skills/aimux-orchestrator/references/review.md +18 -0
- package/src/cli/client/daemon-client.ts +16 -0
- package/src/cli/client/workspace-resolver.ts +61 -8
- package/src/cli/commands/tab/await.ts +1 -1
- package/src/cli/commands/tab/close.ts +1 -1
- package/src/cli/commands/tab/create.ts +263 -128
- package/src/cli/commands/tab/focus.ts +1 -1
- package/src/cli/commands/tab/prompt-io.ts +8 -2
- package/src/cli/commands/tab/run.ts +10 -2
- package/src/cli/commands/tab/send.ts +12 -7
- package/src/cli/commands/tab/snapshot.ts +2 -1
- package/src/cli/commands/tab/tail.ts +1 -1
- package/src/cli/commands/tab/wait.ts +2 -1
- package/src/cli/commands/worker/await.ts +34 -0
- package/src/cli/commands/worker/doctor.ts +153 -0
- package/src/cli/commands/worker/list.ts +67 -0
- package/src/cli/commands/worker/prompt.ts +74 -0
- package/src/cli/commands/worker/run.ts +143 -0
- package/src/cli/commands/worker/shared.ts +515 -0
- package/src/cli/commands/worker/stop.ts +113 -0
- package/src/cli/commands/worker/submit.ts +40 -0
- package/src/cli/commands/workspace/close.ts +1 -1
- package/src/cli/commands/workspace/create.ts +2 -1
- package/src/cli/commands/workspace/switch.ts +1 -1
- package/src/cli/commands/worktree/create-core.ts +27 -7
- package/src/cli/commands/worktree/create.ts +18 -3
- package/src/cli/commands/worktree/remove.ts +32 -10
- package/src/cli/completion/entry.ts +181 -0
- package/src/cli/completion/install.ts +222 -0
- package/src/cli/completion/plan.ts +216 -0
- package/src/cli/completion/scripts.ts +147 -0
- package/src/cli/completion/sources.ts +74 -0
- package/src/cli/context.ts +15 -0
- package/src/cli/flags.ts +45 -2
- package/src/cli/index.ts +40 -18
- package/src/cli/output.ts +3 -0
- package/src/cli/registry.ts +14 -0
- package/src/daemon/daemon.ts +58 -8
- package/src/daemon/session-registry.ts +5 -0
- package/src/doctor.ts +4 -0
- package/src/git/worktree.ts +23 -1
- package/src/index.tsx +53 -92
- package/src/ipc/manager-protocol.ts +15 -2
- package/src/ipc/protocol.ts +26 -3
- package/src/platform/worktree-paths.ts +21 -1
- package/src/state/session-persistence.ts +2 -0
- package/src/state/types.ts +4 -0
- package/src/state/validation.ts +1 -0
- 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
|
{
|