@brimveyn/aimux 1.19.7 → 1.20.1

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.
@@ -0,0 +1,33 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { SHARED_FLAGS } from '../../flags'
4
+ import { writeJson } from '../../output'
5
+ import { DEFAULT_TIMEOUT_MS } from '../tab/await-turn'
6
+ import {
7
+ awaitExistingWorker,
8
+ resolveWorkerTab,
9
+ workerEnvelope,
10
+ workerOutcomeExitCode,
11
+ workerView,
12
+ } from './shared'
13
+
14
+ export const workerAwait: CliCommand = {
15
+ args: [{ name: 'worker', required: true }],
16
+ flags: [
17
+ ...SHARED_FLAGS,
18
+ { description: 'overall turn cap in milliseconds', kind: 'number', name: 'timeout' },
19
+ ],
20
+ group: 'worker',
21
+ run: async (ctx) => {
22
+ const tab = await resolveWorkerTab(ctx, ctx.args.positionals[0] ?? '')
23
+ const outcome = await awaitExistingWorker(
24
+ ctx,
25
+ tab.id,
26
+ typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_TIMEOUT_MS
27
+ )
28
+ writeJson(workerEnvelope(workerView(ctx, tab), outcome))
29
+ return workerOutcomeExitCode(outcome)
30
+ },
31
+ summary: "Await an existing worker's in-flight turn",
32
+ verb: 'await',
33
+ }
@@ -0,0 +1,129 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ import type { CliCommand } from '../../registry'
5
+
6
+ import { loadConfig } from '../../../config'
7
+ import { MANAGER_CAPABILITY_WORKER_METADATA } from '../../../ipc/manager-protocol'
8
+ import {
9
+ IPC_CAPABILITY_LIST_TABS,
10
+ IPC_CAPABILITY_QUESTION_EVENTS,
11
+ IPC_CAPABILITY_THIN_ATTACH,
12
+ IPC_CAPABILITY_TURN_LIFECYCLE,
13
+ IPC_CAPABILITY_WORKER_METADATA,
14
+ IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS,
15
+ } from '../../../ipc/protocol'
16
+ import {
17
+ getAllAssistantOptions,
18
+ isCommandAvailable,
19
+ parseCommand,
20
+ } from '../../../pty/command-registry'
21
+ import { SHARED_FLAGS } from '../../flags'
22
+ import { EXIT_OK, EXIT_RUNTIME, writeJson } from '../../output'
23
+ import { WORKER_SCHEMA_VERSION } from './shared'
24
+
25
+ const REQUIRED_DAEMON_CAPABILITIES = [
26
+ IPC_CAPABILITY_LIST_TABS,
27
+ IPC_CAPABILITY_QUESTION_EVENTS,
28
+ IPC_CAPABILITY_THIN_ATTACH,
29
+ IPC_CAPABILITY_TURN_LIFECYCLE,
30
+ IPC_CAPABILITY_WORKER_METADATA,
31
+ IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS,
32
+ ] as const
33
+
34
+ export const workerDoctor: CliCommand = {
35
+ args: [],
36
+ flags: SHARED_FLAGS,
37
+ group: 'worker',
38
+ run: async (ctx) => {
39
+ const daemon = await ctx.getDaemon()
40
+ const workspace = ctx.getWorkspace()
41
+ const { version } = await import('../../../../package.json')
42
+ const { customCommands } = loadConfig()
43
+ const assistants = getAllAssistantOptions(customCommands).map((assistant) => ({
44
+ available: isCommandAvailable(
45
+ parseCommand(customCommands[assistant.id] ?? assistant.command).executable
46
+ ),
47
+ id: assistant.id,
48
+ supportsEffort: assistant.model?.buildEffortArgs !== undefined,
49
+ supportsModel: assistant.model?.buildModelArgs !== undefined,
50
+ }))
51
+ const daemonCapabilities = daemon.getCapabilities()
52
+ const managerCapabilities = daemon.getManagerCapabilities()
53
+ const missingDaemonCapabilities = REQUIRED_DAEMON_CAPABILITIES.filter(
54
+ (capability) => !daemonCapabilities.includes(capability)
55
+ )
56
+ const missingManagerCapabilities = [MANAGER_CAPABILITY_WORKER_METADATA].filter(
57
+ (capability) => !managerCapabilities.includes(capability)
58
+ )
59
+ const primaryWorktree = workspace.worktrees?.find((worktree) => worktree.source === 'primary')
60
+ const availableAssistants = assistants.filter((assistant) => assistant.available)
61
+ const skillPath = fileURLToPath(
62
+ new URL('../../../../skills/aimux-orchestrator/', import.meta.url)
63
+ )
64
+ const issues: string[] = []
65
+ if (missingDaemonCapabilities.length > 0) {
66
+ issues.push(
67
+ `restart or update aimux; daemon is missing: ${missingDaemonCapabilities.join(', ')}`
68
+ )
69
+ }
70
+ if (missingManagerCapabilities.length > 0) {
71
+ issues.push(
72
+ `restart the terminal manager; it is missing: ${missingManagerCapabilities.join(', ')}`
73
+ )
74
+ }
75
+ if (primaryWorktree === undefined) {
76
+ issues.push('workspace has no primary worktree; default isolated worker runs are unavailable')
77
+ }
78
+ if (availableAssistants.length === 0) {
79
+ issues.push('no configured assistant executable is available on PATH')
80
+ }
81
+ if (!existsSync(skillPath)) {
82
+ issues.push(`packaged orchestrator skill is missing: ${skillPath}`)
83
+ }
84
+ const ready = issues.length === 0
85
+ writeJson({
86
+ assistants,
87
+ checks: {
88
+ assistants: {
89
+ available: availableAssistants.map((assistant) => assistant.id),
90
+ ok: availableAssistants.length > 0,
91
+ },
92
+ daemonCapabilities: {
93
+ missing: missingDaemonCapabilities,
94
+ ok: missingDaemonCapabilities.length === 0,
95
+ },
96
+ managerCapabilities: {
97
+ missing: missingManagerCapabilities,
98
+ ok: missingManagerCapabilities.length === 0,
99
+ },
100
+ skill: { ok: existsSync(skillPath), path: skillPath },
101
+ workspace: {
102
+ hasPrimaryWorktree: primaryWorktree !== undefined,
103
+ ok: primaryWorktree !== undefined,
104
+ },
105
+ },
106
+ cliVersion: version,
107
+ daemon: {
108
+ appVersion: daemon.getAppVersion(),
109
+ capabilities: daemonCapabilities,
110
+ managerCapabilities,
111
+ managerProtocolVersion: daemon.getManagerSelectedVersion(),
112
+ processVersion: daemon.getProcessVersion(),
113
+ protocolVersion: daemon.getSelectedVersion(),
114
+ },
115
+ issues,
116
+ ready,
117
+ schemaVersion: WORKER_SCHEMA_VERSION,
118
+ skillPath,
119
+ workspace: {
120
+ id: workspace.id,
121
+ name: workspace.name,
122
+ projectPath: workspace.projectPath ?? null,
123
+ },
124
+ })
125
+ return ready ? EXIT_OK : EXIT_RUNTIME
126
+ },
127
+ summary: 'Check worker prerequisites, versions, assistants, and workspace',
128
+ verb: 'doctor',
129
+ }
@@ -0,0 +1,25 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { SHARED_FLAGS } from '../../flags'
4
+ import { EXIT_OK, writeJson } from '../../output'
5
+ import { listNamedWorkerTabs, resolveWorkerTab, WORKER_SCHEMA_VERSION, workerView } from './shared'
6
+
7
+ export const workerList: CliCommand = {
8
+ args: [{ name: 'worker' }],
9
+ flags: SHARED_FLAGS,
10
+ group: 'worker',
11
+ run: async (ctx) => {
12
+ const selector = ctx.args.positionals[0]
13
+ const tabs =
14
+ selector === undefined
15
+ ? await listNamedWorkerTabs(ctx)
16
+ : [await resolveWorkerTab(ctx, selector)]
17
+ writeJson({
18
+ schemaVersion: WORKER_SCHEMA_VERSION,
19
+ workers: tabs.map((tab) => workerView(ctx, tab)),
20
+ })
21
+ return EXIT_OK
22
+ },
23
+ summary: 'List named workers with liveness and worktree context',
24
+ verb: 'list',
25
+ }
@@ -0,0 +1,49 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { SHARED_FLAGS } from '../../flags'
4
+ import { writeJson } from '../../output'
5
+ import { DEFAULT_TIMEOUT_MS } from '../tab/await-turn'
6
+ import { resolvePromptText } from '../tab/prompt-io'
7
+ import {
8
+ dispatchWorkerPrompt,
9
+ resolveWorkerTab,
10
+ workerEnvelope,
11
+ workerOutcomeExitCode,
12
+ workerView,
13
+ } from './shared'
14
+
15
+ export const workerPrompt: CliCommand = {
16
+ args: [{ name: 'worker', required: true }, { name: 'text' }],
17
+ flags: [
18
+ ...SHARED_FLAGS,
19
+ { description: 'read the prompt from this file', kind: 'string', name: 'prompt-file' },
20
+ { description: 'read the prompt from stdin', kind: 'boolean', name: 'stdin' },
21
+ {
22
+ description: 'return after prompt uptake instead of turn completion',
23
+ kind: 'boolean',
24
+ name: 'detach',
25
+ },
26
+ { description: 'overall turn cap in milliseconds', kind: 'number', name: 'timeout' },
27
+ ],
28
+ group: 'worker',
29
+ run: async (ctx) => {
30
+ const selector = ctx.args.positionals[0] ?? ''
31
+ const tab = await resolveWorkerTab(ctx, selector)
32
+ const promptFile =
33
+ typeof ctx.args.flags['prompt-file'] === 'string' ? ctx.args.flags['prompt-file'] : undefined
34
+ const text = await resolvePromptText(
35
+ promptFile,
36
+ ctx.args.flags.stdin === true,
37
+ ctx.args.positionals[1]
38
+ )
39
+ const outcome = await dispatchWorkerPrompt(ctx, tab.id, text, {
40
+ detach: ctx.args.flags.detach === true,
41
+ timeoutMs:
42
+ typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_TIMEOUT_MS,
43
+ })
44
+ writeJson(workerEnvelope(workerView(ctx, tab), outcome))
45
+ return workerOutcomeExitCode(outcome)
46
+ },
47
+ summary: 'Prompt an existing named worker and await its outcome',
48
+ verb: 'prompt',
49
+ }
@@ -0,0 +1,97 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { CliUsageError, SHARED_FLAGS } from '../../flags'
4
+ import { writeJson } from '../../output'
5
+ import { DEFAULT_TIMEOUT_MS } from '../tab/await-turn'
6
+ import { createCliTab } from '../tab/create'
7
+ import { resolvePromptText } from '../tab/prompt-io'
8
+ import {
9
+ dispatchWorkerPrompt,
10
+ validateWorkerName,
11
+ workerEnvelope,
12
+ workerOutcomeExitCode,
13
+ workerView,
14
+ } from './shared'
15
+
16
+ export const workerRun: CliCommand = {
17
+ args: [{ name: 'text' }],
18
+ flags: [
19
+ ...SHARED_FLAGS,
20
+ { description: 'unique workspace-scoped worker name', kind: 'string', name: 'name' },
21
+ {
22
+ description: 'assistant id (claude, codex, opencode, ...)',
23
+ kind: 'string',
24
+ name: 'assistant',
25
+ },
26
+ { description: 'model passed through to the assistant', kind: 'string', name: 'model' },
27
+ {
28
+ description: 'reasoning effort passed through to the assistant',
29
+ kind: 'string',
30
+ name: 'effort',
31
+ },
32
+ { description: 'read the prompt from this file', kind: 'string', name: 'prompt-file' },
33
+ { description: 'read the prompt from stdin', kind: 'boolean', name: 'stdin' },
34
+ {
35
+ description: 'return after prompt uptake instead of turn completion',
36
+ kind: 'boolean',
37
+ name: 'detach',
38
+ },
39
+ { description: 'overall turn cap in milliseconds', kind: 'number', name: 'timeout' },
40
+ { description: 'co-locate in an existing worktree id', kind: 'string', name: 'worktree' },
41
+ {
42
+ description: 'run in the workspace active worktree instead of creating one',
43
+ kind: 'boolean',
44
+ name: 'no-worktree',
45
+ },
46
+ { description: 'base ref for the fresh worktree (default HEAD)', kind: 'string', name: 'base' },
47
+ {
48
+ description: 'branch for the fresh worktree (default aimux/<name>)',
49
+ kind: 'string',
50
+ name: 'branch',
51
+ },
52
+ ],
53
+ group: 'worker',
54
+ run: async (ctx) => {
55
+ const name = typeof ctx.args.flags.name === 'string' ? ctx.args.flags.name : ''
56
+ const assistant = typeof ctx.args.flags.assistant === 'string' ? ctx.args.flags.assistant : ''
57
+ validateWorkerName(name)
58
+ if (assistant === '') throw new CliUsageError('--assistant is required')
59
+ const worktree =
60
+ typeof ctx.args.flags.worktree === 'string' ? ctx.args.flags.worktree : undefined
61
+ const noWorktree = ctx.args.flags['no-worktree'] === true
62
+ if (worktree !== undefined && noWorktree) {
63
+ throw new CliUsageError('--worktree and --no-worktree are mutually exclusive')
64
+ }
65
+ const promptFile =
66
+ typeof ctx.args.flags['prompt-file'] === 'string' ? ctx.args.flags['prompt-file'] : undefined
67
+ const text = await resolvePromptText(
68
+ promptFile,
69
+ ctx.args.flags.stdin === true,
70
+ ctx.args.positionals[0]
71
+ )
72
+ const result = await createCliTab(ctx, {
73
+ assistantId: assistant,
74
+ base: typeof ctx.args.flags.base === 'string' ? ctx.args.flags.base : undefined,
75
+ branch: typeof ctx.args.flags.branch === 'string' ? ctx.args.flags.branch : undefined,
76
+ effort: typeof ctx.args.flags.effort === 'string' ? ctx.args.flags.effort : undefined,
77
+ model: typeof ctx.args.flags.model === 'string' ? ctx.args.flags.model : undefined,
78
+ newWorktree: noWorktree || worktree !== undefined ? undefined : name,
79
+ title: name,
80
+ workerName: name,
81
+ worktreeId: worktree,
82
+ })
83
+ const tabs = await (await ctx.getDaemon()).listTabs(ctx.getWorkspace().id)
84
+ const tab = tabs.tabs.find((entry) => entry.id === result.tabId)
85
+ 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, {
88
+ detach: ctx.args.flags.detach === true,
89
+ timeoutMs:
90
+ typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_TIMEOUT_MS,
91
+ })
92
+ writeJson(workerEnvelope(worker, outcome))
93
+ return workerOutcomeExitCode(outcome)
94
+ },
95
+ summary: 'Create a named worker, dispatch a prompt, and await its outcome',
96
+ verb: 'run',
97
+ }
@@ -0,0 +1,255 @@
1
+ import type { 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 { CliUsageError } from '../../flags'
13
+ import { EXIT_OK, EXIT_QUESTION, EXIT_RUNTIME, EXIT_TIMEOUT } from '../../output'
14
+ import { snapshotTailLines } from '../../snapshot-render'
15
+ import { awaitTurn, type TurnOutcome } from '../tab/await-turn'
16
+ import { buildPromptPayload, writePromptPayload } from '../tab/prompt-io'
17
+
18
+ export const WORKER_SCHEMA_VERSION = 1
19
+ export const WORKER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
20
+ const QUESTION_TAIL_LINES = 25
21
+ const DETACH_UPTAKE_TIMEOUT_MS = 15_000
22
+
23
+ export interface WorkerView {
24
+ activity?: TabSessionSummary['activity']
25
+ assistant: string
26
+ branch: string | null
27
+ command: string
28
+ lastLine?: string
29
+ name: string
30
+ path: string | null
31
+ status: TabSessionSummary['status']
32
+ tabId: string
33
+ title: string
34
+ worktreeId: string | null
35
+ }
36
+
37
+ export interface WorkerOutcome {
38
+ durationMs: number
39
+ error?: string
40
+ kind?: string
41
+ options?: string[]
42
+ question?: string
43
+ status: 'completed' | 'dispatched' | 'question' | 'timeout' | 'error'
44
+ uptake?: { confirmed: boolean; ms?: number }
45
+ }
46
+
47
+ export function validateWorkerName(name: string): void {
48
+ if (!WORKER_NAME_PATTERN.test(name)) {
49
+ throw new CliUsageError(
50
+ 'worker name must be 1-64 characters: letters, numbers, dot, underscore, or hyphen'
51
+ )
52
+ }
53
+ }
54
+
55
+ function worktreeFor(ctx: CliContext, worktreeId: string | undefined): WorktreeRecord | undefined {
56
+ if (worktreeId === undefined) return undefined
57
+ return ctx.getWorkspace().worktrees?.find((worktree) => worktree.id === worktreeId)
58
+ }
59
+
60
+ export function workerView(ctx: CliContext, tab: TabSessionSummary): WorkerView {
61
+ if (tab.workerName === undefined) {
62
+ throw new Error(`tab is not a named worker: ${tab.id}`)
63
+ }
64
+ const worktree = worktreeFor(ctx, tab.worktreeId)
65
+ return {
66
+ activity: tab.activity,
67
+ assistant: tab.assistant,
68
+ branch: worktree?.branch ?? null,
69
+ command: tab.command,
70
+ lastLine: tab.lastLine,
71
+ name: tab.workerName,
72
+ path: worktree?.path ?? null,
73
+ status: tab.status,
74
+ tabId: tab.id,
75
+ title: tab.title,
76
+ worktreeId: tab.worktreeId ?? null,
77
+ }
78
+ }
79
+
80
+ export async function listNamedWorkerTabs(ctx: CliContext): Promise<TabSessionSummary[]> {
81
+ const daemon = await ctx.getDaemon()
82
+ if (
83
+ !daemon.hasCapability(IPC_CAPABILITY_LIST_TABS) ||
84
+ !daemon.hasCapability(IPC_CAPABILITY_WORKER_METADATA)
85
+ ) {
86
+ throw new Error('daemon predates worker commands — restart aimux')
87
+ }
88
+ return (await daemon.listTabs(ctx.getWorkspace().id)).tabs.filter(
89
+ (tab) => tab.workerName !== undefined
90
+ )
91
+ }
92
+
93
+ export async function resolveWorkerTab(
94
+ ctx: CliContext,
95
+ 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) {
103
+ throw new Error(
104
+ `worker selector is ambiguous: ${selector} (${matches.map((tab) => tab.id).join(', ')})`
105
+ )
106
+ }
107
+ throw new Error(`worker not found: ${selector}`)
108
+ }
109
+
110
+ export function normalizeTurnOutcome(outcome: TurnOutcome): WorkerOutcome {
111
+ switch (outcome.outcome) {
112
+ case 'completed':
113
+ return { durationMs: outcome.durationMs, status: 'completed' }
114
+ case 'question':
115
+ return {
116
+ durationMs: outcome.durationMs,
117
+ kind: outcome.kind,
118
+ options: outcome.options,
119
+ question: outcome.question,
120
+ status: 'question',
121
+ }
122
+ case 'timeout':
123
+ return { durationMs: outcome.durationMs, status: 'timeout' }
124
+ case 'error':
125
+ return { durationMs: outcome.durationMs, error: outcome.error, status: 'error' }
126
+ }
127
+ }
128
+
129
+ export function workerOutcomeExitCode(outcome: WorkerOutcome): number {
130
+ switch (outcome.status) {
131
+ case 'completed':
132
+ case 'dispatched':
133
+ return EXIT_OK
134
+ case 'question':
135
+ return EXIT_QUESTION
136
+ case 'timeout':
137
+ return EXIT_TIMEOUT
138
+ case 'error':
139
+ return EXIT_RUNTIME
140
+ }
141
+ }
142
+
143
+ async function requireTurnCapabilities(ctx: CliContext): Promise<{
144
+ daemon: Awaited<ReturnType<CliContext['getDaemon']>>
145
+ }> {
146
+ const daemon = await ctx.getDaemon()
147
+ if (
148
+ !daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH) ||
149
+ !daemon.hasCapability(IPC_CAPABILITY_TURN_LIFECYCLE) ||
150
+ !daemon.hasCapability(IPC_CAPABILITY_QUESTION_EVENTS)
151
+ ) {
152
+ throw new Error('daemon predates worker turn lifecycle — restart aimux')
153
+ }
154
+ return { daemon }
155
+ }
156
+
157
+ export async function dispatchWorkerPrompt(
158
+ ctx: CliContext,
159
+ tabId: string,
160
+ text: string,
161
+ options: { detach: boolean; timeoutMs: number }
162
+ ): Promise<WorkerOutcome> {
163
+ const { daemon } = await requireTurnCapabilities(ctx)
164
+ const workspace = ctx.getWorkspace()
165
+ 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}`)
167
+ const payload = buildPromptPayload(text, false)
168
+
169
+ if (!options.detach) {
170
+ const outcome = await awaitTurn({
171
+ assumeWorking: false,
172
+ daemon,
173
+ onArmed: async () => {
174
+ await writePromptPayload(daemon, tabId, payload, true)
175
+ },
176
+ tabId,
177
+ timeoutMs: options.timeoutMs,
178
+ })
179
+ return normalizeTurnOutcome(outcome)
180
+ }
181
+
182
+ 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
200
+ if (!result.confirmed) {
201
+ return {
202
+ durationMs: Date.now() - startedAt,
203
+ error: 'prompt was written but worker uptake was not confirmed',
204
+ status: 'error',
205
+ uptake: result,
206
+ }
207
+ }
208
+ return {
209
+ durationMs: Date.now() - startedAt,
210
+ status: 'dispatched',
211
+ uptake: result,
212
+ }
213
+ }
214
+
215
+ export async function awaitExistingWorker(
216
+ ctx: CliContext,
217
+ tabId: string,
218
+ timeoutMs: number
219
+ ): Promise<WorkerOutcome> {
220
+ const { daemon } = await requireTurnCapabilities(ctx)
221
+ const attach = await daemon.attach({
222
+ cols: 0,
223
+ rows: 0,
224
+ sessionId: ctx.getWorkspace().id,
225
+ thin: true,
226
+ })
227
+ const tab = attach.tabs.find((entry) => entry.id === tabId)
228
+ if (!tab) throw new Error(`tab not found: ${tabId}`)
229
+ if (tab.activity === 'waiting-input') {
230
+ const question =
231
+ tab.viewport && tab.viewport.lines.length > 0
232
+ ? snapshotTailLines(tab.viewport, QUESTION_TAIL_LINES, { trim: true }).join('\n')
233
+ : ''
234
+ return { durationMs: 0, kind: 'question', question, status: 'question' }
235
+ }
236
+ return normalizeTurnOutcome(
237
+ await awaitTurn({
238
+ assumeWorking: tab.activity === 'working',
239
+ daemon,
240
+ tabId,
241
+ timeoutMs,
242
+ })
243
+ )
244
+ }
245
+
246
+ export function workerEnvelope(
247
+ worker: WorkerView,
248
+ outcome?: WorkerOutcome
249
+ ): Record<string, unknown> {
250
+ return {
251
+ schemaVersion: WORKER_SCHEMA_VERSION,
252
+ worker,
253
+ ...(outcome === undefined ? {} : { outcome }),
254
+ }
255
+ }
@@ -0,0 +1,84 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { isGitWorktreeDirty, removeGitWorktree } from '../../../git/worktree'
4
+ import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
5
+ import { SHARED_FLAGS } from '../../flags'
6
+ import { EXIT_OK, writeJson } from '../../output'
7
+ import { resolveWorkerTab, WORKER_SCHEMA_VERSION, workerView } from './shared'
8
+
9
+ export const workerStop: CliCommand = {
10
+ args: [{ name: 'worker', required: true }],
11
+ flags: [
12
+ ...SHARED_FLAGS,
13
+ {
14
+ description: 'remove its aimux-created worktree after closing the tab',
15
+ kind: 'boolean',
16
+ name: 'cleanup-worktree',
17
+ },
18
+ { description: 'force removal of a dirty worktree', kind: 'boolean', name: 'force' },
19
+ ],
20
+ group: 'worker',
21
+ run: async (ctx) => {
22
+ const tab = await resolveWorkerTab(ctx, ctx.args.positionals[0] ?? '')
23
+ const worker = workerView(ctx, tab)
24
+ const daemon = await ctx.getDaemon()
25
+ const workspace = ctx.getWorkspace()
26
+ const cleanup = ctx.args.flags['cleanup-worktree'] === true
27
+ const record =
28
+ tab.worktreeId === undefined
29
+ ? undefined
30
+ : workspace.worktrees?.find((worktree) => worktree.id === tab.worktreeId)
31
+
32
+ if (cleanup) {
33
+ if (record === undefined) throw new Error('worker has no registered worktree to clean up')
34
+ if (record.source !== 'aimux-temp' || !record.createdByAimux) {
35
+ throw new Error('refusing to clean up a primary or externally managed worktree')
36
+ }
37
+ const siblings = (await daemon.listTabs(workspace.id)).tabs.filter(
38
+ (entry) => entry.id !== tab.id && entry.worktreeId === record.id
39
+ )
40
+ if (siblings.length > 0) {
41
+ throw new Error(
42
+ `refusing to clean up shared worktree; live tabs: ${siblings.map((entry) => entry.id).join(', ')}`
43
+ )
44
+ }
45
+ if (!daemon.hasCapability(IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS)) {
46
+ throw new Error('daemon predates safe worktree cleanup — restart aimux')
47
+ }
48
+ if (ctx.args.flags.force !== true && (await isGitWorktreeDirty(record.path))) {
49
+ throw new Error('refusing to clean up a dirty worktree without --force')
50
+ }
51
+ }
52
+
53
+ await daemon.expectOk('closeTab', { tabId: tab.id })
54
+ let worktreeRemoved = false
55
+ if (cleanup && record !== undefined) {
56
+ await removeGitWorktree({
57
+ force: ctx.args.flags.force === true,
58
+ repoPath: record.repoRoot,
59
+ targetPath: record.path,
60
+ })
61
+ try {
62
+ await daemon.expectOk('removeWorktreeRecord', {
63
+ sessionId: workspace.id,
64
+ worktreeId: record.id,
65
+ })
66
+ } catch (error) {
67
+ const message = error instanceof Error ? error.message : String(error)
68
+ throw new Error(
69
+ `worker stopped and git worktree removed, but catalog reconciliation failed: ${message}`
70
+ )
71
+ }
72
+ worktreeRemoved = true
73
+ }
74
+ writeJson({
75
+ closed: true,
76
+ schemaVersion: WORKER_SCHEMA_VERSION,
77
+ worker,
78
+ worktreeRemoved,
79
+ })
80
+ return EXIT_OK
81
+ },
82
+ summary: 'Stop a named worker and optionally clean up its worktree',
83
+ verb: 'stop',
84
+ }