martty 0.3.1 → 0.3.2-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/README.md +47 -12
- package/bin/martty.js +5 -0
- package/lib/acp-agent-pool.js +101 -4
- package/lib/acp-client.js +63 -4
- package/lib/acp-session-status.js +45 -3
- package/lib/agent.js +5 -10
- package/lib/creator-overlay.js +66 -9
- package/lib/harness-registry.js +2 -2
- package/lib/harnesses.js +11 -2
- package/lib/index.js +4 -1
- package/lib/install-migration.js +456 -0
- package/lib/queue-view.js +7 -1
- package/lib/status-view.js +15 -1
- package/lib/tui-plugin-store.js +34 -3
- package/lib/tui-queue.js +4 -0
- package/package.json +13 -12
- package/skills/tui-plugin-development/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -15,9 +15,10 @@ x64. Requires Node.js 22.19+.
|
|
|
15
15
|
|
|
16
16
|
> **Rename note:** the project and recommended npm package have moved from
|
|
17
17
|
> DeepSeek Harness TUI / `martty` to **Martty** / `martty`.
|
|
18
|
-
>
|
|
19
|
-
>
|
|
20
|
-
>
|
|
18
|
+
> Configuration and session data remain compatible. The production profile is
|
|
19
|
+
> now `martty`; legacy `tui` profiles continue to work. The `dsh-tui` command
|
|
20
|
+
> is no longer installed by the `martty` package — see
|
|
21
|
+
> [Migrating from DeepSeek Harness TUI / the scoped package](#migrating-from-deepseek-harness-tui--the-scoped-package).
|
|
21
22
|
|
|
22
23
|
## Global Agent TUI
|
|
23
24
|
|
|
@@ -49,8 +50,35 @@ missing profile and installs the TUI plus its ACP dependency; no global
|
|
|
49
50
|
|
|
50
51
|
`martty` is the recommended package name. Starting with `0.2.13`, it and the
|
|
51
52
|
legacy `martty` package are published by the same CI run
|
|
52
|
-
with identical versions and artifacts.
|
|
53
|
-
|
|
53
|
+
with identical versions and artifacts. The scoped name is deprecated; switch
|
|
54
|
+
package names with:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm install --global martty
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
No removal is needed first, whatever state the legacy package is in —
|
|
61
|
+
published versions of `martty` register `dsh-tui`
|
|
62
|
+
(every version) and `martty` (0.2.14/0.2.20) global commands, and `npm link`ed
|
|
63
|
+
legacy checkouts own the same names (a checkout of this repository links as
|
|
64
|
+
`martty`, so it replaces — or is replaced by — an installed `martty` with no
|
|
65
|
+
collision). The `martty` package declares only `martty-cli` so npm never
|
|
66
|
+
refuses the install over a bin another package owns (`EEXIST`); a
|
|
67
|
+
postinstall step then retires the stale `martty`/`dsh-tui`/`dsb` links that
|
|
68
|
+
verifiably belonged to the legacy package (a third-party `dsh-tui` stays
|
|
69
|
+
untouched), creates the `martty` command itself, and detaches the legacy
|
|
70
|
+
package's bin declarations so a later `npm rm -g` can't take `martty` down
|
|
71
|
+
with it. Removing `martty` afterwards is optional
|
|
72
|
+
cleanup; `martty` warns at launch while it remains — on an interactive
|
|
73
|
+
terminal the notice prints when the session exits, so the TUI cannot hide
|
|
74
|
+
it. With `--ignore-scripts`
|
|
75
|
+
the postinstall never runs — invoke `martty-cli` once (or `npx martty`) and
|
|
76
|
+
the first launch finishes the migration itself. And after `npm rm -g
|
|
77
|
+
martty`, the `martty` command left behind is a self-cleaning shim: invoking
|
|
78
|
+
it once prints the reinstall hint and removes it — nothing dead is left
|
|
79
|
+
in `<prefix>/bin`.
|
|
80
|
+
|
|
81
|
+
Inside a `dsh` profile the same switch is:
|
|
54
82
|
|
|
55
83
|
```sh
|
|
56
84
|
dsh plugin --profile tui remove martty
|
|
@@ -58,8 +86,13 @@ dsh plugin --profile martty add martty@latest
|
|
|
58
86
|
```
|
|
59
87
|
|
|
60
88
|
The recommended profile changes from `tui` to `martty`; runtime behavior,
|
|
61
|
-
config, and session data remain unchanged. `
|
|
62
|
-
|
|
89
|
+
config, and session data remain unchanged. Legacy `tui` profiles remain
|
|
90
|
+
compatible.
|
|
91
|
+
|
|
92
|
+
The `martty` package no longer installs a `dsh-tui` command: the name
|
|
93
|
+
collided with both the legacy package and an unrelated `dsh-tui` package on
|
|
94
|
+
npm, which blocked `npm install --global martty` outright. If your fingers
|
|
95
|
+
still type `dsh-tui`, alias it: `alias dsh-tui=martty`.
|
|
63
96
|
|
|
64
97
|
The profile Host mounts the ACP plugin on Base, then starts a separate TUI
|
|
65
98
|
Client process over standard ACP stdin/stdout. For standalone use, run
|
|
@@ -198,10 +231,11 @@ Creator-authored UI Presets and Theme Plugins preview as process-local dynamic
|
|
|
198
231
|
Packages, then persist explicitly under `$MARTTY_HOME/plugins` through
|
|
199
232
|
`tui_plugin_save`; `tui_plugin_read` resumes authoring after restart.
|
|
200
233
|
|
|
201
|
-
`MARTTY_HOME` resolves explicitly first, then to
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
234
|
+
`MARTTY_HOME` (or `OMA_HOME`) resolves explicitly first, then to
|
|
235
|
+
`$DSH_HOME/.oma`, then to `~/.oma` — the configuration home Martty shares
|
|
236
|
+
with Backchat. UI choices live in `$MARTTY_HOME/settings.json`. On first use,
|
|
237
|
+
a legacy `.martty` directory and old Creator artifacts/settings are copied
|
|
238
|
+
forward without deleting or overwriting the old files.
|
|
205
239
|
|
|
206
240
|
Third-party TUI plugins remain ordinary installed packages. Their Host-side
|
|
207
241
|
registrar contributes an absolute Client module entry to `tuiClientPlugins`;
|
|
@@ -228,7 +262,8 @@ npx --yes martty --demo
|
|
|
228
262
|
|
|
229
263
|
The profile path remains recommended for an existing DSH installation that
|
|
230
264
|
wants standard plugin management and a durable `martty` profile. `martty` is the
|
|
231
|
-
primary command; `dsh-tui`
|
|
265
|
+
primary command; the legacy `dsh-tui` command is no longer installed
|
|
266
|
+
(`alias dsh-tui=martty` restores it).
|
|
232
267
|
|
|
233
268
|
## Highlights
|
|
234
269
|
|
package/bin/martty.js
CHANGED
|
@@ -9,10 +9,15 @@ import { resolveDependencyStack } from '../lib/agent.js'
|
|
|
9
9
|
import { bootClient, parseClientArgv, painterArgs, uiSettingsPath } from '../lib/boot.js'
|
|
10
10
|
import { runHarnessCommandAsync } from '../lib/harnesses.js'
|
|
11
11
|
import { nativeBinary } from '../lib/spawn-tui.js'
|
|
12
|
+
import { reportLaunchNotices, warnAboutLegacyPackage } from '../lib/install-migration.js'
|
|
12
13
|
|
|
13
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
14
15
|
const configuredBin = process.env.MARTTY_BIN || process.env.DSH_TUI_BIN
|
|
15
16
|
|
|
17
|
+
const migrationNotices = []
|
|
18
|
+
warnAboutLegacyPackage({ argv1: process.argv[1], write: (line) => migrationNotices.push(line) })
|
|
19
|
+
reportLaunchNotices(migrationNotices)
|
|
20
|
+
|
|
16
21
|
const argv = process.argv.slice(2)
|
|
17
22
|
const wantDemoSkin = argv.includes('--demo-skin')
|
|
18
23
|
const wantDemo = argv.includes('--demo')
|
package/lib/acp-agent-pool.js
CHANGED
|
@@ -10,6 +10,21 @@ const identity = spec => JSON.stringify([spec.command, spec.args ?? [],
|
|
|
10
10
|
const namespace = spec => createHash('sha256').update(identity(spec)).digest('hex').slice(0, 24)
|
|
11
11
|
const setupMethods = new Set(['session/new', 'session/load', 'session/resume'])
|
|
12
12
|
|
|
13
|
+
/** `configOptions` array from a setup/set result or a `config_option_update`. */
|
|
14
|
+
function readConfigOptions(value) {
|
|
15
|
+
const found = value?.configOptions ?? value?.config_options
|
|
16
|
+
return Array.isArray(found) ? found : undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `[configId, value]` pair for one option entry, or undefined when incomplete. */
|
|
20
|
+
function optionEntry(option) {
|
|
21
|
+
if (option === null || typeof option !== 'object' || Array.isArray(option)) return undefined
|
|
22
|
+
const id = option.id ?? option.configId ?? option.config_id
|
|
23
|
+
const value = option.currentValue ?? option.current_value
|
|
24
|
+
if (typeof id !== 'string' || id.length === 0 || value === undefined) return undefined
|
|
25
|
+
return [id, structuredClone(value)]
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticError, timeoutMs = 20 * 60_000 }) {
|
|
14
29
|
const input = new PassThrough()
|
|
15
30
|
const output = new PassThrough()
|
|
@@ -30,10 +45,15 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
|
|
|
30
45
|
const writeAgent = (owner, value) => owner.handle.stdin.write(JSON.stringify(value) + '\n')
|
|
31
46
|
|
|
32
47
|
function metadata(owner) {
|
|
48
|
+
// Send Now reads this snapshot per session (`steer_route`). Steering and
|
|
49
|
+
// MiniMax extensions are advertised on initialize `_meta`, which
|
|
50
|
+
// `agentCapabilities` does not carry.
|
|
51
|
+
const negotiated = owner.init?._meta
|
|
33
52
|
return {
|
|
34
53
|
id: owner.id,
|
|
35
54
|
agentInfo: owner.init?.agentInfo,
|
|
36
55
|
agentCapabilities: owner.init?.agentCapabilities ?? {},
|
|
56
|
+
...(negotiated && typeof negotiated === 'object' ? { _meta: negotiated } : {}),
|
|
37
57
|
authMethods: (owner.init?.authMethods ?? []).map(method => {
|
|
38
58
|
const id = owner.id === 'h1' ? method.id : `${owner.id}:${method.id}`
|
|
39
59
|
authMethods.set(id, { owner, id: method.id })
|
|
@@ -80,9 +100,17 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
|
|
|
80
100
|
}
|
|
81
101
|
if (setupMethods.has(pending.method) && result && typeof result === 'object') {
|
|
82
102
|
const raw = result.sessionId ?? pending.params?.sessionId
|
|
83
|
-
|
|
103
|
+
const bound = sessionId(owner, raw)
|
|
104
|
+
recordOptions(owner, bound, readConfigOptions(result))
|
|
105
|
+
result = { ...result, sessionId: bound,
|
|
84
106
|
_meta: { ...result._meta, marttyConnection: { ...metadata(owner), cwd: pending.params?.cwd } } }
|
|
85
107
|
}
|
|
108
|
+
if (pending.method === 'session/set_config_option' && result && typeof result === 'object') {
|
|
109
|
+
recordOptions(owner, pending.sessionKey, readConfigOptions(result)
|
|
110
|
+
?? (pending.params?.configId !== undefined
|
|
111
|
+
? [{ id: pending.params.configId, currentValue: pending.params.value }]
|
|
112
|
+
: undefined))
|
|
113
|
+
}
|
|
86
114
|
if (pending.method === 'session/list' && Array.isArray(result?.sessions)) {
|
|
87
115
|
result = { ...result, sessions: result.sessions.map(session => ({ ...session,
|
|
88
116
|
sessionId: sessionId(owner, session.sessionId) })) }
|
|
@@ -95,17 +123,67 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
|
|
|
95
123
|
owner.failed = diagnosticError(error, owner.handle)
|
|
96
124
|
for (const pending of owner.pending.values()) {
|
|
97
125
|
if (pending.reject) pending.reject(owner.failed)
|
|
98
|
-
|
|
126
|
+
// `agentExited` marks the reply as pool-synthesized so the client can
|
|
127
|
+
// tell a dead child apart from a refusal the agent itself authored.
|
|
128
|
+
else write(response(owner, { error: { code: -32603, message: owner.failed.message, data: { agentExited: true } } }, pending))
|
|
99
129
|
}
|
|
100
130
|
owner.pending.clear()
|
|
101
131
|
}
|
|
102
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Track each session's config options and what the connection last applied.
|
|
135
|
+
* `session/set_config_option` is session-scoped in ACP, but agents like
|
|
136
|
+
* pi-acp apply it to global state — a newer session's defaults then leak
|
|
137
|
+
* into older sessions' turns. Remembering every session's own options lets
|
|
138
|
+
* `session/prompt` re-assert them first, so each turn runs the model and
|
|
139
|
+
* effort its session chose. New sessions never assert: they just record
|
|
140
|
+
* the defaults their setup snapshot reported.
|
|
141
|
+
* @param {*} owner
|
|
142
|
+
* @param {string} sessionKey - namespaced session id
|
|
143
|
+
* @param {Array|undefined} options - `configOptions` entries
|
|
144
|
+
*/
|
|
145
|
+
function recordOptions(owner, sessionKey, options) {
|
|
146
|
+
if (typeof sessionKey !== 'string' || !options) return
|
|
147
|
+
const entries = options.map(optionEntry).filter(Boolean)
|
|
148
|
+
if (entries.length === 0) return
|
|
149
|
+
const config = owner.configs.get(sessionKey) ?? new Map()
|
|
150
|
+
for (const [id, value] of entries) {
|
|
151
|
+
config.set(id, value)
|
|
152
|
+
owner.applied.set(id, value)
|
|
153
|
+
}
|
|
154
|
+
owner.configs.set(sessionKey, config)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Re-apply a session's recorded config options the connection no longer
|
|
159
|
+
* holds — one `set_config_option` per drifted option, in recorded order.
|
|
160
|
+
* Best effort: a rejected assert must not block the turn it guarded.
|
|
161
|
+
*/
|
|
162
|
+
async function applySessionConfig(owner, sessionKey) {
|
|
163
|
+
const config = owner.configs.get(sessionKey)
|
|
164
|
+
const bound = sessions.get(sessionKey)
|
|
165
|
+
if (!config || !bound || bound.owner !== owner || owner.failed || closed) return
|
|
166
|
+
for (const [configId, value] of config) {
|
|
167
|
+
if (JSON.stringify(owner.applied.get(configId)) === JSON.stringify(value)) continue
|
|
168
|
+
const id = `martty-config-${++nextRequest}`
|
|
169
|
+
const params = { sessionId: bound.raw, configId, value }
|
|
170
|
+
const result = await new Promise((resolve, reject) => {
|
|
171
|
+
owner.pending.set(id, { resolve, reject, method: 'session/set_config_option', params, sessionKey })
|
|
172
|
+
writeAgent(owner, { jsonrpc: '2.0', id, method: 'session/set_config_option', params })
|
|
173
|
+
}).catch(() => undefined)
|
|
174
|
+
if (result === undefined) break // assert failed — keep remaining options eligible
|
|
175
|
+
recordOptions(owner, sessionKey, readConfigOptions(result)
|
|
176
|
+
?? [{ id: configId, currentValue: value }])
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
103
180
|
function connection(spec) {
|
|
104
181
|
const key = identity(spec)
|
|
105
182
|
const existing = connections.get(key)
|
|
106
183
|
if (existing && !existing.failed) return existing
|
|
107
184
|
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
|
|
185
|
+
sessions: new Map(), pending: new Map(), init: undefined, initializing: undefined, failed: undefined,
|
|
186
|
+
configs: new Map(), applied: new Map(), configChain: Promise.resolve() }
|
|
109
187
|
connections.set(key, owner)
|
|
110
188
|
owner.handle.child.on('error', error => fail(owner, error))
|
|
111
189
|
owner.handle.child.once('close', (code, signal) => fail(owner,
|
|
@@ -124,6 +202,10 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
|
|
|
124
202
|
return
|
|
125
203
|
}
|
|
126
204
|
const forwarded = { ...message, params: incomingParams(owner, message.params) }
|
|
205
|
+
if (message.method === 'session/update'
|
|
206
|
+
&& forwarded.params?.update?.sessionUpdate === 'config_option_update') {
|
|
207
|
+
recordOptions(owner, forwarded.params.sessionId, readConfigOptions(forwarded.params.update))
|
|
208
|
+
}
|
|
127
209
|
if (message.id !== undefined) {
|
|
128
210
|
const id = `martty-agent-${++nextRequest}`
|
|
129
211
|
callbacks.set(id, { owner, id: message.id })
|
|
@@ -214,6 +296,8 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
|
|
|
214
296
|
if (message.method.startsWith('_dsh/cordis/') && readCordisCapability(owner.init) === null) {
|
|
215
297
|
throw new Error('agent has not advertised _dsh/cordis')
|
|
216
298
|
}
|
|
299
|
+
const sessionKey = typeof params?.sessionId === 'string' ? params.sessionId
|
|
300
|
+
: typeof params?.session_id === 'string' ? params.session_id : undefined
|
|
217
301
|
if (params && typeof params === 'object') {
|
|
218
302
|
params = { ...params }
|
|
219
303
|
for (const key of ['sessionId', 'session_id']) {
|
|
@@ -224,9 +308,22 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
|
|
|
224
308
|
const forwarded = { ...message, params }
|
|
225
309
|
if (message.id !== undefined) {
|
|
226
310
|
const id = `martty-client-${++nextRequest}`
|
|
227
|
-
owner.pending.set(id, { id: message.id, method: message.method, params })
|
|
311
|
+
owner.pending.set(id, { id: message.id, method: message.method, params, sessionKey })
|
|
228
312
|
forwarded.id = id
|
|
229
313
|
}
|
|
314
|
+
if (message.method === 'session/prompt' && sessionKey !== undefined) {
|
|
315
|
+
// The prompt lands on stdin only after its session's config options
|
|
316
|
+
// are re-asserted — the chain keeps cross-session order. Registering
|
|
317
|
+
// the pending reply above (never inside the chain) keeps close() able
|
|
318
|
+
// to settle it while an assert is still in flight.
|
|
319
|
+
owner.configChain = owner.configChain.then(async () => {
|
|
320
|
+
// Asserts resolve `undefined` on a failed set_config_option rather
|
|
321
|
+
// than rejecting, so this chain cannot drop the prompt.
|
|
322
|
+
await applySessionConfig(owner, sessionKey)
|
|
323
|
+
if (!closed && !owner.failed) writeAgent(owner, forwarded)
|
|
324
|
+
})
|
|
325
|
+
return
|
|
326
|
+
}
|
|
230
327
|
writeAgent(owner, forwarded)
|
|
231
328
|
} catch (error) {
|
|
232
329
|
if (message.id !== undefined) {
|
package/lib/acp-client.js
CHANGED
|
@@ -105,7 +105,12 @@ export function apply(ctx, config = {}) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
liveAgent = null
|
|
108
|
-
const
|
|
108
|
+
const noteAgentDiagnostics = installAgentWarnings(ctx)
|
|
109
|
+
const service = createAgentPool(agent, {
|
|
110
|
+
spawnAgent: (spec) => spawnAgent(spec, noteAgentDiagnostics),
|
|
111
|
+
resolveAgent,
|
|
112
|
+
diagnosticError,
|
|
113
|
+
})
|
|
109
114
|
if (liveAgentExitHook !== null) process.removeListener('exit', liveAgentExitHook)
|
|
110
115
|
liveAgentExitHook = () => service.close()
|
|
111
116
|
process.once('exit', liveAgentExitHook)
|
|
@@ -113,14 +118,14 @@ export function apply(ctx, config = {}) {
|
|
|
113
118
|
provide(ctx, service)
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
function spawnAgent(agent) {
|
|
121
|
+
function spawnAgent(agent, onDiagnosticLine) {
|
|
117
122
|
const child = spawn(agent.command, agent.args ?? [], {
|
|
118
123
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
119
124
|
env: harnessEnvironment(agent.env),
|
|
120
125
|
})
|
|
121
126
|
child.stdin.on('error', () => {})
|
|
122
127
|
child.stdout.on('error', () => {})
|
|
123
|
-
const diagnostics = drainDiagnostics(child.stderr)
|
|
128
|
+
const diagnostics = drainDiagnostics(child.stderr, onDiagnosticLine)
|
|
124
129
|
return {
|
|
125
130
|
kind: 'spawn',
|
|
126
131
|
command: agent.command,
|
|
@@ -133,14 +138,67 @@ function spawnAgent(agent) {
|
|
|
133
138
|
}
|
|
134
139
|
}
|
|
135
140
|
|
|
136
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Agent stderr lines worth surfacing in the input dock: warnings and
|
|
143
|
+
* failures the agent reports but survives (e.g. a bundle entry that did
|
|
144
|
+
* not activate) would otherwise be invisible until something exits.
|
|
145
|
+
*/
|
|
146
|
+
const DIAGNOSTIC_NOTICE = /warn|error|fail|did not activate|refus|incompat/i
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Feed warning-shaped agent stderr lines to a `conversation.input.dock`
|
|
150
|
+
* notice when the client tree carries `tuiSlots`. Returns undefined when the
|
|
151
|
+
* slot service is absent — stderr stays diagnostics-only as before.
|
|
152
|
+
*/
|
|
153
|
+
function installAgentWarnings(ctx) {
|
|
154
|
+
const slots = ctx.get?.('tuiSlots') ?? ctx.tuiSlots
|
|
155
|
+
if (typeof slots?.register !== 'function') return undefined
|
|
156
|
+
let controller
|
|
157
|
+
try {
|
|
158
|
+
controller = slots.register(
|
|
159
|
+
{ name: 'conversation.input.dock', id: 'acp-agent-warnings' },
|
|
160
|
+
[],
|
|
161
|
+
)
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
const shown = []
|
|
166
|
+
return (line) => {
|
|
167
|
+
if (!DIAGNOSTIC_NOTICE.test(line)) return
|
|
168
|
+
shown.push(line.trimEnd())
|
|
169
|
+
if (shown.length > 4) shown.shift()
|
|
170
|
+
try {
|
|
171
|
+
controller.update([{ id: 'latest', kind: 'notice', level: 'warn', text: shown.join('\n') }])
|
|
172
|
+
} catch {
|
|
173
|
+
// The contribution is gone (disposed or re-mounted); stderr keeps
|
|
174
|
+
// flowing to the diagnostics tail regardless.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function drainDiagnostics(stream, onLine) {
|
|
137
180
|
const limit = 8192
|
|
138
181
|
let tail = Buffer.alloc(0)
|
|
182
|
+
let pending = ''
|
|
139
183
|
let state = 'text'
|
|
184
|
+
const emitLines = (text, flush = false) => {
|
|
185
|
+
if (onLine === undefined) return
|
|
186
|
+
pending += text
|
|
187
|
+
for (let index = pending.indexOf('\n'); index !== -1; index = pending.indexOf('\n')) {
|
|
188
|
+
const line = pending.slice(0, index)
|
|
189
|
+
pending = pending.slice(index + 1)
|
|
190
|
+
if (line.trim() !== '') onLine(line)
|
|
191
|
+
}
|
|
192
|
+
if (flush && pending.trim() !== '') {
|
|
193
|
+
onLine(pending)
|
|
194
|
+
pending = ''
|
|
195
|
+
}
|
|
196
|
+
}
|
|
140
197
|
// stderr is not a UI surface. Drain it even without a diagnostics consumer,
|
|
141
198
|
// strip control strings incrementally, and never retain their payloads.
|
|
142
199
|
stream.setEncoding('utf8')
|
|
143
200
|
stream.on('error', () => {})
|
|
201
|
+
stream.on('close', () => emitLines('', true))
|
|
144
202
|
stream.on('data', (chunk) => {
|
|
145
203
|
let text = ''
|
|
146
204
|
for (const char of chunk) {
|
|
@@ -174,6 +232,7 @@ function drainDiagnostics(stream) {
|
|
|
174
232
|
text += char
|
|
175
233
|
}
|
|
176
234
|
if (text.length === 0) return
|
|
235
|
+
emitLines(text)
|
|
177
236
|
const combined = Buffer.concat([tail, Buffer.from(text)])
|
|
178
237
|
let start = Math.max(0, combined.length - limit)
|
|
179
238
|
// Never split a UTF-8 code point or retain the large incoming allocation.
|
|
@@ -68,6 +68,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
68
68
|
let selectionKnown = false
|
|
69
69
|
let connection = 'connecting'
|
|
70
70
|
let server
|
|
71
|
+
let dshRuntime
|
|
71
72
|
const auth = { status: undefined, method: undefined }
|
|
72
73
|
let initializeId
|
|
73
74
|
let authMethods = []
|
|
@@ -94,7 +95,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
94
95
|
const value = stateFor(sessionId)
|
|
95
96
|
const { permissionPreset: _permissionPreset, sandboxMode: _sandboxMode,
|
|
96
97
|
connectionFacts, ...visible } = value
|
|
97
|
-
return structuredClone({ connection, server, auth, ...connectionFacts, ...visible })
|
|
98
|
+
return structuredClone({ connection, server, dshRuntime, auth, ...connectionFacts, ...visible })
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
function publish() {
|
|
@@ -146,6 +147,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
146
147
|
selectionKnown = false
|
|
147
148
|
connection = 'connecting'
|
|
148
149
|
server = undefined
|
|
150
|
+
dshRuntime = undefined
|
|
149
151
|
auth.status = undefined
|
|
150
152
|
auth.method = undefined
|
|
151
153
|
delete auth.message
|
|
@@ -197,13 +199,44 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
197
199
|
if (!object(message)) return
|
|
198
200
|
if (message.id !== undefined && message.id === initializeId) {
|
|
199
201
|
initializeId = undefined
|
|
200
|
-
connection = message.error === undefined ? 'attached' : 'failed'
|
|
201
202
|
fallback.error = message.error?.message
|
|
202
203
|
const result = object(message.result) ? message.result : undefined
|
|
203
204
|
server = readString(result?.agentInfo, 'name')
|
|
205
|
+
// The runtime the ACP agent actually booted (dsh-acp advertises it in
|
|
206
|
+
// agentCapabilities._meta.dsh.runtime); shown in /status so users can
|
|
207
|
+
// tell bundled from host installs (issue #157).
|
|
208
|
+
dshRuntime = readDshRuntime(result?.agentCapabilities)
|
|
204
209
|
const methods = Array.isArray(result?.authMethods) ? result.authMethods : []
|
|
205
210
|
authMethods = methods
|
|
206
211
|
// initialize advertises choices, not the credential used by this process.
|
|
212
|
+
if (message.error === undefined) {
|
|
213
|
+
connection = 'attached'
|
|
214
|
+
} else {
|
|
215
|
+
// An agent that answered `initialize` with a refusal is still
|
|
216
|
+
// attached — only a locally synthesized failure (the pool's
|
|
217
|
+
// agentExited marker for a dead child) counts as 'failed'. A refused
|
|
218
|
+
// handshake may advertise auth methods in its error data (the -32000
|
|
219
|
+
// convention): surface sign-in the same way the refused
|
|
220
|
+
// session/new path does. A refusal demanding auth while declaring
|
|
221
|
+
// no methods can never be remedied, so it stays 'failed'.
|
|
222
|
+
const offered = Array.isArray(message.error.data?.authMethods)
|
|
223
|
+
? message.error.data.authMethods : []
|
|
224
|
+
if (message.error.data?.agentExited === true
|
|
225
|
+
|| (isAuthRequired(message.error) && offered.length === 0)) {
|
|
226
|
+
connection = 'failed'
|
|
227
|
+
} else {
|
|
228
|
+
connection = 'refused'
|
|
229
|
+
if (isAuthRequired(message.error)) {
|
|
230
|
+
authMethods = offered
|
|
231
|
+
auth.status = 'needs sign-in'
|
|
232
|
+
}
|
|
233
|
+
// The refusal's own words are the diagnosis — our transport's
|
|
234
|
+
// appended "Agent stderr:" tail is unrelated launch noise.
|
|
235
|
+
if (typeof fallback.error === 'string') {
|
|
236
|
+
fallback.error = fallback.error.split('\nAgent stderr:')[0].trimEnd()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
207
240
|
publish()
|
|
208
241
|
return
|
|
209
242
|
}
|
|
@@ -230,7 +263,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
|
|
|
230
263
|
if (message.error !== undefined) {
|
|
231
264
|
if (isAuthRequired(message.error)) auth.status = 'needs sign-in'
|
|
232
265
|
else {
|
|
233
|
-
connection = 'failed'
|
|
266
|
+
connection = message.error.data?.agentExited === true ? 'failed' : 'refused'
|
|
234
267
|
const value = stateFor(setup.sessionId, false)
|
|
235
268
|
value.error = readString(message.error, 'message') ?? 'Session setup failed'
|
|
236
269
|
value.state = 'idle'
|
|
@@ -416,6 +449,15 @@ function isAuthRequired(error) {
|
|
|
416
449
|
|| error.code === 'auth_required' || error.code === 'AuthRequired')
|
|
417
450
|
}
|
|
418
451
|
|
|
452
|
+
function readDshRuntime(capabilities) {
|
|
453
|
+
const runtime = capabilities?._meta?.dsh?.runtime
|
|
454
|
+
if (!object(runtime)) return undefined
|
|
455
|
+
const version = readString(runtime, 'version')
|
|
456
|
+
const source = readString(runtime, 'source')
|
|
457
|
+
if (version === undefined && source === undefined) return undefined
|
|
458
|
+
return { version, source, base: readString(runtime, 'base') }
|
|
459
|
+
}
|
|
460
|
+
|
|
419
461
|
function readString(value, ...keys) {
|
|
420
462
|
if (!object(value)) return undefined
|
|
421
463
|
for (const key of keys) {
|
package/lib/agent.js
CHANGED
|
@@ -75,16 +75,11 @@ export function resolveStackedAgent(anchor = import.meta.url, options = {}) {
|
|
|
75
75
|
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
76
76
|
for (const profile of ['tui-test', 'martty', 'tui']) {
|
|
77
77
|
const bin = join(home, 'profiles', profile, 'node_modules', '.bin', 'dsh-acp')
|
|
78
|
-
const creator =
|
|
79
|
-
home,
|
|
80
|
-
'profiles',
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
'@openma',
|
|
84
|
-
'deepseek-harness-tui',
|
|
85
|
-
'creator',
|
|
86
|
-
)
|
|
87
|
-
if (existsSync(bin) && existsSync(join(creator, 'package.json'))) {
|
|
78
|
+
const creator = [
|
|
79
|
+
join(home, 'profiles', profile, 'node_modules', '@openma', 'deepseek-harness-tui', 'creator'),
|
|
80
|
+
join(home, 'profiles', profile, 'node_modules', 'martty', 'creator'),
|
|
81
|
+
].find((dir) => existsSync(join(dir, 'package.json')))
|
|
82
|
+
if (existsSync(bin) && creator) {
|
|
88
83
|
return { command: bin, args: ['--bundle', creator] }
|
|
89
84
|
}
|
|
90
85
|
}
|
package/lib/creator-overlay.js
CHANGED
|
@@ -25,6 +25,38 @@ const jsonOutput = {
|
|
|
25
25
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Whether an acquireScope rejection says the preset is not registered yet.
|
|
30
|
+
* Preset declarations register asynchronously while the Host activates, so
|
|
31
|
+
* the overlay can run while the registry is still empty — this is the one
|
|
32
|
+
* failure worth retrying. Everything else (broken compositions, transport
|
|
33
|
+
* failures) fails fast.
|
|
34
|
+
*/
|
|
35
|
+
function presetNotFound(error) {
|
|
36
|
+
return error?.code === 'agent-preset/not-found'
|
|
37
|
+
|| /^Unknown agent preset: /.test(error?.message ?? '')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Acquire one preset scope lease, retrying `agent-preset/not-found` until
|
|
42
|
+
* `waitMs` elapses — the registry may still be collecting declarations when
|
|
43
|
+
* this plugin mounts. A registry without `acquireScope` resolves undefined
|
|
44
|
+
* so the caller can reject it with the usual lease check.
|
|
45
|
+
*/
|
|
46
|
+
async function acquirePresetScope(agentPresets, preset, waitMs, pollMs) {
|
|
47
|
+
const acquire = agentPresets?.acquireScope
|
|
48
|
+
if (typeof acquire !== 'function') return undefined
|
|
49
|
+
const deadline = Date.now() + waitMs
|
|
50
|
+
for (;;) {
|
|
51
|
+
try {
|
|
52
|
+
return await acquire.call(agentPresets, preset)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!presetNotFound(error) || Date.now() >= deadline) throw error
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
function requireAgent(exec) {
|
|
29
61
|
if (exec?.agent === undefined) {
|
|
30
62
|
throw new Error('TUI artifact tools require an Agent-backed Creator session')
|
|
@@ -190,7 +222,7 @@ const skillContent = skillDocument.slice(skillFrontmatter[0].length)
|
|
|
190
222
|
|
|
191
223
|
/**
|
|
192
224
|
* @param {object} ctx
|
|
193
|
-
* @param {{ preset?: string }} [config]
|
|
225
|
+
* @param {{ preset?: string, scopeWaitMs?: number, scopePollMs?: number }} [config]
|
|
194
226
|
*/
|
|
195
227
|
export async function apply(ctx, config = {}) {
|
|
196
228
|
// Standalone launches mount this overlay directly into the ACP Host tree,
|
|
@@ -202,13 +234,38 @@ export async function apply(ctx, config = {}) {
|
|
|
202
234
|
if (typeof preset !== 'string' || preset.length === 0) {
|
|
203
235
|
throw new Error('tui-creator-overlay: preset must be a non-empty string')
|
|
204
236
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
237
|
+
// The preset registry hands out scope keys through acquireScope (a lease
|
|
238
|
+
// carrying generation.key); standingKeyFor never existed on the shipped
|
|
239
|
+
// registry. Holding the lease for the overlay's lifetime keeps the scope
|
|
240
|
+
// generation alive until the overlay is disposed. Declarations land after
|
|
241
|
+
// dependent rows activate, so a `not-found` result is retried briefly.
|
|
242
|
+
const scopeWaitMs = Number.isFinite(config.scopeWaitMs) ? Math.max(0, config.scopeWaitMs) : 10_000
|
|
243
|
+
const scopePollMs = Number.isFinite(config.scopePollMs) ? Math.max(1, config.scopePollMs) : 50
|
|
244
|
+
const lease = await acquirePresetScope(ctx.agentPresets, preset, scopeWaitMs, scopePollMs)
|
|
245
|
+
if (lease === null || typeof lease !== 'object' || !('key' in lease)) {
|
|
246
|
+
throw new Error(`tui-creator-overlay: preset scope is unavailable for ${JSON.stringify(preset)}`)
|
|
247
|
+
}
|
|
248
|
+
const release = typeof lease[Symbol.asyncDispose] === 'function'
|
|
249
|
+
? () => lease[Symbol.asyncDispose]()
|
|
250
|
+
: async () => {}
|
|
251
|
+
let overlay
|
|
252
|
+
try {
|
|
253
|
+
const scopeModule = await ctx.loader.import('@deepseek-ai/dsh-scope')
|
|
254
|
+
if (typeof scopeModule?.createScope !== 'function') {
|
|
255
|
+
throw new Error('tui-creator-overlay: profile dsh-scope module is unavailable')
|
|
256
|
+
}
|
|
257
|
+
overlay = scopeModule.createScope(ctx, lease.key)
|
|
258
|
+
} catch (error) {
|
|
259
|
+
await release()
|
|
260
|
+
throw error
|
|
261
|
+
}
|
|
262
|
+
const dispose = async () => {
|
|
263
|
+
try {
|
|
264
|
+
await overlay.dispose()
|
|
265
|
+
} finally {
|
|
266
|
+
await release()
|
|
267
|
+
}
|
|
209
268
|
}
|
|
210
|
-
const { createScope } = scopeModule
|
|
211
|
-
const overlay = createScope(ctx, key)
|
|
212
269
|
try {
|
|
213
270
|
const toolsModule = await ctx.loader.import('@deepseek-ai/dsh-tools')
|
|
214
271
|
if (typeof toolsModule?.defineTool !== 'function') {
|
|
@@ -240,9 +297,9 @@ export async function apply(ctx, config = {}) {
|
|
|
240
297
|
toolsModule.defineTool,
|
|
241
298
|
createTuiPluginStore({ root: config.artifactRoot }),
|
|
242
299
|
)
|
|
243
|
-
ctx.effect(() =>
|
|
300
|
+
ctx.effect(() => dispose, `tui-creator-overlay(${JSON.stringify(preset)})`)
|
|
244
301
|
} catch (error) {
|
|
245
|
-
await
|
|
302
|
+
await dispose()
|
|
246
303
|
throw error
|
|
247
304
|
}
|
|
248
305
|
}
|
package/lib/harness-registry.js
CHANGED
|
@@ -19,13 +19,13 @@ import { downloadFile } from './download.js'
|
|
|
19
19
|
|
|
20
20
|
export const ACP_REGISTRY_URL = 'https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json'
|
|
21
21
|
|
|
22
|
-
const OPENMA_PI_ACP = '@openma/pi-acp@
|
|
22
|
+
export const OPENMA_PI_ACP = '@openma/pi-acp@latest'
|
|
23
23
|
|
|
24
24
|
function preferMarttyPiAcp(records) {
|
|
25
25
|
return records.map((record) => record.id === 'pi-acp' ? {
|
|
26
26
|
...record,
|
|
27
27
|
label: 'Pi (OpenMA)',
|
|
28
|
-
version: '
|
|
28
|
+
version: 'latest',
|
|
29
29
|
distributions: [{
|
|
30
30
|
type: 'npx', command: 'npx', args: [OPENMA_PI_ACP], env: {},
|
|
31
31
|
}],
|
package/lib/harnesses.js
CHANGED
|
@@ -16,12 +16,15 @@ import {
|
|
|
16
16
|
installRegistryBinary,
|
|
17
17
|
managedBinaryPath,
|
|
18
18
|
normalizeAcpRegistry,
|
|
19
|
+
OPENMA_PI_ACP,
|
|
19
20
|
readAcpRegistrySnapshot,
|
|
20
21
|
} from './harness-registry.js'
|
|
21
22
|
|
|
22
23
|
export { fetchAcpRegistry } from './harness-registry.js'
|
|
23
24
|
|
|
24
25
|
const HARNESS_ID = /^[a-z0-9][a-z0-9-]*$/
|
|
26
|
+
const PI_ACP_LATEST = OPENMA_PI_ACP
|
|
27
|
+
const PI_ACP_SPEC = /^(?:@openma\/)?pi-acp(?:@.+)?$/
|
|
25
28
|
const ANSI = Object.freeze({ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', cyan: '\x1b[36m', green: '\x1b[32m' })
|
|
26
29
|
|
|
27
30
|
// Kept as a source-compatible empty export. The source of truth is the
|
|
@@ -361,8 +364,14 @@ function validateHarness(value) {
|
|
|
361
364
|
|| Object.entries(env).some(([key, item]) => key.length === 0 || typeof item !== 'string'))) {
|
|
362
365
|
throw new Error('harness env must be an object of strings')
|
|
363
366
|
}
|
|
364
|
-
const
|
|
365
|
-
|
|
367
|
+
const isNpx = /^npx(?:\.(?:cmd|bat|exe))?$/i.test(path.win32.basename(value.command))
|
|
368
|
+
const normalizedArgs = isNpx
|
|
369
|
+
? [...args]
|
|
370
|
+
.filter((arg) => arg !== '--yes' && arg !== '--prefer-offline')
|
|
371
|
+
// A pinned pi-acp spec (any old `pi-acp@x` or `@openma/pi-acp@x` entry)
|
|
372
|
+
// always resolves `latest` at runtime instead — harness upgrades ship
|
|
373
|
+
// through npm, never through a Martty release.
|
|
374
|
+
.map((arg) => PI_ACP_SPEC.test(arg) ? PI_ACP_LATEST : arg)
|
|
366
375
|
: [...args]
|
|
367
376
|
return {
|
|
368
377
|
id: value.id,
|