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.
- package/lib/acp-agent-pool.js +282 -0
- package/lib/acp-client.js +14 -234
- package/lib/acp-session-status.js +36 -15
- package/lib/boot.js +3 -0
- package/lib/harness-badge.js +137 -0
- package/lib/harness-environment.js +9 -0
- package/lib/harness-package.js +2 -1
- package/lib/harness-registry.js +1 -0
- package/lib/harness-removal.js +1 -1
- package/lib/harness-view.js +40 -94
- package/lib/index.js +1 -0
- package/lib/lobe-icons.LICENSE +21 -0
- package/lib/mux.js +18 -3
- package/lib/tui-slots.js +2 -0
- package/package.json +5 -3
- package/vendor/darwin-arm64/martty +0 -0
- package/vendor/darwin-x64/martty +0 -0
- package/vendor/linux-arm64/martty +0 -0
- package/vendor/linux-x64/martty +0 -0
- package/vendor/win32-x64/martty.exe +0 -0
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
96
|
-
if (liveAgent !== 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.
|
|
102
|
+
liveAgent.close()
|
|
99
103
|
} catch {
|
|
100
104
|
// already gone
|
|
101
105
|
}
|
|
102
106
|
}
|
|
103
107
|
liveAgent = null
|
|
104
|
-
const service =
|
|
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:
|
|
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
|
|
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,
|
|
94
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
210
|
-
delete
|
|
217
|
+
target.status = 'configured'
|
|
218
|
+
delete target.message
|
|
211
219
|
} else {
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
&&
|
|
259
|
-
|
|
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
|
+
}
|
package/lib/harness-package.js
CHANGED
|
@@ -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: { ...
|
|
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',
|
package/lib/harness-registry.js
CHANGED
|
@@ -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,
|
package/lib/harness-removal.js
CHANGED
|
@@ -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('
|
|
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)
|
package/lib/harness-view.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** Built-in Client Plugin:
|
|
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
|
-
|
|
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
|
|
78
|
-
const
|
|
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
|
|
230
|
-
|
|
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 (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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: '
|
|
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: '
|
|
644
|
-
value: (entries.find(entry => entry.value === selected) ?? entries.find((entry) => entry.
|
|
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;
|
|
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 (
|
|
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
|
-
&&
|
|
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/') &&
|
|
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.
|
|
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
|
package/vendor/darwin-x64/martty
CHANGED
|
Binary file
|
|
Binary file
|
package/vendor/linux-x64/martty
CHANGED
|
Binary file
|
|
Binary file
|