martty 0.3.2-beta.0 → 0.3.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,9 +15,10 @@ x64. Requires Node.js 22.19+.
15
15
 
16
16
  > **Rename note:** the project and recommended npm package have moved from
17
17
  > DeepSeek Harness TUI / `martty` to **Martty** / `martty`.
18
- > The `dsh-tui` command, configuration, and session data remain compatible.
19
- > The production profile is now `martty`; legacy `tui` profiles
20
- > continue to work.
18
+ > Configuration and session data remain compatible. The production profile is
19
+ > now `martty`; legacy `tui` profiles continue to work. The `dsh-tui` command
20
+ > is no longer installed by the `martty` package — see
21
+ > [Migrating from DeepSeek Harness TUI / the scoped package](#migrating-from-deepseek-harness-tui--the-scoped-package).
21
22
 
22
23
  ## Global Agent TUI
23
24
 
@@ -49,8 +50,35 @@ missing profile and installs the TUI plus its ACP dependency; no global
49
50
 
50
51
  `martty` is the recommended package name. Starting with `0.2.13`, it and the
51
52
  legacy `martty` package are published by the same CI run
52
- with identical versions and artifacts. Existing scoped-package installs remain
53
- supported. To switch package names:
53
+ with identical versions and artifacts. The scoped name is deprecated; switch
54
+ package names with:
55
+
56
+ ```sh
57
+ npm install --global martty
58
+ ```
59
+
60
+ No removal is needed first, whatever state the legacy package is in —
61
+ published versions of `martty` register `dsh-tui`
62
+ (every version) and `martty` (0.2.14/0.2.20) global commands, and `npm link`ed
63
+ legacy checkouts own the same names (a checkout of this repository links as
64
+ `martty`, so it replaces — or is replaced by — an installed `martty` with no
65
+ collision). The `martty` package declares only `martty-cli` so npm never
66
+ refuses the install over a bin another package owns (`EEXIST`); a
67
+ postinstall step then retires the stale `martty`/`dsh-tui`/`dsb` links that
68
+ verifiably belonged to the legacy package (a third-party `dsh-tui` stays
69
+ untouched), creates the `martty` command itself, and detaches the legacy
70
+ package's bin declarations so a later `npm rm -g` can't take `martty` down
71
+ with it. Removing `martty` afterwards is optional
72
+ cleanup; `martty` warns at launch while it remains — on an interactive
73
+ terminal the notice prints when the session exits, so the TUI cannot hide
74
+ it. With `--ignore-scripts`
75
+ the postinstall never runs — invoke `martty-cli` once (or `npx martty`) and
76
+ the first launch finishes the migration itself. And after `npm rm -g
77
+ martty`, the `martty` command left behind is a self-cleaning shim: invoking
78
+ it once prints the reinstall hint and removes it — nothing dead is left
79
+ in `<prefix>/bin`.
80
+
81
+ Inside a `dsh` profile the same switch is:
54
82
 
55
83
  ```sh
56
84
  dsh plugin --profile tui remove martty
@@ -58,8 +86,13 @@ dsh plugin --profile martty add martty@latest
58
86
  ```
59
87
 
60
88
  The recommended profile changes from `tui` to `martty`; runtime behavior,
61
- config, and session data remain unchanged. `dsh-tui` and legacy `tui`
62
- profiles remain compatible.
89
+ config, and session data remain unchanged. Legacy `tui` profiles remain
90
+ compatible.
91
+
92
+ The `martty` package no longer installs a `dsh-tui` command: the name
93
+ collided with both the legacy package and an unrelated `dsh-tui` package on
94
+ npm, which blocked `npm install --global martty` outright. If your fingers
95
+ still type `dsh-tui`, alias it: `alias dsh-tui=martty`.
63
96
 
64
97
  The profile Host mounts the ACP plugin on Base, then starts a separate TUI
65
98
  Client process over standard ACP stdin/stdout. For standalone use, run
@@ -229,7 +262,8 @@ npx --yes martty --demo
229
262
 
230
263
  The profile path remains recommended for an existing DSH installation that
231
264
  wants standard plugin management and a durable `martty` profile. `martty` is the
232
- primary command; `dsh-tui` remains a compatibility alias.
265
+ primary command; the legacy `dsh-tui` command is no longer installed
266
+ (`alias dsh-tui=martty` restores it).
233
267
 
234
268
  ## Highlights
235
269
 
package/bin/martty.js CHANGED
@@ -9,10 +9,15 @@ import { resolveDependencyStack } from '../lib/agent.js'
9
9
  import { bootClient, parseClientArgv, painterArgs, uiSettingsPath } from '../lib/boot.js'
10
10
  import { runHarnessCommandAsync } from '../lib/harnesses.js'
11
11
  import { nativeBinary } from '../lib/spawn-tui.js'
12
+ import { reportLaunchNotices, warnAboutLegacyPackage } from '../lib/install-migration.js'
12
13
 
13
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
14
15
  const configuredBin = process.env.MARTTY_BIN || process.env.DSH_TUI_BIN
15
16
 
17
+ const migrationNotices = []
18
+ warnAboutLegacyPackage({ argv1: process.argv[1], write: (line) => migrationNotices.push(line) })
19
+ reportLaunchNotices(migrationNotices)
20
+
16
21
  const argv = process.argv.slice(2)
17
22
  const wantDemoSkin = argv.includes('--demo-skin')
18
23
  const wantDemo = argv.includes('--demo')
@@ -123,7 +123,9 @@ export function createAgentPool(initial, { spawnAgent, resolveAgent, diagnosticE
123
123
  owner.failed = diagnosticError(error, owner.handle)
124
124
  for (const pending of owner.pending.values()) {
125
125
  if (pending.reject) pending.reject(owner.failed)
126
- else write(response(owner, { error: { code: -32603, message: owner.failed.message } }, pending))
126
+ // `agentExited` marks the reply as pool-synthesized so the client can
127
+ // tell a dead child apart from a refusal the agent itself authored.
128
+ else write(response(owner, { error: { code: -32603, message: owner.failed.message, data: { agentExited: true } } }, pending))
127
129
  }
128
130
  owner.pending.clear()
129
131
  }
package/lib/acp-client.js CHANGED
@@ -105,7 +105,12 @@ export function apply(ctx, config = {}) {
105
105
  }
106
106
  }
107
107
  liveAgent = null
108
- const service = createAgentPool(agent, { spawnAgent, resolveAgent, diagnosticError })
108
+ const noteAgentDiagnostics = installAgentWarnings(ctx)
109
+ const service = createAgentPool(agent, {
110
+ spawnAgent: (spec) => spawnAgent(spec, noteAgentDiagnostics),
111
+ resolveAgent,
112
+ diagnosticError,
113
+ })
109
114
  if (liveAgentExitHook !== null) process.removeListener('exit', liveAgentExitHook)
110
115
  liveAgentExitHook = () => service.close()
111
116
  process.once('exit', liveAgentExitHook)
@@ -113,14 +118,14 @@ export function apply(ctx, config = {}) {
113
118
  provide(ctx, service)
114
119
  }
115
120
 
116
- function spawnAgent(agent) {
121
+ function spawnAgent(agent, onDiagnosticLine) {
117
122
  const child = spawn(agent.command, agent.args ?? [], {
118
123
  stdio: ['pipe', 'pipe', 'pipe'],
119
124
  env: harnessEnvironment(agent.env),
120
125
  })
121
126
  child.stdin.on('error', () => {})
122
127
  child.stdout.on('error', () => {})
123
- const diagnostics = drainDiagnostics(child.stderr)
128
+ const diagnostics = drainDiagnostics(child.stderr, onDiagnosticLine)
124
129
  return {
125
130
  kind: 'spawn',
126
131
  command: agent.command,
@@ -133,14 +138,67 @@ function spawnAgent(agent) {
133
138
  }
134
139
  }
135
140
 
136
- function drainDiagnostics(stream) {
141
+ /**
142
+ * Agent stderr lines worth surfacing in the input dock: warnings and
143
+ * failures the agent reports but survives (e.g. a bundle entry that did
144
+ * not activate) would otherwise be invisible until something exits.
145
+ */
146
+ const DIAGNOSTIC_NOTICE = /warn|error|fail|did not activate|refus|incompat/i
147
+
148
+ /**
149
+ * Feed warning-shaped agent stderr lines to a `conversation.input.dock`
150
+ * notice when the client tree carries `tuiSlots`. Returns undefined when the
151
+ * slot service is absent — stderr stays diagnostics-only as before.
152
+ */
153
+ function installAgentWarnings(ctx) {
154
+ const slots = ctx.get?.('tuiSlots') ?? ctx.tuiSlots
155
+ if (typeof slots?.register !== 'function') return undefined
156
+ let controller
157
+ try {
158
+ controller = slots.register(
159
+ { name: 'conversation.input.dock', id: 'acp-agent-warnings' },
160
+ [],
161
+ )
162
+ } catch {
163
+ return undefined
164
+ }
165
+ const shown = []
166
+ return (line) => {
167
+ if (!DIAGNOSTIC_NOTICE.test(line)) return
168
+ shown.push(line.trimEnd())
169
+ if (shown.length > 4) shown.shift()
170
+ try {
171
+ controller.update([{ id: 'latest', kind: 'notice', level: 'warn', text: shown.join('\n') }])
172
+ } catch {
173
+ // The contribution is gone (disposed or re-mounted); stderr keeps
174
+ // flowing to the diagnostics tail regardless.
175
+ }
176
+ }
177
+ }
178
+
179
+ function drainDiagnostics(stream, onLine) {
137
180
  const limit = 8192
138
181
  let tail = Buffer.alloc(0)
182
+ let pending = ''
139
183
  let state = 'text'
184
+ const emitLines = (text, flush = false) => {
185
+ if (onLine === undefined) return
186
+ pending += text
187
+ for (let index = pending.indexOf('\n'); index !== -1; index = pending.indexOf('\n')) {
188
+ const line = pending.slice(0, index)
189
+ pending = pending.slice(index + 1)
190
+ if (line.trim() !== '') onLine(line)
191
+ }
192
+ if (flush && pending.trim() !== '') {
193
+ onLine(pending)
194
+ pending = ''
195
+ }
196
+ }
140
197
  // stderr is not a UI surface. Drain it even without a diagnostics consumer,
141
198
  // strip control strings incrementally, and never retain their payloads.
142
199
  stream.setEncoding('utf8')
143
200
  stream.on('error', () => {})
201
+ stream.on('close', () => emitLines('', true))
144
202
  stream.on('data', (chunk) => {
145
203
  let text = ''
146
204
  for (const char of chunk) {
@@ -174,6 +232,7 @@ function drainDiagnostics(stream) {
174
232
  text += char
175
233
  }
176
234
  if (text.length === 0) return
235
+ emitLines(text)
177
236
  const combined = Buffer.concat([tail, Buffer.from(text)])
178
237
  let start = Math.max(0, combined.length - limit)
179
238
  // Never split a UTF-8 code point or retain the large incoming allocation.
@@ -68,6 +68,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
68
68
  let selectionKnown = false
69
69
  let connection = 'connecting'
70
70
  let server
71
+ let dshRuntime
71
72
  const auth = { status: undefined, method: undefined }
72
73
  let initializeId
73
74
  let authMethods = []
@@ -94,7 +95,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
94
95
  const value = stateFor(sessionId)
95
96
  const { permissionPreset: _permissionPreset, sandboxMode: _sandboxMode,
96
97
  connectionFacts, ...visible } = value
97
- return structuredClone({ connection, server, auth, ...connectionFacts, ...visible })
98
+ return structuredClone({ connection, server, dshRuntime, auth, ...connectionFacts, ...visible })
98
99
  }
99
100
 
100
101
  function publish() {
@@ -146,6 +147,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
146
147
  selectionKnown = false
147
148
  connection = 'connecting'
148
149
  server = undefined
150
+ dshRuntime = undefined
149
151
  auth.status = undefined
150
152
  auth.method = undefined
151
153
  delete auth.message
@@ -197,13 +199,44 @@ export function installAcpSessionStatus(ctx, options = {}) {
197
199
  if (!object(message)) return
198
200
  if (message.id !== undefined && message.id === initializeId) {
199
201
  initializeId = undefined
200
- connection = message.error === undefined ? 'attached' : 'failed'
201
202
  fallback.error = message.error?.message
202
203
  const result = object(message.result) ? message.result : undefined
203
204
  server = readString(result?.agentInfo, 'name')
205
+ // The runtime the ACP agent actually booted (dsh-acp advertises it in
206
+ // agentCapabilities._meta.dsh.runtime); shown in /status so users can
207
+ // tell bundled from host installs (issue #157).
208
+ dshRuntime = readDshRuntime(result?.agentCapabilities)
204
209
  const methods = Array.isArray(result?.authMethods) ? result.authMethods : []
205
210
  authMethods = methods
206
211
  // initialize advertises choices, not the credential used by this process.
212
+ if (message.error === undefined) {
213
+ connection = 'attached'
214
+ } else {
215
+ // An agent that answered `initialize` with a refusal is still
216
+ // attached — only a locally synthesized failure (the pool's
217
+ // agentExited marker for a dead child) counts as 'failed'. A refused
218
+ // handshake may advertise auth methods in its error data (the -32000
219
+ // convention): surface sign-in the same way the refused
220
+ // session/new path does. A refusal demanding auth while declaring
221
+ // no methods can never be remedied, so it stays 'failed'.
222
+ const offered = Array.isArray(message.error.data?.authMethods)
223
+ ? message.error.data.authMethods : []
224
+ if (message.error.data?.agentExited === true
225
+ || (isAuthRequired(message.error) && offered.length === 0)) {
226
+ connection = 'failed'
227
+ } else {
228
+ connection = 'refused'
229
+ if (isAuthRequired(message.error)) {
230
+ authMethods = offered
231
+ auth.status = 'needs sign-in'
232
+ }
233
+ // The refusal's own words are the diagnosis — our transport's
234
+ // appended "Agent stderr:" tail is unrelated launch noise.
235
+ if (typeof fallback.error === 'string') {
236
+ fallback.error = fallback.error.split('\nAgent stderr:')[0].trimEnd()
237
+ }
238
+ }
239
+ }
207
240
  publish()
208
241
  return
209
242
  }
@@ -230,7 +263,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
230
263
  if (message.error !== undefined) {
231
264
  if (isAuthRequired(message.error)) auth.status = 'needs sign-in'
232
265
  else {
233
- connection = 'failed'
266
+ connection = message.error.data?.agentExited === true ? 'failed' : 'refused'
234
267
  const value = stateFor(setup.sessionId, false)
235
268
  value.error = readString(message.error, 'message') ?? 'Session setup failed'
236
269
  value.state = 'idle'
@@ -416,6 +449,15 @@ function isAuthRequired(error) {
416
449
  || error.code === 'auth_required' || error.code === 'AuthRequired')
417
450
  }
418
451
 
452
+ function readDshRuntime(capabilities) {
453
+ const runtime = capabilities?._meta?.dsh?.runtime
454
+ if (!object(runtime)) return undefined
455
+ const version = readString(runtime, 'version')
456
+ const source = readString(runtime, 'source')
457
+ if (version === undefined && source === undefined) return undefined
458
+ return { version, source, base: readString(runtime, 'base') }
459
+ }
460
+
419
461
  function readString(value, ...keys) {
420
462
  if (!object(value)) return undefined
421
463
  for (const key of keys) {
package/lib/agent.js CHANGED
@@ -75,16 +75,11 @@ export function resolveStackedAgent(anchor = import.meta.url, options = {}) {
75
75
  const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
76
76
  for (const profile of ['tui-test', 'martty', 'tui']) {
77
77
  const bin = join(home, 'profiles', profile, 'node_modules', '.bin', 'dsh-acp')
78
- const creator = join(
79
- home,
80
- 'profiles',
81
- profile,
82
- 'node_modules',
83
- '@openma',
84
- 'deepseek-harness-tui',
85
- 'creator',
86
- )
87
- if (existsSync(bin) && existsSync(join(creator, 'package.json'))) {
78
+ const creator = [
79
+ join(home, 'profiles', profile, 'node_modules', '@openma', 'deepseek-harness-tui', 'creator'),
80
+ join(home, 'profiles', profile, 'node_modules', 'martty', 'creator'),
81
+ ].find((dir) => existsSync(join(dir, 'package.json')))
82
+ if (existsSync(bin) && creator) {
88
83
  return { command: bin, args: ['--bundle', creator] }
89
84
  }
90
85
  }
@@ -25,6 +25,38 @@ const jsonOutput = {
25
25
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
26
26
  }
27
27
 
28
+ /**
29
+ * Whether an acquireScope rejection says the preset is not registered yet.
30
+ * Preset declarations register asynchronously while the Host activates, so
31
+ * the overlay can run while the registry is still empty — this is the one
32
+ * failure worth retrying. Everything else (broken compositions, transport
33
+ * failures) fails fast.
34
+ */
35
+ function presetNotFound(error) {
36
+ return error?.code === 'agent-preset/not-found'
37
+ || /^Unknown agent preset: /.test(error?.message ?? '')
38
+ }
39
+
40
+ /**
41
+ * Acquire one preset scope lease, retrying `agent-preset/not-found` until
42
+ * `waitMs` elapses — the registry may still be collecting declarations when
43
+ * this plugin mounts. A registry without `acquireScope` resolves undefined
44
+ * so the caller can reject it with the usual lease check.
45
+ */
46
+ async function acquirePresetScope(agentPresets, preset, waitMs, pollMs) {
47
+ const acquire = agentPresets?.acquireScope
48
+ if (typeof acquire !== 'function') return undefined
49
+ const deadline = Date.now() + waitMs
50
+ for (;;) {
51
+ try {
52
+ return await acquire.call(agentPresets, preset)
53
+ } catch (error) {
54
+ if (!presetNotFound(error) || Date.now() >= deadline) throw error
55
+ await new Promise((resolve) => setTimeout(resolve, pollMs))
56
+ }
57
+ }
58
+ }
59
+
28
60
  function requireAgent(exec) {
29
61
  if (exec?.agent === undefined) {
30
62
  throw new Error('TUI artifact tools require an Agent-backed Creator session')
@@ -190,7 +222,7 @@ const skillContent = skillDocument.slice(skillFrontmatter[0].length)
190
222
 
191
223
  /**
192
224
  * @param {object} ctx
193
- * @param {{ preset?: string }} [config]
225
+ * @param {{ preset?: string, scopeWaitMs?: number, scopePollMs?: number }} [config]
194
226
  */
195
227
  export async function apply(ctx, config = {}) {
196
228
  // Standalone launches mount this overlay directly into the ACP Host tree,
@@ -202,13 +234,38 @@ export async function apply(ctx, config = {}) {
202
234
  if (typeof preset !== 'string' || preset.length === 0) {
203
235
  throw new Error('tui-creator-overlay: preset must be a non-empty string')
204
236
  }
205
- const key = await ctx.agentPresets.standingKeyFor(preset)
206
- const scopeModule = await ctx.loader.import('@deepseek-ai/dsh-scope')
207
- if (typeof scopeModule?.createScope !== 'function') {
208
- throw new Error('tui-creator-overlay: profile dsh-scope module is unavailable')
237
+ // The preset registry hands out scope keys through acquireScope (a lease
238
+ // carrying generation.key); standingKeyFor never existed on the shipped
239
+ // registry. Holding the lease for the overlay's lifetime keeps the scope
240
+ // generation alive until the overlay is disposed. Declarations land after
241
+ // dependent rows activate, so a `not-found` result is retried briefly.
242
+ const scopeWaitMs = Number.isFinite(config.scopeWaitMs) ? Math.max(0, config.scopeWaitMs) : 10_000
243
+ const scopePollMs = Number.isFinite(config.scopePollMs) ? Math.max(1, config.scopePollMs) : 50
244
+ const lease = await acquirePresetScope(ctx.agentPresets, preset, scopeWaitMs, scopePollMs)
245
+ if (lease === null || typeof lease !== 'object' || !('key' in lease)) {
246
+ throw new Error(`tui-creator-overlay: preset scope is unavailable for ${JSON.stringify(preset)}`)
247
+ }
248
+ const release = typeof lease[Symbol.asyncDispose] === 'function'
249
+ ? () => lease[Symbol.asyncDispose]()
250
+ : async () => {}
251
+ let overlay
252
+ try {
253
+ const scopeModule = await ctx.loader.import('@deepseek-ai/dsh-scope')
254
+ if (typeof scopeModule?.createScope !== 'function') {
255
+ throw new Error('tui-creator-overlay: profile dsh-scope module is unavailable')
256
+ }
257
+ overlay = scopeModule.createScope(ctx, lease.key)
258
+ } catch (error) {
259
+ await release()
260
+ throw error
261
+ }
262
+ const dispose = async () => {
263
+ try {
264
+ await overlay.dispose()
265
+ } finally {
266
+ await release()
267
+ }
209
268
  }
210
- const { createScope } = scopeModule
211
- const overlay = createScope(ctx, key)
212
269
  try {
213
270
  const toolsModule = await ctx.loader.import('@deepseek-ai/dsh-tools')
214
271
  if (typeof toolsModule?.defineTool !== 'function') {
@@ -240,9 +297,9 @@ export async function apply(ctx, config = {}) {
240
297
  toolsModule.defineTool,
241
298
  createTuiPluginStore({ root: config.artifactRoot }),
242
299
  )
243
- ctx.effect(() => () => overlay.dispose(), `tui-creator-overlay(${JSON.stringify(preset)})`)
300
+ ctx.effect(() => dispose, `tui-creator-overlay(${JSON.stringify(preset)})`)
244
301
  } catch (error) {
245
- await overlay.dispose()
302
+ await dispose()
246
303
  throw error
247
304
  }
248
305
  }
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}`)
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.2-beta.0",
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/release-check.test.mjs ../scripts/check-static-elf.test.mjs ../scripts/smoke-old-linux.test.mjs ../scripts/cargo-guard.test.mjs ../scripts/build-npm.test.mjs ../scripts/client-profile.test.mjs ../scripts/plugin-runner.test.mjs ../scripts/profile-link-resolution.test.mjs ../scripts/packaged-acp-permission.test.mjs ../scripts/profile-smoke-tui.test.mjs ../scripts/release.test.mjs ../scripts/jsonrpc-line-transport.test.mjs ../scripts/tui-theme.test.mjs ../scripts/tui-presets.test.mjs ../scripts/tui-plugin-store.test.mjs ../scripts/tui-local-plugins.test.mjs ../scripts/tui-client-plugin-registry.test.mjs ../scripts/tui-slots.test.mjs ../scripts/tui-commands.test.mjs ../scripts/tui-overlay.test.mjs ../scripts/tui-agents.test.mjs ../scripts/tui-queue.test.mjs ../scripts/mux.test.mjs ../scripts/acp-client.test.mjs ../scripts/acp-agent-pool.test.mjs ../scripts/harnesses.test.mjs ../scripts/harness-discovery.test.mjs ../scripts/harness-discovery-scenario.test.mjs ../scripts/harness-view.test.mjs ../scripts/harness-onboarding.test.mjs ../scripts/harness-registry.test.mjs ../scripts/harness-badge.test.mjs ../scripts/harness-package.test.mjs ../scripts/acp-client-events.test.mjs ../scripts/acp-session-config.test.mjs ../scripts/acp-session-plan.test.mjs ../scripts/acp-session-stats.test.mjs ../scripts/acp-session-status.test.mjs ../scripts/agents-view.test.mjs ../scripts/plan-view.test.mjs ../scripts/queue-view.test.mjs ../scripts/stats-view.test.mjs ../scripts/status-view.test.mjs ../scripts/deepseek-logo.test.mjs ../scripts/runner.test.mjs ../scripts/inspect.test.mjs ../scripts/creator-overlay.test.mjs ../scripts/real-agent-e2e.test.mjs",
20
+ "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,7 +71,8 @@
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",
@@ -96,10 +97,10 @@
96
97
  "agent-client-protocol"
97
98
  ],
98
99
  "optionalDependencies": {
99
- "@openma/martty-darwin-arm64": "0.3.2-beta.0",
100
- "@openma/martty-darwin-x64": "0.3.2-beta.0",
101
- "@openma/martty-linux-x64": "0.3.2-beta.0",
102
- "@openma/martty-linux-arm64": "0.3.2-beta.0",
103
- "@openma/martty-win32-x64": "0.3.2-beta.0"
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
  }