martty 0.2.11

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -0
  3. package/bin/dsh-tui.js +68 -0
  4. package/cordis.patch.yml +30 -0
  5. package/creator/cordis.patch.yml +6 -0
  6. package/creator/package.json +10 -0
  7. package/lib/acp-client-events.js +65 -0
  8. package/lib/acp-client.js +114 -0
  9. package/lib/acp-host.js +24 -0
  10. package/lib/acp-session-config.js +376 -0
  11. package/lib/acp-session-plan.js +196 -0
  12. package/lib/acp-session-stats.js +239 -0
  13. package/lib/agent.js +64 -0
  14. package/lib/boot.js +119 -0
  15. package/lib/client-process.js +11 -0
  16. package/lib/client-run.js +379 -0
  17. package/lib/cordis-protocol.js +51 -0
  18. package/lib/creator-overlay.js +77 -0
  19. package/lib/demo-skin.js +79 -0
  20. package/lib/ember.js +20 -0
  21. package/lib/index.js +226 -0
  22. package/lib/inspect.js +971 -0
  23. package/lib/jsonrpc-line-transport.js +155 -0
  24. package/lib/mux.js +281 -0
  25. package/lib/palettes/default.json +44 -0
  26. package/lib/palettes/ember.json +44 -0
  27. package/lib/plan-view.js +92 -0
  28. package/lib/profile-acp-client.js +11 -0
  29. package/lib/right-demo.js +55 -0
  30. package/lib/runner.js +94 -0
  31. package/lib/spawn-tui.js +179 -0
  32. package/lib/stats-view.js +90 -0
  33. package/lib/tui-commands.js +144 -0
  34. package/lib/tui-overlay.js +252 -0
  35. package/lib/tui-slots.js +351 -0
  36. package/lib/tui-theme.js +463 -0
  37. package/package.json +83 -0
  38. package/skills/tui-plugin-development/SKILL.md +172 -0
  39. package/vendor/darwin-arm64/dsh-tui +0 -0
  40. package/vendor/darwin-x64/dsh-tui +0 -0
  41. package/vendor/linux-arm64/dsh-tui +0 -0
  42. package/vendor/linux-x64/dsh-tui +0 -0
  43. package/vendor/win32-x64/dsh-tui.exe +0 -0
package/lib/runner.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Host-side TUI launcher.
3
+ *
4
+ * The dsh profile keeps the Base and ACP server on its Host Cordis tree. This
5
+ * plugin starts a separate Node process for the TUI Client Cordis tree and
6
+ * connects the two processes through the Client process's standard ACP
7
+ * stdin/stdout. Separate inherited descriptors carry the user's TTY.
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import { createRequire } from 'node:module'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ export const name = 'dsh-tui-runner'
15
+ export const inject = ['loader', 'acpServer', 'cmdlineArgs', 'appExit']
16
+
17
+ const clientEntry = fileURLToPath(new URL('./client-process.js', import.meta.url))
18
+ const requireFromTui = createRequire(import.meta.url)
19
+
20
+ function ownAcpBridge() {
21
+ const specifier = '@openma/deepseek-harness-acp/bridge'
22
+ try {
23
+ return requireFromTui.resolve(specifier)
24
+ } catch {
25
+ // A source-linked package may rely on the active profile's installation.
26
+ return specifier
27
+ }
28
+ }
29
+
30
+ function appExitOf(ctx) {
31
+ return ctx.root?.get?.('appExit') ?? ctx.get?.('appExit') ?? ctx.appExit
32
+ }
33
+
34
+ /** @param {object} ctx */
35
+ export async function apply(ctx) {
36
+ const bridge = await ctx.loader.import(ownAcpBridge())
37
+ if (typeof bridge.nodeAcpStream !== 'function') {
38
+ throw new Error('dsh-tui: ACP bridge does not export nodeAcpStream')
39
+ }
40
+ const child = spawn(
41
+ process.execPath,
42
+ [clientEntry, ...ctx.cmdlineArgs.get()],
43
+ {
44
+ // ACP owns the Client process's stdin/stdout. Its fd 3/4 retain the
45
+ // user's terminal for the Rust painter spawned inside that process.
46
+ stdio: ['pipe', 'pipe', 'inherit', process.stdin, process.stdout],
47
+ env: process.env,
48
+ },
49
+ )
50
+ const agentToClient = child.stdin
51
+ const clientToAgent = child.stdout
52
+ agentToClient?.on?.('error', () => {})
53
+ clientToAgent?.on?.('error', () => {})
54
+
55
+ let connection
56
+ let released = false
57
+ let exitRequested = false
58
+ const requestExit = (code) => {
59
+ if (exitRequested) return
60
+ exitRequested = true
61
+ appExitOf(ctx)?.(code)
62
+ }
63
+ const release = async () => {
64
+ if (released) return
65
+ released = true
66
+ process.off('exit', onProcessExit)
67
+ if (child.exitCode === null) child.kill('SIGTERM')
68
+ await connection?.dispose?.()
69
+ }
70
+ const onProcessExit = () => {
71
+ if (child.exitCode === null) child.kill('SIGTERM')
72
+ }
73
+ child.once('error', (error) => {
74
+ console.error(`dsh-tui: failed to start Client process: ${error.message}`)
75
+ requestExit(1)
76
+ void release()
77
+ })
78
+ child.once('exit', (code, signal) => {
79
+ requestExit(code ?? (signal === 'SIGINT' ? 130 : 1))
80
+ void release()
81
+ })
82
+ process.once('exit', onProcessExit)
83
+
84
+ try {
85
+ connection = ctx.acpServer.connect(
86
+ bridge.nodeAcpStream(clientToAgent, agentToClient),
87
+ )
88
+ await connection
89
+ } catch (error) {
90
+ await release()
91
+ throw error
92
+ }
93
+ ctx.effect(() => release)
94
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Spawn the platform-native `dsh-tui` binary for plugin / demo-skin attach.
3
+ *
4
+ * Unix extra fds: 3 = Node→TUI (Rust reads), 4 = TUI→Node (Rust writes).
5
+ * Windows (and DSH_TUI_FORCE_TCP=1): authenticated loopback TCP.
6
+ */
7
+
8
+ import { spawn } from 'node:child_process'
9
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
10
+ import fs from 'node:fs'
11
+ import { createServer } from 'node:net'
12
+ import path from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
16
+
17
+ /** Select the first existing native painter in development-to-release order. */
18
+ export function selectNativeBinary({ envBin, devBin, packagedBin }) {
19
+ for (const candidate of [envBin, devBin, packagedBin]) {
20
+ if (typeof candidate === 'string' && candidate.length > 0 && fs.existsSync(candidate)) {
21
+ return candidate
22
+ }
23
+ }
24
+ return undefined
25
+ }
26
+
27
+ /**
28
+ * Packaged native binary for this platform, or throw if it was not staged.
29
+ * Honors `DSH_TUI_BIN` when that path exists (source / cargo re-exec).
30
+ * @returns {string}
31
+ */
32
+ export function nativeBinary() {
33
+ const envBin = process.env.DSH_TUI_BIN
34
+ const key = `${process.platform}-${process.arch}`
35
+ const exe = process.platform === 'win32' ? 'dsh-tui.exe' : 'dsh-tui'
36
+ const bin = path.join(__dirname, '..', 'vendor', key, exe)
37
+ const devBin = path.join(__dirname, '..', '..', 'target', 'debug', exe)
38
+ const selected = selectNativeBinary({ envBin, devBin, packagedBin: bin })
39
+ if (selected === undefined) {
40
+ let have = []
41
+ try {
42
+ have = fs.readdirSync(path.join(__dirname, '..', 'vendor'))
43
+ } catch {
44
+ // vendor dir missing entirely
45
+ }
46
+ throw new Error(
47
+ `martty: no native binary for ${key}` +
48
+ ` (packaged: ${have.join(', ') || 'none'});` +
49
+ ' rebuild the package on this machine with scripts/build-npm.sh',
50
+ )
51
+ }
52
+ return selected
53
+ }
54
+
55
+ /**
56
+ * Spawn the TUI in attach mode. Extra args are appended after `--attach-fds`
57
+ * or `--attach-tcp <addr>`. `--demo-skin` is dropped so Node cannot re-exec
58
+ * itself in a loop.
59
+ * @param {string} bin
60
+ * @param {string[]} [extraArgs]
61
+ * @param {{ stdin?: number | 'inherit', stdout?: number | 'inherit' }} [tty]
62
+ * @returns {Promise<{ child: import('node:child_process').ChildProcess, input: import('node:stream').Writable, output: import('node:stream').Readable, resume?: () => void }>}
63
+ */
64
+ export async function spawnPluginTui(bin, extraArgs = [], tty = {}) {
65
+ const extra = extraArgs.filter((arg) => arg !== '--demo-skin')
66
+ const ttyIn = tty.stdin ?? 'inherit'
67
+ const ttyOut = tty.stdout ?? 'inherit'
68
+ const useTcp = process.platform === 'win32' || process.env.DSH_TUI_FORCE_TCP === '1'
69
+ if (!useTcp) {
70
+ const child = spawn(bin, ['--attach-fds', ...extra], {
71
+ stdio: [ttyIn, ttyOut, 'inherit', 'pipe', 'pipe'],
72
+ })
73
+
74
+ // Extra-fd writes race the TUI exiting (window closed, or the binary died
75
+ // before a JSON-RPC response flushed). Node treats EPIPE on a Socket with
76
+ // no 'error' listener as an unhandled exception; the child's 'exit' handler
77
+ // is the lifetime signal, so the stream error only needs to be non-fatal.
78
+ // Same posture as @deepseek-ai/dsh-sdk-client toward the runtime stdin.
79
+ child.stdio[3].on('error', () => {})
80
+ child.stdio[4].on('error', () => {})
81
+ return { child, input: child.stdio[4], output: child.stdio[3] }
82
+ }
83
+
84
+ const token = randomBytes(32).toString('hex')
85
+ const server = createServer()
86
+ await new Promise((resolve, reject) => {
87
+ server.once('error', reject)
88
+ server.listen(0, '127.0.0.1', resolve)
89
+ })
90
+ const address = server.address()
91
+ if (address === null || typeof address === 'string') {
92
+ server.close()
93
+ throw new Error('dsh-tui runner failed to allocate a loopback TCP port')
94
+ }
95
+
96
+ const authenticated = waitForAuthenticatedSocket(server, token)
97
+ const child = spawn(bin, ['--attach-tcp', `127.0.0.1:${address.port}`, ...extra], {
98
+ stdio: [ttyIn, ttyOut, 'inherit'],
99
+ env: { ...process.env, DSH_TUI_ATTACH_TOKEN: token },
100
+ })
101
+ let onChildError
102
+ let onChildExit
103
+ const childFailed = new Promise((_, reject) => {
104
+ onChildError = reject
105
+ onChildExit = (code, signal) => {
106
+ reject(new Error(`dsh-tui exited before connecting (code=${code}, signal=${signal})`))
107
+ }
108
+ child.once('error', onChildError)
109
+ child.once('exit', onChildExit)
110
+ })
111
+ try {
112
+ const socket = await Promise.race([authenticated, childFailed])
113
+ child.off('error', onChildError)
114
+ child.off('exit', onChildExit)
115
+ return { child, input: socket, output: socket, resume: () => socket.resume() }
116
+ } catch (error) {
117
+ server.close()
118
+ try {
119
+ child.kill('SIGTERM')
120
+ } catch {
121
+ // child may already be gone
122
+ }
123
+ throw error
124
+ }
125
+ }
126
+
127
+ function waitForAuthenticatedSocket(server, token) {
128
+ return new Promise((resolve, reject) => {
129
+ const timeout = setTimeout(() => {
130
+ cleanup()
131
+ server.close()
132
+ reject(new Error('dsh-tui timed out connecting to the loopback transport'))
133
+ }, 10_000)
134
+ timeout.unref?.()
135
+
136
+ const cleanup = () => {
137
+ clearTimeout(timeout)
138
+ server.off('error', fail)
139
+ server.off('connection', authenticate)
140
+ }
141
+ const fail = (error) => {
142
+ cleanup()
143
+ reject(error)
144
+ }
145
+ const authenticate = (socket) => {
146
+ let buffered = Buffer.alloc(0)
147
+ const onData = (chunk) => {
148
+ buffered = Buffer.concat([buffered, chunk])
149
+ if (buffered.length > 1024) {
150
+ socket.destroy()
151
+ return
152
+ }
153
+ const newline = buffered.indexOf(0x0a)
154
+ if (newline < 0) return
155
+ socket.off('data', onData)
156
+ const supplied = buffered.subarray(0, newline).toString('utf8').trim()
157
+ const expectedBytes = Buffer.from(token)
158
+ const suppliedBytes = Buffer.from(supplied)
159
+ const valid = suppliedBytes.length === expectedBytes.length
160
+ && timingSafeEqual(suppliedBytes, expectedBytes)
161
+ if (!valid) {
162
+ socket.destroy()
163
+ return
164
+ }
165
+ socket.pause()
166
+ const remainder = buffered.subarray(newline + 1)
167
+ if (remainder.length > 0) socket.unshift(remainder)
168
+ cleanup()
169
+ server.close()
170
+ resolve(socket)
171
+ }
172
+ socket.on('data', onData)
173
+ socket.on('error', () => {})
174
+ }
175
+
176
+ server.on('error', fail)
177
+ server.on('connection', authenticate)
178
+ })
179
+ }
@@ -0,0 +1,90 @@
1
+ /** Built-in Client Plugin: standard ACP usage and timing in the composer dock. */
2
+
3
+ export const name = 'stats-view'
4
+ export const inject = ['acpSessionStats', 'tuiSlots']
5
+
6
+ export function apply(ctx) {
7
+ let current = ctx.acpSessionStats.current()
8
+ let panel
9
+ const stopSlot = ctx.tuiSlots.inject('conversation.composer.dock', () => {
10
+ panel = ctx.tuiSlots.register(
11
+ { name: 'conversation.composer.dock', id: 'stats' },
12
+ nodesOf(current),
13
+ )
14
+ return () => panel.dispose()
15
+ })
16
+ const stopStats = ctx.acpSessionStats.subscribe((snapshot) => {
17
+ current = snapshot
18
+ panel?.update(nodesOf(current))
19
+ })
20
+ return () => {
21
+ stopStats?.()
22
+ stopSlot?.()
23
+ }
24
+ }
25
+
26
+ function nodesOf(snapshot) {
27
+ const usage = snapshot?.usage ?? {}
28
+ const stats = snapshot?.stats ?? {}
29
+ const nodes = []
30
+ if ((usage.input ?? 0) > 0 || (usage.output ?? 0) > 0) {
31
+ nodes.push(node('tokens', `Input ${formatTokens(usage.input)} tok · Output ${formatTokens(usage.output)} tok`))
32
+ }
33
+ if ((stats.turns ?? 0) > 0 || (stats.steps ?? 0) > 0) {
34
+ nodes.push(node(
35
+ 'counts',
36
+ `${stats.turns ?? 0} ${plural(stats.turns ?? 0, 'turn')} · ${stats.steps ?? 0} ${plural(stats.steps ?? 0, 'step')}`,
37
+ ))
38
+ }
39
+ if ((usage.input ?? 0) > 0) {
40
+ const cacheRead = usage.cacheRead ?? usage.cached ?? 0
41
+ const cacheWrite = usage.cacheWrite ?? 0
42
+ const eligible = usage.input + cacheRead + cacheWrite
43
+ const rate = eligible > 0 ? Math.round(cacheRead / eligible * 100) : 0
44
+ nodes.push(node('cache', `Cache hit ${rate}%`))
45
+ }
46
+ if ((stats.llmMillis ?? 0) > 0 || (stats.toolMillis ?? 0) > 0) {
47
+ nodes.push(node(
48
+ 'time',
49
+ `LLM ${formatDuration(stats.llmMillis ?? 0)} · Tool call ${formatDuration(stats.toolMillis ?? 0)}`,
50
+ ))
51
+ }
52
+ if ((stats.ttftCount ?? 0) > 0 || ((usage.output ?? 0) > 0 && (stats.llmMillis ?? 0) > 0)) {
53
+ const parts = []
54
+ if ((stats.ttftCount ?? 0) > 0) {
55
+ parts.push(`TTFT avg ${formatDuration(stats.ttftTotalMillis / stats.ttftCount)}`)
56
+ }
57
+ if ((usage.output ?? 0) > 0 && (stats.llmMillis ?? 0) > 0) {
58
+ parts.push(`${formatRate(usage.output / (stats.llmMillis / 1000))} tok/s`)
59
+ }
60
+ nodes.push(node('speed', parts.join(' · ')))
61
+ }
62
+ return nodes
63
+ }
64
+
65
+ function node(id, title) {
66
+ return { id, kind: 'generic', title, body: '' }
67
+ }
68
+
69
+ function plural(value, singular) { return value === 1 ? singular : `${singular}s` }
70
+
71
+ function formatTokens(value = 0) {
72
+ if (value < 1000) return String(value)
73
+ const unit = value < 1_000_000 ? 1000 : 1_000_000
74
+ const suffix = unit === 1000 ? 'K' : 'M'
75
+ const scaled = value / unit
76
+ const rounded = scaled >= 100 ? Math.round(scaled) : Math.round(scaled * 10) / 10
77
+ return `${rounded}${suffix}`
78
+ }
79
+
80
+ function formatDuration(ms) {
81
+ if (ms < 60_000) return `${Math.round(ms / 100) / 10}s`
82
+ const seconds = Math.round(ms / 1000)
83
+ return `${Math.floor(seconds / 60)}m${seconds % 60}s`
84
+ }
85
+
86
+ function formatRate(value) {
87
+ return String(Math.round(value * 10) / 10)
88
+ }
89
+
90
+ export { formatDuration, formatTokens, nodesOf }
@@ -0,0 +1,144 @@
1
+ /** Lifecycle-owned local slash commands for TUI Client Plugins. */
2
+
3
+ import { Service } from '@deepseek-ai/cordis'
4
+ import { CORDIS_METHODS } from './cordis-protocol.js'
5
+
6
+ export const name = 'tui-commands'
7
+ export const inject = []
8
+
9
+ const PROTOCOL = 0
10
+ const NAME = /^[a-z0-9][a-z0-9-]*$/
11
+
12
+ class TuiCommandsService extends Service {
13
+ constructor(ctx, core) {
14
+ super(ctx, 'tuiCommands')
15
+ this.core = core
16
+ }
17
+
18
+ register(options, handler) {
19
+ return this.core.register(this.ctx, options, handler)
20
+ }
21
+
22
+ dispatch(params) {
23
+ return this.core.dispatch(params)
24
+ }
25
+
26
+ list() {
27
+ return this.core.list()
28
+ }
29
+
30
+ bindNotify(notify) {
31
+ return this.core.bindNotify(notify)
32
+ }
33
+ }
34
+
35
+ function validateCommand(options) {
36
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
37
+ throw new Error('tuiCommands.register: options must be an object')
38
+ }
39
+ if (typeof options.name !== 'string' || !NAME.test(options.name)) {
40
+ throw new Error('tuiCommands.register: name must be a lowercase slash identifier without /')
41
+ }
42
+ if (typeof options.description !== 'string' || options.description.length === 0) {
43
+ throw new Error('tuiCommands.register: description must be a non-empty string')
44
+ }
45
+ const extra = Object.keys(options).filter((key) => key !== 'name' && key !== 'description')
46
+ if (extra.length > 0) {
47
+ throw new Error(`tuiCommands.register: unknown option field(s) ${extra.join(', ')}`)
48
+ }
49
+ return {
50
+ name: options.name,
51
+ description: options.description,
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @param {object} ctx
57
+ * @param {{ notify?: (method: string, params: object) => void }} [options]
58
+ */
59
+ export function installTuiCommands(ctx, options = {}) {
60
+ const entries = new Map()
61
+ let send = typeof options.notify === 'function' ? options.notify : undefined
62
+
63
+ function publish() {
64
+ if (typeof send !== 'function') return
65
+ send(CORDIS_METHODS.commandsUpdate, {
66
+ protocol: PROTOCOL,
67
+ commands: [...entries.values()].map((entry) => ({ ...entry.command })),
68
+ })
69
+ }
70
+
71
+ function bindNotify(notify) {
72
+ if (typeof notify !== 'function') throw new Error('tuiCommands.bindNotify: notify must be a function')
73
+ send = notify
74
+ publish()
75
+ }
76
+
77
+ function register(effectCtx, options, handler) {
78
+ const command = validateCommand(options)
79
+ if (typeof handler !== 'function') {
80
+ throw new Error('tuiCommands.register: handler must be a function')
81
+ }
82
+ if (entries.has(command.name)) {
83
+ throw new Error(`tuiCommands.register: command "${command.name}" is already registered`)
84
+ }
85
+ const entry = { command, handler, active: false }
86
+ const setup = () => {
87
+ if (entries.has(command.name)) {
88
+ throw new Error(`tuiCommands.register: command "${command.name}" is already registered`)
89
+ }
90
+ entry.active = true
91
+ entries.set(command.name, entry)
92
+ publish()
93
+ return () => {
94
+ if (!entry.active) return
95
+ entry.active = false
96
+ if (entries.get(command.name) === entry) entries.delete(command.name)
97
+ publish()
98
+ }
99
+ }
100
+ const release = typeof effectCtx.effect === 'function'
101
+ ? effectCtx.effect(setup, `tuiCommands.register(${JSON.stringify(command.name)})`)
102
+ : setup()
103
+ let disposed = false
104
+ return () => {
105
+ if (disposed) return
106
+ disposed = true
107
+ return release?.()
108
+ }
109
+ }
110
+
111
+ async function dispatch(params) {
112
+ if (params === null || typeof params !== 'object' || params.protocol !== PROTOCOL) {
113
+ throw new Error('tuiCommands.dispatch: unsupported command payload')
114
+ }
115
+ const entry = entries.get(params.name)
116
+ if (entry === undefined || !entry.active) {
117
+ throw new Error(`tuiCommands.dispatch: command "${String(params.name)}" is not registered`)
118
+ }
119
+ const args = typeof params.args === 'string' ? params.args : ''
120
+ return entry.handler(args, { name: entry.command.name })
121
+ }
122
+
123
+ function list() {
124
+ return [...entries.values()].map((entry) => ({ ...entry.command }))
125
+ }
126
+
127
+ const core = { register, dispatch, list, bindNotify }
128
+ const service = typeof ctx.provide === 'function'
129
+ ? new TuiCommandsService(ctx, core)
130
+ : {
131
+ register(options, handler) {
132
+ return register(ctx, options, handler)
133
+ },
134
+ dispatch,
135
+ list,
136
+ bindNotify,
137
+ }
138
+ if (typeof ctx.provide !== 'function') ctx.tuiCommands = service
139
+ return service
140
+ }
141
+
142
+ export function apply(ctx) {
143
+ installTuiCommands(ctx)
144
+ }