@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.
- package/README.md +6 -0
- package/package.json +3 -2
- package/skills/aimux-orchestrator/SKILL.md +93 -0
- package/skills/aimux-orchestrator/assets/ledger.template.md +18 -0
- package/skills/aimux-orchestrator/references/prompts.md +57 -0
- package/skills/aimux-orchestrator/references/review.md +18 -0
- package/src/cli/client/daemon-client.ts +16 -0
- package/src/cli/commands/tab/create.ts +236 -125
- package/src/cli/commands/tab/prompt-io.ts +2 -1
- package/src/cli/commands/worker/await.ts +33 -0
- package/src/cli/commands/worker/doctor.ts +129 -0
- package/src/cli/commands/worker/list.ts +25 -0
- package/src/cli/commands/worker/prompt.ts +49 -0
- package/src/cli/commands/worker/run.ts +97 -0
- package/src/cli/commands/worker/shared.ts +255 -0
- package/src/cli/commands/worker/stop.ts +84 -0
- package/src/cli/commands/worktree/remove.ts +29 -9
- package/src/cli/index.ts +21 -9
- package/src/cli/registry.ts +12 -0
- package/src/daemon/daemon.ts +58 -8
- package/src/daemon/session-registry.ts +5 -0
- package/src/git/worktree.ts +8 -0
- package/src/index.tsx +18 -81
- package/src/ipc/manager-protocol.ts +15 -2
- package/src/ipc/protocol.ts +26 -3
- 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,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
|
+
}
|