martty 0.2.38 → 0.2.39-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/acp-agent-pool.js +282 -0
- package/lib/acp-client.js +12 -233
- package/lib/acp-session-status.js +35 -15
- package/lib/harness-removal.js +1 -1
- package/lib/harness-view.js +39 -94
- package/lib/index.js +1 -0
- package/lib/mux.js +18 -3
- package/package.json +3 -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,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides `ctx.acpClient` plus the standard-ACP-backed
|
|
5
5
|
* `ctx.acpSessionConfig`. Does not import a harness, dsh, or dsh-acp.
|
|
6
|
-
*
|
|
6
|
+
* Spawned sessions retain their connection; setDefaultAgent changes the recipe
|
|
7
|
+
* used by the next session/new. An externally owned config.stream stays fixed.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import spawn from 'cross-spawn'
|
|
10
|
-
import { PassThrough } from 'node:stream'
|
|
11
11
|
import { installAcpClientEvents } from './acp-client-events.js'
|
|
12
12
|
import { installAcpSessionConfig } from './acp-session-config.js'
|
|
13
13
|
import { installAcpSessionPlan } from './acp-session-plan.js'
|
|
14
14
|
import { installAcpSessionStats } from './acp-session-stats.js'
|
|
15
15
|
import { tokenizeCommandArgs } from './command-args.js'
|
|
16
|
+
import { createAgentPool } from './acp-agent-pool.js'
|
|
16
17
|
|
|
17
18
|
export { installAcpSessionConfig } from './acp-session-config.js'
|
|
18
19
|
export { installAcpSessionPlan } from './acp-session-plan.js'
|
|
@@ -85,6 +86,8 @@ export function apply(ctx, config = {}) {
|
|
|
85
86
|
if (
|
|
86
87
|
liveAgent !== null
|
|
87
88
|
&& liveAgent.child.exitCode === null
|
|
89
|
+
&& !liveAgent.child.killed
|
|
90
|
+
&& !liveAgent.stdin.destroyed
|
|
88
91
|
&& liveAgent.command === agent.command
|
|
89
92
|
&& JSON.stringify(liveAgent.args) === JSON.stringify(agent.args ?? [])
|
|
90
93
|
&& JSON.stringify(liveAgent.env ?? {}) === JSON.stringify(agent.env ?? {})
|
|
@@ -92,16 +95,19 @@ export function apply(ctx, config = {}) {
|
|
|
92
95
|
provide(ctx, liveAgent)
|
|
93
96
|
return
|
|
94
97
|
}
|
|
95
|
-
//
|
|
96
|
-
if (liveAgent !== null
|
|
98
|
+
// A new Client tree owns a new pool; dispose every child of the old tree.
|
|
99
|
+
if (liveAgent !== null) {
|
|
97
100
|
try {
|
|
98
|
-
liveAgent.
|
|
101
|
+
liveAgent.close()
|
|
99
102
|
} catch {
|
|
100
103
|
// already gone
|
|
101
104
|
}
|
|
102
105
|
}
|
|
103
106
|
liveAgent = null
|
|
104
|
-
const service =
|
|
107
|
+
const service = createAgentPool(agent, { spawnAgent, resolveAgent, diagnosticError })
|
|
108
|
+
if (liveAgentExitHook !== null) process.removeListener('exit', liveAgentExitHook)
|
|
109
|
+
liveAgentExitHook = () => service.close()
|
|
110
|
+
process.once('exit', liveAgentExitHook)
|
|
105
111
|
liveAgent = service
|
|
106
112
|
provide(ctx, service)
|
|
107
113
|
}
|
|
@@ -185,233 +191,6 @@ function diagnosticError(error, handle) {
|
|
|
185
191
|
})
|
|
186
192
|
}
|
|
187
193
|
|
|
188
|
-
function waitForSpawn(handle) {
|
|
189
|
-
if (handle.child.pid !== undefined) return Promise.resolve()
|
|
190
|
-
return new Promise((resolve, reject) => {
|
|
191
|
-
const spawned = () => {
|
|
192
|
-
handle.child.off('error', failed)
|
|
193
|
-
resolve()
|
|
194
|
-
}
|
|
195
|
-
const failed = (error) => {
|
|
196
|
-
handle.child.off('spawn', spawned)
|
|
197
|
-
reject(error)
|
|
198
|
-
}
|
|
199
|
-
handle.child.once('spawn', spawned)
|
|
200
|
-
handle.child.once('error', failed)
|
|
201
|
-
})
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function createSpawnService(agent) {
|
|
205
|
-
const input = new PassThrough()
|
|
206
|
-
const output = new PassThrough()
|
|
207
|
-
const switchListeners = new Set()
|
|
208
|
-
const failureListeners = new Set()
|
|
209
|
-
let current = spawnAgent(agent)
|
|
210
|
-
let closed = false
|
|
211
|
-
let pendingSwitch
|
|
212
|
-
let hasSwitched = false
|
|
213
|
-
|
|
214
|
-
const attach = (handle) => {
|
|
215
|
-
input.pipe(handle.stdin, { end: false })
|
|
216
|
-
handle.stdout.pipe(output, { end: false })
|
|
217
|
-
}
|
|
218
|
-
const detach = (handle) => {
|
|
219
|
-
input.unpipe(handle.stdin)
|
|
220
|
-
handle.stdout.unpipe(output)
|
|
221
|
-
}
|
|
222
|
-
attach(current)
|
|
223
|
-
|
|
224
|
-
const service = {
|
|
225
|
-
kind: 'spawn',
|
|
226
|
-
command: current.command,
|
|
227
|
-
args: current.args,
|
|
228
|
-
...(current.env !== undefined ? { env: current.env } : {}),
|
|
229
|
-
stdin: input,
|
|
230
|
-
stdout: output,
|
|
231
|
-
child: current.child,
|
|
232
|
-
diagnostics() { return current.diagnostics() },
|
|
233
|
-
onSwitch(listener) {
|
|
234
|
-
if (typeof listener !== 'function') throw new Error('acpClient.onSwitch needs a function')
|
|
235
|
-
switchListeners.add(listener)
|
|
236
|
-
return () => switchListeners.delete(listener)
|
|
237
|
-
},
|
|
238
|
-
onFailure(listener) {
|
|
239
|
-
if (typeof listener !== 'function') throw new Error('acpClient.onFailure needs a function')
|
|
240
|
-
failureListeners.add(listener)
|
|
241
|
-
return () => failureListeners.delete(listener)
|
|
242
|
-
},
|
|
243
|
-
observeClient(message) {
|
|
244
|
-
if (pendingSwitch === undefined || message?.id === undefined) return
|
|
245
|
-
if (!['initialize', 'authenticate', 'session/new'].includes(message.method)) return
|
|
246
|
-
pendingSwitch.requests.set(message.id, message.method)
|
|
247
|
-
// Browser/device sign-in is user-driven, not a machine setup operation.
|
|
248
|
-
if (message.method === 'authenticate') pendingSwitch.pause()
|
|
249
|
-
else pendingSwitch.arm()
|
|
250
|
-
},
|
|
251
|
-
observeAgent(message) {
|
|
252
|
-
const pending = pendingSwitch
|
|
253
|
-
if (pending === undefined || message?.id === undefined) return
|
|
254
|
-
const method = pending.requests.get(message.id)
|
|
255
|
-
if (method === undefined) return
|
|
256
|
-
pending.requests.delete(message.id)
|
|
257
|
-
if (message.error !== undefined) {
|
|
258
|
-
// Authentication is user-driven; do not time out while an auth form is open.
|
|
259
|
-
if (method !== 'initialize' && message.error?.code === -32000) {
|
|
260
|
-
pending.pause()
|
|
261
|
-
return
|
|
262
|
-
}
|
|
263
|
-
pending.reject(Object.assign(new Error(`${method}: ${message.error?.message ?? 'ACP setup failed'}`), {
|
|
264
|
-
method, acpError: structuredClone(message.error),
|
|
265
|
-
}))
|
|
266
|
-
return
|
|
267
|
-
}
|
|
268
|
-
if (method === 'initialize') {
|
|
269
|
-
if (!Number.isInteger(message.result?.protocolVersion)) {
|
|
270
|
-
pending.reject(new Error('initialize: invalid ACP response'))
|
|
271
|
-
return
|
|
272
|
-
}
|
|
273
|
-
pending.initialized = true
|
|
274
|
-
pending.server = message.result?.agentInfo?.name
|
|
275
|
-
} else if (method === 'authenticate') {
|
|
276
|
-
// Once sign-in returns, the ensuing session/new must finish promptly.
|
|
277
|
-
pending.arm()
|
|
278
|
-
} else if (method === 'session/new') {
|
|
279
|
-
const sessionId = message.result?.sessionId
|
|
280
|
-
if (!pending.initialized || typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
281
|
-
pending.reject(new Error('session/new: invalid ACP session response'))
|
|
282
|
-
return
|
|
283
|
-
}
|
|
284
|
-
pending.resolve({
|
|
285
|
-
sessionId,
|
|
286
|
-
...(typeof pending.server === 'string' ? { server: pending.server } : {}),
|
|
287
|
-
})
|
|
288
|
-
}
|
|
289
|
-
},
|
|
290
|
-
async switchAgent(nextAgent, { timeoutMs = 20 * 60_000 } = {}) {
|
|
291
|
-
if (closed) throw new Error('acpClient is closed')
|
|
292
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('ACP setup timeout must be positive')
|
|
293
|
-
const spec = resolveAgent({ agent: nextAgent })
|
|
294
|
-
const next = spawnAgent(spec)
|
|
295
|
-
try {
|
|
296
|
-
await waitForSpawn(next)
|
|
297
|
-
for (const listener of switchListeners) await listener(next, current)
|
|
298
|
-
if (next.child.exitCode !== null || next.child.signalCode !== null) {
|
|
299
|
-
throw new Error(`ACP process exited (${next.child.signalCode ?? next.child.exitCode}) during handoff`)
|
|
300
|
-
}
|
|
301
|
-
} catch (error) {
|
|
302
|
-
try {
|
|
303
|
-
next.child.kill('SIGTERM')
|
|
304
|
-
} catch {
|
|
305
|
-
// failed spawns may not own a process
|
|
306
|
-
}
|
|
307
|
-
throw diagnosticError(error, next)
|
|
308
|
-
}
|
|
309
|
-
pendingSwitch?.reject(new Error('Harness switch was superseded'))
|
|
310
|
-
const previous = current
|
|
311
|
-
detach(previous)
|
|
312
|
-
attach(next)
|
|
313
|
-
current = next
|
|
314
|
-
service.command = next.command
|
|
315
|
-
service.args = next.args
|
|
316
|
-
service.env = next.env
|
|
317
|
-
service.child = next.child
|
|
318
|
-
hasSwitched = true
|
|
319
|
-
const ready = waitForReady(timeoutMs)
|
|
320
|
-
watchCurrent(next)
|
|
321
|
-
try {
|
|
322
|
-
previous.child.kill('SIGTERM')
|
|
323
|
-
} catch {
|
|
324
|
-
// already gone
|
|
325
|
-
}
|
|
326
|
-
// Rust starts initialize only after the local command returns. Awaiting
|
|
327
|
-
// ready here would deadlock; callers persist selection when this settles.
|
|
328
|
-
return { ready }
|
|
329
|
-
},
|
|
330
|
-
close(error = new Error('acpClient is closed')) {
|
|
331
|
-
if (closed) return
|
|
332
|
-
failTransport(error)
|
|
333
|
-
closed = true
|
|
334
|
-
detach(current)
|
|
335
|
-
try {
|
|
336
|
-
current.child.kill('SIGTERM')
|
|
337
|
-
} catch {
|
|
338
|
-
// already gone
|
|
339
|
-
}
|
|
340
|
-
input.destroy()
|
|
341
|
-
output.destroy()
|
|
342
|
-
if (liveAgent === service) liveAgent = null
|
|
343
|
-
},
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function waitForReady(timeoutMs) {
|
|
347
|
-
let resolve
|
|
348
|
-
let reject
|
|
349
|
-
let timer
|
|
350
|
-
const ready = new Promise((yes, no) => { resolve = yes; reject = no })
|
|
351
|
-
// A child may fail before the caller has received the handoff result.
|
|
352
|
-
ready.catch(() => {})
|
|
353
|
-
const pending = {
|
|
354
|
-
requests: new Map(),
|
|
355
|
-
initialized: false,
|
|
356
|
-
server: undefined,
|
|
357
|
-
arm() {
|
|
358
|
-
clearTimeout(timer)
|
|
359
|
-
timer = setTimeout(() => {
|
|
360
|
-
failTransport(new Error(`ACP setup timed out after ${timeoutMs / 1000}s`))
|
|
361
|
-
detach(current)
|
|
362
|
-
try { current.child.kill('SIGTERM') } catch { /* already gone */ }
|
|
363
|
-
}, timeoutMs)
|
|
364
|
-
timer.unref?.()
|
|
365
|
-
},
|
|
366
|
-
pause() { clearTimeout(timer) },
|
|
367
|
-
resolve(value) { settle(resolve, value) },
|
|
368
|
-
reject(error) { settle(reject, diagnosticError(error, current)) },
|
|
369
|
-
}
|
|
370
|
-
const settle = (complete, value) => {
|
|
371
|
-
if (pendingSwitch !== pending) return
|
|
372
|
-
pendingSwitch = undefined
|
|
373
|
-
clearTimeout(timer)
|
|
374
|
-
complete(value)
|
|
375
|
-
}
|
|
376
|
-
pendingSwitch = pending
|
|
377
|
-
pending.arm()
|
|
378
|
-
return ready
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function failTransport(error) {
|
|
382
|
-
const failure = diagnosticError(error, current)
|
|
383
|
-
pendingSwitch?.reject(failure)
|
|
384
|
-
for (const listener of failureListeners) listener(failure)
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function watchCurrent(handle) {
|
|
388
|
-
handle.child.on('error', (err) => {
|
|
389
|
-
if (current !== handle || closed) return
|
|
390
|
-
if (hasSwitched) {
|
|
391
|
-
failTransport(err)
|
|
392
|
-
return
|
|
393
|
-
}
|
|
394
|
-
service.close(err)
|
|
395
|
-
})
|
|
396
|
-
// 'close' follows the stdio drain, so final startup errors are not lost.
|
|
397
|
-
handle.child.once('close', (code, signal) => {
|
|
398
|
-
if (current !== handle || closed) return
|
|
399
|
-
failTransport(new Error(`ACP process exited (${signal ?? code ?? 'unknown'}) before completing the request`))
|
|
400
|
-
})
|
|
401
|
-
}
|
|
402
|
-
watchCurrent(current)
|
|
403
|
-
if (liveAgentExitHook !== null) process.removeListener('exit', liveAgentExitHook)
|
|
404
|
-
liveAgentExitHook = () => {
|
|
405
|
-
try {
|
|
406
|
-
service.close()
|
|
407
|
-
} catch {
|
|
408
|
-
// already gone
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
process.once('exit', liveAgentExitHook)
|
|
412
|
-
return service
|
|
413
|
-
}
|
|
414
|
-
|
|
415
194
|
function provide(ctx, service) {
|
|
416
195
|
if (typeof ctx.provide === 'function') {
|
|
417
196
|
ctx.provide('acpClient', service)
|
|
@@ -61,6 +61,8 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
61
61
|
|
|
62
62
|
const listeners = new Set()
|
|
63
63
|
const sessions = new Map()
|
|
64
|
+
const connections = new Map()
|
|
65
|
+
const connectionAuthMethods = new Map()
|
|
64
66
|
const fallback = zeroSession(undefined, false)
|
|
65
67
|
let sessionId
|
|
66
68
|
let selectionKnown = false
|
|
@@ -70,7 +72,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
70
72
|
let initializeId
|
|
71
73
|
let authMethods = []
|
|
72
74
|
const pendingRequests = new Set()
|
|
73
|
-
const pendingAuthenticate = new
|
|
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,17 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
239
247
|
const bound = readString(message.result, 'sessionId', 'session_id') ?? setup.sessionId
|
|
240
248
|
if (bound !== undefined) {
|
|
241
249
|
stateFor(bound).session = { sessionId: bound, bound: true, started: setup.started }
|
|
250
|
+
const owner = message.result?._meta?.marttyConnection
|
|
251
|
+
if (owner) {
|
|
252
|
+
let facts = connections.get(owner.id)
|
|
253
|
+
if (!facts) {
|
|
254
|
+
facts = { connection: 'attached', server: owner.agentInfo?.name,
|
|
255
|
+
auth: { status: owner.authMethods?.length ? 'configured' : undefined, method: undefined } }
|
|
256
|
+
connections.set(owner.id, facts)
|
|
257
|
+
}
|
|
258
|
+
stateFor(bound).connectionFacts = facts
|
|
259
|
+
for (const method of owner.authMethods ?? []) connectionAuthMethods.set(method.id, { facts, method })
|
|
260
|
+
}
|
|
242
261
|
delete stateFor(bound).error
|
|
243
262
|
if (authMethods.length > 0) {
|
|
244
263
|
auth.status = 'configured'
|
|
@@ -254,12 +273,13 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
254
273
|
const promptSessionId = pendingPrompts.get(message.id)
|
|
255
274
|
pendingPrompts.delete(message.id)
|
|
256
275
|
let changed = false
|
|
276
|
+
const value = stateFor(promptSessionId)
|
|
277
|
+
const target = value.connectionFacts?.auth ?? auth
|
|
257
278
|
if (message.error !== undefined && isAuthRequired(message.error)
|
|
258
|
-
&&
|
|
259
|
-
|
|
279
|
+
&& target.status !== 'needs sign-in') {
|
|
280
|
+
target.status = 'needs sign-in'
|
|
260
281
|
changed = true
|
|
261
282
|
}
|
|
262
|
-
const value = stateFor(promptSessionId)
|
|
263
283
|
if (!hasPendingPrompt(promptSessionId) && value.state !== 'idle') {
|
|
264
284
|
value.state = 'idle'
|
|
265
285
|
changed = true
|
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,31 @@ 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
|
+
if (openNow && !options.hostOwned) return newSession()
|
|
242
|
+
openView({ id: 'harness-saved', title: 'Default Harness saved', nodes: [{
|
|
243
|
+
id: 'notice', kind: 'notice', level: 'info',
|
|
244
|
+
text: options.hostOwned
|
|
245
|
+
? `${entry.label} is saved for the next standalone session. The current profile owns its Harness.`
|
|
246
|
+
: `${entry.label} will be used for new sessions. Enter opens a new tab now. Esc keeps the current session.`,
|
|
247
|
+
}] }, options.hostOwned ? undefined : { onSubmit: newSession })
|
|
248
|
+
if (!options.hostOwned) return { action: 'harness-selected', harness: {
|
|
249
|
+
id: entry.id, label: entry.label, command: entry.command, args: entry.args ?? [],
|
|
250
|
+
...(entry.env !== undefined ? { env: entry.env } : {}),
|
|
251
|
+
} }
|
|
307
252
|
}
|
|
308
253
|
|
|
309
254
|
function configured(entry) {
|
|
@@ -372,14 +317,14 @@ export function apply(ctx, options = {}) {
|
|
|
372
317
|
downloads.delete(job.key)
|
|
373
318
|
hide()
|
|
374
319
|
}
|
|
375
|
-
openView({ id: 'harness-installing', title: `Download complete · ${job.entry.label} · enter
|
|
320
|
+
openView({ id: 'harness-installing', title: `Download complete · ${job.entry.label} · enter new session · esc close`, nodes: [
|
|
376
321
|
{ id: 'complete', kind: 'notice', level: 'info', text: job.registered
|
|
377
322
|
? `${job.entry.label} is installed and configured.`
|
|
378
323
|
: `${job.entry.label} is installed. Your newer configuration is unchanged.` },
|
|
379
|
-
{ id: 'next', kind: 'text', text: 'Setup is complete. Enter
|
|
324
|
+
{ id: 'next', kind: 'text', text: 'Setup is complete. Enter opens a new session with this Harness. Esc closes without switching.' },
|
|
380
325
|
] }, { onSubmit: () => {
|
|
381
326
|
acknowledge()
|
|
382
|
-
return save(job.entry.id)
|
|
327
|
+
return save(job.entry.id, undefined, undefined, true)
|
|
383
328
|
}, onCancel: acknowledge })
|
|
384
329
|
return
|
|
385
330
|
}
|
|
@@ -591,8 +536,8 @@ export function apply(ctx, options = {}) {
|
|
|
591
536
|
} })()
|
|
592
537
|
}
|
|
593
538
|
|
|
594
|
-
commandRegistration = ctx.tuiCommands.register({ name: 'harness', description: '
|
|
595
|
-
input: { hint: '[id] | add | remove [id] | find [query]', options: commandChoices() },
|
|
539
|
+
commandRegistration = ctx.tuiCommands.register({ name: 'harness', description: 'Choose the default Harness for new sessions',
|
|
540
|
+
input: { hint: '[id] [--new] | add | remove [id] | find [query]', options: commandChoices() },
|
|
596
541
|
}, async (args) => {
|
|
597
542
|
if (disposed) return
|
|
598
543
|
refreshCommandChoices()
|
|
@@ -620,7 +565,7 @@ export function apply(ctx, options = {}) {
|
|
|
620
565
|
}
|
|
621
566
|
if (tokens[0] !== undefined) {
|
|
622
567
|
const saved = discoverHarnesses(settingsPath, options).find((entry) => entry.id === tokens[0])
|
|
623
|
-
if (saved !== undefined) return save(saved.id, saved)
|
|
568
|
+
if (saved !== undefined) return save(saved.id, saved, undefined, tokens.includes('--new'))
|
|
624
569
|
const job = [...downloads.values()].filter((job) => job.entry.id === tokens[0]).at(-1)
|
|
625
570
|
return job === undefined ? save(tokens[0]) : showDownload(job)
|
|
626
571
|
}
|
|
@@ -640,14 +585,14 @@ export function apply(ctx, options = {}) {
|
|
|
640
585
|
}
|
|
641
586
|
const downloaded = downloadOptions()
|
|
642
587
|
if (entries.length === 0 && downloaded.length === 0) return browse()
|
|
643
|
-
openSelect({ id: 'harness', title: '
|
|
644
|
-
value: (entries.find(entry => entry.value === selected) ?? entries.find((entry) => entry.
|
|
588
|
+
openSelect({ id: 'harness', title: 'Default Harness · for new sessions',
|
|
589
|
+
value: (entries.find(entry => entry.value === selected) ?? entries.find((entry) => entry.value === selectedHarness(settingsPath)?.id) ?? entries[0] ?? downloaded[0]).value,
|
|
645
590
|
options: [...entries, ...downloaded, addOption],
|
|
646
591
|
}, { onDelete: id => removal(id),
|
|
647
592
|
onSubmit: (id) => id === ADD ? browse() : id.startsWith(DOWNLOAD) ? showDownload(downloads.get(id.slice(DOWNLOAD.length))) : save(id) })
|
|
648
593
|
}
|
|
649
594
|
return () => {
|
|
650
|
-
disposed = true; ++flow;
|
|
595
|
+
disposed = true; ++flow; operation?.abort()
|
|
651
596
|
for (const job of downloads.values()) job.controller.abort()
|
|
652
597
|
ownedOverlay?.close(); stopDownloadSlot?.(); commandRegistration?.()
|
|
653
598
|
}
|
package/lib/index.js
CHANGED
|
@@ -248,6 +248,7 @@ export async function applyShell(ctx, options = {}) {
|
|
|
248
248
|
}
|
|
249
249
|
if (message.method === CORDIS_METHODS.sessionActive) {
|
|
250
250
|
const active = message.params?.sessionId
|
|
251
|
+
agent.selectSession?.(active)
|
|
251
252
|
clientEvents.selectSession(
|
|
252
253
|
typeof active === 'string' && active.length > 0 ? active : undefined,
|
|
253
254
|
)
|
package/lib/mux.js
CHANGED
|
@@ -165,6 +165,13 @@ export function muxAcpAndCompositor(opts) {
|
|
|
165
165
|
let initializeId
|
|
166
166
|
/** @type {{ protocol: number } | null} */
|
|
167
167
|
let agentCordis = null
|
|
168
|
+
const initializedConnections = new Set()
|
|
169
|
+
const capabilityFor = message => {
|
|
170
|
+
if (typeof agent.capabilityFor !== 'function') return agentCordis
|
|
171
|
+
try { return agent.capabilityFor(message) } catch { return null }
|
|
172
|
+
}
|
|
173
|
+
const withOrigin = (message, callback) => typeof agent.withOrigin === 'function'
|
|
174
|
+
? agent.withOrigin(message, callback) : callback()
|
|
168
175
|
|
|
169
176
|
onJsonLines(agent.stdout, (line) => {
|
|
170
177
|
let message
|
|
@@ -186,14 +193,21 @@ export function muxAcpAndCompositor(opts) {
|
|
|
186
193
|
return
|
|
187
194
|
}
|
|
188
195
|
if (isHostMessage(message)) {
|
|
189
|
-
if (
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "martty",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.39-beta.0",
|
|
4
4
|
"description": "Terminal-native ACP client UI; Cordis client tree, any ACP agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"type": "module",
|
|
15
15
|
"main": "lib/index.js",
|
|
16
16
|
"scripts": {
|
|
17
|
-
"test:harness-ui": "node --test ../scripts/harness-management-tui.test.mjs",
|
|
17
|
+
"test:harness-ui": "node --test ../scripts/harness-management-tui.test.mjs ../scripts/harness-session-tui.test.mjs",
|
|
18
18
|
"pretest": "node --test ../scripts/workflow-release.test.mjs ../scripts/package-alias.test.mjs ../scripts/harness-removal.test.mjs",
|
|
19
|
-
"test": "node --test ../scripts/download.test.mjs ../scripts/package-native.test.mjs ../scripts/check-release-tag.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/packaged-acp-permission.test.mjs ../scripts/profile-smoke-tui.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-presets.test.mjs ../scripts/tui-plugin-store.test.mjs ../scripts/tui-local-plugins.test.mjs ../scripts/tui-client-plugin-registry.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/tui-agents.test.mjs ../scripts/tui-queue.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/harnesses.test.mjs ../scripts/harness-discovery.test.mjs ../scripts/harness-discovery-scenario.test.mjs ../scripts/harness-view.test.mjs ../scripts/harness-onboarding.test.mjs ../scripts/harness-registry.test.mjs ../scripts/harness-package.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/agents-view.test.mjs ../scripts/plan-view.test.mjs ../scripts/queue-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs",
|
|
19
|
+
"test": "node --test ../scripts/download.test.mjs ../scripts/package-native.test.mjs ../scripts/check-release-tag.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/packaged-acp-permission.test.mjs ../scripts/profile-smoke-tui.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-presets.test.mjs ../scripts/tui-plugin-store.test.mjs ../scripts/tui-local-plugins.test.mjs ../scripts/tui-client-plugin-registry.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/tui-agents.test.mjs ../scripts/tui-queue.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/acp-agent-pool.test.mjs ../scripts/harnesses.test.mjs ../scripts/harness-discovery.test.mjs ../scripts/harness-discovery-scenario.test.mjs ../scripts/harness-view.test.mjs ../scripts/harness-onboarding.test.mjs ../scripts/harness-registry.test.mjs ../scripts/harness-package.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/agents-view.test.mjs ../scripts/plan-view.test.mjs ../scripts/queue-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs",
|
|
20
20
|
"test:profile-install-matrix": "node --test ../scripts/profile-install-matrix.test.mjs"
|
|
21
21
|
},
|
|
22
22
|
"publishConfig": {
|
|
Binary file
|
package/vendor/darwin-x64/martty
CHANGED
|
Binary file
|
|
Binary file
|
package/vendor/linux-x64/martty
CHANGED
|
Binary file
|
|
Binary file
|