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 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
- > The `dsh-tui` command, configuration, and session data remain compatible.
19
- > The production profile is now `martty`; legacy `tui` profiles
20
- > continue to work.
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. Existing scoped-package installs remain
53
- supported. To switch package names:
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. `dsh-tui` and legacy `tui`
62
- profiles remain compatible.
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 `$DSH_HOME/.martty`, then to
202
- `~/.martty`. UI choices live in `$MARTTY_HOME/settings.json`. On first use,
203
- legacy Creator artifacts and settings are copied forward without deleting or
204
- overwriting the old files.
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` remains a compatibility alias.
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')
@@ -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
- result = { ...result, sessionId: sessionId(owner, raw),
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
- else write(response(owner, { error: { code: -32603, message: owner.failed.message } }, pending))
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 service = createAgentPool(agent, { spawnAgent, resolveAgent, diagnosticError })
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
- function drainDiagnostics(stream) {
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 = join(
79
- home,
80
- 'profiles',
81
- profile,
82
- 'node_modules',
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
  }
@@ -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
- const key = await ctx.agentPresets.standingKeyFor(preset)
206
- const scopeModule = await ctx.loader.import('@deepseek-ai/dsh-scope')
207
- if (typeof scopeModule?.createScope !== 'function') {
208
- throw new Error('tui-creator-overlay: profile dsh-scope module is unavailable')
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(() => () => overlay.dispose(), `tui-creator-overlay(${JSON.stringify(preset)})`)
300
+ ctx.effect(() => dispose, `tui-creator-overlay(${JSON.stringify(preset)})`)
244
301
  } catch (error) {
245
- await overlay.dispose()
302
+ await dispose()
246
303
  throw error
247
304
  }
248
305
  }
@@ -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@0.1.3'
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: '0.1.3',
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 normalizedArgs = /^npx(?:\.(?:cmd|bat|exe))?$/i.test(path.win32.basename(value.command))
365
- ? [...args].filter((arg) => arg !== '--yes' && arg !== '--prefer-offline')
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,