martty 0.2.37 → 0.2.39-beta.0

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.
@@ -0,0 +1,282 @@
1
+ /** ACP connections live as long as their sessions, independently of the default recipe. */
2
+ import { PassThrough } from 'node:stream'
3
+ import { AsyncLocalStorage } from 'node:async_hooks'
4
+ import { createHash } from 'node:crypto'
5
+ import { onJsonLines } from './mux.js'
6
+ import { readCordisCapability } from './cordis-protocol.js'
7
+
8
+ const identity = spec => JSON.stringify([spec.command, spec.args ?? [],
9
+ Object.entries(spec.env ?? {}).sort(([a], [b]) => a.localeCompare(b))])
10
+ const namespace = spec => createHash('sha256').update(identity(spec)).digest('hex').slice(0, 24)
11
+ const setupMethods = new Set(['session/new', 'session/load', 'session/resume'])
12
+
13
+ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticError, timeoutMs = 20 * 60_000 }) {
14
+ const input = new PassThrough()
15
+ const output = new PassThrough()
16
+ const connections = new Map()
17
+ const sessions = new Map()
18
+ const callbacks = new Map()
19
+ const authMethods = new Map()
20
+ const context = new AsyncLocalStorage()
21
+ let nextConnection = 0
22
+ let nextRequest = 0
23
+ let defaultAgent = initial
24
+ let initializeParams
25
+ let active
26
+ let lastSetup
27
+ let closed = false
28
+
29
+ const write = value => { if (!closed) output.write(JSON.stringify(value) + '\n') }
30
+ const writeAgent = (owner, value) => owner.handle.stdin.write(JSON.stringify(value) + '\n')
31
+
32
+ function metadata(owner) {
33
+ return {
34
+ id: owner.id,
35
+ agentInfo: owner.init?.agentInfo,
36
+ agentCapabilities: owner.init?.agentCapabilities ?? {},
37
+ authMethods: (owner.init?.authMethods ?? []).map(method => {
38
+ const id = owner.id === 'h1' ? method.id : `${owner.id}:${method.id}`
39
+ authMethods.set(id, { owner, id: method.id })
40
+ return { ...method, id }
41
+ }),
42
+ command: owner.spec.command,
43
+ args: owner.spec.args ?? [],
44
+ }
45
+ }
46
+
47
+ function sessionId(owner, raw) {
48
+ if (typeof raw !== 'string' || !raw) return raw
49
+ if (owner.sessions.has(raw)) return owner.sessions.get(raw)
50
+ let id = owner.id === 'h1' ? raw : `martty:${owner.namespace}:${raw}`
51
+ while (sessions.has(id)) id = `martty:${owner.namespace}:${id}`
52
+ owner.sessions.set(raw, id)
53
+ sessions.set(id, { owner, raw })
54
+ return id
55
+ }
56
+
57
+ function incomingParams(owner, params) {
58
+ if (params === null || typeof params !== 'object') return params
59
+ const value = { ...params }
60
+ for (const key of ['sessionId', 'session_id', 'parentSessionId']) {
61
+ if (typeof value[key] === 'string') value[key] = sessionId(owner, value[key])
62
+ }
63
+ return value
64
+ }
65
+
66
+ function response(owner, message, pending) {
67
+ message = { ...message, _meta: { ...message._meta, marttyConnectionId: owner.id } }
68
+ if (message.error !== undefined) {
69
+ const error = message.error
70
+ const detail = owner.failed?.message === error.message ? owner.failed
71
+ : diagnosticError(new Error(error.message ?? 'ACP request failed'), owner.handle)
72
+ return { ...message, jsonrpc: '2.0', id: pending.id, error: { ...error, message: detail.message,
73
+ data: { ...(error.data && typeof error.data === 'object' ? error.data : { detail: error.data }),
74
+ marttyConnection: { ...metadata(owner), cwd: pending.params?.cwd } } } }
75
+ }
76
+ let result = message.result
77
+ if (pending.method === 'initialize') {
78
+ owner.init = result
79
+ metadata(owner)
80
+ }
81
+ if (setupMethods.has(pending.method) && result && typeof result === 'object') {
82
+ const raw = result.sessionId ?? pending.params?.sessionId
83
+ result = { ...result, sessionId: sessionId(owner, raw),
84
+ _meta: { ...result._meta, marttyConnection: { ...metadata(owner), cwd: pending.params?.cwd } } }
85
+ }
86
+ if (pending.method === 'session/list' && Array.isArray(result?.sessions)) {
87
+ result = { ...result, sessions: result.sessions.map(session => ({ ...session,
88
+ sessionId: sessionId(owner, session.sessionId) })) }
89
+ }
90
+ return { ...message, jsonrpc: '2.0', id: pending.id, result }
91
+ }
92
+
93
+ function fail(owner, error) {
94
+ if (owner.failed || closed) return
95
+ owner.failed = diagnosticError(error, owner.handle)
96
+ for (const pending of owner.pending.values()) {
97
+ if (pending.reject) pending.reject(owner.failed)
98
+ else write(response(owner, { error: { code: -32603, message: owner.failed.message } }, pending))
99
+ }
100
+ owner.pending.clear()
101
+ }
102
+
103
+ function connection(spec) {
104
+ const key = identity(spec)
105
+ const existing = connections.get(key)
106
+ if (existing && !existing.failed) return existing
107
+ const owner = { id: `h${++nextConnection}`, namespace: namespace(spec), spec, handle: spawnAgent(spec),
108
+ sessions: new Map(), pending: new Map(), init: undefined, initializing: undefined, failed: undefined }
109
+ connections.set(key, owner)
110
+ owner.handle.child.on('error', error => fail(owner, error))
111
+ owner.handle.child.once('close', (code, signal) => fail(owner,
112
+ new Error(`ACP process exited (${signal ?? code ?? 'unknown'}) before completing the request`)))
113
+ onJsonLines(owner.handle.stdout, line => {
114
+ let message
115
+ try { message = JSON.parse(line) } catch { return }
116
+ if (message.id !== undefined && typeof message.method !== 'string') {
117
+ const pending = owner.pending.get(message.id)
118
+ if (!pending) return
119
+ owner.pending.delete(message.id)
120
+ if (pending.resolve) {
121
+ if (message.error) pending.reject(Object.assign(new Error(message.error.message), { acpError: message.error }))
122
+ else pending.resolve(message.result)
123
+ } else write(response(owner, message, pending))
124
+ return
125
+ }
126
+ const forwarded = { ...message, params: incomingParams(owner, message.params) }
127
+ if (message.id !== undefined) {
128
+ const id = `martty-agent-${++nextRequest}`
129
+ callbacks.set(id, { owner, id: message.id })
130
+ forwarded.id = id
131
+ }
132
+ // Origin metadata is consumed locally by the mux, never sent to an Agent.
133
+ forwarded._meta = { ...forwarded._meta, marttyConnectionId: owner.id }
134
+ write(forwarded)
135
+ })
136
+ return owner
137
+ }
138
+
139
+ async function initialize(owner) {
140
+ if (owner.failed) throw owner.failed
141
+ if (owner.init) return
142
+ if (!initializeParams) throw new Error('ACP initialize must precede session/new')
143
+ if (!owner.initializing) {
144
+ owner.initializing = new Promise((resolve, reject) => {
145
+ const id = `martty-initialize-${++nextRequest}`
146
+ const timer = setTimeout(() => {
147
+ owner.pending.delete(id)
148
+ reject(new Error(`ACP setup timed out after ${timeoutMs / 1000}s`))
149
+ }, timeoutMs)
150
+ timer.unref?.()
151
+ const settle = callback => value => { clearTimeout(timer); callback(value) }
152
+ owner.pending.set(id, { resolve: settle(resolve), reject: settle(reject) })
153
+ writeAgent(owner, { jsonrpc: '2.0', id, method: 'initialize', params: initializeParams })
154
+ }).then(value => { owner.init = value; metadata(owner) })
155
+ .catch(error => { owner.initializing = undefined; throw error })
156
+ }
157
+ await owner.initializing
158
+ }
159
+
160
+ function ownerFor(message) {
161
+ const sid = message.params?.sessionId ?? message.params?.session_id
162
+ if (typeof sid === 'string') {
163
+ const bound = sessions.get(sid)
164
+ if (bound) return bound.owner
165
+ if (!setupMethods.has(message.method)) throw new Error(`Unknown ACP session: ${sid}`)
166
+ const saved = /^martty:([a-f0-9]{24}):([\s\S]+)$/.exec(sid)
167
+ if (saved) {
168
+ const owner = [...connections.values()].find(owner => owner.namespace === saved[1] && !owner.failed)
169
+ ?? (namespace(defaultAgent) === saved[1] ? connection(defaultAgent) : undefined)
170
+ if (!owner) throw new Error('Select the session’s original Harness before resuming it')
171
+ sessions.set(sid, { owner, raw: saved[2] })
172
+ owner.sessions.set(saved[2], sid)
173
+ return owner
174
+ }
175
+ }
176
+ return context.getStore() ?? active
177
+ }
178
+
179
+ async function outgoing(message) {
180
+ if (closed) return
181
+ if (typeof message.method !== 'string') {
182
+ const callback = callbacks.get(message.id)
183
+ if (callback) {
184
+ callbacks.delete(message.id)
185
+ writeAgent(callback.owner, { ...message, id: callback.id })
186
+ }
187
+ return
188
+ }
189
+ let owner
190
+ let params = message.params
191
+ try {
192
+ // Capture the recipe before awaiting initialization. Later default edits
193
+ // cannot retarget a session/new request already in flight.
194
+ if (message.method === 'session/new') {
195
+ owner = authMethods.get(params?._meta?.marttyAuthMethod)?.owner ?? connection(defaultAgent)
196
+ lastSetup = owner
197
+ if (params?._meta?.marttyAuthMethod) {
198
+ params = { ...params, _meta: { ...params._meta } }
199
+ delete params._meta.marttyAuthMethod
200
+ }
201
+ await initialize(owner)
202
+ } else if (message.method === 'initialize') {
203
+ initializeParams = structuredClone(params ?? {})
204
+ owner = active
205
+ } else if (message.method === 'authenticate') {
206
+ const method = authMethods.get(params?.methodId)
207
+ owner = method?.owner ?? lastSetup ?? active
208
+ if (method) params = { ...params, methodId: method.id }
209
+ } else {
210
+ owner = ownerFor(message)
211
+ if (setupMethods.has(message.method)) await initialize(owner)
212
+ }
213
+ if (owner.failed) throw owner.failed
214
+ if (message.method.startsWith('_dsh/cordis/') && readCordisCapability(owner.init) === null) {
215
+ throw new Error('agent has not advertised _dsh/cordis')
216
+ }
217
+ if (params && typeof params === 'object') {
218
+ params = { ...params }
219
+ for (const key of ['sessionId', 'session_id']) {
220
+ const bound = sessions.get(params[key])
221
+ if (bound) params[key] = bound.raw
222
+ }
223
+ }
224
+ const forwarded = { ...message, params }
225
+ if (message.id !== undefined) {
226
+ const id = `martty-client-${++nextRequest}`
227
+ owner.pending.set(id, { id: message.id, method: message.method, params })
228
+ forwarded.id = id
229
+ }
230
+ writeAgent(owner, forwarded)
231
+ } catch (error) {
232
+ if (message.id !== undefined) {
233
+ const failed = { error: { ...error.acpError, code: error.acpError?.code ?? -32603, message: error.message } }
234
+ write(owner ? response(owner, failed, message) : { jsonrpc:'2.0', id:message.id, ...failed })
235
+ }
236
+ }
237
+ }
238
+
239
+ active = connection(initial)
240
+ onJsonLines(input, line => {
241
+ let message
242
+ try { message = JSON.parse(line) } catch { return }
243
+ void outgoing(message)
244
+ })
245
+
246
+ return {
247
+ kind: 'spawn', stdin: input, stdout: output,
248
+ get child() { return active.handle.child },
249
+ get command() { return active.spec.command },
250
+ get args() { return active.spec.args ?? [] },
251
+ get env() { return active.spec.env },
252
+ diagnostics() { return active.handle.diagnostics() },
253
+ setDefaultAgent(spec) { defaultAgent = resolveAgent({ agent: spec }) },
254
+ hasAgent(spec) { const owner = connections.get(identity(spec)); return !!owner && !owner.failed },
255
+ selectSession(id) {
256
+ const bound = sessions.get(id)
257
+ if (bound) active = bound.owner
258
+ else if (!id && lastSetup) active = lastSetup
259
+ },
260
+ capabilityFor(message) {
261
+ const id = message?._meta?.marttyConnectionId
262
+ const owner = id ? [...connections.values()].find(owner => owner.id === id) : ownerFor(message)
263
+ return readCordisCapability(owner?.init)
264
+ },
265
+ withOrigin(message, callback) {
266
+ const id = message?._meta?.marttyConnectionId
267
+ const owner = [...connections.values()].find(owner => owner.id === id)
268
+ return context.run(owner ?? active, callback)
269
+ },
270
+ close() {
271
+ if (closed) return
272
+ for (const owner of connections.values()) {
273
+ fail(owner, new Error('acpClient is closed'))
274
+ try { owner.handle.child.kill('SIGTERM') } catch { /* already exited */ }
275
+ }
276
+ closed = true
277
+ callbacks.clear()
278
+ input.destroy()
279
+ output.destroy()
280
+ },
281
+ }
282
+ }
package/lib/acp-client.js CHANGED
@@ -3,16 +3,17 @@
3
3
  *
4
4
  * Provides `ctx.acpClient` plus the standard-ACP-backed
5
5
  * `ctx.acpSessionConfig`. Does not import a harness, dsh, or dsh-acp.
6
- * Switching agents is `{ command, args }` (or `config.stream`).
6
+ * Spawned sessions retain their connection; setDefaultAgent changes the recipe
7
+ * used by the next session/new. An externally owned config.stream stays fixed.
7
8
  */
8
9
 
9
10
  import spawn from 'cross-spawn'
10
- import { PassThrough } from 'node:stream'
11
11
  import { installAcpClientEvents } from './acp-client-events.js'
12
12
  import { installAcpSessionConfig } from './acp-session-config.js'
13
13
  import { installAcpSessionPlan } from './acp-session-plan.js'
14
14
  import { installAcpSessionStats } from './acp-session-stats.js'
15
15
  import { tokenizeCommandArgs } from './command-args.js'
16
+ import { createAgentPool } from './acp-agent-pool.js'
16
17
 
17
18
  export { installAcpSessionConfig } from './acp-session-config.js'
18
19
  export { installAcpSessionPlan } from './acp-session-plan.js'
@@ -85,6 +86,8 @@ export function apply(ctx, config = {}) {
85
86
  if (
86
87
  liveAgent !== null
87
88
  && liveAgent.child.exitCode === null
89
+ && !liveAgent.child.killed
90
+ && !liveAgent.stdin.destroyed
88
91
  && liveAgent.command === agent.command
89
92
  && JSON.stringify(liveAgent.args) === JSON.stringify(agent.args ?? [])
90
93
  && JSON.stringify(liveAgent.env ?? {}) === JSON.stringify(agent.env ?? {})
@@ -92,16 +95,19 @@ export function apply(ctx, config = {}) {
92
95
  provide(ctx, liveAgent)
93
96
  return
94
97
  }
95
- // Replacing the agent: the previous child must not outlive its spec.
96
- if (liveAgent !== null && liveAgent.child.exitCode === null) {
98
+ // A new Client tree owns a new pool; dispose every child of the old tree.
99
+ if (liveAgent !== null) {
97
100
  try {
98
- liveAgent.child.kill('SIGTERM')
101
+ liveAgent.close()
99
102
  } catch {
100
103
  // already gone
101
104
  }
102
105
  }
103
106
  liveAgent = null
104
- const service = createSpawnService(agent)
107
+ const service = createAgentPool(agent, { spawnAgent, resolveAgent, diagnosticError })
108
+ if (liveAgentExitHook !== null) process.removeListener('exit', liveAgentExitHook)
109
+ liveAgentExitHook = () => service.close()
110
+ process.once('exit', liveAgentExitHook)
105
111
  liveAgent = service
106
112
  provide(ctx, service)
107
113
  }
@@ -185,233 +191,6 @@ function diagnosticError(error, handle) {
185
191
  })
186
192
  }
187
193
 
188
- function waitForSpawn(handle) {
189
- if (handle.child.pid !== undefined) return Promise.resolve()
190
- return new Promise((resolve, reject) => {
191
- const spawned = () => {
192
- handle.child.off('error', failed)
193
- resolve()
194
- }
195
- const failed = (error) => {
196
- handle.child.off('spawn', spawned)
197
- reject(error)
198
- }
199
- handle.child.once('spawn', spawned)
200
- handle.child.once('error', failed)
201
- })
202
- }
203
-
204
- function createSpawnService(agent) {
205
- const input = new PassThrough()
206
- const output = new PassThrough()
207
- const switchListeners = new Set()
208
- const failureListeners = new Set()
209
- let current = spawnAgent(agent)
210
- let closed = false
211
- let pendingSwitch
212
- let hasSwitched = false
213
-
214
- const attach = (handle) => {
215
- input.pipe(handle.stdin, { end: false })
216
- handle.stdout.pipe(output, { end: false })
217
- }
218
- const detach = (handle) => {
219
- input.unpipe(handle.stdin)
220
- handle.stdout.unpipe(output)
221
- }
222
- attach(current)
223
-
224
- const service = {
225
- kind: 'spawn',
226
- command: current.command,
227
- args: current.args,
228
- ...(current.env !== undefined ? { env: current.env } : {}),
229
- stdin: input,
230
- stdout: output,
231
- child: current.child,
232
- diagnostics() { return current.diagnostics() },
233
- onSwitch(listener) {
234
- if (typeof listener !== 'function') throw new Error('acpClient.onSwitch needs a function')
235
- switchListeners.add(listener)
236
- return () => switchListeners.delete(listener)
237
- },
238
- onFailure(listener) {
239
- if (typeof listener !== 'function') throw new Error('acpClient.onFailure needs a function')
240
- failureListeners.add(listener)
241
- return () => failureListeners.delete(listener)
242
- },
243
- observeClient(message) {
244
- if (pendingSwitch === undefined || message?.id === undefined) return
245
- if (!['initialize', 'authenticate', 'session/new'].includes(message.method)) return
246
- pendingSwitch.requests.set(message.id, message.method)
247
- // Browser/device sign-in is user-driven, not a machine setup operation.
248
- if (message.method === 'authenticate') pendingSwitch.pause()
249
- else pendingSwitch.arm()
250
- },
251
- observeAgent(message) {
252
- const pending = pendingSwitch
253
- if (pending === undefined || message?.id === undefined) return
254
- const method = pending.requests.get(message.id)
255
- if (method === undefined) return
256
- pending.requests.delete(message.id)
257
- if (message.error !== undefined) {
258
- // Authentication is user-driven; do not time out while an auth form is open.
259
- if (method !== 'initialize' && message.error?.code === -32000) {
260
- pending.pause()
261
- return
262
- }
263
- pending.reject(Object.assign(new Error(`${method}: ${message.error?.message ?? 'ACP setup failed'}`), {
264
- method, acpError: structuredClone(message.error),
265
- }))
266
- return
267
- }
268
- if (method === 'initialize') {
269
- if (!Number.isInteger(message.result?.protocolVersion)) {
270
- pending.reject(new Error('initialize: invalid ACP response'))
271
- return
272
- }
273
- pending.initialized = true
274
- pending.server = message.result?.agentInfo?.name
275
- } else if (method === 'authenticate') {
276
- // Once sign-in returns, the ensuing session/new must finish promptly.
277
- pending.arm()
278
- } else if (method === 'session/new') {
279
- const sessionId = message.result?.sessionId
280
- if (!pending.initialized || typeof sessionId !== 'string' || sessionId.length === 0) {
281
- pending.reject(new Error('session/new: invalid ACP session response'))
282
- return
283
- }
284
- pending.resolve({
285
- sessionId,
286
- ...(typeof pending.server === 'string' ? { server: pending.server } : {}),
287
- })
288
- }
289
- },
290
- async switchAgent(nextAgent, { timeoutMs = 20 * 60_000 } = {}) {
291
- if (closed) throw new Error('acpClient is closed')
292
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('ACP setup timeout must be positive')
293
- const spec = resolveAgent({ agent: nextAgent })
294
- const next = spawnAgent(spec)
295
- try {
296
- await waitForSpawn(next)
297
- for (const listener of switchListeners) await listener(next, current)
298
- if (next.child.exitCode !== null || next.child.signalCode !== null) {
299
- throw new Error(`ACP process exited (${next.child.signalCode ?? next.child.exitCode}) during handoff`)
300
- }
301
- } catch (error) {
302
- try {
303
- next.child.kill('SIGTERM')
304
- } catch {
305
- // failed spawns may not own a process
306
- }
307
- throw diagnosticError(error, next)
308
- }
309
- pendingSwitch?.reject(new Error('Harness switch was superseded'))
310
- const previous = current
311
- detach(previous)
312
- attach(next)
313
- current = next
314
- service.command = next.command
315
- service.args = next.args
316
- service.env = next.env
317
- service.child = next.child
318
- hasSwitched = true
319
- const ready = waitForReady(timeoutMs)
320
- watchCurrent(next)
321
- try {
322
- previous.child.kill('SIGTERM')
323
- } catch {
324
- // already gone
325
- }
326
- // Rust starts initialize only after the local command returns. Awaiting
327
- // ready here would deadlock; callers persist selection when this settles.
328
- return { ready }
329
- },
330
- close(error = new Error('acpClient is closed')) {
331
- if (closed) return
332
- failTransport(error)
333
- closed = true
334
- detach(current)
335
- try {
336
- current.child.kill('SIGTERM')
337
- } catch {
338
- // already gone
339
- }
340
- input.destroy()
341
- output.destroy()
342
- if (liveAgent === service) liveAgent = null
343
- },
344
- }
345
-
346
- function waitForReady(timeoutMs) {
347
- let resolve
348
- let reject
349
- let timer
350
- const ready = new Promise((yes, no) => { resolve = yes; reject = no })
351
- // A child may fail before the caller has received the handoff result.
352
- ready.catch(() => {})
353
- const pending = {
354
- requests: new Map(),
355
- initialized: false,
356
- server: undefined,
357
- arm() {
358
- clearTimeout(timer)
359
- timer = setTimeout(() => {
360
- failTransport(new Error(`ACP setup timed out after ${timeoutMs / 1000}s`))
361
- detach(current)
362
- try { current.child.kill('SIGTERM') } catch { /* already gone */ }
363
- }, timeoutMs)
364
- timer.unref?.()
365
- },
366
- pause() { clearTimeout(timer) },
367
- resolve(value) { settle(resolve, value) },
368
- reject(error) { settle(reject, diagnosticError(error, current)) },
369
- }
370
- const settle = (complete, value) => {
371
- if (pendingSwitch !== pending) return
372
- pendingSwitch = undefined
373
- clearTimeout(timer)
374
- complete(value)
375
- }
376
- pendingSwitch = pending
377
- pending.arm()
378
- return ready
379
- }
380
-
381
- function failTransport(error) {
382
- const failure = diagnosticError(error, current)
383
- pendingSwitch?.reject(failure)
384
- for (const listener of failureListeners) listener(failure)
385
- }
386
-
387
- function watchCurrent(handle) {
388
- handle.child.on('error', (err) => {
389
- if (current !== handle || closed) return
390
- if (hasSwitched) {
391
- failTransport(err)
392
- return
393
- }
394
- service.close(err)
395
- })
396
- // 'close' follows the stdio drain, so final startup errors are not lost.
397
- handle.child.once('close', (code, signal) => {
398
- if (current !== handle || closed) return
399
- failTransport(new Error(`ACP process exited (${signal ?? code ?? 'unknown'}) before completing the request`))
400
- })
401
- }
402
- watchCurrent(current)
403
- if (liveAgentExitHook !== null) process.removeListener('exit', liveAgentExitHook)
404
- liveAgentExitHook = () => {
405
- try {
406
- service.close()
407
- } catch {
408
- // already gone
409
- }
410
- }
411
- process.once('exit', liveAgentExitHook)
412
- return service
413
- }
414
-
415
194
  function provide(ctx, service) {
416
195
  if (typeof ctx.provide === 'function') {
417
196
  ctx.provide('acpClient', service)
@@ -61,6 +61,8 @@ export function installAcpSessionStatus(ctx, options = {}) {
61
61
 
62
62
  const listeners = new Set()
63
63
  const sessions = new Map()
64
+ const connections = new Map()
65
+ const connectionAuthMethods = new Map()
64
66
  const fallback = zeroSession(undefined, false)
65
67
  let sessionId
66
68
  let selectionKnown = false
@@ -70,7 +72,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
70
72
  let initializeId
71
73
  let authMethods = []
72
74
  const pendingRequests = new Set()
73
- const pendingAuthenticate = new Set()
75
+ const pendingAuthenticate = new Map()
74
76
  const pendingSetup = new Map()
75
77
  const pendingPrompts = new Map()
76
78
 
@@ -90,8 +92,9 @@ export function installAcpSessionStatus(ctx, options = {}) {
90
92
 
91
93
  function current() {
92
94
  const value = stateFor(sessionId)
93
- const { permissionPreset: _permissionPreset, sandboxMode: _sandboxMode, ...visible } = value
94
- return structuredClone({ connection, server, auth, ...visible })
95
+ const { permissionPreset: _permissionPreset, sandboxMode: _sandboxMode,
96
+ connectionFacts, ...visible } = value
97
+ return structuredClone({ connection, server, auth, ...connectionFacts, ...visible })
95
98
  }
96
99
 
97
100
  function publish() {
@@ -135,6 +138,8 @@ export function installAcpSessionStatus(ctx, options = {}) {
135
138
  if (message.method === 'initialize' && message.id !== undefined) {
136
139
  pendingRequests.clear()
137
140
  sessions.clear()
141
+ connections.clear()
142
+ connectionAuthMethods.clear()
138
143
  Object.assign(fallback, zeroSession(undefined, false))
139
144
  delete fallback.error
140
145
  sessionId = undefined
@@ -154,12 +159,14 @@ export function installAcpSessionStatus(ctx, options = {}) {
154
159
  }
155
160
  if (message.id !== undefined) pendingRequests.add(message.id)
156
161
  if (message.method === 'authenticate' && message.id !== undefined) {
157
- pendingAuthenticate.add(message.id)
158
162
  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
162
- auth.status = 'signing in'
163
+ const owned = connectionAuthMethods.get(methodId)
164
+ const target = owned?.facts.auth ?? auth
165
+ pendingAuthenticate.set(message.id, target)
166
+ const method = owned?.method ?? authMethods.find((candidate) => candidate?.id === methodId)
167
+ if (methodId !== undefined) target.method = readString(method, 'name', 'label') ?? methodId
168
+ delete target.message
169
+ target.status = 'signing in'
163
170
  publish()
164
171
  return
165
172
  }
@@ -204,13 +211,14 @@ export function installAcpSessionStatus(ctx, options = {}) {
204
211
  // its authentication or run state in the new connection.
205
212
  if (response(message) && !pendingRequests.delete(message.id)) return
206
213
  if (message.id !== undefined && pendingAuthenticate.has(message.id)) {
214
+ const target = pendingAuthenticate.get(message.id)
207
215
  pendingAuthenticate.delete(message.id)
208
216
  if (message.error === undefined) {
209
- auth.status = 'configured'
210
- delete auth.message
217
+ target.status = 'configured'
218
+ delete target.message
211
219
  } else {
212
- auth.status = 'sign-in failed'
213
- auth.message = readString(message.error?.data, 'details', 'message')
220
+ target.status = 'sign-in failed'
221
+ target.message = readString(message.error?.data, 'details', 'message')
214
222
  ?? readString(message.error, 'message') ?? 'Authentication failed'
215
223
  }
216
224
  publish()
@@ -239,6 +247,17 @@ export function installAcpSessionStatus(ctx, options = {}) {
239
247
  const bound = readString(message.result, 'sessionId', 'session_id') ?? setup.sessionId
240
248
  if (bound !== undefined) {
241
249
  stateFor(bound).session = { sessionId: bound, bound: true, started: setup.started }
250
+ const owner = message.result?._meta?.marttyConnection
251
+ if (owner) {
252
+ let facts = connections.get(owner.id)
253
+ if (!facts) {
254
+ facts = { connection: 'attached', server: owner.agentInfo?.name,
255
+ auth: { status: owner.authMethods?.length ? 'configured' : undefined, method: undefined } }
256
+ connections.set(owner.id, facts)
257
+ }
258
+ stateFor(bound).connectionFacts = facts
259
+ for (const method of owner.authMethods ?? []) connectionAuthMethods.set(method.id, { facts, method })
260
+ }
242
261
  delete stateFor(bound).error
243
262
  if (authMethods.length > 0) {
244
263
  auth.status = 'configured'
@@ -254,12 +273,13 @@ export function installAcpSessionStatus(ctx, options = {}) {
254
273
  const promptSessionId = pendingPrompts.get(message.id)
255
274
  pendingPrompts.delete(message.id)
256
275
  let changed = false
276
+ const value = stateFor(promptSessionId)
277
+ const target = value.connectionFacts?.auth ?? auth
257
278
  if (message.error !== undefined && isAuthRequired(message.error)
258
- && auth.status !== 'needs sign-in') {
259
- auth.status = 'needs sign-in'
279
+ && target.status !== 'needs sign-in') {
280
+ target.status = 'needs sign-in'
260
281
  changed = true
261
282
  }
262
- const value = stateFor(promptSessionId)
263
283
  if (!hasPendingPrompt(promptSessionId) && value.state !== 'idle') {
264
284
  value.state = 'idle'
265
285
  changed = true
@@ -46,7 +46,7 @@ export function planHarnessRemoval(settingsPath, id, options = {}) {
46
46
  const entries = savedHarnesses(settingsPath)
47
47
  const entry = entries.find(entry => entry.id === id)
48
48
  if (!entry) throw new Error('Only a saved Harness configuration can be removed')
49
- if (options.isCurrent?.(entry)) throw new Error('Switch to another Harness before removing the current Harness')
49
+ if (options.isCurrent?.(entry)) throw new Error('This Harness is still running. Restart Martty with another default before removing it')
50
50
  const forced = [options.forcedHarness, ...(options.defaults ?? []).filter(entry => entry.source === 'forced')].filter(Boolean)
51
51
  if (forced.some(value => value.id === id || value.command === entry.command)) throw new Error('This Harness is product-forced and cannot be removed here')
52
52
  const installation = privateInstallation(settingsPath, entry)
@@ -1,7 +1,7 @@
1
- /** Built-in Client Plugin: discover, prepare, and switch standalone ACP Harnesses. */
1
+ /** Built-in Client Plugin: configure default ACP Harnesses and open new sessions. */
2
2
  import {
3
3
  addHarnessAsync, discoverHarnessCandidates, discoverHarnesses, fetchAcpRegistry,
4
- setDefaultHarness, tokenizeHarnessArgs, upsertHarness, savedHarnesses,
4
+ setDefaultHarness, selectedHarness, tokenizeHarnessArgs, upsertHarness, savedHarnesses,
5
5
  } from './harnesses.js'
6
6
  import { planHarnessRemoval, removeHarness } from './harness-removal.js'
7
7
  import { scanHarnessCandidates } from './harness-discovery.js'
@@ -47,7 +47,6 @@ export function apply(ctx, options = {}) {
47
47
  let registrySnapshot = options.registry ?? readAcpRegistrySnapshot({ ...options, settingsPath })
48
48
  let candidateSnapshot = []
49
49
  let flow = 0
50
- let switchVersion = 0
51
50
  let disposed = false
52
51
  let operation
53
52
  let ownedOverlay
@@ -64,9 +63,9 @@ export function apply(ctx, options = {}) {
64
63
  downloadNotice = ctx.tuiSlots.register({ name: 'conversation.input.dock', id: 'harness-downloads', order: -20 }, [])
65
64
  return () => downloadNotice.dispose()
66
65
  })
67
- let runningRecipe = options.forcedHarness
68
- let failedRecipe
66
+ const runningRecipe = options.forcedHarness
69
67
  const isRunning = (entry) => {
68
+ if (typeof ctx.acpClient?.hasAgent === 'function') return ctx.acpClient.hasAgent(entry)
70
69
  const child = ctx.acpClient?.child
71
70
  if (child?.exitCode != null || child?.signalCode != null) return false
72
71
  const live = ctx.acpClient?.command ? ctx.acpClient : runningRecipe
@@ -74,16 +73,15 @@ export function apply(ctx, options = {}) {
74
73
  id: '', command: live.command, args: live.args, env: live.env,
75
74
  })
76
75
  }
77
- const isCurrent = (entry) => isRunning(entry) && recipeIdentity(entry) !== failedRecipe
78
- const currentOption = (entry) => isCurrent(entry) ? { label: `${entry.label} (current)`, disabled: true } : {}
79
- const currentFirst = (left, right) => Number(isCurrent(right)) - Number(isCurrent(left))
76
+ const currentOption = (entry) => entry.id === selectedHarness(settingsPath)?.id ? { label: `${entry.label} (default)` } : {}
77
+ const currentFirst = (left, right) => Number(right.id === selectedHarness(settingsPath)?.id) - Number(left.id === selectedHarness(settingsPath)?.id)
80
78
  const choices = () => discoverHarnesses(settingsPath, options).sort(currentFirst).map((entry) => ({
81
79
  value: entry.id, label: entry.label, description: `${entry.source} · ${commandText(entry)}`,
82
80
  ...currentOption(entry),
83
81
  }))
84
82
  const commandChoices = () => [...choices(), { ...addOption, value: 'add' }]
85
83
  const refreshCommandChoices = () => commandRegistration?.update({ input: {
86
- hint: '[id] | add | remove [id] | find [query]', options: commandChoices(),
84
+ hint: '[id] [--new] | add | remove [id] | find [query]', options: commandChoices(),
87
85
  } })
88
86
 
89
87
  function openView(spec, handlers) {
@@ -226,84 +224,31 @@ export function apply(ctx, options = {}) {
226
224
  } finally { removing.delete(id) }
227
225
  }
228
226
 
229
- async function switchNow(entry, download) {
230
- if (disposed) return
231
- if (removing.has(entry.id)) throw new Error('This Harness is being removed; wait until removal finishes')
232
- const version = ++switchVersion
233
- failedRecipe = undefined
234
- pendingFailure = undefined
235
- try {
236
- if (typeof ctx.acpClient?.switchAgent !== 'function') throw new Error('Harness switching is unavailable on this ACP transport')
237
- // Connect registers the prepared recipe, independently of sign-in/session setup.
238
- // A failed or cancelled login must not make an explicitly added Harness disappear.
239
- upsertHarness(settingsPath, entry)
240
- refreshCommandChoices()
241
- const handoff = await ctx.acpClient.switchAgent({ command: entry.command, args: entry.args,
242
- ...(entry.env !== undefined ? { env: entry.env } : {}) })
243
- if (disposed || version !== switchVersion) {
244
- void handoff?.ready?.catch(() => {})
245
- return
246
- }
247
- // The process is current already; readiness controls persistence, not
248
- // which running recipe the picker/composer identifies.
249
- refreshCommandChoices()
250
- const commit = () => {
251
- if (disposed || version !== switchVersion) return
252
- setDefaultHarness(settingsPath, entry.id)
253
- runningRecipe = entry
254
- refreshCommandChoices()
255
- if (download !== undefined && downloads.get(download.key) === download) {
256
- downloads.delete(download.key)
257
- notifyDownloads()
258
- }
259
- }
260
- // The action lets the painter initialize/session/new; awaiting ready here deadlocks it.
261
- if (handoff?.ready !== undefined) {
262
- void handoff.ready.then(commit).catch((error) => {
263
- if (!disposed && version === switchVersion) {
264
- failedRecipe = recipeIdentity(entry)
265
- refreshCommandChoices()
266
- retryView(`Could not connect ${entry.label}`, error, () => switchNow(entry, download))
267
- }
268
- })
269
- } else commit() // Older stream integrations keep their existing contract.
270
- return { action: 'harness-switched', harness: {
271
- id: entry.id, label: entry.label, command: entry.command, args: entry.args,
272
- ...(entry.env !== undefined ? { env: entry.env } : {}),
273
- } }
274
- } catch (error) { retryView(`Could not connect ${entry.label}`, error, () => switchNow(entry, download)) }
275
- }
276
-
277
- async function save(id, preparedEntry, download) {
278
- if (disposed) return
279
- const entry = preparedEntry ?? discoverHarnesses(settingsPath, options).find((candidate) => candidate.id === id)
227
+ async function save(id, preparedEntry, download, openNow = false) {
228
+ const entry = preparedEntry ?? discoverHarnesses(settingsPath, options).find(entry => entry.id === id)
280
229
  if (entry === undefined) throw new Error(`unknown harness ${JSON.stringify(id)}`)
281
- if (!options.hostOwned && isCurrent(entry)) return
282
- if (options.hostOwned) {
283
- upsertHarness(settingsPath, entry)
284
- setDefaultHarness(settingsPath, id)
285
- refreshCommandChoices()
286
- if (download !== undefined && downloads.get(download.key) === download) {
287
- downloads.delete(download.key)
288
- notifyDownloads()
289
- }
290
- openView({ id: 'harness-saved', title: 'Harness saved', nodes: [{
291
- id: 'notice', kind: 'notice', level: 'info',
292
- text: `${entry.label} is saved for a new standalone session. The current dsh profile and session remain Host-owned.`,
293
- }] })
294
- return
295
- }
296
- // Selecting a registered recipe is a switch, not Add/Install. The saved
297
- // launcher owns its cache; a per-process preparation flag is not evidence
298
- // that the package needs downloading again.
299
- if (ctx.acpSessionStatus?.current?.().session?.started === true) {
300
- openSelect({ id: 'harness-confirm', title: 'Switch Harness? · starts a new session', value: 'switch', options: [
301
- { value: 'switch', label: `Switch to ${entry.label}`, description: 'Current session stays available in /session' },
302
- { value: 'cancel', label: 'Stay in current session', description: 'Keep using the current Harness' },
303
- ] }, { onSubmit: (action) => action === 'switch' ? switchNow(entry, download) : undefined })
304
- return
230
+ if (removing.has(id)) throw new Error('This Harness is being removed; wait until removal finishes')
231
+ upsertHarness(settingsPath, entry)
232
+ setDefaultHarness(settingsPath, id)
233
+ ctx.acpClient?.setDefaultAgent?.({ command: entry.command, args: entry.args ?? [],
234
+ ...(entry.env !== undefined ? { env: entry.env } : {}) })
235
+ refreshCommandChoices()
236
+ if (download !== undefined && downloads.get(download.key) === download) {
237
+ downloads.delete(download.key)
238
+ notifyDownloads()
305
239
  }
306
- return switchNow(entry, download)
240
+ const newSession = () => ({ action: 'new-session' })
241
+ if (openNow && !options.hostOwned) return newSession()
242
+ openView({ id: 'harness-saved', title: 'Default Harness saved', nodes: [{
243
+ id: 'notice', kind: 'notice', level: 'info',
244
+ text: options.hostOwned
245
+ ? `${entry.label} is saved for the next standalone session. The current profile owns its Harness.`
246
+ : `${entry.label} will be used for new sessions. Enter opens a new tab now. Esc keeps the current session.`,
247
+ }] }, options.hostOwned ? undefined : { onSubmit: newSession })
248
+ if (!options.hostOwned) return { action: 'harness-selected', harness: {
249
+ id: entry.id, label: entry.label, command: entry.command, args: entry.args ?? [],
250
+ ...(entry.env !== undefined ? { env: entry.env } : {}),
251
+ } }
307
252
  }
308
253
 
309
254
  function configured(entry) {
@@ -372,14 +317,14 @@ export function apply(ctx, options = {}) {
372
317
  downloads.delete(job.key)
373
318
  hide()
374
319
  }
375
- openView({ id: 'harness-installing', title: `Download complete · ${job.entry.label} · enter switch · esc close`, nodes: [
320
+ openView({ id: 'harness-installing', title: `Download complete · ${job.entry.label} · enter new session · esc close`, nodes: [
376
321
  { id: 'complete', kind: 'notice', level: 'info', text: job.registered
377
322
  ? `${job.entry.label} is installed and configured.`
378
323
  : `${job.entry.label} is installed. Your newer configuration is unchanged.` },
379
- { id: 'next', kind: 'text', text: 'Setup is complete. Enter switches to this Harness using its saved configuration. Esc closes without switching.' },
324
+ { id: 'next', kind: 'text', text: 'Setup is complete. Enter opens a new session with this Harness. Esc closes without switching.' },
380
325
  ] }, { onSubmit: () => {
381
326
  acknowledge()
382
- return save(job.entry.id)
327
+ return save(job.entry.id, undefined, undefined, true)
383
328
  }, onCancel: acknowledge })
384
329
  return
385
330
  }
@@ -591,8 +536,8 @@ export function apply(ctx, options = {}) {
591
536
  } })()
592
537
  }
593
538
 
594
- commandRegistration = ctx.tuiCommands.register({ name: 'harness', description: 'Switch or add a Harness; switching starts a new session',
595
- input: { hint: '[id] | add | remove [id] | find [query]', options: commandChoices() },
539
+ commandRegistration = ctx.tuiCommands.register({ name: 'harness', description: 'Choose the default Harness for new sessions',
540
+ input: { hint: '[id] [--new] | add | remove [id] | find [query]', options: commandChoices() },
596
541
  }, async (args) => {
597
542
  if (disposed) return
598
543
  refreshCommandChoices()
@@ -620,7 +565,7 @@ export function apply(ctx, options = {}) {
620
565
  }
621
566
  if (tokens[0] !== undefined) {
622
567
  const saved = discoverHarnesses(settingsPath, options).find((entry) => entry.id === tokens[0])
623
- if (saved !== undefined) return save(saved.id, saved)
568
+ if (saved !== undefined) return save(saved.id, saved, undefined, tokens.includes('--new'))
624
569
  const job = [...downloads.values()].filter((job) => job.entry.id === tokens[0]).at(-1)
625
570
  return job === undefined ? save(tokens[0]) : showDownload(job)
626
571
  }
@@ -640,14 +585,14 @@ export function apply(ctx, options = {}) {
640
585
  }
641
586
  const downloaded = downloadOptions()
642
587
  if (entries.length === 0 && downloaded.length === 0) return browse()
643
- openSelect({ id: 'harness', title: 'Switch Harness · starts a new session',
644
- value: (entries.find(entry => entry.value === selected) ?? entries.find((entry) => entry.disabled) ?? entries[0] ?? downloaded[0]).value,
588
+ openSelect({ id: 'harness', title: 'Default Harness · for new sessions',
589
+ value: (entries.find(entry => entry.value === selected) ?? entries.find((entry) => entry.value === selectedHarness(settingsPath)?.id) ?? entries[0] ?? downloaded[0]).value,
645
590
  options: [...entries, ...downloaded, addOption],
646
591
  }, { onDelete: id => removal(id),
647
592
  onSubmit: (id) => id === ADD ? browse() : id.startsWith(DOWNLOAD) ? showDownload(downloads.get(id.slice(DOWNLOAD.length))) : save(id) })
648
593
  }
649
594
  return () => {
650
- disposed = true; ++flow; ++switchVersion; operation?.abort()
595
+ disposed = true; ++flow; operation?.abort()
651
596
  for (const job of downloads.values()) job.controller.abort()
652
597
  ownedOverlay?.close(); stopDownloadSlot?.(); commandRegistration?.()
653
598
  }
package/lib/index.js CHANGED
@@ -248,6 +248,7 @@ export async function applyShell(ctx, options = {}) {
248
248
  }
249
249
  if (message.method === CORDIS_METHODS.sessionActive) {
250
250
  const active = message.params?.sessionId
251
+ agent.selectSession?.(active)
251
252
  clientEvents.selectSession(
252
253
  typeof active === 'string' && active.length > 0 ? active : undefined,
253
254
  )
package/lib/mux.js CHANGED
@@ -165,6 +165,13 @@ export function muxAcpAndCompositor(opts) {
165
165
  let initializeId
166
166
  /** @type {{ protocol: number } | null} */
167
167
  let agentCordis = null
168
+ const initializedConnections = new Set()
169
+ const capabilityFor = message => {
170
+ if (typeof agent.capabilityFor !== 'function') return agentCordis
171
+ try { return agent.capabilityFor(message) } catch { return null }
172
+ }
173
+ const withOrigin = (message, callback) => typeof agent.withOrigin === 'function'
174
+ ? agent.withOrigin(message, callback) : callback()
168
175
 
169
176
  onJsonLines(agent.stdout, (line) => {
170
177
  let message
@@ -186,14 +193,21 @@ export function muxAcpAndCompositor(opts) {
186
193
  return
187
194
  }
188
195
  if (isHostMessage(message)) {
189
- if (agentCordis !== null) onHost?.(message)
196
+ if (capabilityFor(message) !== null) withOrigin(message, () => onHost?.(message))
190
197
  return
191
198
  }
192
199
  if (message?.id !== undefined && typeof message.method !== 'string') pendingClient.delete(message.id)
193
200
  onAcp?.('agent', message)
194
201
  tui.output.write(`${line}\n`)
202
+ const connection = message.result?._meta?.marttyConnection
203
+ if (connection && !initializedConnections.has(connection.id)) {
204
+ initializedConnections.add(connection.id)
205
+ const capability = readCordisCapability(connection)
206
+ if (capability !== null) withOrigin(message, () => onCordisReady?.(capability))
207
+ }
195
208
  if (initializeId !== undefined && message && typeof message === 'object' && message.id === initializeId) {
196
209
  initializeId = undefined
210
+ if (message._meta?.marttyConnectionId) initializedConnections.add(message._meta.marttyConnectionId)
197
211
  agentCordis = readCordisCapability(message.result)
198
212
  if (agentCordis !== null) onCordisReady?.(agentCordis)
199
213
  }
@@ -251,7 +265,7 @@ export function muxAcpAndCompositor(opts) {
251
265
  if (
252
266
  typeof message.method === 'string'
253
267
  && message.method.startsWith('_dsh/cordis/')
254
- && agentCordis === null
268
+ && capabilityFor(message) === null
255
269
  ) {
256
270
  if (message.id !== undefined) {
257
271
  writeJsonLine(tui.output, {
@@ -287,6 +301,7 @@ export function muxAcpAndCompositor(opts) {
287
301
  failAgent,
288
302
  resetAgent() {
289
303
  initializeId = undefined
304
+ initializedConnections.clear()
290
305
  agentCordis = null
291
306
  failAgent(new Error('ACP Agent was replaced'))
292
307
  },
@@ -297,7 +312,7 @@ export function muxAcpAndCompositor(opts) {
297
312
  )
298
313
  },
299
314
  requestAgent(method, params) {
300
- if (method.startsWith('_dsh/cordis/') && agentCordis === null) {
315
+ if (method.startsWith('_dsh/cordis/') && capabilityFor({ method, params }) === null) {
301
316
  return Promise.reject(new Error('agent has not advertised _dsh/cordis'))
302
317
  }
303
318
  const id = `tui-host-${++nextHostId}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martty",
3
- "version": "0.2.37",
3
+ "version": "0.2.39-beta.0",
4
4
  "description": "Terminal-native ACP client UI; Cordis client tree, any ACP agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,9 +14,9 @@
14
14
  "type": "module",
15
15
  "main": "lib/index.js",
16
16
  "scripts": {
17
- "test:harness-ui": "node --test ../scripts/harness-management-tui.test.mjs",
17
+ "test:harness-ui": "node --test ../scripts/harness-management-tui.test.mjs ../scripts/harness-session-tui.test.mjs",
18
18
  "pretest": "node --test ../scripts/workflow-release.test.mjs ../scripts/package-alias.test.mjs ../scripts/harness-removal.test.mjs",
19
- "test": "node --test ../scripts/download.test.mjs ../scripts/package-native.test.mjs ../scripts/check-release-tag.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/packaged-acp-permission.test.mjs ../scripts/profile-smoke-tui.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-presets.test.mjs ../scripts/tui-plugin-store.test.mjs ../scripts/tui-local-plugins.test.mjs ../scripts/tui-client-plugin-registry.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/tui-agents.test.mjs ../scripts/tui-queue.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/harnesses.test.mjs ../scripts/harness-discovery.test.mjs ../scripts/harness-discovery-scenario.test.mjs ../scripts/harness-view.test.mjs ../scripts/harness-onboarding.test.mjs ../scripts/harness-registry.test.mjs ../scripts/harness-package.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/agents-view.test.mjs ../scripts/plan-view.test.mjs ../scripts/queue-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs",
19
+ "test": "node --test ../scripts/download.test.mjs ../scripts/package-native.test.mjs ../scripts/check-release-tag.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/packaged-acp-permission.test.mjs ../scripts/profile-smoke-tui.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-presets.test.mjs ../scripts/tui-plugin-store.test.mjs ../scripts/tui-local-plugins.test.mjs ../scripts/tui-client-plugin-registry.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/tui-agents.test.mjs ../scripts/tui-queue.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/acp-agent-pool.test.mjs ../scripts/harnesses.test.mjs ../scripts/harness-discovery.test.mjs ../scripts/harness-discovery-scenario.test.mjs ../scripts/harness-view.test.mjs ../scripts/harness-onboarding.test.mjs ../scripts/harness-registry.test.mjs ../scripts/harness-package.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/agents-view.test.mjs ../scripts/plan-view.test.mjs ../scripts/queue-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs",
20
20
  "test:profile-install-matrix": "node --test ../scripts/profile-install-matrix.test.mjs"
21
21
  },
22
22
  "publishConfig": {
@@ -76,12 +76,12 @@
76
76
  },
77
77
  "dependencies": {
78
78
  "@deepseek-ai/cordis": "^4.0.1",
79
- "@openma/deepseek-harness-acp": "0.4.29",
79
+ "@openma/deepseek-harness-acp": "0.4.31",
80
80
  "cross-spawn": "^7.0.6",
81
81
  "node-downloader-helper": "2.1.11"
82
82
  },
83
83
  "devDependencies": {
84
- "@deepseek-ai/dsh": "0.1.1-rc.2"
84
+ "@deepseek-ai/dsh": "0.1.5-rc.1"
85
85
  },
86
86
  "engines": {
87
87
  "node": ">=18"
Binary file
Binary file
Binary file
Binary file
Binary file