martty 0.2.34 → 0.2.35
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 +108 -11
- package/bin/martty.js +52 -5
- package/lib/acp-client.js +296 -20
- package/lib/acp-registry.snapshot.json +1450 -0
- package/lib/acp-session-config.js +23 -0
- package/lib/acp-session-plan.js +10 -1
- package/lib/acp-session-stats.js +11 -0
- package/lib/acp-session-status.js +70 -25
- package/lib/agent.js +24 -4
- package/lib/boot.js +29 -5
- package/lib/command-args.js +28 -0
- package/lib/download.js +165 -0
- package/lib/harness-discovery.js +41 -0
- package/lib/harness-package.js +200 -0
- package/lib/harness-registry.js +468 -0
- package/lib/harness-removal.js +88 -0
- package/lib/harness-view.js +639 -46
- package/lib/harnesses.js +1053 -41
- package/lib/index.js +4 -0
- package/lib/mux.js +23 -0
- package/lib/plan-view.js +18 -3
- package/lib/status-view.js +2 -0
- package/lib/tui-commands.js +7 -1
- package/lib/tui-overlay.js +27 -5
- package/package.json +7 -4
- package/vendor/darwin-arm64/martty +0 -0
- package/vendor/darwin-x64/martty +0 -0
- package/vendor/linux-arm64/martty +0 -0
- package/vendor/linux-x64/martty +0 -0
- package/vendor/win32-x64/martty.exe +0 -0
|
@@ -64,6 +64,7 @@ class AcpSessionConfigService extends Service {
|
|
|
64
64
|
/** Install the `ctx.acpSessionConfig` standard ACP state service. */
|
|
65
65
|
export function installAcpSessionConfig(ctx) {
|
|
66
66
|
let sessionId
|
|
67
|
+
let sessionGeneration = 0
|
|
67
68
|
let selectionKnown = false
|
|
68
69
|
const optionsBySession = new Map()
|
|
69
70
|
let requestTui
|
|
@@ -142,6 +143,15 @@ export function installAcpSessionConfig(ctx) {
|
|
|
142
143
|
|
|
143
144
|
function observeClient(message) {
|
|
144
145
|
if (!isObject(message) || message.id === undefined || typeof message.method !== 'string') return
|
|
146
|
+
if (message.method === 'initialize') {
|
|
147
|
+
sessionGeneration++
|
|
148
|
+
pending.clear()
|
|
149
|
+
sessionId = undefined
|
|
150
|
+
selectionKnown = false
|
|
151
|
+
optionsBySession.clear()
|
|
152
|
+
publish()
|
|
153
|
+
return
|
|
154
|
+
}
|
|
145
155
|
if (SETUP_METHODS.has(message.method)) {
|
|
146
156
|
const requested = message.method !== 'session/new'
|
|
147
157
|
? readString(message.params, 'sessionId', 'session_id')
|
|
@@ -209,12 +219,15 @@ export function installAcpSessionConfig(ctx) {
|
|
|
209
219
|
throw new Error(`acpSessionConfig.set: option "${id}" is not advertised by the current Session`)
|
|
210
220
|
}
|
|
211
221
|
validateValue(option, value)
|
|
222
|
+
const requestedSession = targetSessionId
|
|
223
|
+
const requestedGeneration = sessionGeneration
|
|
212
224
|
const result = await requestTui(CORDIS_METHODS.sessionConfigSet, {
|
|
213
225
|
protocol: CORDIS_PROTOCOL,
|
|
214
226
|
sessionId: targetSessionId,
|
|
215
227
|
configId: id,
|
|
216
228
|
value,
|
|
217
229
|
})
|
|
230
|
+
assertSession(requestedSession, requestedGeneration)
|
|
218
231
|
if (!isObject(result)) {
|
|
219
232
|
throw new Error('acpSessionConfig.set: native ACP client returned an invalid response')
|
|
220
233
|
}
|
|
@@ -227,6 +240,12 @@ export function installAcpSessionConfig(ctx) {
|
|
|
227
240
|
return setForSession(sessionId, id, value)
|
|
228
241
|
}
|
|
229
242
|
|
|
243
|
+
function assertSession(expectedSession, expectedGeneration) {
|
|
244
|
+
if (sessionGeneration !== expectedGeneration || !optionsBySession.has(expectedSession)) {
|
|
245
|
+
throw new Error('acpSessionConfig: Session changed while configuring options')
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
230
249
|
function transaction(selector) {
|
|
231
250
|
const transactionSessionId = sessionId
|
|
232
251
|
if (transactionSessionId === undefined) {
|
|
@@ -234,6 +253,8 @@ export function installAcpSessionConfig(ctx) {
|
|
|
234
253
|
}
|
|
235
254
|
const transactionOptions = optionsBySession.get(transactionSessionId) ?? []
|
|
236
255
|
const option = resolveTransactionOption(selector, transactionOptions)
|
|
256
|
+
const originalSession = transactionSessionId
|
|
257
|
+
const originalGeneration = sessionGeneration
|
|
237
258
|
const original = cloneJson(option.currentValue ?? option.current_value)
|
|
238
259
|
if (original === undefined) {
|
|
239
260
|
throw new Error(
|
|
@@ -248,6 +269,7 @@ export function installAcpSessionConfig(ctx) {
|
|
|
248
269
|
let settled = false
|
|
249
270
|
|
|
250
271
|
const write = async (value) => {
|
|
272
|
+
assertSession(originalSession, originalGeneration)
|
|
251
273
|
const result = await setForSession(transactionSessionId, option.id, value)
|
|
252
274
|
applied = cloneJson(value)
|
|
253
275
|
return result
|
|
@@ -269,6 +291,7 @@ export function installAcpSessionConfig(ctx) {
|
|
|
269
291
|
if (finalizing) return finalizing
|
|
270
292
|
desired = cloneJson(value)
|
|
271
293
|
const settle = async () => {
|
|
294
|
+
assertSession(originalSession, originalGeneration)
|
|
272
295
|
if (!Object.is(applied, desired)) await write(desired)
|
|
273
296
|
settled = true
|
|
274
297
|
return listFor(transactionSessionId)
|
package/lib/acp-session-plan.js
CHANGED
|
@@ -101,7 +101,16 @@ export function installAcpSessionPlan(ctx) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
function observeClient(message) {
|
|
104
|
-
if (!isObject(message) || message.id === undefined
|
|
104
|
+
if (!isObject(message) || message.id === undefined) return
|
|
105
|
+
if (message.method === 'initialize') {
|
|
106
|
+
pending.clear()
|
|
107
|
+
plansBySession.clear()
|
|
108
|
+
sessionId = undefined
|
|
109
|
+
selectionKnown = false
|
|
110
|
+
publish()
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (!SETUP_METHODS.has(message.method)) return
|
|
105
114
|
pending.set(message.id, {
|
|
106
115
|
sessionId: message.method !== 'session/new'
|
|
107
116
|
? readString(message.params, 'sessionId', 'session_id')
|
package/lib/acp-session-stats.js
CHANGED
|
@@ -114,6 +114,17 @@ export function installAcpSessionStats(ctx, options = {}) {
|
|
|
114
114
|
|
|
115
115
|
function observeClient(message) {
|
|
116
116
|
if (!object(message) || message.id === undefined || typeof message.method !== 'string') return
|
|
117
|
+
if (message.method === 'initialize') {
|
|
118
|
+
pendingSetup.clear()
|
|
119
|
+
pendingPrompts.clear()
|
|
120
|
+
activePrompts.clear()
|
|
121
|
+
toolStarts.clear()
|
|
122
|
+
values.clear()
|
|
123
|
+
sessionId = undefined
|
|
124
|
+
selectionKnown = false
|
|
125
|
+
publish()
|
|
126
|
+
return
|
|
127
|
+
}
|
|
117
128
|
if (SETUP_METHODS.has(message.method)) {
|
|
118
129
|
pendingSetup.set(message.id, {
|
|
119
130
|
sessionId: message.method !== 'session/new'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Standard-ACP-backed run-state projection for the Client tree.
|
|
3
3
|
*
|
|
4
4
|
* Folds the non-statistics facts `/status` needs — connection, server,
|
|
5
|
-
* authenticate state, session binding, model, effort, permission, plan,
|
|
5
|
+
* authenticate state, session binding/use, model, effort, permission, plan,
|
|
6
6
|
* agent preset, and the running/idle state — from messages the mux already
|
|
7
7
|
* observes. Statistics stay in `acpSessionStats`; this service never
|
|
8
8
|
* counts tokens or timings, so there is exactly one stats source.
|
|
@@ -43,7 +43,7 @@ class AcpSessionStatusService extends Service {
|
|
|
43
43
|
function zeroSession(sessionId, bound = sessionId !== undefined) {
|
|
44
44
|
return {
|
|
45
45
|
state: 'idle',
|
|
46
|
-
session: { sessionId, bound },
|
|
46
|
+
session: { sessionId, bound, started: false },
|
|
47
47
|
model: undefined,
|
|
48
48
|
effort: undefined,
|
|
49
49
|
permission: undefined,
|
|
@@ -68,6 +68,8 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
68
68
|
let server
|
|
69
69
|
const auth = { status: undefined, method: undefined }
|
|
70
70
|
let initializeId
|
|
71
|
+
let authMethods = []
|
|
72
|
+
const pendingRequests = new Set()
|
|
71
73
|
const pendingAuthenticate = new Set()
|
|
72
74
|
const pendingSetup = new Map()
|
|
73
75
|
const pendingPrompts = new Map()
|
|
@@ -131,22 +133,43 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
131
133
|
function observeClient(message) {
|
|
132
134
|
if (!object(message) || typeof message.method !== 'string') return
|
|
133
135
|
if (message.method === 'initialize' && message.id !== undefined) {
|
|
136
|
+
pendingRequests.clear()
|
|
137
|
+
sessions.clear()
|
|
138
|
+
Object.assign(fallback, zeroSession(undefined, false))
|
|
139
|
+
delete fallback.error
|
|
140
|
+
sessionId = undefined
|
|
141
|
+
selectionKnown = false
|
|
142
|
+
connection = 'connecting'
|
|
143
|
+
server = undefined
|
|
144
|
+
auth.status = undefined
|
|
145
|
+
auth.method = undefined
|
|
146
|
+
delete auth.message
|
|
147
|
+
authMethods = []
|
|
148
|
+
pendingAuthenticate.clear()
|
|
149
|
+
pendingSetup.clear()
|
|
150
|
+
pendingPrompts.clear()
|
|
134
151
|
initializeId = message.id
|
|
152
|
+
publish()
|
|
135
153
|
return
|
|
136
154
|
}
|
|
155
|
+
if (message.id !== undefined) pendingRequests.add(message.id)
|
|
137
156
|
if (message.method === 'authenticate' && message.id !== undefined) {
|
|
138
157
|
pendingAuthenticate.add(message.id)
|
|
158
|
+
const methodId = readString(message.params, 'methodId')
|
|
159
|
+
const method = authMethods.find((candidate) => candidate?.id === methodId)
|
|
160
|
+
if (methodId !== undefined) auth.method = readString(method, 'name', 'label') ?? methodId
|
|
161
|
+
delete auth.message
|
|
139
162
|
auth.status = 'signing in'
|
|
140
163
|
publish()
|
|
141
164
|
return
|
|
142
165
|
}
|
|
143
166
|
if (SETUP_METHODS.has(message.method) && message.id !== undefined) {
|
|
144
|
-
pendingSetup.set(
|
|
145
|
-
message.
|
|
146
|
-
message.method !== 'session/new'
|
|
167
|
+
pendingSetup.set(message.id, {
|
|
168
|
+
sessionId: message.method !== 'session/new'
|
|
147
169
|
? readString(message.params, 'sessionId', 'session_id')
|
|
148
170
|
: undefined,
|
|
149
|
-
|
|
171
|
+
started: message.method !== 'session/new',
|
|
172
|
+
})
|
|
150
173
|
return
|
|
151
174
|
}
|
|
152
175
|
if (message.method === 'session/prompt' && message.id !== undefined) {
|
|
@@ -155,6 +178,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
155
178
|
if (!selectionKnown && sessionId === undefined) sessionId = promptSessionId
|
|
156
179
|
pendingPrompts.set(message.id, promptSessionId)
|
|
157
180
|
const value = stateFor(promptSessionId)
|
|
181
|
+
value.session.started = true
|
|
158
182
|
if (value.state === 'idle') {
|
|
159
183
|
value.state = 'starting'
|
|
160
184
|
publishIf(promptSessionId)
|
|
@@ -166,42 +190,61 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
166
190
|
if (!object(message)) return
|
|
167
191
|
if (message.id !== undefined && message.id === initializeId) {
|
|
168
192
|
initializeId = undefined
|
|
169
|
-
connection = 'attached'
|
|
193
|
+
connection = message.error === undefined ? 'attached' : 'failed'
|
|
194
|
+
fallback.error = message.error?.message
|
|
170
195
|
const result = object(message.result) ? message.result : undefined
|
|
171
196
|
server = readString(result?.agentInfo, 'name')
|
|
172
197
|
const methods = Array.isArray(result?.authMethods) ? result.authMethods : []
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (method !== undefined && auth.status === undefined) {
|
|
176
|
-
auth.method = readString(method, 'name', 'label') ?? method.id
|
|
177
|
-
}
|
|
198
|
+
authMethods = methods
|
|
199
|
+
// initialize advertises choices, not the credential used by this process.
|
|
178
200
|
publish()
|
|
179
201
|
return
|
|
180
202
|
}
|
|
203
|
+
// Unknown replies can belong to a replaced Agent. Never let them restore
|
|
204
|
+
// its authentication or run state in the new connection.
|
|
205
|
+
if (response(message) && !pendingRequests.delete(message.id)) return
|
|
181
206
|
if (message.id !== undefined && pendingAuthenticate.has(message.id)) {
|
|
182
207
|
pendingAuthenticate.delete(message.id)
|
|
183
208
|
if (message.error === undefined) {
|
|
184
209
|
auth.status = 'configured'
|
|
185
|
-
|
|
186
|
-
auth.status = 'needs sign-in'
|
|
210
|
+
delete auth.message
|
|
187
211
|
} else {
|
|
188
|
-
auth.status =
|
|
212
|
+
auth.status = 'sign-in failed'
|
|
213
|
+
auth.message = readString(message.error?.data, 'details', 'message')
|
|
214
|
+
?? readString(message.error, 'message') ?? 'Authentication failed'
|
|
189
215
|
}
|
|
190
216
|
publish()
|
|
191
217
|
return
|
|
192
218
|
}
|
|
193
219
|
if (message.id !== undefined && pendingSetup.has(message.id)) {
|
|
194
|
-
const
|
|
220
|
+
const setup = pendingSetup.get(message.id)
|
|
195
221
|
pendingSetup.delete(message.id)
|
|
196
222
|
if (message.error !== undefined) {
|
|
197
223
|
if (isAuthRequired(message.error)) auth.status = 'needs sign-in'
|
|
198
|
-
|
|
199
|
-
|
|
224
|
+
else {
|
|
225
|
+
connection = 'failed'
|
|
226
|
+
const value = stateFor(setup.sessionId, false)
|
|
227
|
+
value.error = readString(message.error, 'message') ?? 'Session setup failed'
|
|
228
|
+
value.state = 'idle'
|
|
229
|
+
value.model = undefined
|
|
230
|
+
value.effort = undefined
|
|
200
231
|
}
|
|
201
|
-
|
|
232
|
+
stateFor(setup.sessionId, false).session = {
|
|
233
|
+
sessionId: setup.sessionId, bound: false, started: false,
|
|
234
|
+
}
|
|
235
|
+
if (!selectionKnown) sessionId = setup.sessionId
|
|
202
236
|
} else {
|
|
203
|
-
|
|
204
|
-
|
|
237
|
+
connection = 'attached'
|
|
238
|
+
delete fallback.error
|
|
239
|
+
const bound = readString(message.result, 'sessionId', 'session_id') ?? setup.sessionId
|
|
240
|
+
if (bound !== undefined) {
|
|
241
|
+
stateFor(bound).session = { sessionId: bound, bound: true, started: setup.started }
|
|
242
|
+
delete stateFor(bound).error
|
|
243
|
+
if (authMethods.length > 0) {
|
|
244
|
+
auth.status = 'configured'
|
|
245
|
+
delete auth.message
|
|
246
|
+
}
|
|
247
|
+
}
|
|
205
248
|
if (!selectionKnown) sessionId = bound
|
|
206
249
|
}
|
|
207
250
|
publish()
|
|
@@ -304,14 +347,16 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
304
347
|
? snapshot.sessionId
|
|
305
348
|
: sessionId
|
|
306
349
|
const value = stateFor(configSessionId)
|
|
307
|
-
|
|
308
|
-
value.
|
|
350
|
+
if (!selectionKnown && sessionId === undefined) sessionId = configSessionId
|
|
351
|
+
value.model = optionValue(snapshot.options, 'model', 'model')
|
|
352
|
+
value.effort = optionValue(snapshot.options, 'thought_level', 'effort')
|
|
309
353
|
publishIf(configSessionId)
|
|
310
354
|
}
|
|
311
355
|
|
|
312
|
-
function optionValue(options, id) {
|
|
356
|
+
function optionValue(options, category, id) {
|
|
313
357
|
if (!Array.isArray(options)) return undefined
|
|
314
|
-
const option = options.find((candidate) => candidate?.
|
|
358
|
+
const option = options.find((candidate) => candidate?.category === category)
|
|
359
|
+
?? options.find((candidate) => candidate?.id === id)
|
|
315
360
|
if (option === undefined) return undefined
|
|
316
361
|
const raw = option.currentValue ?? option.current_value
|
|
317
362
|
return typeof raw === 'string' ? raw : undefined
|
package/lib/agent.js
CHANGED
|
@@ -6,11 +6,12 @@ import { homedir } from 'node:os'
|
|
|
6
6
|
import { dirname, join, resolve } from 'node:path'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import { selectedHarness } from './harnesses.js'
|
|
9
|
+
import { tokenizeCommandArgs } from './command-args.js'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Resolve the ACP package and TUI's internal Creator overlay bundle.
|
|
12
13
|
* @param {string | URL} [anchor]
|
|
13
|
-
* @returns {{ command: string, args: string[] }}
|
|
14
|
+
* @returns {{ command: string, args: string[], env?: Record<string, string> }}
|
|
14
15
|
*/
|
|
15
16
|
export function resolveDependencyStack(anchor = import.meta.url) {
|
|
16
17
|
const req = createRequire(anchor)
|
|
@@ -31,20 +32,39 @@ export function resolveDependencyStack(anchor = import.meta.url) {
|
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Resolve the standalone ACP command without affecting the profile path.
|
|
35
|
+
* `forcedHarness` is an internal product initialization value, not a CLI
|
|
36
|
+
* argument; null/omitted means that no product Harness is forced.
|
|
34
37
|
* @param {string | URL} [anchor]
|
|
35
|
-
* @param {{ settingsPath?: string }} [options]
|
|
38
|
+
* @param {{ settingsPath?: string, forcedHarness?: { id: string, label: string, command: string, args?: string[] } | null }} [options]
|
|
36
39
|
* @returns {{ command: string, args: string[] }}
|
|
37
40
|
*/
|
|
38
41
|
export function resolveStackedAgent(anchor = import.meta.url, options = {}) {
|
|
39
42
|
const envCmd = process.env.DSH_TUI_AGENT
|
|
40
43
|
if (typeof envCmd === 'string' && envCmd.trim().length > 0) {
|
|
41
|
-
const tokens = envCmd
|
|
44
|
+
const tokens = tokenizeCommandArgs(envCmd)
|
|
45
|
+
if (!tokens[0]) throw new Error('DSH_TUI_AGENT needs a non-empty command')
|
|
42
46
|
return { command: tokens[0], args: tokens.slice(1) }
|
|
43
47
|
}
|
|
48
|
+
if (options.forcedHarness !== undefined && options.forcedHarness !== null) {
|
|
49
|
+
const configured = options.forcedHarness
|
|
50
|
+
if (typeof configured !== 'object' || Array.isArray(configured)
|
|
51
|
+
|| typeof configured.command !== 'string' || configured.command.trim().length === 0) {
|
|
52
|
+
throw new Error('forcedHarness must declare a non-empty command')
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
command: configured.command,
|
|
56
|
+
args: Array.isArray(configured.args) ? configured.args.map(String) : [],
|
|
57
|
+
...(configured.env !== undefined ? { env: { ...configured.env } } : {}),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
44
60
|
if (typeof options.settingsPath === 'string') {
|
|
45
61
|
const selected = selectedHarness(options.settingsPath)
|
|
46
62
|
if (selected !== undefined) {
|
|
47
|
-
return {
|
|
63
|
+
return {
|
|
64
|
+
command: selected.command,
|
|
65
|
+
args: selected.args,
|
|
66
|
+
...(selected.env !== undefined ? { env: { ...selected.env } } : {}),
|
|
67
|
+
}
|
|
48
68
|
}
|
|
49
69
|
}
|
|
50
70
|
try {
|
package/lib/boot.js
CHANGED
|
@@ -67,6 +67,8 @@ import { installTuiLocalPlugins } from './tui-local-plugins.js'
|
|
|
67
67
|
* @param {{ stdin: import('node:stream').Writable, stdout: import('node:stream').Readable, child?: import('node:child_process').ChildProcess }} [options.stream]
|
|
68
68
|
* @param {{ stdin: number | 'inherit', stdout: number | 'inherit' }} [options.tty]
|
|
69
69
|
* @param {string} [options.settingsPath]
|
|
70
|
+
* Internal product-only forced Harness; null means no pinned Harness.
|
|
71
|
+
* @param {{ id: string, label: string, command: string, args?: string[] } | null} [options.forcedHarness]
|
|
70
72
|
* @param {string} [options.artifactRoot]
|
|
71
73
|
* @param {Array<{ id: string, label: string, command: string, args?: string[], source?: string }>} [options.harnessDefaults]
|
|
72
74
|
* @param {string} [options.harnessPathValue]
|
|
@@ -75,10 +77,16 @@ import { installTuiLocalPlugins } from './tui-local-plugins.js'
|
|
|
75
77
|
export async function bootClient(options = {}) {
|
|
76
78
|
const { Context } = await import('@deepseek-ai/cordis')
|
|
77
79
|
const ctx = new Context()
|
|
80
|
+
const settingsPath = options.settingsPath ?? uiSettingsPath(options.extraArgs ?? [])
|
|
81
|
+
const forcedHarness = options.forcedHarness ?? null
|
|
78
82
|
const acpConfig = options.stream !== undefined
|
|
79
83
|
? { stream: options.stream }
|
|
80
|
-
: {
|
|
81
|
-
|
|
84
|
+
: {
|
|
85
|
+
agent: options.agent ?? resolveStackedAgent(import.meta.url, {
|
|
86
|
+
settingsPath,
|
|
87
|
+
forcedHarness,
|
|
88
|
+
}),
|
|
89
|
+
}
|
|
82
90
|
if (options.settingsPath === undefined) {
|
|
83
91
|
migrateLegacyUiSettings(settingsPath, legacyUiSettingsPaths(options.extraArgs ?? []))
|
|
84
92
|
}
|
|
@@ -96,9 +104,16 @@ export async function bootClient(options = {}) {
|
|
|
96
104
|
harnessDefaults = []
|
|
97
105
|
}
|
|
98
106
|
}
|
|
107
|
+
if (forcedHarness !== null) {
|
|
108
|
+
const forcedEntry = { ...forcedHarness, source: 'forced' }
|
|
109
|
+
const forcedIndex = harnessDefaults.findIndex(({ id }) => id === forcedHarness.id)
|
|
110
|
+
if (forcedIndex === -1) harnessDefaults = [forcedEntry, ...harnessDefaults]
|
|
111
|
+
else harnessDefaults = harnessDefaults.map((entry, index) => index === forcedIndex ? forcedEntry : entry)
|
|
112
|
+
}
|
|
99
113
|
const harnessConfig = {
|
|
100
114
|
settingsPath,
|
|
101
115
|
defaults: harnessDefaults,
|
|
116
|
+
forcedHarness,
|
|
102
117
|
pathValue: options.harnessPathValue,
|
|
103
118
|
hostOwned: options.stream !== undefined,
|
|
104
119
|
}
|
|
@@ -292,10 +307,12 @@ export function migrateLegacyUiSettings(settingsPath, legacyPaths) {
|
|
|
292
307
|
|
|
293
308
|
/**
|
|
294
309
|
* Parse `--agent` / `--agent-arg` from argv. Remaining flags pass through to Rust.
|
|
310
|
+
* `forcedHarness` is an internal product initialization value, not a CLI
|
|
311
|
+
* argument; null/omitted means that no product Harness is forced.
|
|
295
312
|
* `--agent` is also forwarded via {@link painterArgs} so Terminal Auth can
|
|
296
313
|
* re-exec the same command.
|
|
297
314
|
* @param {string[]} argv
|
|
298
|
-
* @param {{ settingsPath?: string }} [options]
|
|
315
|
+
* @param {{ settingsPath?: string, forcedHarness?: { id: string, label: string, command: string, args?: string[] } | null }} [options]
|
|
299
316
|
* @returns {{ agent: { command: string, args: string[] }, rustArgs: string[] }}
|
|
300
317
|
*/
|
|
301
318
|
export function parseClientArgv(argv, options = {}) {
|
|
@@ -305,12 +322,16 @@ export function parseClientArgv(argv, options = {}) {
|
|
|
305
322
|
for (let i = 0; i < argv.length; i += 1) {
|
|
306
323
|
const token = argv[i]
|
|
307
324
|
if (token === '--agent') {
|
|
325
|
+
if (!argv[i + 1]?.trim() || argv[i + 1].startsWith('--')) {
|
|
326
|
+
throw Object.assign(new Error('--agent needs a value'), { exitCode: 2 })
|
|
327
|
+
}
|
|
308
328
|
command = argv[i + 1]
|
|
309
329
|
i += 1
|
|
310
330
|
continue
|
|
311
331
|
}
|
|
312
332
|
if (token === '--agent-arg') {
|
|
313
|
-
|
|
333
|
+
if (argv[i + 1] === undefined) throw Object.assign(new Error('--agent-arg needs a value'), { exitCode: 2 })
|
|
334
|
+
args.push(argv[i + 1])
|
|
314
335
|
i += 1
|
|
315
336
|
continue
|
|
316
337
|
}
|
|
@@ -319,7 +340,10 @@ export function parseClientArgv(argv, options = {}) {
|
|
|
319
340
|
if (command === undefined) {
|
|
320
341
|
const settingsPath = options.settingsPath ?? uiSettingsPath(rustArgs)
|
|
321
342
|
return {
|
|
322
|
-
agent: resolveStackedAgent(import.meta.url, {
|
|
343
|
+
agent: resolveStackedAgent(import.meta.url, {
|
|
344
|
+
settingsPath,
|
|
345
|
+
forcedHarness: options.forcedHarness,
|
|
346
|
+
}),
|
|
323
347
|
rustArgs,
|
|
324
348
|
}
|
|
325
349
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Split an argv string without shell execution, expansion, or substitution. */
|
|
2
|
+
export function tokenizeCommandArgs(value) {
|
|
3
|
+
const tokens = []
|
|
4
|
+
let token = '', quote
|
|
5
|
+
let escaped = false, started = false
|
|
6
|
+
for (const char of String(value ?? '')) {
|
|
7
|
+
if (escaped) {
|
|
8
|
+
if (quote === '"' && !['\\', '"', '$', '`', '\n'].includes(char)) token += '\\'
|
|
9
|
+
token += char
|
|
10
|
+
escaped = false
|
|
11
|
+
} else if (char === '\\' && quote !== "'") {
|
|
12
|
+
escaped = true; started = true
|
|
13
|
+
} else if (quote !== undefined) {
|
|
14
|
+
if (char === quote) quote = undefined
|
|
15
|
+
else token += char
|
|
16
|
+
} else if (char === '"' || char === "'") {
|
|
17
|
+
quote = char; started = true
|
|
18
|
+
} else if (/\s/.test(char)) {
|
|
19
|
+
if (started) { tokens.push(token); token = ''; started = false }
|
|
20
|
+
} else {
|
|
21
|
+
token += char; started = true
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (escaped) token += '\\'
|
|
25
|
+
if (quote !== undefined) throw Object.assign(new Error(`Unclosed ${quote} quote in command`), { exitCode: 2 })
|
|
26
|
+
if (started) tokens.push(token)
|
|
27
|
+
return tokens
|
|
28
|
+
}
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { DownloaderHelper } from 'node-downloader-helper'
|
|
2
|
+
import { lstatSync, statSync } from 'node:fs'
|
|
3
|
+
import { rm } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
function httpUrl(value, base) {
|
|
7
|
+
const parsed = new URL(value, base)
|
|
8
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
9
|
+
throw new Error('download requires an HTTP or HTTPS URL')
|
|
10
|
+
}
|
|
11
|
+
return parsed.href
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Compatibility boundary for the pinned SDK 2.1.11: its response callback can
|
|
15
|
+
// throw outside start()'s promise, and resolves relative redirects against the
|
|
16
|
+
// initial URL. Wrap that callback, not the transport or global HTTP modules.
|
|
17
|
+
// Re-run the redirect regressions when upgrading the SDK's private hook.
|
|
18
|
+
class ArchiveDownloader extends DownloaderHelper {
|
|
19
|
+
__downloadRequest(resolve, reject) {
|
|
20
|
+
const requestUrl = this.requestURL
|
|
21
|
+
const request = super.__downloadRequest(resolve, reject)
|
|
22
|
+
const [onResponse] = request.listeners('response')
|
|
23
|
+
request.removeListener('response', onResponse)
|
|
24
|
+
request.once('response', (response) => {
|
|
25
|
+
try {
|
|
26
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
27
|
+
if (![301, 302, 303, 307, 308].includes(response.statusCode) || !response.headers.location) {
|
|
28
|
+
throw new Error(`download returned HTTP ${response.statusCode} without a usable redirect`)
|
|
29
|
+
}
|
|
30
|
+
response.headers.location = httpUrl(response.headers.location, requestUrl)
|
|
31
|
+
}
|
|
32
|
+
onResponse.call(request, response)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
response.destroy()
|
|
35
|
+
request.destroy()
|
|
36
|
+
this.emit('error', error)
|
|
37
|
+
reject(error)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
return request
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Download into a caller-owned staging directory, without a total-time limit. */
|
|
45
|
+
export async function downloadFile(url, destination, options = {}) {
|
|
46
|
+
const label = options.label ?? 'binary download'
|
|
47
|
+
const cancelled = () => Object.assign(new Error(`${label} cancelled`), { name: 'AbortError' })
|
|
48
|
+
if (options.signal?.aborted) throw cancelled()
|
|
49
|
+
url = httpUrl(url)
|
|
50
|
+
const filePath = path.resolve(destination)
|
|
51
|
+
// This adapter owns only a new staging file, never a pre-existing user file.
|
|
52
|
+
try {
|
|
53
|
+
lstatSync(filePath)
|
|
54
|
+
throw new Error(`download destination already exists: ${filePath}`)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code !== 'ENOENT') throw error
|
|
57
|
+
}
|
|
58
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? 30_000
|
|
59
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? 60_000
|
|
60
|
+
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024
|
|
61
|
+
const request = new AbortController()
|
|
62
|
+
const downloader = new ArchiveDownloader(url, path.dirname(filePath), {
|
|
63
|
+
fileName: path.basename(filePath),
|
|
64
|
+
override: true,
|
|
65
|
+
// The panel owns retry. SDK retry/resume can otherwise outlive cancellation.
|
|
66
|
+
retry: false,
|
|
67
|
+
resumeOnIncomplete: false,
|
|
68
|
+
resumeIfFileExists: false,
|
|
69
|
+
forceResume: false,
|
|
70
|
+
removeOnStop: false,
|
|
71
|
+
removeOnFail: false,
|
|
72
|
+
httpRequestOptions: { signal: request.signal },
|
|
73
|
+
httpsRequestOptions: { signal: request.signal },
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
let state = 'running'
|
|
78
|
+
let phase = 'connecting'
|
|
79
|
+
let receivedBytes = 0
|
|
80
|
+
let totalBytes
|
|
81
|
+
let timer
|
|
82
|
+
const cleanListeners = () => {
|
|
83
|
+
clearTimeout(timer)
|
|
84
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
85
|
+
}
|
|
86
|
+
const fail = (error) => {
|
|
87
|
+
if (state !== 'running') return
|
|
88
|
+
state = 'stopping'
|
|
89
|
+
cleanListeners()
|
|
90
|
+
request.abort(error)
|
|
91
|
+
// SDK emits "download" before finishing stream setup. Let that setup
|
|
92
|
+
// finish, then await closed handles before removing a partial file (Windows).
|
|
93
|
+
void Promise.resolve().then(async () => {
|
|
94
|
+
try {
|
|
95
|
+
await downloader.stop()
|
|
96
|
+
await rm(filePath, { force: true })
|
|
97
|
+
} catch (cleanupError) {
|
|
98
|
+
reject(new Error(`${error.message}; download cleanup failed: ${cleanupError.message}`, { cause: error }))
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
reject(error)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
const onAbort = () => fail(cancelled())
|
|
105
|
+
const resetTimer = () => {
|
|
106
|
+
clearTimeout(timer)
|
|
107
|
+
if (state !== 'running') return
|
|
108
|
+
const duration = phase === 'connecting' ? connectTimeoutMs : idleTimeoutMs
|
|
109
|
+
timer = setTimeout(() => fail(new Error(
|
|
110
|
+
`${label} timed out (${phase} for ${duration}ms; received ${receivedBytes} bytes)`,
|
|
111
|
+
)), duration)
|
|
112
|
+
}
|
|
113
|
+
const progress = (detail) => {
|
|
114
|
+
if (state !== 'running') return
|
|
115
|
+
try {
|
|
116
|
+
options.onProgress?.({ phase: 'download', receivedBytes,
|
|
117
|
+
...(totalBytes === undefined ? {} : { totalBytes }),
|
|
118
|
+
...(detail === undefined ? {} : { detail }) })
|
|
119
|
+
} catch (error) { fail(error) }
|
|
120
|
+
}
|
|
121
|
+
const tooLarge = () => fail(new Error(`${label} is larger than ${maxBytes} bytes`))
|
|
122
|
+
downloader.on('download', (info) => {
|
|
123
|
+
if (state !== 'running') return
|
|
124
|
+
if (info.totalSize > maxBytes) { tooLarge(); return }
|
|
125
|
+
totalBytes = info.totalSize > 0 ? info.totalSize : undefined
|
|
126
|
+
phase = 'idle'
|
|
127
|
+
resetTimer()
|
|
128
|
+
progress('Connected; receiving archive data…')
|
|
129
|
+
})
|
|
130
|
+
downloader.on('progress', (info) => {
|
|
131
|
+
if (state !== 'running') return
|
|
132
|
+
if (info.downloaded > receivedBytes) {
|
|
133
|
+
receivedBytes = info.downloaded
|
|
134
|
+
resetTimer()
|
|
135
|
+
}
|
|
136
|
+
if (receivedBytes > maxBytes) { tooLarge(); return }
|
|
137
|
+
progress()
|
|
138
|
+
})
|
|
139
|
+
downloader.on('end', (info) => {
|
|
140
|
+
if (state !== 'running') return
|
|
141
|
+
try {
|
|
142
|
+
if (info.incomplete || path.resolve(info.filePath) !== filePath) {
|
|
143
|
+
throw new Error(`${label} is incomplete or has an unexpected destination`)
|
|
144
|
+
}
|
|
145
|
+
const size = statSync(filePath).size
|
|
146
|
+
if (size > maxBytes) { tooLarge(); return }
|
|
147
|
+
if (totalBytes !== undefined && size !== totalBytes) {
|
|
148
|
+
throw new Error(`${label} is incomplete: expected ${totalBytes} bytes, received ${size}`)
|
|
149
|
+
}
|
|
150
|
+
state = 'complete'
|
|
151
|
+
cleanListeners()
|
|
152
|
+
resolve({ filePath, receivedBytes: size })
|
|
153
|
+
} catch (error) { fail(error) }
|
|
154
|
+
})
|
|
155
|
+
// Keep an error listener through stop/cleanup; native abort can emit late.
|
|
156
|
+
downloader.on('error', (error) => fail(new Error(`${label} failed: ${error.message}`, { cause: error })))
|
|
157
|
+
downloader.on('stop', () => fail(new Error(`${label} stopped before completion`)))
|
|
158
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
159
|
+
resetTimer()
|
|
160
|
+
progress('Connecting to download server…')
|
|
161
|
+
if (state !== 'running') return
|
|
162
|
+
// start() resolves true on STOP as well as completion. Only "end" is success.
|
|
163
|
+
downloader.start().catch((error) => fail(new Error(`${label} failed: ${error.message}`, { cause: error })))
|
|
164
|
+
})
|
|
165
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Run filesystem probes off the Client event loop; never launch an agent. */
|
|
2
|
+
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
|
|
3
|
+
import { discoverHarnessCandidates } from './harnesses.js'
|
|
4
|
+
|
|
5
|
+
export async function scanHarnessCandidates(settingsPath, options = {}, query = '') {
|
|
6
|
+
const { signal } = options
|
|
7
|
+
signal?.throwIfAborted()
|
|
8
|
+
// Pass only discovery data, not transport callbacks or unrelated plugin options.
|
|
9
|
+
const scoped = Object.fromEntries([
|
|
10
|
+
'registry', 'defaults', 'pathValue', 'pathExt', 'platform', 'arch', 'installRoot',
|
|
11
|
+
].filter((key) => options[key] !== undefined).map((key) => [key, options[key]]))
|
|
12
|
+
scoped.settingsPath = settingsPath
|
|
13
|
+
const worker = new Worker(new URL(import.meta.url), {
|
|
14
|
+
workerData: { harnessDiscovery: true, settingsPath, options: scoped, query },
|
|
15
|
+
// No CLI/test-runner flags apply to this plain filesystem worker.
|
|
16
|
+
execArgv: [],
|
|
17
|
+
})
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
let settled = false
|
|
20
|
+
const finish = (error, entries) => {
|
|
21
|
+
if (settled) return
|
|
22
|
+
settled = true
|
|
23
|
+
signal?.removeEventListener('abort', cancel)
|
|
24
|
+
void worker.terminate()
|
|
25
|
+
if (error) reject(error)
|
|
26
|
+
else resolve(entries)
|
|
27
|
+
}
|
|
28
|
+
const cancel = () => finish(signal.reason)
|
|
29
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
30
|
+
worker.once('message', (entries) => finish(undefined, entries))
|
|
31
|
+
worker.once('error', (error) => finish(error))
|
|
32
|
+
worker.once('exit', (code) => {
|
|
33
|
+
if (!settled) finish(new Error(`Harness discovery worker exited before returning results (${code})`))
|
|
34
|
+
})
|
|
35
|
+
if (signal?.aborted) cancel()
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!isMainThread && workerData?.harnessDiscovery === true) {
|
|
40
|
+
parentPort.postMessage(discoverHarnessCandidates(workerData.settingsPath, workerData.options, workerData.query))
|
|
41
|
+
}
|