@brimveyn/aimux 1.18.4 → 1.19.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/package.json +1 -1
- package/src/cli/commands/tab/list.ts +34 -3
- package/src/cli/commands/tab/prompt-io.ts +83 -0
- package/src/cli/commands/tab/run.ts +222 -0
- package/src/cli/commands/tab/send.ts +63 -59
- package/src/cli/index.ts +1 -0
- package/src/cli/output.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/daemon/daemon.ts +41 -0
- package/src/ipc/protocol.ts +84 -2
- package/src/pty/assistant-question-extractor.ts +124 -0
- package/src/pty/assistant-status-detection-loop.ts +80 -3
- package/src/pty/assistant-status-detector.ts +13 -2
- package/src/pty/last-line.ts +29 -0
- package/src/session-backend/remote-session-backend.ts +5 -0
- package/src/state/types.ts +8 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +6 -3
- package/src/ui/components/layout/sidebar/worktree-row.tsx +5 -3
- package/src/ui/components/layout/status-bar.tsx +7 -3
- package/src/ui/theme-store.ts +23 -14
- package/src/ui/theme.ts +1 -0
package/package.json
CHANGED
|
@@ -1,20 +1,51 @@
|
|
|
1
1
|
import type { CliCommand } from '../../registry'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
IPC_CAPABILITY_LIST_TABS,
|
|
5
|
+
IPC_CAPABILITY_LIST_TABS_LAST_LINE,
|
|
6
|
+
IPC_CAPABILITY_THIN_ATTACH,
|
|
7
|
+
type TabSessionSummary,
|
|
8
|
+
} from '../../../ipc/protocol'
|
|
4
9
|
import { SHARED_FLAGS } from '../../flags'
|
|
5
10
|
import { EXIT_OK, writeJson } from '../../output'
|
|
6
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The daemon always populates `lastLine` on `listTabs` summaries (it's cheap),
|
|
14
|
+
* but the field bloats the common poll, so we strip it unless `--verbose` was
|
|
15
|
+
* asked for. Keeping the default output byte-identical to pre-v13 avoids
|
|
16
|
+
* churning downstream consumers.
|
|
17
|
+
*/
|
|
18
|
+
function stripLastLine(tab: TabSessionSummary): TabSessionSummary {
|
|
19
|
+
const { lastLine: _lastLine, ...rest } = tab
|
|
20
|
+
return rest
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
export const tabList: CliCommand = {
|
|
8
24
|
args: [],
|
|
9
|
-
flags:
|
|
25
|
+
flags: [
|
|
26
|
+
...SHARED_FLAGS,
|
|
27
|
+
{
|
|
28
|
+
description: "include each tab's last non-blank rendered line",
|
|
29
|
+
kind: 'boolean',
|
|
30
|
+
name: 'verbose',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
10
33
|
group: 'tab',
|
|
11
34
|
run: async (ctx) => {
|
|
12
35
|
const workspace = ctx.getWorkspace()
|
|
13
36
|
const daemon = await ctx.getDaemon()
|
|
37
|
+
const verbose = ctx.args.flags.verbose === true
|
|
38
|
+
|
|
39
|
+
if (verbose && !daemon.hasCapability(IPC_CAPABILITY_LIST_TABS_LAST_LINE)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'daemon predates tab list --verbose (listTabsLastLine) — restart aimux to pick up the new daemon'
|
|
42
|
+
)
|
|
43
|
+
}
|
|
14
44
|
|
|
15
45
|
if (daemon.hasCapability(IPC_CAPABILITY_LIST_TABS)) {
|
|
16
46
|
const result = await daemon.listTabs(workspace.id)
|
|
17
|
-
|
|
47
|
+
const tabs = verbose ? result.tabs : result.tabs.map(stripLastLine)
|
|
48
|
+
writeJson({ activeTabId: result.activeTabId, tabs })
|
|
18
49
|
return EXIT_OK
|
|
19
50
|
}
|
|
20
51
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared prompt-write plumbing for `tab send` and `tab run`. Both need to lower
|
|
3
|
+
* a chord/paste buffer into the protocol's string form and submit it with the
|
|
4
|
+
* bracketed-paste `\r` deferral, so the logic lives here once.
|
|
5
|
+
*/
|
|
6
|
+
import type { DaemonClient } from '../../client/daemon-client'
|
|
7
|
+
|
|
8
|
+
import { bracketedPaste, notationToBytes } from '../../chord'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Gap between the bracketed-paste write and the trailing carriage return when
|
|
12
|
+
* submitting a pasted block. Claude Code (and other paste-aware TUIs) buffer
|
|
13
|
+
* every byte between the paste-start/paste-end markers; a `\r` that arrives in
|
|
14
|
+
* the same burst as the paste-end marker is folded into the paste buffer as
|
|
15
|
+
* literal content instead of being read as a submit keystroke. A short settle
|
|
16
|
+
* lets the receiver exit paste mode before the Enter lands.
|
|
17
|
+
*/
|
|
18
|
+
export const PASTE_SUBMIT_SETTLE_MS = 50
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lower a chord/paste buffer of bytes into the string the protocol expects.
|
|
22
|
+
* Every byte we emit is < 0x80 (control chars or printable ASCII), so a
|
|
23
|
+
* Latin-1 decode is faithful — the receiving PTY's UTF-8 path treats each
|
|
24
|
+
* single byte as itself.
|
|
25
|
+
*/
|
|
26
|
+
function bytesToString(bytes: Buffer): string {
|
|
27
|
+
let out = ''
|
|
28
|
+
for (const byte of bytes) {
|
|
29
|
+
out += String.fromCharCode(byte)
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PromptPayload {
|
|
35
|
+
/** The protocol-ready string to write. */
|
|
36
|
+
data: string
|
|
37
|
+
/**
|
|
38
|
+
* Whether `data` is a bracketed-paste block. A trailing `\r` must be sent as
|
|
39
|
+
* a separate, settled write for these — see PASTE_SUBMIT_SETTLE_MS.
|
|
40
|
+
*/
|
|
41
|
+
bracketed: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the payload for a prompt. `asKeys` interprets the text as a vim-style
|
|
46
|
+
* chord (e.g. `<C-c>`); otherwise it's wrapped as a bracketed paste when
|
|
47
|
+
* multi-line.
|
|
48
|
+
*/
|
|
49
|
+
export function buildPromptPayload(text: string, asKeys: boolean): PromptPayload {
|
|
50
|
+
if (asKeys) {
|
|
51
|
+
return { bracketed: false, data: bytesToString(notationToBytes(text)) }
|
|
52
|
+
}
|
|
53
|
+
const data = bracketedPaste(text)
|
|
54
|
+
return { bracketed: data !== text, data }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Write a prompt payload to a tab, optionally appending a submit `\r`. The
|
|
59
|
+
* payload write and the Enter are always separate `write` requests; for a
|
|
60
|
+
* bracketed paste the Enter is deferred by PASTE_SUBMIT_SETTLE_MS so it isn't
|
|
61
|
+
* swallowed by the paste buffer. Returns the number of bytes written.
|
|
62
|
+
*/
|
|
63
|
+
export async function writePromptPayload(
|
|
64
|
+
daemon: DaemonClient,
|
|
65
|
+
tabId: string,
|
|
66
|
+
payload: PromptPayload,
|
|
67
|
+
appendEnter: boolean
|
|
68
|
+
): Promise<number> {
|
|
69
|
+
await daemon.expectOk('write', { data: payload.data, tabId })
|
|
70
|
+
let bytesWritten = Buffer.byteLength(payload.data, 'utf8')
|
|
71
|
+
if (appendEnter) {
|
|
72
|
+
// A bracketed paste swallows a same-burst `\r`, so settle first, then
|
|
73
|
+
// submit as an independent write. Plain text / key chords carry no paste
|
|
74
|
+
// markers, so the Enter can follow immediately — but still as its own
|
|
75
|
+
// write so the two paths stay uniform.
|
|
76
|
+
if (payload.bracketed) {
|
|
77
|
+
await Bun.sleep(PASTE_SUBMIT_SETTLE_MS)
|
|
78
|
+
}
|
|
79
|
+
await daemon.expectOk('write', { data: '\r', tabId })
|
|
80
|
+
bytesWritten += 1
|
|
81
|
+
}
|
|
82
|
+
return bytesWritten
|
|
83
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aimux tab run <tabId>` — the one authoritative verb an orchestrator needs to
|
|
3
|
+
* drive a worker turn. It collapses the spawn→send→uptake→await→snapshot chain
|
|
4
|
+
* into a single event-driven call: stage the prompt, submit it, then block on
|
|
5
|
+
* the daemon's v13 turn-lifecycle events (`tabTurnComplete` / `tabQuestion`)
|
|
6
|
+
* until the turn ends, the worker asks something, the tab dies, or the overall
|
|
7
|
+
* timeout trips. Exactly one JSON object is emitted and the exit code encodes
|
|
8
|
+
* the outcome, so a driver can branch without re-snapshotting the screen.
|
|
9
|
+
*/
|
|
10
|
+
import type { QuestionKind } from '../../../state/types'
|
|
11
|
+
import type { CliCommand } from '../../registry'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
IPC_CAPABILITY_QUESTION_EVENTS,
|
|
15
|
+
IPC_CAPABILITY_THIN_ATTACH,
|
|
16
|
+
IPC_CAPABILITY_TURN_LIFECYCLE,
|
|
17
|
+
} from '../../../ipc/protocol'
|
|
18
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
19
|
+
import { EXIT_OK, EXIT_QUESTION, EXIT_RUNTIME, EXIT_TIMEOUT, writeJson } from '../../output'
|
|
20
|
+
import { buildPromptPayload, writePromptPayload } from './prompt-io'
|
|
21
|
+
|
|
22
|
+
/** Overall cap on a single turn — 15 min, long enough for a heavy build task. */
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 900_000
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The four terminal shapes of a `tab run`. Modelled as a discriminated union so
|
|
27
|
+
* the JSON we emit and the exit code we return are derived from one value, and
|
|
28
|
+
* so the outcome→exit mapping can be unit-tested without a live daemon.
|
|
29
|
+
* `durationMs` is measured from prompt submit, not attach, so it reflects the
|
|
30
|
+
* worker's think time rather than our connection overhead.
|
|
31
|
+
*/
|
|
32
|
+
export type RunOutcome =
|
|
33
|
+
| { durationMs: number; outcome: 'completed' }
|
|
34
|
+
| { durationMs: number; error: string; outcome: 'error' }
|
|
35
|
+
| {
|
|
36
|
+
durationMs: number
|
|
37
|
+
kind: QuestionKind
|
|
38
|
+
options?: string[]
|
|
39
|
+
outcome: 'question'
|
|
40
|
+
question: string
|
|
41
|
+
}
|
|
42
|
+
| { durationMs: number; outcome: 'timeout' }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Map an outcome to its process exit code. Pure and total over the union so a
|
|
46
|
+
* driver's `case $?` stays exhaustive: 0 completed, 10 question/permission
|
|
47
|
+
* (worker is blocked and wants input), 3 the tab errored/exited, 124 we hit the
|
|
48
|
+
* overall cap.
|
|
49
|
+
*/
|
|
50
|
+
export function outcomeExitCode(outcome: RunOutcome): number {
|
|
51
|
+
switch (outcome.outcome) {
|
|
52
|
+
case 'completed':
|
|
53
|
+
return EXIT_OK
|
|
54
|
+
case 'question':
|
|
55
|
+
return EXIT_QUESTION
|
|
56
|
+
case 'error':
|
|
57
|
+
return EXIT_RUNTIME
|
|
58
|
+
case 'timeout':
|
|
59
|
+
return EXIT_TIMEOUT
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the prompt text from exactly one source. We require exactly one of
|
|
65
|
+
* `--prompt-file`, `--stdin`, or the positional `[text]` so an orchestrator
|
|
66
|
+
* never silently sends the wrong buffer when two sources are set (e.g. a stale
|
|
67
|
+
* positional plus a fresh `--prompt-file`).
|
|
68
|
+
*/
|
|
69
|
+
async function resolvePromptText(
|
|
70
|
+
promptFile: string | undefined,
|
|
71
|
+
fromStdin: boolean,
|
|
72
|
+
positionalText: string | undefined
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
const sources = [promptFile !== undefined, fromStdin, positionalText !== undefined].filter(
|
|
75
|
+
(present) => present
|
|
76
|
+
).length
|
|
77
|
+
if (sources !== 1) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'provide exactly one prompt source: --prompt-file <f>, --stdin, or a [text] positional'
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
if (promptFile !== undefined) return Bun.file(promptFile).text()
|
|
83
|
+
if (fromStdin) return Bun.stdin.text()
|
|
84
|
+
return positionalText ?? ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const tabRun: CliCommand = {
|
|
88
|
+
args: [{ name: 'tabId', required: true }, { name: 'text' }],
|
|
89
|
+
flags: [
|
|
90
|
+
...SHARED_FLAGS,
|
|
91
|
+
{ description: 'read the prompt from this file', kind: 'string', name: 'prompt-file' },
|
|
92
|
+
{ description: 'read the prompt from stdin', kind: 'boolean', name: 'stdin' },
|
|
93
|
+
{
|
|
94
|
+
description: 'overall turn cap in milliseconds (default 900000 = 15 min)',
|
|
95
|
+
kind: 'number',
|
|
96
|
+
name: 'timeout',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
description: 'stage the prompt without submitting (still waits)',
|
|
100
|
+
kind: 'boolean',
|
|
101
|
+
name: 'no-enter',
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
group: 'tab',
|
|
105
|
+
run: async (ctx) => {
|
|
106
|
+
const tabId = ctx.args.positionals[0]
|
|
107
|
+
if (typeof tabId !== 'string' || tabId.length === 0) {
|
|
108
|
+
throw new Error('tabId is required')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const promptFile =
|
|
112
|
+
typeof ctx.args.flags['prompt-file'] === 'string' ? ctx.args.flags['prompt-file'] : undefined
|
|
113
|
+
const fromStdin = ctx.args.flags.stdin === true
|
|
114
|
+
const text = await resolvePromptText(promptFile, fromStdin, ctx.args.positionals[1])
|
|
115
|
+
|
|
116
|
+
const timeoutMs =
|
|
117
|
+
typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_TIMEOUT_MS
|
|
118
|
+
const appendEnter = ctx.args.flags['no-enter'] !== true
|
|
119
|
+
|
|
120
|
+
const workspace = ctx.getWorkspace()
|
|
121
|
+
const daemon = await ctx.getDaemon()
|
|
122
|
+
if (
|
|
123
|
+
!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH) ||
|
|
124
|
+
!daemon.hasCapability(IPC_CAPABILITY_TURN_LIFECYCLE) ||
|
|
125
|
+
!daemon.hasCapability(IPC_CAPABILITY_QUESTION_EVENTS)
|
|
126
|
+
) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'daemon predates tab run (turnLifecycle/questionEvents) — restart aimux to pick up the new daemon'
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const attach = await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
|
|
133
|
+
if (!attach.tabs.some((t) => t.id === tabId)) {
|
|
134
|
+
throw new Error(`tab not found: ${tabId}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const payload = buildPromptPayload(text, false)
|
|
138
|
+
|
|
139
|
+
return new Promise<number>((resolve) => {
|
|
140
|
+
// Subscribe BEFORE writing: these events fire only on transitions, so a
|
|
141
|
+
// late subscription would race the worker starting its turn.
|
|
142
|
+
let start = Date.now()
|
|
143
|
+
// Uptake guard. The tab may sit `idle` from a prior turn; a stale
|
|
144
|
+
// `tabTurnComplete` (or the settle window closing on that old idle) must
|
|
145
|
+
// not read as "this turn completed". Only honour completion once we've
|
|
146
|
+
// seen the tab go `working` after our submit.
|
|
147
|
+
let sawWorking = false
|
|
148
|
+
|
|
149
|
+
const settle = (outcome: RunOutcome): void => {
|
|
150
|
+
cleanup()
|
|
151
|
+
writeJson(outcome)
|
|
152
|
+
resolve(outcomeExitCode(outcome))
|
|
153
|
+
}
|
|
154
|
+
const durationMs = (): number => Date.now() - start
|
|
155
|
+
|
|
156
|
+
const offStatus = daemon.on('tabStatus', (p) => {
|
|
157
|
+
if (p.tabId !== tabId) return
|
|
158
|
+
if (p.status === 'working') sawWorking = true
|
|
159
|
+
})
|
|
160
|
+
const offTurn = daemon.on('tabTurnComplete', (p) => {
|
|
161
|
+
if (p.tabId !== tabId) return
|
|
162
|
+
// Ignore end-of-turn until the worker actually started working, so a
|
|
163
|
+
// lingering pre-submit idle can't be mis-read as completion.
|
|
164
|
+
if (!sawWorking) return
|
|
165
|
+
settle({ durationMs: durationMs(), outcome: 'completed' })
|
|
166
|
+
})
|
|
167
|
+
const offQuestion = daemon.on('tabQuestion', (p) => {
|
|
168
|
+
if (p.tabId !== tabId) return
|
|
169
|
+
// A question is honoured immediately — it can legitimately arrive
|
|
170
|
+
// before `working` (the worker asks before doing anything).
|
|
171
|
+
settle({
|
|
172
|
+
durationMs: durationMs(),
|
|
173
|
+
kind: p.kind,
|
|
174
|
+
options: p.options,
|
|
175
|
+
outcome: 'question',
|
|
176
|
+
question: p.prompt,
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
const offExit = daemon.on('tabExit', (p) => {
|
|
180
|
+
if (p.tabId !== tabId) return
|
|
181
|
+
settle({ durationMs: durationMs(), error: `exit ${p.exitCode}`, outcome: 'error' })
|
|
182
|
+
})
|
|
183
|
+
const offError = daemon.on('tabError', (p) => {
|
|
184
|
+
if (p.tabId !== tabId) return
|
|
185
|
+
settle({ durationMs: durationMs(), error: p.message, outcome: 'error' })
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
settle({ durationMs: durationMs(), outcome: 'timeout' })
|
|
190
|
+
}, timeoutMs)
|
|
191
|
+
|
|
192
|
+
const cleanup = (): void => {
|
|
193
|
+
offStatus()
|
|
194
|
+
offTurn()
|
|
195
|
+
offQuestion()
|
|
196
|
+
offExit()
|
|
197
|
+
offError()
|
|
198
|
+
clearTimeout(timer)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Submit after subscribing, then reset the clock so `durationMs` measures
|
|
202
|
+
// the worker's turn rather than our attach/write overhead. On a write
|
|
203
|
+
// failure the tab likely died — surface it as an error outcome rather
|
|
204
|
+
// than sitting idle until the timeout.
|
|
205
|
+
const submit = async (): Promise<void> => {
|
|
206
|
+
try {
|
|
207
|
+
await writePromptPayload(daemon, tabId, payload, appendEnter)
|
|
208
|
+
start = Date.now()
|
|
209
|
+
} catch (error) {
|
|
210
|
+
settle({
|
|
211
|
+
durationMs: durationMs(),
|
|
212
|
+
error: error instanceof Error ? error.message : String(error),
|
|
213
|
+
outcome: 'error',
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
void submit()
|
|
218
|
+
})
|
|
219
|
+
},
|
|
220
|
+
summary: 'Submit a prompt and block until the turn completes or the worker asks',
|
|
221
|
+
verb: 'run',
|
|
222
|
+
}
|
|
@@ -1,33 +1,12 @@
|
|
|
1
1
|
import type { CliCommand } from '../../registry'
|
|
2
2
|
|
|
3
3
|
import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
|
|
4
|
-
import { bracketedPaste, notationToBytes } from '../../chord'
|
|
5
4
|
import { SHARED_FLAGS } from '../../flags'
|
|
6
5
|
import { EXIT_OK, writeJson } from '../../output'
|
|
6
|
+
import { buildPromptPayload, writePromptPayload } from './prompt-io'
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
* `--enter` submits a pasted block. Claude Code (and other paste-aware TUIs)
|
|
11
|
-
* buffer every byte between the paste-start/paste-end markers; a `\r` that
|
|
12
|
-
* arrives in the same burst as the paste-end marker is folded into the paste
|
|
13
|
-
* buffer as literal content instead of being read as a submit keystroke. A
|
|
14
|
-
* short settle lets the receiver exit paste mode before the Enter lands.
|
|
15
|
-
*/
|
|
16
|
-
const PASTE_SUBMIT_SETTLE_MS = 50
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Lower a chord/paste buffer of bytes into the string the protocol expects.
|
|
20
|
-
* Every byte we emit is < 0x80 (control chars or printable ASCII), so a
|
|
21
|
-
* Latin-1 decode is faithful — the receiving PTY's UTF-8 path treats each
|
|
22
|
-
* single byte as itself.
|
|
23
|
-
*/
|
|
24
|
-
function bytesToString(bytes: Buffer): string {
|
|
25
|
-
let out = ''
|
|
26
|
-
for (const byte of bytes) {
|
|
27
|
-
out += String.fromCharCode(byte)
|
|
28
|
-
}
|
|
29
|
-
return out
|
|
30
|
-
}
|
|
8
|
+
/** Default ceiling for the submit→working transition under --await-submit. */
|
|
9
|
+
const DEFAULT_AWAIT_TIMEOUT_MS = 15_000
|
|
31
10
|
|
|
32
11
|
export const tabSend: CliCommand = {
|
|
33
12
|
args: [{ name: 'tabId', required: true }, { name: 'text' }],
|
|
@@ -44,6 +23,18 @@ export const tabSend: CliCommand = {
|
|
|
44
23
|
kind: 'boolean',
|
|
45
24
|
name: 'stdin',
|
|
46
25
|
},
|
|
26
|
+
{
|
|
27
|
+
description:
|
|
28
|
+
'after submitting, block until the tab transitions to working (uptake confirmed)',
|
|
29
|
+
kind: 'boolean',
|
|
30
|
+
name: 'await-submit',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
description:
|
|
34
|
+
'milliseconds to wait for the working transition with --await-submit (default 15000)',
|
|
35
|
+
kind: 'number',
|
|
36
|
+
name: 'await-timeout',
|
|
37
|
+
},
|
|
47
38
|
],
|
|
48
39
|
group: 'tab',
|
|
49
40
|
run: async (ctx) => {
|
|
@@ -55,30 +46,25 @@ export const tabSend: CliCommand = {
|
|
|
55
46
|
const fromStdin = ctx.args.flags.stdin === true
|
|
56
47
|
const asKeys = ctx.args.flags.keys === true
|
|
57
48
|
const appendEnter = ctx.args.flags.enter === true
|
|
49
|
+
const awaitSubmit = ctx.args.flags['await-submit'] === true
|
|
50
|
+
const awaitTimeoutMs =
|
|
51
|
+
typeof ctx.args.flags['await-timeout'] === 'number'
|
|
52
|
+
? ctx.args.flags['await-timeout']
|
|
53
|
+
: DEFAULT_AWAIT_TIMEOUT_MS
|
|
58
54
|
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
bracketed = data !== stdinText
|
|
71
|
-
}
|
|
72
|
-
} else {
|
|
73
|
-
const text = ctx.args.positionals[1] ?? ''
|
|
74
|
-
if (asKeys) {
|
|
75
|
-
if (text === '') throw new Error('--keys requires the chord notation as <text>')
|
|
76
|
-
data = bytesToString(notationToBytes(text))
|
|
77
|
-
} else {
|
|
78
|
-
data = bracketedPaste(text)
|
|
79
|
-
bracketed = data !== text
|
|
80
|
-
}
|
|
55
|
+
// Uptake only means something once we actually submit the prompt: the
|
|
56
|
+
// working transition is the receiving CLI accepting the Enter. Without
|
|
57
|
+
// --enter there is nothing to confirm, so fail loudly rather than block
|
|
58
|
+
// forever on a transition that can't come.
|
|
59
|
+
if (awaitSubmit && !appendEnter) {
|
|
60
|
+
throw new Error('--await-submit requires --enter')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const text = fromStdin ? await Bun.stdin.text() : (ctx.args.positionals[1] ?? '')
|
|
64
|
+
if (asKeys && text === '') {
|
|
65
|
+
throw new Error('--keys requires the chord notation as <text>')
|
|
81
66
|
}
|
|
67
|
+
const payload = buildPromptPayload(text, asKeys)
|
|
82
68
|
|
|
83
69
|
const workspace = ctx.getWorkspace()
|
|
84
70
|
const daemon = await ctx.getDaemon()
|
|
@@ -89,22 +75,40 @@ export const tabSend: CliCommand = {
|
|
|
89
75
|
}
|
|
90
76
|
|
|
91
77
|
await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
|
|
92
|
-
await daemon.expectOk('write', { data, tabId })
|
|
93
78
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// markers, so the Enter can follow immediately — but still as its own
|
|
99
|
-
// write so the two paths stay uniform.
|
|
100
|
-
if (bracketed) {
|
|
101
|
-
await Bun.sleep(PASTE_SUBMIT_SETTLE_MS)
|
|
102
|
-
}
|
|
103
|
-
await daemon.expectOk('write', { data: '\r', tabId })
|
|
104
|
-
bytesWritten += 1
|
|
79
|
+
if (!awaitSubmit) {
|
|
80
|
+
const bytesWritten = await writePromptPayload(daemon, tabId, payload, appendEnter)
|
|
81
|
+
writeJson({ bytesWritten, ok: true })
|
|
82
|
+
return EXIT_OK
|
|
105
83
|
}
|
|
106
84
|
|
|
107
|
-
|
|
85
|
+
// The daemon only emits tabStatus on TRANSITIONS, so we must be subscribed
|
|
86
|
+
// before the submit write lands — otherwise the working transition can fire
|
|
87
|
+
// between the write and our subscription and be lost forever. Arm the
|
|
88
|
+
// listener + a one-shot promise here, then start the clock right before the
|
|
89
|
+
// Enter so `ms` reflects submit→uptake latency, not setup overhead.
|
|
90
|
+
const uptake = new Promise<{ confirmed: true; ms: number } | { confirmed: false }>(
|
|
91
|
+
(resolve) => {
|
|
92
|
+
const off = daemon.on('tabStatus', (event) => {
|
|
93
|
+
if (event.tabId !== tabId || event.status !== 'working') return
|
|
94
|
+
off()
|
|
95
|
+
clearTimeout(timer)
|
|
96
|
+
resolve({ confirmed: true, ms: Date.now() - start })
|
|
97
|
+
})
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
off()
|
|
100
|
+
resolve({ confirmed: false })
|
|
101
|
+
}, awaitTimeoutMs)
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const start = Date.now()
|
|
106
|
+
const bytesWritten = await writePromptPayload(daemon, tabId, payload, appendEnter)
|
|
107
|
+
const result = await uptake
|
|
108
|
+
|
|
109
|
+
// The bytes WERE written regardless of uptake — the working transition is
|
|
110
|
+
// advisory, so a missed transition is still EXIT_OK.
|
|
111
|
+
writeJson({ bytesWritten, ok: true, submitted: true, uptake: result })
|
|
108
112
|
return EXIT_OK
|
|
109
113
|
},
|
|
110
114
|
summary: 'Write text or a key chord to a tab',
|
package/src/cli/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ const EXIT_CODES_BLOCK = [
|
|
|
20
20
|
' 2 usage error (bad flags, unknown command, missing argument)',
|
|
21
21
|
' 3 runtime error (server replied with error, command failed)',
|
|
22
22
|
' 4 daemon unreachable (socket missing and autostart failed)',
|
|
23
|
+
' 10 question (tab run: worker is blocked on a question/permission)',
|
|
23
24
|
' 124 timeout (tab wait, tab tail --timeout, workspace switch --wait)',
|
|
24
25
|
].join('\n')
|
|
25
26
|
|
package/src/cli/output.ts
CHANGED
|
@@ -22,9 +22,11 @@ export function writeError(message: string): void {
|
|
|
22
22
|
// 2 usage error (bad flags, unknown command, missing argument)
|
|
23
23
|
// 3 runtime error (server replied with `error`, command failed)
|
|
24
24
|
// 4 daemon unreachable (socket missing and autostart failed)
|
|
25
|
+
// 10 question (`tab run`: worker is blocked on a question/permission)
|
|
25
26
|
// 124 timeout (`tab wait`, `tab tail --timeout`, `workspace switch --wait`)
|
|
26
27
|
export const EXIT_OK = 0
|
|
27
28
|
export const EXIT_USAGE = 2
|
|
28
29
|
export const EXIT_RUNTIME = 3
|
|
29
30
|
export const EXIT_DAEMON_UNREACHABLE = 4
|
|
31
|
+
export const EXIT_QUESTION = 10
|
|
30
32
|
export const EXIT_TIMEOUT = 124
|
package/src/cli/registry.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { tabClose } from './commands/tab/close'
|
|
|
5
5
|
import { tabCreate } from './commands/tab/create'
|
|
6
6
|
import { tabFocus } from './commands/tab/focus'
|
|
7
7
|
import { tabList } from './commands/tab/list'
|
|
8
|
+
import { tabRun } from './commands/tab/run'
|
|
8
9
|
import { tabSend } from './commands/tab/send'
|
|
9
10
|
import { tabSnapshot } from './commands/tab/snapshot'
|
|
10
11
|
import { tabTail } from './commands/tab/tail'
|
|
@@ -31,6 +32,7 @@ export const COMMANDS: readonly CliCommand[] = [
|
|
|
31
32
|
tabList,
|
|
32
33
|
tabCreate,
|
|
33
34
|
tabSend,
|
|
35
|
+
tabRun,
|
|
34
36
|
tabFocus,
|
|
35
37
|
tabClose,
|
|
36
38
|
tabSnapshot,
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from '../ipc/protocol'
|
|
22
22
|
import { findSocketProcessPid, spawnDetachedTerminalManager } from '../platform/daemon-control'
|
|
23
23
|
import { type LoopTabView, runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
|
|
24
|
+
import { lastNonBlankLine } from '../pty/last-line'
|
|
24
25
|
import { createDefaultTerminalModes } from '../state/terminal-modes'
|
|
25
26
|
import { TerminalManagerClient } from '../terminal-manager/manager-client'
|
|
26
27
|
import {
|
|
@@ -108,6 +109,18 @@ export function mergeTabRegistryEntry(
|
|
|
108
109
|
return entry
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Turn-complete settle window for the status loop, overridable via
|
|
114
|
+
* `AIMUX_TURN_SETTLE_MS` for slow/loaded machines. Falls back to the loop's
|
|
115
|
+
* own default when unset or non-numeric.
|
|
116
|
+
*/
|
|
117
|
+
function turnCompleteSettleMs(): number | undefined {
|
|
118
|
+
const raw = process.env.AIMUX_TURN_SETTLE_MS
|
|
119
|
+
if (raw == null || raw === '') return undefined
|
|
120
|
+
const parsed = Number(raw)
|
|
121
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
111
124
|
function send(socket: Socket, message: ServerResponse | ServerEvent): void {
|
|
112
125
|
socket.write(encodeMessage(message))
|
|
113
126
|
}
|
|
@@ -376,6 +389,20 @@ export async function runDaemon(): Promise<void> {
|
|
|
376
389
|
logDebug('daemon.status.session', { sessionId, status })
|
|
377
390
|
broadcastAll({ payload: { sessionId, status }, type: 'sessionStatus' })
|
|
378
391
|
},
|
|
392
|
+
onTabQuestion: (tabId, sessionId, detail) => {
|
|
393
|
+
logDebug('daemon.status.question', { kind: detail.kind, sessionId, tabId })
|
|
394
|
+
// v13 / capability `questionEvents`. Same v13 send-time gate as below.
|
|
395
|
+
broadcastAllVersioned(13, {
|
|
396
|
+
payload: {
|
|
397
|
+
kind: detail.kind,
|
|
398
|
+
options: detail.options,
|
|
399
|
+
prompt: detail.prompt,
|
|
400
|
+
sessionId,
|
|
401
|
+
tabId,
|
|
402
|
+
},
|
|
403
|
+
type: 'tabQuestion',
|
|
404
|
+
})
|
|
405
|
+
},
|
|
379
406
|
onTabStatus: (tabId, status, sessionId) => {
|
|
380
407
|
logDebug('daemon.status.tab', { sessionId, status, tabId })
|
|
381
408
|
// Broadcast to every client. Clients silently ignore events for tabIds
|
|
@@ -385,6 +412,14 @@ export async function runDaemon(): Promise<void> {
|
|
|
385
412
|
// client tears down its socket to switch sessions.
|
|
386
413
|
broadcastAll({ payload: { sessionId, status, tabId }, type: 'tabStatus' })
|
|
387
414
|
},
|
|
415
|
+
onTurnComplete: (tabId, sessionId, idleMs) => {
|
|
416
|
+
logDebug('daemon.status.turnComplete', { idleMs, sessionId, tabId })
|
|
417
|
+
// v13 / capability `turnLifecycle`. Gate at send time — pre-v13 parsers
|
|
418
|
+
// throw on unknown event types and would drop the connection. MIN stays
|
|
419
|
+
// at 10, so we fan this only to peers that negotiated at least v13.
|
|
420
|
+
broadcastAllVersioned(13, { payload: { idleMs, sessionId, tabId }, type: 'tabTurnComplete' })
|
|
421
|
+
},
|
|
422
|
+
turnSettleMs: turnCompleteSettleMs(),
|
|
388
423
|
})
|
|
389
424
|
|
|
390
425
|
// Local HTTP server that receives Claude Code hook callbacks for every PTY
|
|
@@ -789,6 +824,12 @@ export async function runDaemon(): Promise<void> {
|
|
|
789
824
|
assistant: entry.assistant,
|
|
790
825
|
command: entry.command,
|
|
791
826
|
id: tabId,
|
|
827
|
+
// Additive v13 field (capability `listTabsLastLine`): the
|
|
828
|
+
// tab's last non-blank rendered line, so a fleet poll can
|
|
829
|
+
// read "what each worker is doing" without a per-tab
|
|
830
|
+
// snapshot. Undefined when the tab has no viewport yet, in
|
|
831
|
+
// which case the field is omitted from the JSON wire.
|
|
832
|
+
lastLine: lastNonBlankLine(entry.viewport),
|
|
792
833
|
status: entry.status ?? 'running',
|
|
793
834
|
title: entry.title ?? '',
|
|
794
835
|
worktreeId: entry.worktreeId,
|