martty 0.3.1 → 0.3.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -72,8 +72,11 @@ export function restoreHostTty() {
72
72
  }
73
73
  }
74
74
  if (process.stdout.isTTY) {
75
+ // Leave the alt screen with ?1047l, not ?1049l: ?1049l also restores the
76
+ // cursor saved at ?1049h, which a second emit on the main screen would
77
+ // move back over the `session <id>` line the painter just printed.
75
78
  process.stdout.write(
76
- '\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?25h\x1b[?1049l',
79
+ '\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?25h\x1b[?1047l',
77
80
  )
78
81
  }
79
82
  }
@@ -0,0 +1,456 @@
1
+ // Migration aid for the package rename `martty` ->
2
+ // `martty`.
3
+ //
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.
14
+ //
15
+ // `martty` is a small shim file, not a symlink: `npm rm -g martty` removes
16
+ // the package but not this command, and a shim can degrade gracefully —
17
+ // when its package is gone it prints a one-line hint and deletes itself,
18
+ // instead of leaving a dead symlink behind.
19
+ //
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.
25
+ //
26
+ // The legacy name is intentionally assembled: `scripts/package-alias.mjs`
27
+ // rewrites the literal `martty` to `martty` in every
28
+ // shipped .js file, which would silently break detection inside the `martty`
29
+ // package itself.
30
+
31
+ import fs from 'node:fs'
32
+ import path from 'node:path'
33
+ import { fileURLToPath, pathToFileURL } from 'node:url'
34
+
35
+ export const LEGACY_PACKAGE = ['@openma', 'deepseek-harness-tui'].join('/')
36
+ const scopedParts = LEGACY_PACKAGE.split('/')
37
+ const LEGACY_PACKAGE_DIR = scopedParts[scopedParts.length - 1]
38
+
39
+ const COMMAND_NAME = 'martty'
40
+ const DECLARED_BIN_NAME = 'martty-cli'
41
+ const BIN_FILE = path.join('bin', 'martty.js')
42
+
43
+ // Bin names the legacy package may own globally: every published version
44
+ // ships `dsh-tui`, 0.2.14/0.2.19-era releases add `dsb`, and 0.2.14/0.2.20
45
+ // also claim `martty` itself. Links are only removed when they verifiably
46
+ // belong to the legacy package — an installed `martty` package keeps its
47
+ // own `martty` command and a third-party `dsh-tui` stays untouched.
48
+ const LEGACY_BIN_NAMES = [COMMAND_NAME, 'dsh-tui', 'dsb']
49
+
50
+ // Marks the `martty` shims this package writes so ownership checks can tell
51
+ // them from a foreign file that happens to mention a martty path.
52
+ const SHIM_MARKER = 'martty-command-shim'
53
+ const NOT_INSTALLED_LINE =
54
+ 'martty: the martty package is not installed - reinstall with: npm i -g martty'
55
+
56
+ const stderrLine = (line) => process.stderr.write(`${line}\n`)
57
+
58
+ // Global installs land in <prefix>/lib/node_modules on POSIX and in
59
+ // <prefix>/node_modules on Windows; npm link uses the same layout.
60
+ export const legacyInstallDirs = (prefix) => [
61
+ path.join(prefix, 'lib', 'node_modules', ...scopedParts),
62
+ path.join(prefix, 'node_modules', ...scopedParts),
63
+ ]
64
+
65
+ // POSIX global bins live in <prefix>/bin as symlinks; Windows creates a
66
+ // msys shim, a .cmd shim, and a .ps1 shim directly under the prefix.
67
+ export const binCandidates = (prefix, name) => [
68
+ path.join(prefix, 'bin', name),
69
+ path.join(prefix, name),
70
+ path.join(prefix, `${name}.cmd`),
71
+ path.join(prefix, `${name}.ps1`),
72
+ ]
73
+
74
+ // A bin entry belongs to the legacy package when its symlink target (POSIX)
75
+ // or its shim contents (Windows) point into the legacy install directory.
76
+ // Anything else — a third-party `dsh-tui` package, a hand-written script —
77
+ // is left untouched.
78
+ export function isLegacyOwned(file) {
79
+ let stat
80
+ try {
81
+ stat = fs.lstatSync(file)
82
+ } catch {
83
+ return false
84
+ }
85
+ try {
86
+ if (stat.isSymbolicLink()) {
87
+ return fs.readlinkSync(file).includes(LEGACY_PACKAGE_DIR)
88
+ }
89
+ if (!stat.isFile()) return false
90
+ return fs.readFileSync(file, 'utf8').includes(LEGACY_PACKAGE_DIR)
91
+ } catch {
92
+ return false
93
+ }
94
+ }
95
+
96
+ // Classify the entry at `file` for the `martty` command slot:
97
+ // 'none' — nothing there
98
+ // 'ours' — resolves to (or points at) this package's bin file
99
+ // 'legacy' — belongs to the legacy package (isLegacyOwned)
100
+ // 'foreign' — something else; never overwritten
101
+ function commandOwner(file, binFile) {
102
+ let stat
103
+ try {
104
+ stat = fs.lstatSync(file)
105
+ } catch {
106
+ return 'none'
107
+ }
108
+ try {
109
+ if (stat.isSymbolicLink()) {
110
+ const target = fs.readlinkSync(file)
111
+ if (path.resolve(path.dirname(file), target) === binFile) return 'ours'
112
+ if (target.includes(LEGACY_PACKAGE_DIR)) return 'legacy'
113
+ // A live link whose target names a `martty/` directory points at some
114
+ // martty installation and is left alone; a dangling one is broken
115
+ // anyway and safe to replace. Anything else belongs to another tool.
116
+ if (/martty[/\\]/.test(target)) {
117
+ return fs.existsSync(path.resolve(path.dirname(file), target)) ? 'ours' : 'none'
118
+ }
119
+ return 'foreign'
120
+ }
121
+ if (!stat.isFile()) return 'foreign'
122
+ const content = fs.readFileSync(file, 'utf8')
123
+ if (content.includes(LEGACY_PACKAGE_DIR)) return 'legacy'
124
+ if (content.includes(SHIM_MARKER) || /node_modules[/\\]martty[/\\]/.test(content)) {
125
+ return 'ours'
126
+ }
127
+ return 'foreign'
128
+ } catch {
129
+ return 'foreign'
130
+ }
131
+ }
132
+
133
+ // The POSIX `martty` command is a shim file, not a symlink: `npm rm -g
134
+ // martty` removes the package but not this command, and a shim can notice
135
+ // its target is gone — it prints a one-line hint and deletes itself instead
136
+ // of leaving a dead symlink behind.
137
+ function posixShim(binFile) {
138
+ const quoted = `'${binFile.replaceAll("'", "'\\''")}'`
139
+ return `#!/bin/sh
140
+ # ${SHIM_MARKER} — written by the martty package; not managed by npm.
141
+ target=${quoted}
142
+ if [ -f "$target" ]; then
143
+ exec node "$target" "$@"
144
+ fi
145
+ echo "${NOT_INSTALLED_LINE}" >&2
146
+ rm -f "$0" 2>/dev/null
147
+ exit 127
148
+ `
149
+ }
150
+
151
+ // Windows receives the same shim triplet npm generates (msys shell wrapper,
152
+ // .cmd, .ps1), with the same degrade-and-remove behavior. `rel` is the bin
153
+ // file's path relative to the shim directory with forward slashes;
154
+ // `backRel` is the backslash variant for cmd.exe.
155
+ function windowsShims(dir, binFile) {
156
+ const rel = path.relative(dir, binFile).split(path.sep).join('/')
157
+ const backRel = rel.split('/').join('\\')
158
+ return {
159
+ [COMMAND_NAME]: `#!/bin/sh
160
+ # ${SHIM_MARKER} — written by the martty package; not managed by npm.
161
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")
162
+
163
+ case \`uname\` in
164
+ *CYGWIN*|*MINGW*|*MSYS*)
165
+ if command -v cygpath > /dev/null 2>&1; then
166
+ basedir=\`cygpath -w "$basedir"\`
167
+ fi
168
+ ;;
169
+ esac
170
+
171
+ target="$basedir/${rel}"
172
+ if [ -f "$target" ]; then
173
+ exec node "$target" "$@"
174
+ fi
175
+ echo "${NOT_INSTALLED_LINE}" >&2
176
+ rm -f "$0" 2>/dev/null
177
+ exit 127
178
+ `,
179
+ [`${COMMAND_NAME}.cmd`]: `@echo off
180
+ rem ${SHIM_MARKER} - written by the martty package; not managed by npm.
181
+ set "target=%~dp0${backRel}"
182
+ if not exist "%target%" goto missing
183
+ node "%target%" %*
184
+ exit /b %ERRORLEVEL%
185
+ :missing
186
+ >&2 echo ${NOT_INSTALLED_LINE}
187
+ del "%~f0" 2>nul
188
+ exit /b 127
189
+ `,
190
+ [`${COMMAND_NAME}.ps1`]: `#!/usr/bin/env pwsh
191
+ # ${SHIM_MARKER} - written by the martty package; not managed by npm.
192
+ $target = Join-Path (Split-Path $MyInvocation.MyCommand.Definition -Parent) '${rel}'
193
+ if (Test-Path -LiteralPath $target) {
194
+ & node $target @args
195
+ exit $LASTEXITCODE
196
+ }
197
+ [Console]::Error.WriteLine('${NOT_INSTALLED_LINE}')
198
+ Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
199
+ exit 127
200
+ `,
201
+ }
202
+ }
203
+
204
+ // Create the `martty` command next to wherever `martty-cli` was invoked
205
+ // from (`argv[1]`) or inside `binDir` — which keeps it correct for global
206
+ // prefixes, `npm link`, and a project's node_modules/.bin alike. Existing
207
+ // entries are only replaced when they are ours or verifiably legacy-owned;
208
+ // a foreign `martty` is left untouched with a note about `martty-cli`.
209
+ export function ensureMarttyLink({ argv1, binDir, packageDir, platform = process.platform, write = stderrLine } = {}) {
210
+ const selfPackageDir = packageDir ?? path.dirname(path.dirname(fileURLToPath(import.meta.url)))
211
+ const binFile = path.join(selfPackageDir, BIN_FILE)
212
+ let dir = binDir ?? null
213
+ if (!dir) {
214
+ // Only an invocation under the declared `martty-cli` name earns a
215
+ // `martty` sibling — a plain `node bin/martty.js` must not scribble a
216
+ // link next to whatever script the caller pointed node at.
217
+ const invokedAs = argv1 ? path.basename(argv1).replace(/\.(cmd|ps1)$/i, '') : null
218
+ if (invokedAs !== DECLARED_BIN_NAME) return false
219
+ dir = path.dirname(argv1)
220
+ }
221
+
222
+ const target = path.join(dir, COMMAND_NAME)
223
+ const owner = commandOwner(target, binFile)
224
+ if (owner === 'ours') return true
225
+ if (owner === 'foreign') {
226
+ write(`martty: \`martty\` in ${dir} belongs to another tool — leaving it untouched.`)
227
+ write('martty: this package\'s command is `martty-cli`.')
228
+ return false
229
+ }
230
+
231
+ try {
232
+ fs.mkdirSync(dir, { recursive: true })
233
+ if (platform === 'win32') {
234
+ for (const [name, content] of Object.entries(windowsShims(dir, binFile))) {
235
+ fs.writeFileSync(path.join(dir, name), content)
236
+ }
237
+ } else {
238
+ fs.rmSync(target, { force: true })
239
+ fs.writeFileSync(target, posixShim(binFile))
240
+ fs.chmodSync(target, 0o755)
241
+ }
242
+ } catch {
243
+ write(`martty: could not create the \`martty\` command in ${dir} — run \`martty-cli\` instead.`)
244
+ return false
245
+ }
246
+ return true
247
+ }
248
+
249
+ function legacyNotice(write) {
250
+ write(`${LEGACY_PACKAGE} is the previous name of this package — it now ships as \`martty\`.`)
251
+ write(`To migrate: npm rm -g ${LEGACY_PACKAGE} && npm install --global martty`)
252
+ }
253
+
254
+ function marttyNotice(write, hint) {
255
+ write(`martty: ${LEGACY_PACKAGE} is still installed. Remove it to finish the rename:`)
256
+ write(`martty: ${hint}`)
257
+ }
258
+
259
+ // Remove the bin entries the legacy package owns in `prefix` (POSIX links
260
+ // and Windows shims alike). Foreign entries never qualify; undeletable
261
+ // ones are skipped — they must never fail an install or a launch.
262
+ function retireLegacyBins(prefix, write) {
263
+ const removed = new Set()
264
+ for (const name of LEGACY_BIN_NAMES) {
265
+ for (const file of binCandidates(prefix, name)) {
266
+ if (!isLegacyOwned(file)) continue
267
+ try {
268
+ fs.rmSync(file, { force: true })
269
+ removed.add(name)
270
+ } catch {
271
+ // An undeletable link must not fail the install; the nudge below
272
+ // still names the blocking package.
273
+ }
274
+ }
275
+ }
276
+ if (removed.size > 0) {
277
+ const list = [...removed].map((name) => `\`${name}\``).join(', ')
278
+ write(`martty: removed the stale ${list} command${removed.size > 1 ? 's' : ''} left by ${LEGACY_PACKAGE} — the TUI now runs as \`martty\`.`)
279
+ }
280
+ }
281
+
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
+
298
+ retireLegacyBins(prefix, write)
299
+
300
+ const packageDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
301
+ 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
+ }
308
+ }
309
+
310
+ // Neutralize the legacy package's `bin` declarations so a later
311
+ // `npm rm -g martty` — which deletes bin links by
312
+ // name without checking what they point at — cannot take the new `martty`
313
+ // command down with it. A real install gets the `bin` field stripped from
314
+ // its installed manifest; an `npm link`ed one is a symlink into the user's
315
+ // checkout — editing that file would dirty their worktree, so the symlink
316
+ // is replaced with a stub package (name + version only) which `npm rm -g`
317
+ // still removes cleanly. Never fails the install.
318
+ export function sanitizeLegacyInstall(prefix, write = stderrLine) {
319
+ for (const legacyDir of legacyInstallDirs(prefix)) {
320
+ let stat
321
+ try {
322
+ stat = fs.lstatSync(legacyDir)
323
+ } catch {
324
+ continue
325
+ }
326
+
327
+ if (stat.isSymbolicLink()) {
328
+ let version
329
+ try {
330
+ const manifest = JSON.parse(fs.readFileSync(path.join(legacyDir, 'package.json'), 'utf8'))
331
+ if (manifest && typeof manifest.version === 'string') version = manifest.version
332
+ } catch {
333
+ // Unreadable linked manifest — the stub below omits the version.
334
+ }
335
+ try {
336
+ fs.rmSync(legacyDir, { force: true })
337
+ fs.mkdirSync(legacyDir, { recursive: true })
338
+ const stub = { name: LEGACY_PACKAGE, ...(version === undefined ? {} : { version }) }
339
+ fs.writeFileSync(path.join(legacyDir, 'package.json'), `${JSON.stringify(stub, null, 2)}\n`)
340
+ } catch {
341
+ write(`martty: could not detach the linked ${LEGACY_PACKAGE} — remove it with \`npm rm -g ${LEGACY_PACKAGE}\`.`)
342
+ }
343
+ continue
344
+ }
345
+
346
+ if (!stat.isDirectory()) continue
347
+ const manifestPath = path.join(legacyDir, 'package.json')
348
+ let manifest
349
+ try {
350
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
351
+ } catch {
352
+ // A missing or unreadable manifest has no readable bin table for a
353
+ // later `npm rm -g` to act on either — nothing to strip.
354
+ continue
355
+ }
356
+ try {
357
+ if (manifest && typeof manifest === 'object' && 'bin' in manifest) {
358
+ delete manifest.bin
359
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
360
+ }
361
+ } catch {
362
+ write(`martty: could not edit the installed ${LEGACY_PACKAGE} manifest — remove it with \`npm rm -g ${LEGACY_PACKAGE}\`.`)
363
+ }
364
+ }
365
+ }
366
+
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
373
+ // bin/martty.js` or a dsh profile layout only gets the warning, never the
374
+ // filesystem changes.
375
+ export function warnAboutLegacyPackage({ argv1, libDir, write = stderrLine, platform = process.platform } = {}) {
376
+ const selfLibDir = libDir ?? path.dirname(fileURLToPath(import.meta.url))
377
+ const packageDir = path.dirname(selfLibDir)
378
+ if (path.basename(packageDir) === LEGACY_PACKAGE_DIR) {
379
+ legacyNotice(write)
380
+ return
381
+ }
382
+
383
+ try {
384
+ ensureMarttyLink({ argv1, packageDir, platform, write })
385
+ } catch {
386
+ // A read-only prefix or a missing bin dir must not break the launch.
387
+ }
388
+
389
+ // Candidate prefixes to converge: the global root this package sits in
390
+ // (lib/node_modules layout), plus the root behind the bin that launched
391
+ // us (covers `npm link` where the package resolves to a checkout).
392
+ const prefixes = []
393
+ const nodeModules = path.dirname(packageDir)
394
+ if (path.basename(nodeModules) === 'node_modules') {
395
+ const layoutDir = path.dirname(nodeModules)
396
+ prefixes.push(path.basename(layoutDir) === 'lib' ? path.dirname(layoutDir) : layoutDir)
397
+ }
398
+ if (argv1) {
399
+ const invokedAs = path.basename(argv1).replace(/\.(cmd|ps1)$/i, '')
400
+ const binDir = path.dirname(argv1)
401
+ if (
402
+ (invokedAs === COMMAND_NAME || invokedAs === DECLARED_BIN_NAME)
403
+ && (path.basename(binDir) === 'bin' || platform === 'win32')
404
+ ) {
405
+ prefixes.push(platform === 'win32' && path.basename(binDir) !== 'bin' ? binDir : path.dirname(binDir))
406
+ }
407
+ }
408
+
409
+ let hint = null
410
+ for (const prefix of new Set(prefixes)) {
411
+ if (!legacyInstallDirs(prefix).some((dir) => fs.existsSync(dir))) continue
412
+ const segments = prefix.split(path.sep)
413
+ const profileAt = segments.lastIndexOf('profiles')
414
+ if (profileAt >= 0) {
415
+ // A dsh profile: warn with the plugin-removal hint, touch nothing —
416
+ // profile layouts are managed by dsh, not npm.
417
+ hint ??= `dsh plugin --profile ${segments[profileAt + 1]} remove ${LEGACY_PACKAGE}`
418
+ continue
419
+ }
420
+ hint ??= `npm rm -g ${LEGACY_PACKAGE}`
421
+ 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)
426
+ } catch {
427
+ // Best effort: nothing here may break the launch.
428
+ }
429
+ }
430
+
431
+ if (hint) marttyNotice(write, hint)
432
+ }
433
+
434
+ // Deliver the launch notices collected by warnAboutLegacyPackage. On an
435
+ // interactive POSIX terminal the TUI repaints the whole screen for the
436
+ // session — printing now would bury the notice behind the first frame — so
437
+ // it is deferred to process exit, where it lands right above the next
438
+ // prompt. Piped stderr and Windows consoles get it immediately: an
439
+ // exit-handler write only flushes synchronously to a POSIX TTY or a file.
440
+ export function reportLaunchNotices(
441
+ lines,
442
+ { tty = process.stderr.isTTY, platform = process.platform, write = stderrLine } = {},
443
+ ) {
444
+ if (lines.length === 0) return
445
+ if (tty && platform !== 'win32') {
446
+ process.once('exit', () => {
447
+ for (const line of lines) write(line)
448
+ })
449
+ return
450
+ }
451
+ for (const line of lines) write(line)
452
+ }
453
+
454
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
455
+ migrate(process.env)
456
+ }
package/lib/queue-view.js CHANGED
@@ -29,11 +29,17 @@ function dockNodes(queue) {
29
29
  if (!Array.isArray(queue?.items) || queue.items.length === 0) return []
30
30
  const expanded = queue.selectedId !== null || queue.editingId !== null
31
31
  const count = Number.isInteger(queue.count) ? queue.count : queue.items.length
32
+ // A finished-but-failed attach never binds: the head cannot be sent, so
33
+ // the collapsed title names the failure instead of promising it.
32
34
  const title = expanded
33
35
  ? queue.editingId !== null
34
36
  ? `Queue · ${count} · enter save · ctrl+d delete · esc cancel`
35
37
  : `Queue · ${count} · ↑/↓ choose · enter edit · esc close`
36
- : `Queue · ${count} · enter send first · ⌥↑ edit`
38
+ : queue.attachFailed === true
39
+ ? `Queue · ${count} · attach failed · prompt stays queued`
40
+ : queue.authPending === true
41
+ ? `Queue · ${count} · sign-in needed · prompt stays queued`
42
+ : `Queue · ${count} · enter send first · ⌥↑ edit`
37
43
  const nodes = [{
38
44
  id: 'summary',
39
45
  kind: 'generic',
@@ -41,13 +41,27 @@ function statusMarkdown(status, stats) {
41
41
  const lines = []
42
42
  lines.push(`- state · ${status.state ?? 'idle'}`)
43
43
  lines.push(`- acp · ${status.connection ?? 'not attached'}`)
44
- if (status.error) lines.push(`- connection error · ${status.error}`)
44
+ if (status.error) {
45
+ // A failure carries its diagnostics tail as extra lines (e.g.
46
+ // "Agent stderr: …"); keep the bullet on the headline and give the rest
47
+ // a fenced block so long refusal text stays readable verbatim.
48
+ const [headline, ...rest] = status.error.split('\n')
49
+ lines.push(`- connection error · ${headline}`)
50
+ if (rest.length > 0) {
51
+ lines.push('', '```text', rest.join('\n'), '```')
52
+ }
53
+ }
45
54
  if (status.auth?.status !== undefined) {
46
55
  lines.push(`- auth · ${status.auth.status}${status.auth.method ? ` · ${status.auth.method}` : ''}`)
47
56
  if (status.auth.message) lines.push(`- auth error · ${status.auth.message}`)
48
57
  }
49
58
  lines.push(`- session · ${status.session?.bound ? status.session.sessionId : 'unbound'}`)
50
59
  if (status.server !== undefined) lines.push(`- server · ${status.server}`)
60
+ if (status.dshRuntime !== undefined) {
61
+ const runtime = [status.dshRuntime.version, status.dshRuntime.source && `via ${status.dshRuntime.source}`]
62
+ .filter(Boolean).join(' ')
63
+ lines.push(`- dsh · ${runtime}`)
64
+ }
51
65
  if (status.model !== undefined) lines.push(`- model · ${status.model}`)
52
66
  if (status.agent !== undefined) lines.push(`- agent · ${status.agent}`)
53
67
  if (status.permission !== undefined) lines.push(`- permission · ${status.permission}`)
@@ -76,14 +76,45 @@ export function tuiPluginRoot(env = process.env) {
76
76
  return path.join(marttyHome(env), 'plugins')
77
77
  }
78
78
 
79
+ /**
80
+ * Martty's configuration home, shared with Backchat: `~/.oma`.
81
+ * Resolution order: explicit `MARTTY_HOME`/`OMA_HOME`, then `$DSH_HOME/.oma`,
82
+ * then `~/.oma`. When the new home is missing but a legacy `.martty`
83
+ * directory sits next to it, its contents are migrated once (config, cache,
84
+ * sessions, managed binaries) so existing installs keep their state.
85
+ */
79
86
  export function marttyHome(env = process.env, userHome = homedir()) {
80
87
  if (typeof env.MARTTY_HOME === 'string' && env.MARTTY_HOME.length > 0) {
81
88
  return env.MARTTY_HOME
82
89
  }
83
- if (typeof env.DSH_HOME === 'string' && env.DSH_HOME.length > 0) {
84
- return path.join(env.DSH_HOME, '.martty')
90
+ if (typeof env.OMA_HOME === 'string' && env.OMA_HOME.length > 0) {
91
+ return env.OMA_HOME
85
92
  }
86
- return path.join(userHome, '.martty')
93
+ const base = typeof env.DSH_HOME === 'string' && env.DSH_HOME.length > 0
94
+ ? env.DSH_HOME
95
+ : userHome
96
+ return migrateMarttyHome(base)
97
+ }
98
+
99
+ function migrateMarttyHome(base) {
100
+ const home = path.join(base, '.oma')
101
+ const legacy = path.join(base, '.martty')
102
+ if (existsSync(home) || !existsSync(legacy)) return home
103
+ // Stage the copy under a temporary sibling so a crash cannot leave a
104
+ // half-migrated `.oma` behind; the legacy directory is left untouched.
105
+ const staged = `${home}.migrating-${process.pid}`
106
+ try {
107
+ rmSync(staged, { recursive: true, force: true })
108
+ cpSync(legacy, staged, { recursive: true, errorOnExist: true })
109
+ renameSync(staged, home)
110
+ process.stderr.write(`martty: migrated ${legacy} to ${home} (shared with Backchat)\n`)
111
+ } catch {
112
+ rmSync(staged, { recursive: true, force: true })
113
+ // Migration is best effort: an unmigrated legacy directory keeps working
114
+ // under its old name rather than blocking startup.
115
+ return legacy
116
+ }
117
+ return home
87
118
  }
88
119
 
89
120
  export function legacyTuiPluginRoot(env = process.env, userHome = homedir()) {
package/lib/tui-queue.js CHANGED
@@ -22,6 +22,8 @@ const EMPTY = Object.freeze({
22
22
  selectedId: null,
23
23
  editingId: null,
24
24
  deleteConfirm: false,
25
+ attachFailed: false,
26
+ authPending: false,
25
27
  })
26
28
 
27
29
  export function installTuiQueue(ctx) {
@@ -62,6 +64,8 @@ export function installTuiQueue(ctx) {
62
64
  selectedId: idOrNull(snapshot.selectedId),
63
65
  editingId: idOrNull(snapshot.editingId),
64
66
  deleteConfirm: snapshot.deleteConfirm === true,
67
+ attachFailed: snapshot.attachFailed === true,
68
+ authPending: snapshot.authPending === true,
65
69
  }
66
70
  const next = current()
67
71
  for (const listener of [...listeners]) listener(next)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martty",
3
- "version": "0.3.1",
3
+ "version": "0.3.2-beta.1",
4
4
  "description": "Terminal-native ACP client UI; Cordis client tree, any ACP agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,9 +14,10 @@
14
14
  "type": "module",
15
15
  "main": "lib/index.js",
16
16
  "scripts": {
17
+ "postinstall": "node lib/install-migration.js",
17
18
  "test:harness-ui": "node --test ../scripts/harness-management-tui.test.mjs ../scripts/harness-session-tui.test.mjs",
18
19
  "pretest": "node --test ../scripts/workflow-release.test.mjs ../scripts/package-alias.test.mjs ../scripts/harness-removal.test.mjs",
19
- "test": "node --test ../scripts/download.test.mjs ../scripts/package-native.test.mjs ../scripts/check-release-tag.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/packaged-acp-permission.test.mjs ../scripts/profile-smoke-tui.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-presets.test.mjs ../scripts/tui-plugin-store.test.mjs ../scripts/tui-local-plugins.test.mjs ../scripts/tui-client-plugin-registry.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/tui-agents.test.mjs ../scripts/tui-queue.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/acp-agent-pool.test.mjs ../scripts/harnesses.test.mjs ../scripts/harness-discovery.test.mjs ../scripts/harness-discovery-scenario.test.mjs ../scripts/harness-view.test.mjs ../scripts/harness-onboarding.test.mjs ../scripts/harness-registry.test.mjs ../scripts/harness-badge.test.mjs ../scripts/harness-package.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/agents-view.test.mjs ../scripts/plan-view.test.mjs ../scripts/queue-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs",
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",
20
21
  "test:profile-install-matrix": "node --test ../scripts/profile-install-matrix.test.mjs"
21
22
  },
22
23
  "publishConfig": {
@@ -56,8 +57,7 @@
56
57
  "./package.json": "./package.json"
57
58
  },
58
59
  "bin": {
59
- "martty": "bin/martty.js",
60
- "dsh-tui": "bin/martty.js"
60
+ "martty-cli": "bin/martty.js"
61
61
  },
62
62
  "files": [
63
63
  "bin",
@@ -71,18 +71,19 @@
71
71
  "dsh": {
72
72
  "bundle": {
73
73
  "patch": "./cordis.patch.yml"
74
- }
74
+ },
75
+ "supported": "0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2"
75
76
  },
76
77
  "dependencies": {
77
78
  "@deepseek-ai/cordis": "^4.0.1",
78
79
  "@lobehub/icons-static-svg": "1.95.0",
79
- "@openma/deepseek-harness-acp": "0.4.35",
80
+ "@openma/deepseek-harness-acp": "0.4.37",
80
81
  "@resvg/resvg-js": "2.6.2",
81
82
  "cross-spawn": "^7.0.6",
82
83
  "node-downloader-helper": "2.1.11"
83
84
  },
84
85
  "devDependencies": {
85
- "@deepseek-ai/dsh": "0.1.7-rc.2"
86
+ "@deepseek-ai/dsh": "0.2.0-rc.2"
86
87
  },
87
88
  "engines": {
88
89
  "node": ">=22.19.0"
@@ -96,10 +97,10 @@
96
97
  "agent-client-protocol"
97
98
  ],
98
99
  "optionalDependencies": {
99
- "@openma/martty-darwin-arm64": "0.3.1",
100
- "@openma/martty-darwin-x64": "0.3.1",
101
- "@openma/martty-linux-x64": "0.3.1",
102
- "@openma/martty-linux-arm64": "0.3.1",
103
- "@openma/martty-win32-x64": "0.3.1"
100
+ "@openma/martty-darwin-arm64": "0.3.2-beta.1",
101
+ "@openma/martty-darwin-x64": "0.3.2-beta.1",
102
+ "@openma/martty-linux-x64": "0.3.2-beta.1",
103
+ "@openma/martty-linux-arm64": "0.3.2-beta.1",
104
+ "@openma/martty-win32-x64": "0.3.2-beta.1"
104
105
  }
105
106
  }
@@ -56,7 +56,7 @@ depend on a value returned by `apply()` as the disposer; register effects
56
56
  through the inspected Cordis APIs.
57
57
 
58
58
  The authoring root is `$MARTTY_HOME/plugins`; `MARTTY_HOME` resolves from the
59
- explicit environment variable, then `$DSH_HOME/.martty`, then `~/.martty`.
59
+ explicit environment variable or `OMA_HOME`, then `$DSH_HOME/.oma`, then `~/.oma`.
60
60
  User selections live separately in `$MARTTY_HOME/settings.json`.
61
61
 
62
62
  ## Inspect is the authority