martty 0.3.2 → 0.3.3
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 +42 -10
- package/bin/martty.js +22 -4
- package/lib/acp-host.js +119 -2
- package/lib/boot.js +6 -0
- package/lib/creator-overlay.js +15 -4
- package/lib/dsh-compat.js +52 -0
- package/lib/dsh-launch.js +217 -0
- package/lib/host-notice-agent.js +88 -0
- package/lib/install-migration.js +39 -52
- package/lib/launch-notice.js +46 -0
- package/package.json +13 -13
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ x64. Requires Node.js 22.19+.
|
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
26
|
npm install --global martty
|
|
27
|
-
martty
|
|
27
|
+
martty-cli # the first launch creates `martty`; use `martty` afterwards
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Martty directly depends on our `@openma/deepseek-harness-acp` implementation.
|
|
@@ -46,6 +46,38 @@ The profile command is the recommended install and upgrade path. It creates a
|
|
|
46
46
|
missing profile and installs the TUI plus its ACP dependency; no global
|
|
47
47
|
`martty` installation is required.
|
|
48
48
|
|
|
49
|
+
dsh installs plugins with pnpm. Martty declares no install lifecycle scripts
|
|
50
|
+
(`preinstall`/`install`/`postinstall`), so it installs under pnpm's strict
|
|
51
|
+
build-script policy (`ERR_PNPM_IGNORED_BUILDS`) without `--ignore-scripts`. A
|
|
52
|
+
profile where an install of 0.3.2 or earlier failed (`martty` is a dependency
|
|
53
|
+
but not in `dsh.profile.bundles`) needs `dsh plugin --profile martty remove
|
|
54
|
+
martty` and then `add` again.
|
|
55
|
+
|
|
56
|
+
With such a profile in place, the npm entry point attaches to it too:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npx martty --dsh martty # same as dsh --profile martty, after a version check
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Harness resolution and supported dsh
|
|
63
|
+
|
|
64
|
+
1. Default (`martty` / `npx martty`): the bundled `@openma/deepseek-harness-acp`
|
|
65
|
+
and its bundled dsh runtime; ambient installs never override it.
|
|
66
|
+
2. `--dsh <profile>`: the global `dsh` on `PATH` with that profile — a profile
|
|
67
|
+
carrying `martty` gets the terminal, one carrying only dsh-acp becomes the
|
|
68
|
+
ACP agent. Below the dsh floor Martty falls back to (1) and shows a short
|
|
69
|
+
notice (`/dismiss` hides it).
|
|
70
|
+
3. As a dsh plugin (`dsh --profile martty`): the hosting dsh.
|
|
71
|
+
4. An explicit dsh path in a harness recipe (`--dsh-path` / `DSH_PATH`).
|
|
72
|
+
|
|
73
|
+
Supported dsh is a floor, `>=0.1.7-rc.2` (`engines.dsh`, which dsh's own plugin
|
|
74
|
+
compatibility check reads), never an exact list. Newer dsh releases are
|
|
75
|
+
attempted — API differences are feature-detected, and a dsh newer than the
|
|
76
|
+
newest tested one (`0.2.1-rc.2`) only warns. Below the floor, or when the Host
|
|
77
|
+
lacks a required capability, `dsh --profile martty` still opens the TUI and the
|
|
78
|
+
conversation explains the installed version, the floor or missing capability,
|
|
79
|
+
and the options (`npx martty`, or install a supported dsh).
|
|
80
|
+
|
|
49
81
|
## Migrating from DeepSeek Harness TUI / the scoped package
|
|
50
82
|
|
|
51
83
|
`martty` is the recommended package name. Starting with `0.2.13`, it and the
|
|
@@ -63,17 +95,17 @@ published versions of `martty` register `dsh-tui`
|
|
|
63
95
|
legacy checkouts own the same names (a checkout of this repository links as
|
|
64
96
|
`martty`, so it replaces — or is replaced by — an installed `martty` with no
|
|
65
97
|
collision). The `martty` package declares only `martty-cli` so npm never
|
|
66
|
-
refuses the install over a bin another package owns (`EEXIST`)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
package's bin declarations so a later
|
|
71
|
-
|
|
98
|
+
refuses the install over a bin another package owns (`EEXIST`). It ships no
|
|
99
|
+
install scripts: the first launch of `martty-cli` (or `npx martty`) retires
|
|
100
|
+
the stale `martty`/`dsh-tui`/`dsb` links that verifiably belonged to the
|
|
101
|
+
legacy package (a third-party `dsh-tui` stays untouched), creates the `martty`
|
|
102
|
+
command itself, and detaches the legacy package's bin declarations so a later
|
|
103
|
+
`npm rm -g` can't take `martty` down with it. Until that first launch a fresh
|
|
104
|
+
install has only `martty-cli`; upgrades from 0.3.2 keep their `martty`.
|
|
105
|
+
Removing `martty` afterwards is optional
|
|
72
106
|
cleanup; `martty` warns at launch while it remains — on an interactive
|
|
73
107
|
terminal the notice prints when the session exits, so the TUI cannot hide
|
|
74
|
-
it.
|
|
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
|
|
108
|
+
it. And after `npm rm -g
|
|
77
109
|
martty`, the `martty` command left behind is a self-cleaning shim: invoking
|
|
78
110
|
it once prints the reinstall hint and removes it — nothing dead is left
|
|
79
111
|
in `<prefix>/bin`.
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
325
|
+
fiber = ctx.plugin(plugin, config)
|
|
326
|
+
await fiber
|
|
247
327
|
} catch (cause) {
|
|
248
|
-
|
|
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)
|
package/lib/creator-overlay.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/install-migration.js
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
7
|
-
// (`martty`, `dsh-tui`, `dsb`)
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
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
|
-
//
|
|
283
|
-
// the legacy package's bin links, create the `martty` command
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
// (
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.3",
|
|
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,23 +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
78
|
"@openma/common": "^0.9.2",
|
|
81
|
-
"@openma/deepseek-harness-acp": "0.4.
|
|
79
|
+
"@openma/deepseek-harness-acp": "0.4.39",
|
|
82
80
|
"@resvg/resvg-js": "2.6.2",
|
|
83
81
|
"cross-spawn": "^7.0.6",
|
|
84
|
-
"node-downloader-helper": "2.1.11"
|
|
82
|
+
"node-downloader-helper": "2.1.11",
|
|
83
|
+
"semver": "^7.8.5"
|
|
85
84
|
},
|
|
86
85
|
"devDependencies": {
|
|
87
86
|
"@deepseek-ai/dsh": "0.2.0-rc.2"
|
|
88
87
|
},
|
|
89
88
|
"engines": {
|
|
90
|
-
"node": ">=22.19.0"
|
|
89
|
+
"node": ">=22.19.0",
|
|
90
|
+
"dsh": ">=0.1.7-rc.2"
|
|
91
91
|
},
|
|
92
92
|
"keywords": [
|
|
93
93
|
"deepseek",
|
|
@@ -98,10 +98,10 @@
|
|
|
98
98
|
"agent-client-protocol"
|
|
99
99
|
],
|
|
100
100
|
"optionalDependencies": {
|
|
101
|
-
"@openma/martty-darwin-arm64": "0.3.
|
|
102
|
-
"@openma/martty-darwin-x64": "0.3.
|
|
103
|
-
"@openma/martty-linux-x64": "0.3.
|
|
104
|
-
"@openma/martty-linux-arm64": "0.3.
|
|
105
|
-
"@openma/martty-win32-x64": "0.3.
|
|
101
|
+
"@openma/martty-darwin-arm64": "0.3.3",
|
|
102
|
+
"@openma/martty-darwin-x64": "0.3.3",
|
|
103
|
+
"@openma/martty-linux-x64": "0.3.3",
|
|
104
|
+
"@openma/martty-linux-arm64": "0.3.3",
|
|
105
|
+
"@openma/martty-win32-x64": "0.3.3"
|
|
106
106
|
}
|
|
107
107
|
}
|