martty 0.3.1-beta.0 → 0.3.2-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/README.md CHANGED
@@ -198,10 +198,11 @@ Creator-authored UI Presets and Theme Plugins preview as process-local dynamic
198
198
  Packages, then persist explicitly under `$MARTTY_HOME/plugins` through
199
199
  `tui_plugin_save`; `tui_plugin_read` resumes authoring after restart.
200
200
 
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.
201
+ `MARTTY_HOME` (or `OMA_HOME`) resolves explicitly first, then to
202
+ `$DSH_HOME/.oma`, then to `~/.oma` — the configuration home Martty shares
203
+ with Backchat. UI choices live in `$MARTTY_HOME/settings.json`. On first use,
204
+ a legacy `.martty` directory and old Creator artifacts/settings are copied
205
+ forward without deleting or overwriting the old files.
205
206
 
206
207
  Third-party TUI plugins remain ordinary installed packages. Their Host-side
207
208
  registrar contributes an absolute Client module entry to `tuiClientPlugins`;
@@ -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) })) }
@@ -100,12 +128,60 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
100
128
  owner.pending.clear()
101
129
  }
102
130
 
131
+ /**
132
+ * Track each session's config options and what the connection last applied.
133
+ * `session/set_config_option` is session-scoped in ACP, but agents like
134
+ * pi-acp apply it to global state — a newer session's defaults then leak
135
+ * into older sessions' turns. Remembering every session's own options lets
136
+ * `session/prompt` re-assert them first, so each turn runs the model and
137
+ * effort its session chose. New sessions never assert: they just record
138
+ * the defaults their setup snapshot reported.
139
+ * @param {*} owner
140
+ * @param {string} sessionKey - namespaced session id
141
+ * @param {Array|undefined} options - `configOptions` entries
142
+ */
143
+ function recordOptions(owner, sessionKey, options) {
144
+ if (typeof sessionKey !== 'string' || !options) return
145
+ const entries = options.map(optionEntry).filter(Boolean)
146
+ if (entries.length === 0) return
147
+ const config = owner.configs.get(sessionKey) ?? new Map()
148
+ for (const [id, value] of entries) {
149
+ config.set(id, value)
150
+ owner.applied.set(id, value)
151
+ }
152
+ owner.configs.set(sessionKey, config)
153
+ }
154
+
155
+ /**
156
+ * Re-apply a session's recorded config options the connection no longer
157
+ * holds — one `set_config_option` per drifted option, in recorded order.
158
+ * Best effort: a rejected assert must not block the turn it guarded.
159
+ */
160
+ async function applySessionConfig(owner, sessionKey) {
161
+ const config = owner.configs.get(sessionKey)
162
+ const bound = sessions.get(sessionKey)
163
+ if (!config || !bound || bound.owner !== owner || owner.failed || closed) return
164
+ for (const [configId, value] of config) {
165
+ if (JSON.stringify(owner.applied.get(configId)) === JSON.stringify(value)) continue
166
+ const id = `martty-config-${++nextRequest}`
167
+ const params = { sessionId: bound.raw, configId, value }
168
+ const result = await new Promise((resolve, reject) => {
169
+ owner.pending.set(id, { resolve, reject, method: 'session/set_config_option', params, sessionKey })
170
+ writeAgent(owner, { jsonrpc: '2.0', id, method: 'session/set_config_option', params })
171
+ }).catch(() => undefined)
172
+ if (result === undefined) break // assert failed — keep remaining options eligible
173
+ recordOptions(owner, sessionKey, readConfigOptions(result)
174
+ ?? [{ id: configId, currentValue: value }])
175
+ }
176
+ }
177
+
103
178
  function connection(spec) {
104
179
  const key = identity(spec)
105
180
  const existing = connections.get(key)
106
181
  if (existing && !existing.failed) return existing
107
182
  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 }
183
+ sessions: new Map(), pending: new Map(), init: undefined, initializing: undefined, failed: undefined,
184
+ configs: new Map(), applied: new Map(), configChain: Promise.resolve() }
109
185
  connections.set(key, owner)
110
186
  owner.handle.child.on('error', error => fail(owner, error))
111
187
  owner.handle.child.once('close', (code, signal) => fail(owner,
@@ -124,6 +200,10 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
124
200
  return
125
201
  }
126
202
  const forwarded = { ...message, params: incomingParams(owner, message.params) }
203
+ if (message.method === 'session/update'
204
+ && forwarded.params?.update?.sessionUpdate === 'config_option_update') {
205
+ recordOptions(owner, forwarded.params.sessionId, readConfigOptions(forwarded.params.update))
206
+ }
127
207
  if (message.id !== undefined) {
128
208
  const id = `martty-agent-${++nextRequest}`
129
209
  callbacks.set(id, { owner, id: message.id })
@@ -214,6 +294,8 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
214
294
  if (message.method.startsWith('_dsh/cordis/') && readCordisCapability(owner.init) === null) {
215
295
  throw new Error('agent has not advertised _dsh/cordis')
216
296
  }
297
+ const sessionKey = typeof params?.sessionId === 'string' ? params.sessionId
298
+ : typeof params?.session_id === 'string' ? params.session_id : undefined
217
299
  if (params && typeof params === 'object') {
218
300
  params = { ...params }
219
301
  for (const key of ['sessionId', 'session_id']) {
@@ -224,9 +306,22 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
224
306
  const forwarded = { ...message, params }
225
307
  if (message.id !== undefined) {
226
308
  const id = `martty-client-${++nextRequest}`
227
- owner.pending.set(id, { id: message.id, method: message.method, params })
309
+ owner.pending.set(id, { id: message.id, method: message.method, params, sessionKey })
228
310
  forwarded.id = id
229
311
  }
312
+ if (message.method === 'session/prompt' && sessionKey !== undefined) {
313
+ // The prompt lands on stdin only after its session's config options
314
+ // are re-asserted — the chain keeps cross-session order. Registering
315
+ // the pending reply above (never inside the chain) keeps close() able
316
+ // to settle it while an assert is still in flight.
317
+ owner.configChain = owner.configChain.then(async () => {
318
+ // Asserts resolve `undefined` on a failed set_config_option rather
319
+ // than rejecting, so this chain cannot drop the prompt.
320
+ await applySessionConfig(owner, sessionKey)
321
+ if (!closed && !owner.failed) writeAgent(owner, forwarded)
322
+ })
323
+ return
324
+ }
230
325
  writeAgent(owner, forwarded)
231
326
  } catch (error) {
232
327
  if (message.id !== undefined) {
@@ -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,
@@ -76,14 +76,45 @@ export function tuiPluginRoot(env = process.env) {
76
76
  return path.join(marttyHome(env), 'plugins')
77
77
  }
78
78
 
79
+ /**
80
+ * Martty's configuration home, shared with Backchat: `~/.oma`.
81
+ * Resolution order: explicit `MARTTY_HOME`/`OMA_HOME`, then `$DSH_HOME/.oma`,
82
+ * then `~/.oma`. When the new home is missing but a legacy `.martty`
83
+ * directory sits next to it, its contents are migrated once (config, cache,
84
+ * sessions, managed binaries) so existing installs keep their state.
85
+ */
79
86
  export function marttyHome(env = process.env, userHome = homedir()) {
80
87
  if (typeof env.MARTTY_HOME === 'string' && env.MARTTY_HOME.length > 0) {
81
88
  return env.MARTTY_HOME
82
89
  }
83
- if (typeof env.DSH_HOME === 'string' && env.DSH_HOME.length > 0) {
84
- return path.join(env.DSH_HOME, '.martty')
90
+ if (typeof env.OMA_HOME === 'string' && env.OMA_HOME.length > 0) {
91
+ return env.OMA_HOME
85
92
  }
86
- return path.join(userHome, '.martty')
93
+ const base = typeof env.DSH_HOME === 'string' && env.DSH_HOME.length > 0
94
+ ? env.DSH_HOME
95
+ : userHome
96
+ return migrateMarttyHome(base)
97
+ }
98
+
99
+ function migrateMarttyHome(base) {
100
+ const home = path.join(base, '.oma')
101
+ const legacy = path.join(base, '.martty')
102
+ if (existsSync(home) || !existsSync(legacy)) return home
103
+ // Stage the copy under a temporary sibling so a crash cannot leave a
104
+ // half-migrated `.oma` behind; the legacy directory is left untouched.
105
+ const staged = `${home}.migrating-${process.pid}`
106
+ try {
107
+ rmSync(staged, { recursive: true, force: true })
108
+ cpSync(legacy, staged, { recursive: true, errorOnExist: true })
109
+ renameSync(staged, home)
110
+ process.stderr.write(`martty: migrated ${legacy} to ${home} (shared with Backchat)\n`)
111
+ } catch {
112
+ rmSync(staged, { recursive: true, force: true })
113
+ // Migration is best effort: an unmigrated legacy directory keeps working
114
+ // under its old name rather than blocking startup.
115
+ return legacy
116
+ }
117
+ return home
87
118
  }
88
119
 
89
120
  export function legacyTuiPluginRoot(env = process.env, userHome = homedir()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martty",
3
- "version": "0.3.1-beta.0",
3
+ "version": "0.3.2-beta.0",
4
4
  "description": "Terminal-native ACP client UI; Cordis client tree, any ACP agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,7 +16,7 @@
16
16
  "scripts": {
17
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/acp-agent-pool.test.mjs ../scripts/harnesses.test.mjs ../scripts/harness-discovery.test.mjs ../scripts/harness-discovery-scenario.test.mjs ../scripts/harness-view.test.mjs ../scripts/harness-onboarding.test.mjs ../scripts/harness-registry.test.mjs ../scripts/harness-badge.test.mjs ../scripts/harness-package.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/agents-view.test.mjs ../scripts/plan-view.test.mjs ../scripts/queue-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs",
19
+ "test": "node --test ../scripts/download.test.mjs ../scripts/package-native.test.mjs ../scripts/check-release-tag.test.mjs ../scripts/release-check.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/packaged-acp-permission.test.mjs ../scripts/profile-smoke-tui.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-presets.test.mjs ../scripts/tui-plugin-store.test.mjs ../scripts/tui-local-plugins.test.mjs ../scripts/tui-client-plugin-registry.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/tui-agents.test.mjs ../scripts/tui-queue.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/acp-agent-pool.test.mjs ../scripts/harnesses.test.mjs ../scripts/harness-discovery.test.mjs ../scripts/harness-discovery-scenario.test.mjs ../scripts/harness-view.test.mjs ../scripts/harness-onboarding.test.mjs ../scripts/harness-registry.test.mjs ../scripts/harness-badge.test.mjs ../scripts/harness-package.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/agents-view.test.mjs ../scripts/plan-view.test.mjs ../scripts/queue-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs",
20
20
  "test:profile-install-matrix": "node --test ../scripts/profile-install-matrix.test.mjs"
21
21
  },
22
22
  "publishConfig": {
@@ -76,13 +76,13 @@
76
76
  "dependencies": {
77
77
  "@deepseek-ai/cordis": "^4.0.1",
78
78
  "@lobehub/icons-static-svg": "1.95.0",
79
- "@openma/deepseek-harness-acp": "0.4.35",
79
+ "@openma/deepseek-harness-acp": "0.4.37",
80
80
  "@resvg/resvg-js": "2.6.2",
81
81
  "cross-spawn": "^7.0.6",
82
82
  "node-downloader-helper": "2.1.11"
83
83
  },
84
84
  "devDependencies": {
85
- "@deepseek-ai/dsh": "0.1.7-rc.2"
85
+ "@deepseek-ai/dsh": "0.2.0-rc.2"
86
86
  },
87
87
  "engines": {
88
88
  "node": ">=22.19.0"
@@ -96,10 +96,10 @@
96
96
  "agent-client-protocol"
97
97
  ],
98
98
  "optionalDependencies": {
99
- "@openma/martty-darwin-arm64": "0.3.1-beta.0",
100
- "@openma/martty-darwin-x64": "0.3.1-beta.0",
101
- "@openma/martty-linux-x64": "0.3.1-beta.0",
102
- "@openma/martty-linux-arm64": "0.3.1-beta.0",
103
- "@openma/martty-win32-x64": "0.3.1-beta.0"
99
+ "@openma/martty-darwin-arm64": "0.3.2-beta.0",
100
+ "@openma/martty-darwin-x64": "0.3.2-beta.0",
101
+ "@openma/martty-linux-x64": "0.3.2-beta.0",
102
+ "@openma/martty-linux-arm64": "0.3.2-beta.0",
103
+ "@openma/martty-win32-x64": "0.3.2-beta.0"
104
104
  }
105
105
  }
@@ -56,7 +56,7 @@ depend on a value returned by `apply()` as the disposer; register effects
56
56
  through the inspected Cordis APIs.
57
57
 
58
58
  The authoring root is `$MARTTY_HOME/plugins`; `MARTTY_HOME` resolves from the
59
- explicit environment variable, then `$DSH_HOME/.martty`, then `~/.martty`.
59
+ explicit environment variable or `OMA_HOME`, then `$DSH_HOME/.oma`, then `~/.oma`.
60
60
  User selections live separately in `$MARTTY_HOME/settings.json`.
61
61
 
62
62
  ## Inspect is the authority