martty 0.3.2-beta.3 → 0.3.3-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/bin/martty.js CHANGED
@@ -10,6 +10,7 @@ import { bootClient, parseClientArgv, painterArgs, uiSettingsPath } from '../lib
10
10
  import { runHarnessCommandAsync } from '../lib/harnesses.js'
11
11
  import { nativeBinary } from '../lib/spawn-tui.js'
12
12
  import { reportLaunchNotices, warnAboutLegacyPackage } from '../lib/install-migration.js'
13
+ import { planDshLaunch, runDshProfile, takeDshFlag } from '../lib/dsh-launch.js'
13
14
 
14
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
15
16
  const configuredBin = process.env.MARTTY_BIN || process.env.DSH_TUI_BIN
@@ -90,12 +91,29 @@ if (argv[0] === 'harness') {
90
91
  // Validate startup arguments before resolving the native painter. A source
91
92
  // checkout must report the same usage errors as an installed package.
92
93
  let parsed
94
+ let dshNotice
93
95
  if (!wantHelp && !wantVersion && !wantDemo && !wantDemoSkin) {
94
- try { parsed = parseClientArgv(argv) }
95
- catch (error) {
96
- console.error(`martty: ${error instanceof Error ? error.message : String(error)}`)
96
+ let dshPlan
97
+ try {
98
+ // `--dsh <profile>` attaches to the global dsh. Its version is checked
99
+ // before anything spawns: below a floor, Martty keeps its bundled
100
+ // harness and says why in the TUI; newer than tested only warns.
101
+ const { profile, rest } = takeDshFlag(argv)
102
+ if (profile !== null) dshPlan = planDshLaunch(profile, rest)
103
+ parsed = parseClientArgv(rest)
104
+ if (dshPlan?.mode === 'agent') parsed = { ...parsed, agent: dshPlan.agent }
105
+ if (dshPlan?.mode === 'bundled') console.error(`martty: ${dshPlan.notice.text}`)
106
+ } catch (error) {
107
+ const message = error instanceof Error ? error.message : String(error)
108
+ console.error(message.startsWith('martty:') ? message : `martty: ${message}`)
97
109
  process.exit(Number.isInteger(error?.exitCode) ? error.exitCode : 1)
98
110
  }
111
+ dshNotice = dshPlan?.notice
112
+ if (dshPlan?.mode === 'profile') {
113
+ // The profile carries Martty itself: dsh boots its Host tree and the
114
+ // profile's runner starts the TUI on this terminal.
115
+ process.exit(runDshProfile(dshPlan))
116
+ }
99
117
  }
100
118
 
101
119
  if (configuredBin && !fs.existsSync(configuredBin)) {
@@ -141,4 +159,4 @@ if (wantDemoSkin) {
141
159
  }
142
160
 
143
161
  process.env.MARTTY_BIN ??= binaryPath
144
- await bootClient({ agent: parsed.agent, extraArgs: painterArgs(parsed) })
162
+ await bootClient({ agent: parsed.agent, extraArgs: painterArgs(parsed), notice: dshNotice })
package/lib/acp-host.js CHANGED
@@ -1,7 +1,12 @@
1
1
  /** Resolve the embeddable ACP plugin from TUI's own dependency graph. */
2
2
 
3
+ import { readFileSync } from 'node:fs'
3
4
  import { createRequire } from 'node:module'
4
5
  import { pathToFileURL } from 'node:url'
6
+ import { DSH_FLOOR, isUntested, requirementOf, satisfiesRange, untestedWarning } from './dsh-compat.js'
7
+ import { createHostNoticeServer } from './host-notice-agent.js'
8
+
9
+ const OWN_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
5
10
 
6
11
  export const name = 'dsh-tui-acp-host'
7
12
  export const inject = ['loader', 'userQuestions', 'permissionPresets']
@@ -203,6 +208,69 @@ export function installSessionPersistenceCompatibility(ctx) {
203
208
  return true
204
209
  }
205
210
 
211
+ function readManifest(anchor, specifier) {
212
+ if (typeof anchor !== 'string') return undefined
213
+ try {
214
+ const file = createRequire(anchor).resolve(`${specifier}/package.json`)
215
+ return { file, manifest: JSON.parse(readFileSync(file, 'utf8')) }
216
+ } catch {
217
+ return undefined
218
+ }
219
+ }
220
+
221
+ /**
222
+ * The hosting dsh and the ACP plugin this entry is about to mount, resolved
223
+ * the way dsh-acp resolves them: the Host anchor is `dshAcpHostBaseUrl` or
224
+ * the profile base; the plugin comes from the profile, then from Martty's
225
+ * own dependency graph.
226
+ */
227
+ export function hostCompatibility(ctx) {
228
+ const hostBase = ctx.get?.('dshAcpHostBaseUrl')
229
+ const host = readManifest(typeof hostBase === 'string' ? hostBase : ctx.baseUrl, '@deepseek-ai/dsh')
230
+ let acp
231
+ for (const anchor of [ctx.baseUrl, import.meta.url]) {
232
+ acp = readManifest(anchor, '@openma/deepseek-harness-acp')
233
+ if (acp !== undefined) break
234
+ }
235
+ const dsh = host?.manifest?.version
236
+ const checks = [{ name: 'martty', version: OWN_VERSION, range: DSH_FLOOR }]
237
+ const acpRange = requirementOf(acp?.manifest)
238
+ if (acpRange !== undefined) {
239
+ checks.push({ name: '@openma/deepseek-harness-acp', version: acp.manifest.version, range: acpRange })
240
+ }
241
+ const unmet = typeof dsh === 'string' ? checks.filter(({ range }) => !satisfiesRange(dsh, range)) : []
242
+ return { dsh, dshPath: host?.file, unmet, untested: isUntested(dsh) }
243
+ }
244
+
245
+ /** The in-TUI explanation when the ACP plugin cannot run on this Host. */
246
+ export function hostNoticeMessage({ dsh, dshPath, reason, unmet = [] }) {
247
+ const lines = ['**Martty cannot attach to this dsh profile.**', '']
248
+ if (reason !== undefined) lines.push(reason, '')
249
+ if (dsh !== undefined) lines.push(`Installed dsh: \`${dsh}\`${dshPath ? ` (${dshPath})` : ''}`)
250
+ for (const { name, version, range } of unmet) {
251
+ lines.push(`\`${name}\` ${version} supports dsh \`${range}\``)
252
+ }
253
+ lines.push(
254
+ '',
255
+ 'To continue:',
256
+ '- run Martty with its bundled harness instead: `npx martty`',
257
+ '- or install a supported dsh: `npm install -g @deepseek-ai/dsh@latest`',
258
+ '- or update this profile\'s plugins: `dsh plugin --profile <name> update`',
259
+ )
260
+ return lines.join('\n')
261
+ }
262
+
263
+ function provideHostNotice(ctx, details) {
264
+ if (ctx.get?.('acpServer') !== undefined) return
265
+ const message = hostNoticeMessage(details)
266
+ process.stderr.write(`${message.replace(/\*\*|`/g, '')}\n`)
267
+ ctx.provide?.('acpServer', createHostNoticeServer(message, { version: OWN_VERSION }))
268
+ }
269
+
270
+ // How long the ACP plugin may wait for a Host service before Martty names
271
+ // the missing capability in the TUI instead of leaving the runner pending.
272
+ export const REQUIRED_SERVICE_WAIT_MS = 10_000
273
+
206
274
  async function mountHostCompatibility(ctx) {
207
275
  // The service can come from the active Host even when module resolution
208
276
  // below lands on ACP's older peer copy, so adapt the live instance directly.
@@ -231,6 +299,16 @@ async function mountHostCompatibility(ctx) {
231
299
  }
232
300
 
233
301
  export async function apply(ctx, config) {
302
+ const compatibility = hostCompatibility(ctx)
303
+ if (compatibility.unmet.length > 0) {
304
+ // Below a declared floor (or an older dsh-acp's exact list): show why in
305
+ // the TUI rather than mounting a plugin that will refuse the Host.
306
+ provideHostNotice(ctx, compatibility)
307
+ return
308
+ }
309
+ if (compatibility.untested) {
310
+ process.stderr.write(`${untestedWarning(compatibility.dsh, compatibility.dshPath)}\n`)
311
+ }
234
312
  await mountHostCompatibility(ctx)
235
313
  installUserQuestionsCompatibility(ctx)
236
314
  const specifier = '@openma/deepseek-harness-acp/plugin'
@@ -242,9 +320,48 @@ export async function apply(ctx, config) {
242
320
  throw new Error(`dsh-tui: failed to import ${specifier} from ${resolved}`, { cause })
243
321
  }
244
322
  const plugin = ctx.loader.unwrapExports(exports)
323
+ let fiber
245
324
  try {
246
- await ctx.plugin(plugin, config)
325
+ fiber = ctx.plugin(plugin, config)
326
+ await fiber
247
327
  } catch (cause) {
248
- throw new Error(`dsh-tui: failed to mount ${specifier}`, { cause })
328
+ provideHostNotice(ctx, { ...compatibility, reason: `The ACP plugin failed to mount: ${errorText(cause)}` })
329
+ return
249
330
  }
331
+ watchAcpPlugin(ctx, fiber, plugin, compatibility)
332
+ }
333
+
334
+ function errorText(error) {
335
+ return error instanceof Error ? error.message : String(error)
336
+ }
337
+
338
+ /**
339
+ * The ACP plugin activates asynchronously (it waits for its injected Host
340
+ * services) and reports a refusal from its own fiber, which dsh's
341
+ * activation report never sees. Surface either outcome in the TUI: a
342
+ * failure names its error; a service still missing after
343
+ * {@link REQUIRED_SERVICE_WAIT_MS} is named as the absent capability.
344
+ */
345
+ function watchAcpPlugin(ctx, fiber, plugin, compatibility) {
346
+ if (ctx.get?.('acpServer') !== undefined) return
347
+ let settled = false
348
+ fiber?.await?.().then(
349
+ () => { settled = true },
350
+ (error) => {
351
+ settled = true
352
+ provideHostNotice(ctx, { ...compatibility, reason: `The ACP plugin did not start: ${errorText(error)}` })
353
+ },
354
+ )
355
+ const timer = setTimeout(() => {
356
+ if (settled || ctx.get?.('acpServer') !== undefined) return
357
+ const missing = (Array.isArray(plugin?.inject) ? plugin.inject : [])
358
+ .filter((service) => ctx.get?.(service) === undefined)
359
+ if (missing.length === 0) return
360
+ provideHostNotice(ctx, {
361
+ ...compatibility,
362
+ reason: `This dsh does not provide ${missing.length === 1 ? 'a service' : 'services'} the ACP plugin requires: ${missing.map((name) => `\`${name}\``).join(', ')}.`,
363
+ })
364
+ }, REQUIRED_SERVICE_WAIT_MS)
365
+ timer.unref?.()
366
+ ctx.effect?.(() => () => clearTimeout(timer), 'martty.acp-plugin-watch')
250
367
  }
package/lib/boot.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { apply as applyLaunchNotice, inject as launchNoticeInject } from './launch-notice.js'
1
2
  import { apply as applyHarnessBadge, inject as harnessBadgeInject } from './harness-badge.js'
2
3
  /**
3
4
  * Boot-time restore of a statically-registered gallery palette (ayu,
@@ -71,6 +72,7 @@ import { installTuiLocalPlugins } from './tui-local-plugins.js'
71
72
  * Internal product-only forced Harness; null means no pinned Harness.
72
73
  * @param {{ id: string, label: string, command: string, args?: string[] } | null} [options.forcedHarness]
73
74
  * @param {string} [options.artifactRoot]
75
+ * @param {{ text: string, level?: 'info' | 'warn' | 'error', dismissAfterMs?: number }} [options.notice]
74
76
  * @param {Array<{ id: string, label: string, command: string, args?: string[], source?: string }>} [options.harnessDefaults]
75
77
  * @param {string} [options.harnessPathValue]
76
78
  * @param {Array<{ id: string, kind: 'theme' | 'ui', entry: string }>} [options.packagePlugins]
@@ -131,6 +133,9 @@ export async function bootClient(options = {}) {
131
133
  restorePreferredTheme(ctx.get('tuiTheme'))
132
134
  await ctx.plugin({ name: 'tui-slots', inject: [], apply: applySlots })
133
135
  await ctx.plugin({ name: 'tui-commands', inject: [], apply: applyCommands })
136
+ if (options.notice !== undefined) {
137
+ await ctx.plugin({ name: 'launch-notice', inject: launchNoticeInject, apply: applyLaunchNotice }, options.notice)
138
+ }
134
139
  await ctx.plugin({ name: 'tui-overlay', inject: [], apply: applyOverlay })
135
140
  await ctx.plugin({ name: 'tui-agents', inject: [], apply: applyAgents })
136
141
  await ctx.plugin({ name: 'tui-queue', inject: [], apply: applyQueue })
@@ -193,6 +198,7 @@ export async function bootClient(options = {}) {
193
198
  restorePreferredTheme(ctx.tuiTheme)
194
199
  applySlots(ctx)
195
200
  applyCommands(ctx)
201
+ if (options.notice !== undefined) applyLaunchNotice(ctx, options.notice)
196
202
  applyOverlay(ctx)
197
203
  applyAgents(ctx)
198
204
  applyQueue(ctx)
@@ -8,9 +8,16 @@ import { installSessionPersistenceCompatibility } from './acp-host.js'
8
8
  import { createTuiPluginStore } from './tui-plugin-store.js'
9
9
 
10
10
  export const name = 'tui-creator-overlay'
11
- export const inject = [
12
- 'agentPresets', 'skills', 'systemPrompt', 'loader', 'tools', 'dynamicCordisRunner',
13
- ]
11
+ export const inject = ['skills', 'systemPrompt', 'loader', 'tools']
12
+
13
+ /**
14
+ * Services the ACP plugin provides. They are awaited by a nested
15
+ * `ctx.inject` rather than by this entry: when the ACP plugin cannot run on
16
+ * the Host (Martty then shows the reason in the TUI), the overlay simply
17
+ * stays idle instead of becoming a pending top-level entry in dsh's
18
+ * activation report.
19
+ */
20
+ export const acpServices = ['agentPresets', 'dynamicCordisRunner']
14
21
 
15
22
  const SKILL_NAME = 'tui-plugin-development'
16
23
  const ROUTING_PROMPT = `# Dynamic TUI Plugin routing
@@ -224,7 +231,11 @@ const skillContent = skillDocument.slice(skillFrontmatter[0].length)
224
231
  * @param {object} ctx
225
232
  * @param {{ preset?: string, scopeWaitMs?: number, scopePollMs?: number }} [config]
226
233
  */
227
- export async function apply(ctx, config = {}) {
234
+ export function apply(ctx, config = {}) {
235
+ ctx.inject(acpServices, (scoped) => applyOverlay(scoped, config))
236
+ }
237
+
238
+ export async function applyOverlay(ctx, config = {}) {
228
239
  // Standalone launches mount this overlay directly into the ACP Host tree,
229
240
  // which never loads martty/acp-host; both trees need the ACP compatibility
230
241
  // surface, and the installer is idempotent when the profile path already
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Martty's dsh compatibility policy: a minimum-version floor, never an
3
+ * exact-version allow-list.
4
+ *
5
+ * - Below the floor (`engines.dsh` in package.json — the field dsh's own
6
+ * plugin loader evaluates for bundles): not attempted; the user gets a
7
+ * message naming the installed version, the floor, and what to do.
8
+ * - At or above the floor: attempted. API differences are handled by feature
9
+ * detection where they are used.
10
+ * - Newer than {@link TESTED_DSH_THROUGH}: attempted with a warning only.
11
+ *
12
+ * A dependency that still declares an exact-version list (dsh-acp <= 0.4.38)
13
+ * is evaluated the same way through {@link requirementOf}: its declared range
14
+ * is what it will enforce at mount time.
15
+ */
16
+
17
+ import { readFileSync } from 'node:fs'
18
+ import semver from 'semver'
19
+
20
+ /** Highest dsh CI exercises Martty with (the `next` leg of the dsh-plugin matrix). */
21
+ export const TESTED_DSH_THROUGH = '0.2.1-rc.2'
22
+
23
+ const ownManifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
24
+
25
+ /** The floor this Martty declares for its hosting dsh. */
26
+ export const DSH_FLOOR = ownManifest.engines?.dsh ?? '*'
27
+
28
+ export function satisfiesRange(version, range) {
29
+ if (typeof version !== 'string' || semver.valid(version) === null) return false
30
+ if (typeof range !== 'string' || range.trim() === '') return false
31
+ return semver.satisfies(version, range, { includePrerelease: true })
32
+ }
33
+
34
+ export function isUntested(version, testedThrough = TESTED_DSH_THROUGH) {
35
+ return typeof version === 'string' && semver.valid(version) !== null && semver.gt(version, testedThrough)
36
+ }
37
+
38
+ /**
39
+ * The dsh range a plugin manifest declares, mirroring dsh's own rule: an
40
+ * `engines.dsh` range decides alone, otherwise the `@deepseek-ai/dsh` peer.
41
+ * @returns {string | undefined}
42
+ */
43
+ export function requirementOf(manifest) {
44
+ const engines = manifest?.engines?.dsh
45
+ if (typeof engines === 'string') return engines
46
+ const peer = manifest?.peerDependencies?.['@deepseek-ai/dsh']
47
+ return typeof peer === 'string' ? peer : undefined
48
+ }
49
+
50
+ export function untestedWarning(version, where) {
51
+ return `martty: dsh ${version}${where ? ` (${where})` : ''} is newer than the newest dsh Martty was tested with (${TESTED_DSH_THROUGH}); continuing.`
52
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * `martty --dsh <profile>`: run against the user's global dsh instead of
3
+ * the bundled dsh-acp runtime.
4
+ *
5
+ * The profile decides how Martty attaches:
6
+ * - a profile carrying the `martty` bundle (`dsh plugin --profile <name> add
7
+ * martty`) is a complete TUI surface: dsh boots its Host tree and its own
8
+ * runner starts the TUI Client, so the launcher hands the terminal to
9
+ * `dsh --profile <name>`;
10
+ * - a profile carrying only `@openma/deepseek-harness-acp` is an ACP server
11
+ * on stdio, so `dsh --profile <name>` becomes the ACP agent this Client
12
+ * spawns.
13
+ *
14
+ * Either way the global dsh version is checked up front against the floor
15
+ * every installed plugin declares. A dsh below a floor is not attempted:
16
+ * the launcher falls back to the bundled dsh-acp runtime and the TUI shows a
17
+ * short notice saying why. A dsh newer than the newest tested one is
18
+ * attempted with a warning — versions are never rejected for being new.
19
+ */
20
+
21
+ import { existsSync, readFileSync } from 'node:fs'
22
+ import { createRequire } from 'node:module'
23
+ import { homedir } from 'node:os'
24
+ import path from 'node:path'
25
+ import crossSpawn from 'cross-spawn'
26
+ import semver from 'semver'
27
+ import { DSH_FLOOR, isUntested, requirementOf, satisfiesRange, TESTED_DSH_THROUGH } from './dsh-compat.js'
28
+
29
+ export const MARTTY_BUNDLE = 'martty'
30
+ export const ACP_BUNDLE = '@openma/deepseek-harness-acp'
31
+ const OWN_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
32
+ const INSTALL_HINT = 'npm install -g @deepseek-ai/dsh@latest'
33
+
34
+ const usage = (message) => Object.assign(new Error(message), { exitCode: 2 })
35
+ const failure = (message) => Object.assign(new Error(message), { exitCode: 1 })
36
+
37
+ /**
38
+ * Split `--dsh <profile>` / `--dsh=<profile>` off argv.
39
+ * @param {string[]} argv
40
+ * @returns {{ profile: string | null, rest: string[] }}
41
+ */
42
+ export function takeDshFlag(argv) {
43
+ let profile = null
44
+ const rest = []
45
+ for (let i = 0; i < argv.length; i += 1) {
46
+ const token = argv[i]
47
+ let value
48
+ if (token === '--dsh') {
49
+ value = argv[i + 1]
50
+ i += 1
51
+ } else if (token.startsWith('--dsh=')) {
52
+ value = token.slice('--dsh='.length)
53
+ } else {
54
+ rest.push(token)
55
+ continue
56
+ }
57
+ if (typeof value !== 'string' || value.trim() === '' || value.startsWith('-')) {
58
+ throw usage('--dsh needs a dsh profile name (for example --dsh martty)')
59
+ }
60
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
61
+ throw usage(`--dsh: ${JSON.stringify(value)} is not a dsh profile name`)
62
+ }
63
+ if (profile !== null && profile !== value) throw usage('--dsh may name only one profile')
64
+ profile = value
65
+ }
66
+ if (profile !== null && rest.some((arg) => arg === '--agent' || arg === '--agent-arg')) {
67
+ throw usage('--dsh selects the agent itself; it cannot be combined with --agent/--agent-arg')
68
+ }
69
+ return { profile, rest }
70
+ }
71
+
72
+ /**
73
+ * The global dsh: whatever `dsh` resolves to on PATH, asked for its version.
74
+ * @returns {{ command: string, version: string }}
75
+ */
76
+ export function resolveGlobalDsh({ env = process.env, spawnSync = crossSpawn.sync } = {}) {
77
+ const command = 'dsh'
78
+ const result = spawnSync(command, ['--version'], { encoding: 'utf8', env, timeout: 30_000 })
79
+ if (result.error?.code === 'ENOENT') {
80
+ throw failure(`martty: --dsh needs a global dsh on PATH, but \`${command}\` was not found. Install it with: ${INSTALL_HINT}`)
81
+ }
82
+ const version = semver.valid(String(result.stdout ?? '').trim().split(/\s+/).at(-1))
83
+ if (result.status !== 0 || version === null) {
84
+ const detail = String(result.stderr ?? result.error?.message ?? '').trim()
85
+ throw failure(`martty: could not read the version of the global \`${command}\`${detail ? `: ${detail}` : ''}`)
86
+ }
87
+ return { command, version }
88
+ }
89
+
90
+ export function dshHome(env = process.env) {
91
+ return env.DSH_HOME?.trim() ? path.resolve(env.DSH_HOME) : path.join(homedir(), '.dsh')
92
+ }
93
+
94
+ function installedManifest(profileDir, name) {
95
+ try {
96
+ const file = createRequire(path.join(profileDir, 'package.json')).resolve(`${name}/package.json`)
97
+ return JSON.parse(readFileSync(file, 'utf8'))
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Read a profile and decide whether the given dsh version may run it.
105
+ * @returns {{ mode: 'profile' | 'agent', dir: string, requirements: { name: string, version: string, range: string, supported: boolean }[] }}
106
+ */
107
+ export function inspectDshProfile(profile, dshVersion, { env = process.env } = {}) {
108
+ const dir = path.join(dshHome(env), 'profiles', profile)
109
+ const manifestPath = path.join(dir, 'package.json')
110
+ if (!existsSync(manifestPath)) {
111
+ throw failure([
112
+ `martty: dsh profile "${profile}" does not exist (${dir}).`,
113
+ `Install Martty into it first: dsh plugin --profile ${profile} add martty@latest`,
114
+ ].join('\n'))
115
+ }
116
+ let manifest
117
+ try {
118
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
119
+ } catch (cause) {
120
+ throw failure(`martty: cannot read dsh profile "${profile}" (${manifestPath}): ${cause.message}`)
121
+ }
122
+ const bundles = Array.isArray(manifest?.dsh?.profile?.bundles) ? manifest.dsh.profile.bundles : []
123
+ const mode = bundles.includes(MARTTY_BUNDLE)
124
+ ? 'profile'
125
+ : bundles.includes(ACP_BUNDLE) ? 'agent' : null
126
+ if (mode === null) {
127
+ throw failure([
128
+ `martty: dsh profile "${profile}" carries neither the ${MARTTY_BUNDLE} nor the ${ACP_BUNDLE} bundle.`,
129
+ `Add Martty to it: dsh plugin --profile ${profile} add martty@latest`,
130
+ ].join('\n'))
131
+ }
132
+ // Every floor that gates the Host: this Martty's, the profile's installed
133
+ // Martty's (`engines.dsh`; releases <= 0.3.2 declared `dsh.supported`), and
134
+ // the ACP plugin's, which it enforces at mount time.
135
+ const requirements = [{ name: `${MARTTY_BUNDLE} (launcher)`, version: OWN_VERSION, range: DSH_FLOOR }]
136
+ const martty = mode === 'profile' ? installedManifest(dir, MARTTY_BUNDLE) : undefined
137
+ const marttyRange = requirementOf(martty) ?? martty?.dsh?.supported
138
+ if (typeof marttyRange === 'string') {
139
+ requirements.push({ name: MARTTY_BUNDLE, version: martty.version, range: marttyRange })
140
+ }
141
+ const acp = installedManifest(dir, ACP_BUNDLE)
142
+ const acpRange = requirementOf(acp)
143
+ if (typeof acpRange === 'string') {
144
+ requirements.push({ name: ACP_BUNDLE, version: acp.version, range: acpRange })
145
+ }
146
+ for (const requirement of requirements) {
147
+ requirement.supported = satisfiesRange(dshVersion, requirement.range)
148
+ }
149
+ return { mode, dir, requirements }
150
+ }
151
+
152
+ /** The notice shown in the TUI when `--dsh` falls back to the bundled runtime. */
153
+ export function fallbackNotice(profile, dsh, requirements) {
154
+ const unmet = requirements.filter(({ supported }) => !supported)
155
+ const needs = unmet.map(({ name, version, range }) => `${name} ${version} needs dsh ${range}`).join('; ')
156
+ return [
157
+ `--dsh ${profile}: the global dsh ${dsh.version} is not supported (${needs}), so Martty is using its bundled harness.`,
158
+ `Install a supported dsh (${INSTALL_HINT}) or update the profile: dsh plugin --profile ${profile} update`,
159
+ ].join('\n')
160
+ }
161
+
162
+ export function untestedNotice(dsh) {
163
+ return `The global dsh ${dsh.version} is newer than the newest dsh Martty was tested with (${TESTED_DSH_THROUGH}); continuing.`
164
+ }
165
+
166
+ /**
167
+ * Resolve `--dsh <profile>`:
168
+ * - `profile`: hand the terminal to `dsh --profile <name>` (the profile carries Martty);
169
+ * - `agent`: spawn `dsh --profile <name>` as this Client's ACP agent;
170
+ * - `bundled`: the global dsh is below a floor — keep the bundled harness and
171
+ * show `notice` in the TUI.
172
+ * Missing dsh / missing profile still fail with a message: there is nothing
173
+ * to attach to and the user named it explicitly.
174
+ * @returns {{ mode: 'profile', command: string, args: string[], warning?: string }
175
+ * | { mode: 'agent', agent: { command: string, args: string[] }, notice?: { text: string, level: 'info' } }
176
+ * | { mode: 'bundled', notice: { text: string, level: 'warn' } }}
177
+ */
178
+ export function planDshLaunch(profile, appArgs, options = {}) {
179
+ const dsh = resolveGlobalDsh(options)
180
+ const { mode, requirements } = inspectDshProfile(profile, dsh.version, options)
181
+ if (requirements.some((requirement) => !requirement.supported)) {
182
+ return { mode: 'bundled', notice: { text: fallbackNotice(profile, dsh, requirements), level: 'warn' } }
183
+ }
184
+ const untested = isUntested(dsh.version) ? untestedNotice(dsh) : undefined
185
+ if (mode === 'profile') {
186
+ return {
187
+ mode, command: dsh.command, args: ['--profile', profile, ...appArgs],
188
+ ...(untested ? { warning: `martty: ${untested}` } : {}),
189
+ }
190
+ }
191
+ return {
192
+ mode, agent: { command: dsh.command, args: ['--profile', profile] },
193
+ ...(untested ? { notice: { text: untested, level: 'info' } } : {}),
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Hand the terminal to `dsh --profile <name>` and return the exit code to
199
+ * use. The child owns the terminal, so this process ignores Ctrl-C while it
200
+ * runs and lets dsh decide what it means.
201
+ */
202
+ export function runDshProfile(plan, { spawnSync = crossSpawn.sync } = {}) {
203
+ if (plan.warning) console.error(plan.warning)
204
+ const ignore = () => {}
205
+ for (const signal of ['SIGINT', 'SIGQUIT']) process.on(signal, ignore)
206
+ try {
207
+ const result = spawnSync(plan.command, plan.args, { stdio: 'inherit' })
208
+ if (result.error) {
209
+ console.error(`martty: failed to launch dsh: ${result.error.message}`)
210
+ return 1
211
+ }
212
+ if (result.status !== null) return result.status
213
+ return result.signal === 'SIGINT' ? 130 : result.signal === 'SIGTERM' ? 143 : 1
214
+ } finally {
215
+ for (const signal of ['SIGINT', 'SIGQUIT']) process.off(signal, ignore)
216
+ }
217
+ }
package/lib/harnesses.js CHANGED
@@ -16,15 +16,18 @@ import {
16
16
  installRegistryBinary,
17
17
  managedBinaryPath,
18
18
  normalizeAcpRegistry,
19
- OPENMA_PI_ACP,
20
19
  readAcpRegistrySnapshot,
21
20
  } from './harness-registry.js'
21
+ import {
22
+ migrateLegacyAcpAgents,
23
+ readCustomAcpAgents,
24
+ removeCustomAcpAgent,
25
+ upsertCustomAcpAgent,
26
+ } from '@openma/common/acp-harnesses/custom-agents'
22
27
 
23
28
  export { fetchAcpRegistry } from './harness-registry.js'
24
29
 
25
30
  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(?:@.+)?$/
28
31
  const PI_ACP_ID = 'pi-acp'
29
32
  const PI_ACP_LEGACY_LABEL = 'Pi (OpenMA)'
30
33
  const ANSI = Object.freeze({ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', cyan: '\x1b[36m', green: '\x1b[32m' })
@@ -368,12 +371,7 @@ function validateHarness(value) {
368
371
  }
369
372
  const isNpx = /^npx(?:\.(?:cmd|bat|exe))?$/i.test(path.win32.basename(value.command))
370
373
  const normalizedArgs = isNpx
371
- ? [...args]
372
- .filter((arg) => arg !== '--yes' && arg !== '--prefer-offline')
373
- // A pinned pi-acp spec (any old `pi-acp@x` or `@openma/pi-acp@x` entry)
374
- // always resolves `latest` at runtime instead — harness upgrades ship
375
- // through npm, never through a Martty release.
376
- .map((arg) => PI_ACP_SPEC.test(arg) ? PI_ACP_LATEST : arg)
374
+ ? [...args].filter((arg) => arg !== '--yes' && arg !== '--prefer-offline')
377
375
  : [...args]
378
376
  const label = typeof value.label === 'string' && value.label.trim().length > 0
379
377
  ? value.label
@@ -389,9 +387,34 @@ function validateHarness(value) {
389
387
  }
390
388
  }
391
389
 
392
- function configuredHarnesses(settings) {
393
- if (!Array.isArray(settings.harnesses)) return []
394
- return settings.harnesses.map(validateHarness)
390
+ // Harness definitions live in <oma>/agents.json, the shared store from
391
+ // @openma/common — Backchat reads and writes the same file. Legacy rows
392
+ // still inside settings.json (and Backchat's config.toml overrides) keep
393
+ // reading through the shared module; the first shared write migrates
394
+ // harnesses[] into config.toml and strips the key so removals stick.
395
+ function settingsHome(settingsPath) {
396
+ return path.dirname(settingsPath)
397
+ }
398
+
399
+ function migrateStoredLabel(agent) {
400
+ // Configs saved when the pi harness was branded "Pi (OpenMA)" keep
401
+ // working; only the stale default display label migrates to the id.
402
+ return agent.id === PI_ACP_ID && agent.label === PI_ACP_LEGACY_LABEL
403
+ ? { ...agent, label: PI_ACP_ID }
404
+ : agent
405
+ }
406
+
407
+ function configuredHarnesses(settingsPath) {
408
+ // enabled=false is Backchat's disable flag: the definition stays stored
409
+ // but a disabled agent must not be selectable or launchable here.
410
+ return readCustomAcpAgents(settingsHome(settingsPath))
411
+ .filter((agent) => agent.enabled !== false)
412
+ .map(migrateStoredLabel)
413
+ }
414
+
415
+ function settingsWithoutHarnesses(settings) {
416
+ const { harnesses: _legacyHarnesses, ...rest } = settings
417
+ return rest
395
418
  }
396
419
 
397
420
  function withoutLegacyActive(settings) {
@@ -414,36 +437,37 @@ function persistedDefaultId(settings) {
414
437
 
415
438
  export function upsertHarness(settingsPath, harness) {
416
439
  const next = validateHarness(harness)
417
- const settings = readSettings(settingsPath)
418
- const harnesses = configuredHarnesses(settings)
419
- const index = harnesses.findIndex(({ id }) => id === next.id)
420
- if (index === -1) harnesses.push(next)
421
- else harnesses[index] = next
422
- writeSettings(settingsPath, { ...withoutLegacyActive(settings), harnesses })
440
+ // The shared write migrates legacy harnesses[] into config.toml and
441
+ // strips the key; read settings after it so a stale copy cannot race
442
+ // the migrated file back in.
443
+ upsertCustomAcpAgent(settingsHome(settingsPath), next)
444
+ const settings = withoutLegacyActive(readSettings(settingsPath))
445
+ writeSettings(settingsPath, settingsWithoutHarnesses(settings))
423
446
  return next
424
447
  }
425
448
 
426
449
  export function setDefaultHarness(settingsPath, id) {
427
- const settings = readSettings(settingsPath)
428
- const harnesses = configuredHarnesses(settings)
450
+ const harnesses = configuredHarnesses(settingsPath)
429
451
  if (!harnesses.some((harness) => harness.id === id)) {
430
452
  throw new Error(`unknown harness ${JSON.stringify(id)}`)
431
453
  }
432
- writeSettings(settingsPath, { ...withoutLegacyActive(settings), harnesses, defaultHarness: id })
454
+ const settings = withoutLegacyActive(readSettings(settingsPath))
455
+ migrateLegacyAcpAgents(settingsHome(settingsPath))
456
+ writeSettings(settingsPath, { ...settingsWithoutHarnesses(settings), defaultHarness: id })
433
457
  }
434
458
 
435
459
  export function savedHarnesses(settingsPath) {
436
- return configuredHarnesses(readSettings(settingsPath))
460
+ return configuredHarnesses(settingsPath)
437
461
  }
438
462
 
439
463
  export function removeHarnessConfiguration(settingsPath, expected) {
440
- const settings = withoutLegacyActive(readSettings(settingsPath))
441
- const harnesses = configuredHarnesses(settings)
442
- const current = harnesses.find(({ id }) => id === expected.id)
464
+ const current = configuredHarnesses(settingsPath).find(({ id }) => id === expected.id)
443
465
  if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error('Harness configuration changed; review removal again')
444
- settings.harnesses = harnesses.filter(({ id }) => id !== expected.id)
445
- if (settings.defaultHarness === expected.id) delete settings.defaultHarness
446
- writeSettings(settingsPath, settings)
466
+ removeCustomAcpAgent(settingsHome(settingsPath), expected.id)
467
+ const settings = withoutLegacyActive(readSettings(settingsPath))
468
+ const rest = settingsWithoutHarnesses(settings)
469
+ if (rest.defaultHarness === expected.id) delete rest.defaultHarness
470
+ writeSettings(settingsPath, rest)
447
471
  }
448
472
 
449
473
  // Kept as a source-compatible alias for integrations compiled against 0.2.30.
@@ -454,7 +478,7 @@ export function selectedHarness(settingsPath) {
454
478
  const settings = readSettings(settingsPath)
455
479
  const id = persistedDefaultId(settings)
456
480
  if (typeof id !== 'string') return undefined
457
- return configuredHarnesses(settings).find((harness) => harness.id === id)
481
+ return configuredHarnesses(settingsPath).find((harness) => harness.id === id)
458
482
  }
459
483
 
460
484
  function executableFile(command) {
@@ -1010,7 +1034,7 @@ export function discoverHarnesses(settingsPath, options = {}) {
1010
1034
  }
1011
1035
 
1012
1036
  function discoverHarnessEntries(settingsPath, options, registryEntries) {
1013
- const configured = configuredHarnesses(readSettings(settingsPath))
1037
+ const configured = configuredHarnesses(settingsPath)
1014
1038
  .map((harness) => ({ ...harness, source: 'configured' }))
1015
1039
  const defaults = (options.defaults ?? []).map((entry) => ({
1016
1040
  ...validateHarness(entry),
@@ -1077,7 +1101,7 @@ function searchScore(entry, query = '') {
1077
1101
  }
1078
1102
 
1079
1103
  function findHarnessCandidates(settingsPath, options = {}, query = '') {
1080
- const configured = configuredHarnesses(readSettings(settingsPath)).map((entry) => ({
1104
+ const configured = configuredHarnesses(settingsPath).map((entry) => ({
1081
1105
  ...entry,
1082
1106
  source: 'configured',
1083
1107
  status: 'Configured',
@@ -0,0 +1,88 @@
1
+ /**
2
+ * A minimal ACP agent that only explains why the real one is unavailable.
3
+ *
4
+ * In the dsh plugin profile the TUI Client attaches to the Host's
5
+ * `acpServer`, which the ACP plugin provides. When that plugin cannot run on
6
+ * this Host (a dsh below the floor, a required Host capability missing, or a
7
+ * mount failure), nothing provides `acpServer`: the runner would wait
8
+ * forever and dsh would only report pending entries. Providing this agent
9
+ * instead lets the TUI start and show the reason in the conversation, with
10
+ * the actions that fix it, while the profile stays otherwise intact.
11
+ */
12
+
13
+ export const NOTICE_SESSION_ID = 'martty-host-notice'
14
+
15
+ function textUpdate(sessionId, text) {
16
+ return {
17
+ jsonrpc: '2.0',
18
+ method: 'session/update',
19
+ params: {
20
+ sessionId,
21
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } },
22
+ },
23
+ }
24
+ }
25
+
26
+ /**
27
+ * @param {string} message - Markdown shown as the agent's message.
28
+ * @param {{ version?: string }} [options]
29
+ * @returns {{ connect(stream: { readable: ReadableStream, writable: WritableStream }): { closed: Promise<void>, dispose(): Promise<void> } }}
30
+ */
31
+ export function createHostNoticeServer(message, { version = '0.0.0' } = {}) {
32
+ return {
33
+ connect(stream) {
34
+ const writer = stream.writable.getWriter()
35
+ const reader = stream.readable.getReader()
36
+ let disposed = false
37
+ const send = (payload) => (disposed ? Promise.resolve() : writer.write(payload).catch(() => {}))
38
+ const respond = (id, result) => send({ jsonrpc: '2.0', id, result })
39
+ const fail = (id, code, text) => send({ jsonrpc: '2.0', id, error: { code, message: text } })
40
+
41
+ async function handle(request) {
42
+ const { id, method, params } = request ?? {}
43
+ if (id === undefined || id === null) return // notifications need no answer
44
+ switch (method) {
45
+ case 'initialize':
46
+ return respond(id, {
47
+ protocolVersion: typeof params?.protocolVersion === 'number' ? params.protocolVersion : 1,
48
+ agentCapabilities: { loadSession: false },
49
+ authMethods: [],
50
+ agentInfo: { name: 'martty-host-notice', title: 'Martty (dsh host unavailable)', version },
51
+ })
52
+ case 'session/new': {
53
+ await respond(id, { sessionId: NOTICE_SESSION_ID })
54
+ return send(textUpdate(NOTICE_SESSION_ID, message))
55
+ }
56
+ case 'session/prompt':
57
+ await send(textUpdate(params?.sessionId ?? NOTICE_SESSION_ID, message))
58
+ return respond(id, { stopReason: 'end_turn' })
59
+ case 'session/cancel':
60
+ return respond(id, null)
61
+ default:
62
+ return fail(id, -32601, message.split('\n')[0])
63
+ }
64
+ }
65
+
66
+ const closed = (async () => {
67
+ try {
68
+ for (;;) {
69
+ const { value, done } = await reader.read()
70
+ if (done) break
71
+ await handle(value)
72
+ }
73
+ } catch {
74
+ // The Client went away; nothing to clean up beyond the streams.
75
+ }
76
+ })()
77
+ return {
78
+ closed,
79
+ async dispose() {
80
+ if (disposed) return
81
+ disposed = true
82
+ await reader.cancel().catch(() => {})
83
+ await writer.close().catch(() => {})
84
+ },
85
+ }
86
+ },
87
+ }
88
+ }
@@ -2,26 +2,35 @@
2
2
  // `martty`.
3
3
  //
4
4
  // npm refuses to link a global bin owned by another package, and it links
5
- // bins before running any lifecycle script — so no script inside this
6
- // package can stop an EEXIST on a name the legacy package may already own
7
- // (`martty`, `dsh-tui`, `dsb`). The published `martty` package therefore
8
- // declares only `martty-cli` — a name nothing else owns — and this
9
- // postinstall step creates the user-facing `martty` command itself after
10
- // retiring any legacy-owned links. It also strips `bin` from the legacy
11
- // package's installed manifest, because `npm rm -g` deletes bin links by
12
- // name without checking what they point at — left in place, removing the
13
- // old package would take the fresh `martty` command down with it.
5
+ // bins before running any lifecycle script — so nothing inside this package
6
+ // can stop an EEXIST on a name the legacy package may already own
7
+ // (`martty`, `dsh-tui`, `dsb`) or on the `martty` shim earlier martty
8
+ // releases wrote. The published `martty` package therefore declares only
9
+ // `martty-cli` — a name nothing else owns — and the user-facing `martty`
10
+ // command is a shim this module writes itself, after retiring any
11
+ // legacy-owned links. It also strips `bin` from the legacy package's
12
+ // installed manifest, because `npm rm -g` deletes bin links by name without
13
+ // checking what they point at — left in place, removing the old package
14
+ // would take the fresh `martty` command down with it.
14
15
  //
15
16
  // `martty` is a small shim file, not a symlink: `npm rm -g martty` removes
16
17
  // the package but not this command, and a shim can degrade gracefully —
17
18
  // when its package is gone it prints a one-line hint and deletes itself,
18
19
  // instead of leaving a dead symlink behind.
19
20
  //
20
- // npm hides postinstall output on success, so the binary re-checks its
21
- // install root at launch: invoked as a real `martty`/`martty-cli` command
22
- // it converges the prefix the same way the postinstall does (retire legacy
23
- // links, recreate `martty`, sanitize) and warns while the legacy package is
24
- // still present — which is also what recovers a `--ignore-scripts` install.
21
+ // None of this runs as an install lifecycle script. A `postinstall` makes
22
+ // every pnpm-managed consumer — `dsh plugin --profile <name> add martty`
23
+ // above all — treat the package as one with build scripts, and pnpm's
24
+ // build-script policy then refuses the install (ERR_PNPM_IGNORED_BUILDS
25
+ // under `strictDepBuilds`, which newer pnpm releases enable by default);
26
+ // npm's allow-scripts policy flags it as well. The binary converges at
27
+ // launch instead: invoked as a real `martty`/`martty-cli` command it creates
28
+ // `martty` next to `martty-cli`, and for a global prefix still holding the
29
+ // legacy package it retires the legacy links and sanitizes the legacy
30
+ // install. That single
31
+ // path covers fresh installs, upgrades, and `--ignore-scripts` installs
32
+ // alike; a fresh `npm i -g martty` exposes `martty-cli` until its first
33
+ // launch writes `martty`.
25
34
  //
26
35
  // The legacy name is intentionally assembled: `scripts/package-alias.mjs`
27
36
  // rewrites the literal `martty` to `martty` in every
@@ -30,7 +39,7 @@
30
39
 
31
40
  import fs from 'node:fs'
32
41
  import path from 'node:path'
33
- import { fileURLToPath, pathToFileURL } from 'node:url'
42
+ import { fileURLToPath } from 'node:url'
34
43
 
35
44
  export const LEGACY_PACKAGE = ['@openma', 'deepseek-harness-tui'].join('/')
36
45
  const scopedParts = LEGACY_PACKAGE.split('/')
@@ -279,32 +288,17 @@ function retireLegacyBins(prefix, write) {
279
288
  }
280
289
  }
281
290
 
282
- // postinstall entry point (`npm_package_name`/`npm_config_*` env): retire
283
- // the legacy package's bin links, create the `martty` command, and nudge
284
- // the user to remove the old package. Never fails the install.
285
- export function migrate(env, write = stderrLine, platform = process.platform) {
286
- if (env.npm_config_global !== 'true') return
287
- const prefix = env.npm_config_prefix
288
- if (!prefix) return
289
- const packageName = env.npm_package_name ?? ''
290
-
291
- if (packageName === LEGACY_PACKAGE) {
292
- legacyNotice(write)
293
- return
294
- }
295
-
296
- const legacyInstalled = legacyInstallDirs(prefix).some((dir) => fs.existsSync(dir))
297
-
291
+ // Converge one global prefix that still holds the legacy package: retire
292
+ // the legacy package's bin links, (re)create the `martty` command in the
293
+ // prefix's bin directory, and detach the legacy `bin` declarations. Every
294
+ // step is best effort and only touches entries this package or the legacy
295
+ // package verifiably owns.
296
+ export function convergePrefix(prefix, { packageDir, platform = process.platform, write = stderrLine } = {}) {
297
+ const selfPackageDir = packageDir ?? path.dirname(path.dirname(fileURLToPath(import.meta.url)))
298
298
  retireLegacyBins(prefix, write)
299
-
300
- const packageDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
301
299
  const binDir = platform === 'win32' ? prefix : path.join(prefix, 'bin')
302
- ensureMarttyLink({ binDir, packageDir, platform, write })
303
-
304
- if (legacyInstalled) {
305
- sanitizeLegacyInstall(prefix, write)
306
- marttyNotice(write, `npm rm -g ${LEGACY_PACKAGE}`)
307
- }
300
+ ensureMarttyLink({ binDir, packageDir: selfPackageDir, platform, write })
301
+ sanitizeLegacyInstall(prefix, write)
308
302
  }
309
303
 
310
304
  // Neutralize the legacy package's `bin` declarations so a later
@@ -364,12 +358,11 @@ export function sanitizeLegacyInstall(prefix, write = stderrLine) {
364
358
  }
365
359
  }
366
360
 
367
- // Launch-time entry point: npm hides postinstall output unless it fails, so
368
- // the binary re-checks its install root on every run. Invoked through a
369
- // real command name (`martty`, `martty-cli`, `npx`) it converges the global
370
- // prefix the same way the postinstall does — retire the legacy links,
371
- // (re)create `martty`, sanitize the legacy install — which is also what
372
- // recovers an install whose scripts were skipped. A bare `node
361
+ // Launch-time entry point — the only migration path; the package ships no
362
+ // install lifecycle script. Invoked through a real command name (`martty`,
363
+ // `martty-cli`, `npx`) it writes the `martty` command next to `martty-cli`
364
+ // and converges a global prefix that still holds the legacy package
365
+ // (retire the legacy links, sanitize the legacy install). A bare `node
373
366
  // bin/martty.js` or a dsh profile layout only gets the warning, never the
374
367
  // filesystem changes.
375
368
  export function warnAboutLegacyPackage({ argv1, libDir, write = stderrLine, platform = process.platform } = {}) {
@@ -419,10 +412,7 @@ export function warnAboutLegacyPackage({ argv1, libDir, write = stderrLine, plat
419
412
  }
420
413
  hint ??= `npm rm -g ${LEGACY_PACKAGE}`
421
414
  try {
422
- retireLegacyBins(prefix, write)
423
- const binDir = platform === 'win32' ? prefix : path.join(prefix, 'bin')
424
- ensureMarttyLink({ binDir, packageDir, platform, write })
425
- sanitizeLegacyInstall(prefix, write)
415
+ convergePrefix(prefix, { packageDir, platform, write })
426
416
  } catch {
427
417
  // Best effort: nothing here may break the launch.
428
418
  }
@@ -451,6 +441,3 @@ export function reportLaunchNotices(
451
441
  for (const line of lines) write(line)
452
442
  }
453
443
 
454
- if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
455
- migrate(process.env)
456
- }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Built-in Client Plugin: one launch-time notice in the composer dock.
3
+ *
4
+ * Used when the launcher had to change course before the TUI started — for
5
+ * example `martty --dsh <profile>` falling back to the bundled harness
6
+ * because the global dsh is below the supported floor. The notice is short,
7
+ * names the reason, and goes away on `/dismiss` or after `dismissAfterMs`.
8
+ */
9
+
10
+ export const name = 'launch-notice'
11
+ export const inject = ['tuiSlots', 'tuiCommands']
12
+
13
+ export const DEFAULT_DISMISS_AFTER_MS = 60_000
14
+
15
+ /**
16
+ * @param {{ text?: string, level?: 'info' | 'warn' | 'error', dismissAfterMs?: number }} [config]
17
+ */
18
+ export function apply(ctx, config = {}) {
19
+ if (typeof config.text !== 'string' || config.text.trim() === '') return undefined
20
+ const level = ['info', 'warn', 'error'].includes(config.level) ? config.level : 'warn'
21
+ const controller = ctx.tuiSlots.register(
22
+ { name: 'conversation.input.dock', id: 'launch-notice', order: -30 },
23
+ [{ id: 'notice', kind: 'notice', level, text: `${config.text.trim()}\n(/dismiss to hide)` }],
24
+ )
25
+ let stopCommand
26
+ let timer
27
+ let dismissed = false
28
+ const dismiss = () => {
29
+ if (dismissed) return
30
+ dismissed = true
31
+ clearTimeout(timer)
32
+ controller.dispose()
33
+ stopCommand?.()
34
+ stopCommand = undefined
35
+ }
36
+ stopCommand = ctx.tuiCommands.register(
37
+ { name: 'dismiss', description: 'Hide the launch notice' },
38
+ async () => dismiss(),
39
+ )
40
+ const after = Number.isFinite(config.dismissAfterMs) ? config.dismissAfterMs : DEFAULT_DISMISS_AFTER_MS
41
+ if (after > 0) {
42
+ timer = setTimeout(dismiss, after)
43
+ timer.unref?.()
44
+ }
45
+ return dismiss
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martty",
3
- "version": "0.3.2-beta.3",
3
+ "version": "0.3.3-beta.1",
4
4
  "description": "Terminal-native ACP client UI; Cordis client tree, any ACP agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,10 +14,9 @@
14
14
  "type": "module",
15
15
  "main": "lib/index.js",
16
16
  "scripts": {
17
- "postinstall": "node lib/install-migration.js",
18
17
  "test:harness-ui": "node --test ../scripts/harness-management-tui.test.mjs ../scripts/harness-session-tui.test.mjs",
19
18
  "pretest": "node --test ../scripts/workflow-release.test.mjs ../scripts/package-alias.test.mjs ../scripts/harness-removal.test.mjs",
20
- "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/install-migration.test.mjs ../scripts/global-install-collision.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/dsh-launch.test.mjs ../scripts/host-notice.test.mjs ../scripts/install-migration.test.mjs ../scripts/global-install-collision.test.mjs ../scripts/real-agent-e2e.test.mjs",
21
20
  "test:profile-install-matrix": "node --test ../scripts/profile-install-matrix.test.mjs"
22
21
  },
23
22
  "publishConfig": {
@@ -71,22 +70,24 @@
71
70
  "dsh": {
72
71
  "bundle": {
73
72
  "patch": "./cordis.patch.yml"
74
- },
75
- "supported": "0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2"
73
+ }
76
74
  },
77
75
  "dependencies": {
78
76
  "@deepseek-ai/cordis": "^4.0.1",
79
77
  "@lobehub/icons-static-svg": "1.95.0",
80
- "@openma/deepseek-harness-acp": "0.4.38-beta.1",
78
+ "@openma/common": "^0.9.2",
79
+ "@openma/deepseek-harness-acp": "0.4.39-beta.2",
81
80
  "@resvg/resvg-js": "2.6.2",
82
81
  "cross-spawn": "^7.0.6",
83
- "node-downloader-helper": "2.1.11"
82
+ "node-downloader-helper": "2.1.11",
83
+ "semver": "^7.8.5"
84
84
  },
85
85
  "devDependencies": {
86
86
  "@deepseek-ai/dsh": "0.2.0-rc.2"
87
87
  },
88
88
  "engines": {
89
- "node": ">=22.19.0"
89
+ "node": ">=22.19.0",
90
+ "dsh": ">=0.1.7-rc.2"
90
91
  },
91
92
  "keywords": [
92
93
  "deepseek",
@@ -97,10 +98,10 @@
97
98
  "agent-client-protocol"
98
99
  ],
99
100
  "optionalDependencies": {
100
- "@openma/martty-darwin-arm64": "0.3.2-beta.3",
101
- "@openma/martty-darwin-x64": "0.3.2-beta.3",
102
- "@openma/martty-linux-x64": "0.3.2-beta.3",
103
- "@openma/martty-linux-arm64": "0.3.2-beta.3",
104
- "@openma/martty-win32-x64": "0.3.2-beta.3"
101
+ "@openma/martty-darwin-arm64": "0.3.3-beta.1",
102
+ "@openma/martty-darwin-x64": "0.3.3-beta.1",
103
+ "@openma/martty-linux-x64": "0.3.3-beta.1",
104
+ "@openma/martty-linux-arm64": "0.3.3-beta.1",
105
+ "@openma/martty-win32-x64": "0.3.3-beta.1"
105
106
  }
106
107
  }