martty 0.2.38 → 0.2.39-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,18 @@
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
+ import { harnessEnvironment } from './harness-environment.js'
11
12
  import { installAcpClientEvents } from './acp-client-events.js'
12
13
  import { installAcpSessionConfig } from './acp-session-config.js'
13
14
  import { installAcpSessionPlan } from './acp-session-plan.js'
14
15
  import { installAcpSessionStats } from './acp-session-stats.js'
15
16
  import { tokenizeCommandArgs } from './command-args.js'
17
+ import { createAgentPool } from './acp-agent-pool.js'
16
18
 
17
19
  export { installAcpSessionConfig } from './acp-session-config.js'
18
20
  export { installAcpSessionPlan } from './acp-session-plan.js'
@@ -85,6 +87,8 @@ export function apply(ctx, config = {}) {
85
87
  if (
86
88
  liveAgent !== null
87
89
  && liveAgent.child.exitCode === null
90
+ && !liveAgent.child.killed
91
+ && !liveAgent.stdin.destroyed
88
92
  && liveAgent.command === agent.command
89
93
  && JSON.stringify(liveAgent.args) === JSON.stringify(agent.args ?? [])
90
94
  && JSON.stringify(liveAgent.env ?? {}) === JSON.stringify(agent.env ?? {})
@@ -92,16 +96,19 @@ export function apply(ctx, config = {}) {
92
96
  provide(ctx, liveAgent)
93
97
  return
94
98
  }
95
- // Replacing the agent: the previous child must not outlive its spec.
96
- if (liveAgent !== null && liveAgent.child.exitCode === null) {
99
+ // A new Client tree owns a new pool; dispose every child of the old tree.
100
+ if (liveAgent !== null) {
97
101
  try {
98
- liveAgent.child.kill('SIGTERM')
102
+ liveAgent.close()
99
103
  } catch {
100
104
  // already gone
101
105
  }
102
106
  }
103
107
  liveAgent = null
104
- const service = createSpawnService(agent)
108
+ const service = createAgentPool(agent, { spawnAgent, resolveAgent, diagnosticError })
109
+ if (liveAgentExitHook !== null) process.removeListener('exit', liveAgentExitHook)
110
+ liveAgentExitHook = () => service.close()
111
+ process.once('exit', liveAgentExitHook)
105
112
  liveAgent = service
106
113
  provide(ctx, service)
107
114
  }
@@ -109,7 +116,7 @@ export function apply(ctx, config = {}) {
109
116
  function spawnAgent(agent) {
110
117
  const child = spawn(agent.command, agent.args ?? [], {
111
118
  stdio: ['pipe', 'pipe', 'pipe'],
112
- env: { ...process.env, ...(agent.env ?? {}) },
119
+ env: harnessEnvironment(agent.env),
113
120
  })
114
121
  child.stdin.on('error', () => {})
115
122
  child.stdout.on('error', () => {})
@@ -185,233 +192,6 @@ function diagnosticError(error, handle) {
185
192
  })
186
193
  }
187
194
 
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
195
  function provide(ctx, service) {
416
196
  if (typeof ctx.provide === 'function') {
417
197
  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,18 @@ 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
+ runtime: { command: owner.command, args: owner.args ?? [] },
256
+ auth: { status: owner.authMethods?.length ? 'configured' : undefined, method: undefined } }
257
+ connections.set(owner.id, facts)
258
+ }
259
+ stateFor(bound).connectionFacts = facts
260
+ for (const method of owner.authMethods ?? []) connectionAuthMethods.set(method.id, { facts, method })
261
+ }
242
262
  delete stateFor(bound).error
243
263
  if (authMethods.length > 0) {
244
264
  auth.status = 'configured'
@@ -254,12 +274,13 @@ export function installAcpSessionStatus(ctx, options = {}) {
254
274
  const promptSessionId = pendingPrompts.get(message.id)
255
275
  pendingPrompts.delete(message.id)
256
276
  let changed = false
277
+ const value = stateFor(promptSessionId)
278
+ const target = value.connectionFacts?.auth ?? auth
257
279
  if (message.error !== undefined && isAuthRequired(message.error)
258
- && auth.status !== 'needs sign-in') {
259
- auth.status = 'needs sign-in'
280
+ && target.status !== 'needs sign-in') {
281
+ target.status = 'needs sign-in'
260
282
  changed = true
261
283
  }
262
- const value = stateFor(promptSessionId)
263
284
  if (!hasPendingPrompt(promptSessionId) && value.state !== 'idle') {
264
285
  value.state = 'idle'
265
286
  changed = true
package/lib/boot.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { apply as applyHarnessBadge, inject as harnessBadgeInject } from './harness-badge.js'
1
2
  /**
2
3
  * Boot-time restore of a statically-registered gallery palette (ayu,
3
4
  * iceberg, …). `/theme` persistence writes `settings.theme`; dynamic
@@ -142,6 +143,7 @@ export async function bootClient(options = {}) {
142
143
  await ctx.plugin({ name: 'stats-view', inject: statsViewInject, apply: applyStatsView })
143
144
  await ctx.plugin({ name: 'acp-session-status', inject: sessionStatusInject, apply: applySessionStatus })
144
145
  await ctx.plugin({ name: 'status-view', inject: statusViewInject, apply: applyStatusView })
146
+ await ctx.plugin({ name: 'harness-badge', inject: harnessBadgeInject, apply: applyHarnessBadge }, harnessConfig)
145
147
  await ctx.plugin({ name: 'harness-view', inject: harnessViewInject, apply: applyHarnessView }, harnessConfig)
146
148
  await ctx.plugin({ name: 'deepseek-logo', inject: deepseekLogoInject, apply: applyDeepseekLogo })
147
149
  const localPlugins = installTuiLocalPlugins(ctx, {
@@ -203,6 +205,7 @@ export async function bootClient(options = {}) {
203
205
  applyStatsView(ctx)
204
206
  applySessionStatus(ctx)
205
207
  applyStatusView(ctx)
208
+ applyHarnessBadge(ctx, harnessConfig)
206
209
  applyHarnessView(ctx, harnessConfig)
207
210
  applyDeepseekLogo(ctx)
208
211
  const localPlugins = installTuiLocalPlugins(ctx, {
@@ -0,0 +1,137 @@
1
+ /** Registry-owned Harness marks. Only the Rust painter touches the terminal. */
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { promises as fs } from 'node:fs'
4
+ import path from 'node:path'
5
+ import { createRequire } from 'node:module'
6
+ import { readAcpRegistrySnapshot } from './harness-registry.js'
7
+ import { discoverHarnesses } from './harnesses.js'
8
+
9
+ export const name = 'harness-badge'
10
+ export const inject = ['acpSessionStatus', 'tuiSlots']
11
+ // ACP Registry does not list DeepSeek yet. Use Lobe's MIT-licensed mark,
12
+ // supplied by the installed SVG library. Registry always takes priority.
13
+ const deepseekBadge = {
14
+ label: 'DeepSeek',
15
+ icon: 'lobe:deepseek',
16
+ }
17
+ const require = createRequire(import.meta.url)
18
+ const MAX_BYTES = 512 * 1024
19
+ const png = bytes => bytes.length <= MAX_BYTES && bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))
20
+
21
+ export function createIconCache(options = {}) {
22
+ const pending = new Map()
23
+ return function load(url) {
24
+ if (typeof url !== 'string' || (url !== 'lobe:deepseek' && !url.startsWith('https://'))) return Promise.resolve(undefined)
25
+ if (pending.has(url)) return pending.get(url)
26
+ const promise = (async () => {
27
+ const local = url === 'lobe:deepseek'
28
+ const version = local ? require('@lobehub/icons-static-svg/package.json').version : ''
29
+ const key = createHash('sha256').update(local ? `${url}@${version}` : url).digest('hex')
30
+ const file = options.settingsPath && path.join(path.dirname(options.settingsPath), 'cache', 'harness-icons', `${key}.png`)
31
+ if (file) {
32
+ try {
33
+ if ((await fs.stat(file)).size <= MAX_BYTES) {
34
+ const cached = await fs.readFile(file)
35
+ if (png(cached)) return cached.toString('base64')
36
+ }
37
+ } catch { /* Missing cache: fetch below. */ }
38
+ }
39
+ let source
40
+ if (local) {
41
+ source = await fs.readFile(require.resolve('@lobehub/icons-static-svg/icons/deepseek.svg'))
42
+ if (source.length > MAX_BYTES) throw new Error('Icon too large')
43
+ } else {
44
+ const response = await (options.fetchImpl ?? fetch)(url, { signal: AbortSignal.timeout(5000) })
45
+ if (!response.ok) throw new Error('Icon unavailable')
46
+ if (Number(response.headers.get('content-length')) > MAX_BYTES) throw new Error('Icon too large')
47
+ const chunks = []
48
+ let size = 0
49
+ for await (const chunk of response.body) {
50
+ size += chunk.length
51
+ if (size > MAX_BYTES) throw new Error('Icon too large')
52
+ chunks.push(chunk)
53
+ }
54
+ source = Buffer.concat(chunks)
55
+ }
56
+ // Lazy import: an unavailable optional native renderer still leaves the name usable.
57
+ const { Resvg } = await import('@resvg/resvg-js')
58
+ let bytes
59
+ if (png(source)) {
60
+ bytes = source
61
+ } else {
62
+ const renderer = new Resvg(source.toString('utf8').replaceAll('currentColor', '#a0a0a0'), {
63
+ fitTo: { mode: 'width', value: 64 }, font: { loadSystemFonts: false },
64
+ })
65
+ if (renderer.width <= 0 || renderer.height <= 0 || renderer.height / renderer.width > 4) throw new Error('Invalid icon dimensions')
66
+ bytes = renderer.render().asPng()
67
+ }
68
+ if (!png(bytes)) throw new Error('Invalid icon')
69
+ if (file) {
70
+ const temporary = `${file}.${randomUUID()}.tmp`
71
+ try {
72
+ await fs.mkdir(path.dirname(file), { recursive: true })
73
+ await fs.writeFile(temporary, bytes)
74
+ await fs.rename(temporary, file)
75
+ } catch { /* A read-only cache must not hide a downloaded icon. */ }
76
+ finally { await fs.rm(temporary, { force: true }).catch(() => {}) }
77
+ }
78
+ return bytes.toString('base64')
79
+ })().catch(() => undefined)
80
+ pending.set(url, promise)
81
+ return promise
82
+ }
83
+ }
84
+
85
+ function packageName(spec) {
86
+ if (typeof spec !== 'string') return undefined
87
+ return /^(?:(@[^/\s]+\/[^@/\s]+)|([^@/\s:]+))(?:@[^\s]+)?$/.exec(spec)?.slice(1).find(Boolean)
88
+ }
89
+
90
+ function runtimePackage(runtime) {
91
+ if (!runtime || !/(?:^|[/\\])npx(?:\.cmd|\.exe)?$/i.test(runtime.command ?? '')) return undefined
92
+ const args = runtime.args ?? []
93
+ const explicit = args.findIndex(arg => arg === '--package' || arg === '-p')
94
+ return packageName(explicit >= 0 ? args[explicit + 1] : args.find(arg => !arg.startsWith('-')))
95
+ }
96
+
97
+ export function apply(ctx, options = {}) {
98
+ const loadIcon = options.loadIcon ?? createIconCache(options)
99
+ let panel, disposed = false, generation = 0, lastKey
100
+ let nodes = []
101
+ const stopSlot = ctx.tuiSlots.inject('conversation.harness', () => {
102
+ panel = ctx.tuiSlots.register({ name: 'conversation.harness', id: 'harness' }, nodes)
103
+ return () => panel.dispose()
104
+ })
105
+ function update(status) {
106
+ const session = status.session?.bound ? status.session.sessionId : undefined
107
+ const key = JSON.stringify([session, status.server, status.runtime])
108
+ if (key === lastKey) return
109
+ lastKey = key
110
+ const token = ++generation
111
+ if (!session) { nodes = []; panel?.update(nodes); return }
112
+ const registry = options.registry ?? readAcpRegistrySnapshot(options)
113
+ const runtime = status.runtime
114
+ const entries = options.entries ?? discoverHarnesses(options.settingsPath, { ...options, registry, pathValue: '' })
115
+ const entry = runtime && entries.find(candidate => candidate.command === runtime.command
116
+ && JSON.stringify(candidate.args ?? []) === JSON.stringify(runtime.args ?? []))
117
+ const npmPackage = runtimePackage(runtime) ?? packageName(status.server)
118
+ const record = registry.find(candidate => candidate.id === entry?.id)
119
+ ?? registry.find(candidate => candidate.id === status.server || candidate.label === status.server)
120
+ ?? (npmPackage && registry.find(candidate => candidate.distributions?.some(distribution =>
121
+ distribution.type === 'npx' && packageName(distribution.args?.[0]) === npmPackage)))
122
+ ?? (['dsh-acp', '@deepseek-ai/dsh-acp', '@openma/deepseek-harness-acp'].includes(status.server)
123
+ || ['@deepseek-ai/dsh-acp', '@openma/deepseek-harness-acp'].includes(npmPackage)
124
+ ? deepseekBadge : undefined)
125
+ const label = (record?.label ?? entry?.label ?? status.server ?? runtime?.command)?.replace(/\s+harness$/i, '')
126
+ nodes = label ? [{ id: session, kind: 'image', name: label, mime: 'image/png' }] : []
127
+ panel?.update(nodes)
128
+ if (record?.icon && nodes.length) void loadIcon(record.icon).then(dataBase64 => {
129
+ if (disposed || token !== generation || !dataBase64) return
130
+ nodes = [{ ...nodes[0], dataBase64 }]
131
+ panel?.update(nodes)
132
+ })
133
+ }
134
+ update(ctx.acpSessionStatus.current())
135
+ const stopStatus = ctx.acpSessionStatus.subscribe(update)
136
+ return () => { disposed = true; ++generation; stopStatus?.(); stopSlot?.() }
137
+ }
@@ -0,0 +1,9 @@
1
+ /** npm exec exports its invocation selectors to children. They belong to Martty's
2
+ * launcher, not a nested Harness runner (npx otherwise treats a package as a bin).
3
+ * Keep registry/proxy/cache/auth settings and explicit Harness overrides intact.
4
+ */
5
+ export function harnessEnvironment(overrides = {}, inherited = process.env) {
6
+ const env = Object.fromEntries(Object.entries(inherited).filter(([key]) =>
7
+ !/^npm_config_(package|call|workspace|workspaces|include_workspace_root)$/i.test(key)))
8
+ return { ...env, ...overrides }
9
+ }
@@ -1,3 +1,4 @@
1
+ import { harnessEnvironment } from './harness-environment.js'
1
2
  import { StringDecoder } from 'node:string_decoder'
2
3
  import spawn from 'cross-spawn'
3
4
 
@@ -155,7 +156,7 @@ export async function prepareHarnessPackage(entry, options = {}) {
155
156
  try {
156
157
  child = (options.spawnImpl ?? spawn)(runner, args, {
157
158
  cwd: options.cwd,
158
- env: { ...process.env, ...distribution.env, ...entry.env, ...options.env },
159
+ env: harnessEnvironment({ ...distribution.env, ...entry.env, ...options.env }),
159
160
  stdio: ['ignore', 'pipe', 'pipe'],
160
161
  windowsHide: true,
161
162
  detached: process.platform !== 'win32',
@@ -128,6 +128,7 @@ export function normalizeAcpRegistry(value, options = {}) {
128
128
  return [{
129
129
  id: agent.id,
130
130
  label: agent.name,
131
+ ...(typeof agent.icon === 'string' ? { icon: agent.icon } : {}),
131
132
  version: agent.version,
132
133
  description: typeof agent.description === 'string' ? agent.description : '',
133
134
  distributions,
@@ -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,32 @@ 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
+ const emptySession = ctx.acpSessionStatus?.current()?.session?.started === false
242
+ if (!options.hostOwned && (openNow || emptySession)) return newSession()
243
+ openView({ id: 'harness-saved', title: 'Default Harness saved', nodes: [{
244
+ id: 'notice', kind: 'notice', level: 'info',
245
+ text: options.hostOwned
246
+ ? `${entry.label} is saved for the next standalone session. The current profile owns its Harness.`
247
+ : `${entry.label} will be used for new sessions. Enter opens a new tab now. Esc keeps the current session.`,
248
+ }] }, options.hostOwned ? undefined : { onSubmit: newSession })
249
+ if (!options.hostOwned) return { action: 'harness-selected', harness: {
250
+ id: entry.id, label: entry.label, command: entry.command, args: entry.args ?? [],
251
+ ...(entry.env !== undefined ? { env: entry.env } : {}),
252
+ } }
307
253
  }
308
254
 
309
255
  function configured(entry) {
@@ -372,14 +318,14 @@ export function apply(ctx, options = {}) {
372
318
  downloads.delete(job.key)
373
319
  hide()
374
320
  }
375
- openView({ id: 'harness-installing', title: `Download complete · ${job.entry.label} · enter switch · esc close`, nodes: [
321
+ openView({ id: 'harness-installing', title: `Download complete · ${job.entry.label} · enter new session · esc close`, nodes: [
376
322
  { id: 'complete', kind: 'notice', level: 'info', text: job.registered
377
323
  ? `${job.entry.label} is installed and configured.`
378
324
  : `${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.' },
325
+ { id: 'next', kind: 'text', text: 'Setup is complete. Enter opens a new session with this Harness. Esc closes without switching.' },
380
326
  ] }, { onSubmit: () => {
381
327
  acknowledge()
382
- return save(job.entry.id)
328
+ return save(job.entry.id, undefined, undefined, true)
383
329
  }, onCancel: acknowledge })
384
330
  return
385
331
  }
@@ -591,8 +537,8 @@ export function apply(ctx, options = {}) {
591
537
  } })()
592
538
  }
593
539
 
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() },
540
+ commandRegistration = ctx.tuiCommands.register({ name: 'harness', description: 'Choose the default Harness for new sessions',
541
+ input: { hint: '[id] [--new] | add | remove [id] | find [query]', options: commandChoices() },
596
542
  }, async (args) => {
597
543
  if (disposed) return
598
544
  refreshCommandChoices()
@@ -620,7 +566,7 @@ export function apply(ctx, options = {}) {
620
566
  }
621
567
  if (tokens[0] !== undefined) {
622
568
  const saved = discoverHarnesses(settingsPath, options).find((entry) => entry.id === tokens[0])
623
- if (saved !== undefined) return save(saved.id, saved)
569
+ if (saved !== undefined) return save(saved.id, saved, undefined, tokens.includes('--new'))
624
570
  const job = [...downloads.values()].filter((job) => job.entry.id === tokens[0]).at(-1)
625
571
  return job === undefined ? save(tokens[0]) : showDownload(job)
626
572
  }
@@ -640,14 +586,14 @@ export function apply(ctx, options = {}) {
640
586
  }
641
587
  const downloaded = downloadOptions()
642
588
  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,
589
+ openSelect({ id: 'harness', title: 'Default Harness · for new sessions',
590
+ value: (entries.find(entry => entry.value === selected) ?? entries.find((entry) => entry.value === selectedHarness(settingsPath)?.id) ?? entries[0] ?? downloaded[0]).value,
645
591
  options: [...entries, ...downloaded, addOption],
646
592
  }, { onDelete: id => removal(id),
647
593
  onSubmit: (id) => id === ADD ? browse() : id.startsWith(DOWNLOAD) ? showDownload(downloads.get(id.slice(DOWNLOAD.length))) : save(id) })
648
594
  }
649
595
  return () => {
650
- disposed = true; ++flow; ++switchVersion; operation?.abort()
596
+ disposed = true; ++flow; operation?.abort()
651
597
  for (const job of downloads.values()) job.controller.abort()
652
598
  ownedOverlay?.close(); stopDownloadSlot?.(); commandRegistration?.()
653
599
  }
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
  )
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 LobeHub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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/lib/tui-slots.js CHANGED
@@ -16,9 +16,11 @@ export const SLOT_NAMES = Object.freeze([
16
16
  'conversation.input.dock',
17
17
  'conversation.navigation.dock',
18
18
  'conversation.composer.dock',
19
+ 'conversation.harness',
19
20
  ])
20
21
 
21
22
  const SLOT_DEFINITIONS = Object.freeze({
23
+ 'conversation.harness': Object.freeze({ kind: 'single', scope: 'session' }),
22
24
  'welcome.hero': Object.freeze({ kind: 'single', scope: 'root' }),
23
25
  'welcome.info': Object.freeze({ kind: 'single', scope: 'root' }),
24
26
  'chrome.right': Object.freeze({ kind: 'list', scope: 'root' }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martty",
3
- "version": "0.2.38",
3
+ "version": "0.2.39-beta.1",
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-badge.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,7 +76,9 @@
76
76
  },
77
77
  "dependencies": {
78
78
  "@deepseek-ai/cordis": "^4.0.1",
79
+ "@lobehub/icons-static-svg": "1.95.0",
79
80
  "@openma/deepseek-harness-acp": "0.4.31",
81
+ "@resvg/resvg-js": "2.6.2",
80
82
  "cross-spawn": "^7.0.6",
81
83
  "node-downloader-helper": "2.1.11"
82
84
  },
Binary file
Binary file
Binary file
Binary file
Binary file