@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.
- package/README.md +9 -1
- package/package.json +1 -1
- package/skills/aimux-orchestrator/SKILL.md +39 -5
- package/skills/aimux-orchestrator/references/prompts.md +11 -0
- package/src/auto-rename/coordinator.ts +206 -32
- package/src/auto-rename/heuristic-title.ts +57 -0
- package/src/auto-rename/prompt-capture.ts +19 -0
- package/src/auto-rename/prompt-gate.ts +82 -0
- package/src/auto-rename/title-format.ts +38 -0
- package/src/auto-rename/title-runner.ts +32 -21
- 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 +27 -3
- package/src/cli/commands/tab/focus.ts +1 -1
- package/src/cli/commands/tab/prompt-io.ts +6 -1
- 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 +5 -4
- package/src/cli/commands/worker/doctor.ts +25 -1
- package/src/cli/commands/worker/list.ts +50 -8
- package/src/cli/commands/worker/prompt.ts +31 -6
- package/src/cli/commands/worker/run.ts +56 -10
- package/src/cli/commands/worker/shared.ts +312 -52
- package/src/cli/commands/worker/stop.ts +39 -10
- 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 +3 -1
- 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 +20 -10
- package/src/cli/output.ts +3 -0
- package/src/cli/registry.ts +2 -0
- package/src/daemon/daemon.ts +11 -0
- package/src/doctor.ts +4 -0
- package/src/git/worktree.ts +15 -1
- package/src/index.tsx +35 -11
- 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
|
-
{
|
|
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
|
-
{
|
|
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(
|
|
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(
|
|
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 {
|
|
14
|
-
|
|
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
|
-
|
|
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?: {
|
|
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(
|
|
102
|
+
function worktreeFor(
|
|
103
|
+
workspace: SessionRecord,
|
|
104
|
+
worktreeId: string | undefined
|
|
105
|
+
): WorktreeRecord | undefined {
|
|
56
106
|
if (worktreeId === undefined) return undefined
|
|
57
|
-
return
|
|
107
|
+
return workspace.worktrees?.find((worktree) => worktree.id === worktreeId)
|
|
58
108
|
}
|
|
59
109
|
|
|
60
|
-
export function 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(
|
|
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
|
-
|
|
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
|
|
89
|
-
(tab) => tab.workerName !== undefined
|
|
90
|
-
)
|
|
141
|
+
return daemon
|
|
91
142
|
}
|
|
92
143
|
|
|
93
|
-
export async function
|
|
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<
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
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} (${
|
|
189
|
+
`worker selector is ambiguous in workspace "${workspace.name}": ${selector} (${local
|
|
190
|
+
.map((tab) => tab.id)
|
|
191
|
+
.join(', ')})`
|
|
105
192
|
)
|
|
106
193
|
}
|
|
107
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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:
|
|
204
|
-
status: '
|
|
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:
|
|
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
|
}
|