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.
@@ -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)
@@ -101,7 +101,16 @@ export function installAcpSessionPlan(ctx) {
101
101
  }
102
102
 
103
103
  function observeClient(message) {
104
- if (!isObject(message) || message.id === undefined || !SETUP_METHODS.has(message.method)) return
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')
@@ -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.id,
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
- const method = methods.find((candidate) => object(candidate)
174
- && typeof candidate.id === 'string' && candidate.id.length > 0)
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
- } else if (isAuthRequired(message.error)) {
186
- auth.status = 'needs sign-in'
210
+ delete auth.message
187
211
  } else {
188
- auth.status = undefined
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 requested = pendingSetup.get(message.id)
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
- if (requested !== undefined) {
199
- stateFor(requested, false).session.bound = false
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
- if (!selectionKnown) sessionId = requested
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
- const bound = readString(message.result, 'sessionId', 'session_id') ?? requested
204
- if (bound !== undefined) stateFor(bound).session = { sessionId: bound, bound: true }
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
- value.model = optionValue(snapshot.options, 'model') ?? value.model
308
- value.effort = optionValue(snapshot.options, 'effort') ?? value.effort
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?.id === id)
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.trim().split(/\s+/)
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 { command: selected.command, args: selected.args }
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
- : { agent: options.agent ?? resolveStackedAgent() }
81
- const settingsPath = options.settingsPath ?? uiSettingsPath(options.extraArgs ?? [])
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
- args.push(argv[i + 1] ?? '')
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, { settingsPath }),
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
+ }
@@ -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
+ }