martty 0.2.16 → 0.2.17

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
@@ -57,6 +57,20 @@ The package carries ACP as a runtime dependency and exports its Creator Host
57
57
  overlay internally. The profile bundle mounts both on the Host Base tree;
58
58
  neither enters the Client tree. Creator adds TUI plugin guidance to the
59
59
  existing `cordis` preset and does not use ACP for skill registration.
60
+ Creator-authored UI Presets and Theme Plugins preview as process-local dynamic
61
+ Packages, then persist explicitly under `$MARTTY_HOME/plugins` through
62
+ `tui_plugin_save`; `tui_plugin_read` resumes authoring after restart.
63
+
64
+ `MARTTY_HOME` resolves explicitly first, then to `$DSH_HOME/.martty`, then to
65
+ `~/.martty`. UI choices live in `$MARTTY_HOME/settings.json`. On first use,
66
+ legacy Creator artifacts and settings are copied forward without deleting or
67
+ overwriting the old files.
68
+
69
+ Third-party TUI plugins remain ordinary installed packages. Their Host-side
70
+ registrar contributes an absolute Client module entry to `tuiClientPlugins`;
71
+ the Host runner serializes only that directory into the separate Client
72
+ process. Package entries and Creator artifacts share one Client lifecycle
73
+ manager, with installed packages winning same-id conflicts.
60
74
 
61
75
  Node and Rust use inherited pipes on Unix and an authenticated loopback TCP
62
76
  socket on Windows. This compositor channel carries theme/render data only;
package/cordis.patch.yml CHANGED
@@ -20,6 +20,9 @@
20
20
  disabled: true
21
21
 
22
22
  - insert:
23
+ - id: tui-client-plugins
24
+ name: 'martty/client-plugin-registry'
25
+
23
26
  - id: tui-acp-host
24
27
  name: 'martty/acp-host'
25
28
 
package/lib/boot.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { apply as applyAcpClient } from './acp-client.js'
10
+ import { constants, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
10
11
  import { homedir } from 'node:os'
11
12
  import path from 'node:path'
12
13
  import { apply as applyCordisClientRunner } from './inspect.js'
@@ -23,12 +24,17 @@ import { apply as applyStatsView, inject as statsViewInject } from './stats-view
23
24
  import { apply as applySessionStatus, inject as sessionStatusInject } from './acp-session-status.js'
24
25
  import { apply as applyStatusView, inject as statusViewInject } from './status-view.js'
25
26
  import { apply as applyDeepseekLogo, inject as deepseekLogoInject } from './deepseek-logo.js'
27
+ import { createTuiPluginStore, marttyHome } from './tui-plugin-store.js'
28
+ import { installTuiLocalPlugins } from './tui-local-plugins.js'
26
29
 
27
30
  /**
28
31
  * @param {object} [options]
29
32
  * @param {{ command: string, args?: string[] }} [options.agent]
30
33
  * @param {{ stdin: import('node:stream').Writable, stdout: import('node:stream').Readable, child?: import('node:child_process').ChildProcess }} [options.stream]
31
34
  * @param {{ stdin: number | 'inherit', stdout: number | 'inherit' }} [options.tty]
35
+ * @param {string} [options.settingsPath]
36
+ * @param {string} [options.artifactRoot]
37
+ * @param {Array<{ id: string, kind: 'theme' | 'ui-preset', entry: string }>} [options.packagePlugins]
32
38
  */
33
39
  export async function bootClient(options = {}) {
34
40
  const { Context } = await import('@deepseek-ai/cordis')
@@ -36,11 +42,13 @@ export async function bootClient(options = {}) {
36
42
  const acpConfig = options.stream !== undefined
37
43
  ? { stream: options.stream }
38
44
  : { agent: options.agent ?? resolveStackedAgent() }
39
- const presetConfig = {
40
- settingsPath: options.settingsPath ?? uiSettingsPath(options.extraArgs ?? []),
45
+ const settingsPath = options.settingsPath ?? uiSettingsPath(options.extraArgs ?? [])
46
+ if (options.settingsPath === undefined) {
47
+ migrateLegacyUiSettings(settingsPath, legacyUiSettingsPaths(options.extraArgs ?? []))
41
48
  }
49
+ const presetConfig = { settingsPath }
42
50
  if (typeof ctx.plugin === 'function') {
43
- await ctx.plugin({ name: 'tui-theme', inject: [], apply: applyTheme })
51
+ await ctx.plugin({ name: 'tui-theme', inject: [], apply: applyTheme }, presetConfig)
44
52
  await ctx.plugin({ name: 'tui-slots', inject: [], apply: applySlots })
45
53
  await ctx.plugin({ name: 'tui-commands', inject: [], apply: applyCommands })
46
54
  await ctx.plugin({ name: 'tui-overlay', inject: [], apply: applyOverlay })
@@ -52,6 +60,20 @@ export async function bootClient(options = {}) {
52
60
  await ctx.plugin({ name: 'acp-session-status', inject: sessionStatusInject, apply: applySessionStatus })
53
61
  await ctx.plugin({ name: 'status-view', inject: statusViewInject, apply: applyStatusView })
54
62
  await ctx.plugin({ name: 'deepseek-logo', inject: deepseekLogoInject, apply: applyDeepseekLogo })
63
+ const localPlugins = installTuiLocalPlugins(ctx, {
64
+ store: createTuiPluginStore({ root: options.artifactRoot }),
65
+ packages: options.packagePlugins ?? [],
66
+ tuiTheme: ctx.get('tuiTheme'),
67
+ tuiPresets: ctx.get('tuiPresets'),
68
+ tuiSlots: ctx.get('tuiSlots'),
69
+ tuiCommands: ctx.get('tuiCommands'),
70
+ tuiOverlay: ctx.get('tuiOverlay'),
71
+ acpSessionConfig: ctx.get('acpSessionConfig'),
72
+ acpSessionPlan: ctx.get('acpSessionPlan'),
73
+ acpSessionStats: ctx.get('acpSessionStats'),
74
+ acpSessionStatus: ctx.get('acpSessionStatus'),
75
+ })
76
+ await localPlugins.discover()
55
77
  await ctx.plugin({
56
78
  name: 'tui-cordis-client-runner',
57
79
  inject: [
@@ -72,7 +94,7 @@ export async function bootClient(options = {}) {
72
94
  { extraArgs: options.extraArgs ?? [], tty: options.tty },
73
95
  )
74
96
  } else {
75
- applyTheme(ctx)
97
+ applyTheme(ctx, presetConfig)
76
98
  applySlots(ctx)
77
99
  applyCommands(ctx)
78
100
  applyOverlay(ctx)
@@ -84,19 +106,100 @@ export async function bootClient(options = {}) {
84
106
  applySessionStatus(ctx)
85
107
  applyStatusView(ctx)
86
108
  applyDeepseekLogo(ctx)
109
+ const localPlugins = installTuiLocalPlugins(ctx, {
110
+ store: createTuiPluginStore({ root: options.artifactRoot }),
111
+ packages: options.packagePlugins ?? [],
112
+ tuiTheme: ctx.tuiTheme,
113
+ tuiPresets: ctx.tuiPresets,
114
+ tuiSlots: ctx.tuiSlots,
115
+ tuiCommands: ctx.tuiCommands,
116
+ tuiOverlay: ctx.tuiOverlay,
117
+ acpSessionConfig: ctx.acpSessionConfig,
118
+ acpSessionPlan: ctx.acpSessionPlan,
119
+ acpSessionStats: ctx.acpSessionStats,
120
+ acpSessionStatus: ctx.acpSessionStatus,
121
+ })
122
+ await localPlugins.discover()
87
123
  applyCordisClientRunner(ctx)
88
124
  await applyShell(ctx, { extraArgs: options.extraArgs ?? [], tty: options.tty })
89
125
  }
90
126
  return ctx
91
127
  }
92
128
 
93
- export function uiSettingsPath(extraArgs = []) {
129
+ /** Parse the Host registry snapshot passed across the process boundary. */
130
+ export function parseClientPluginsEnv(value) {
131
+ if (value === undefined || value === '') return []
132
+ let parsed
133
+ try {
134
+ parsed = JSON.parse(value)
135
+ } catch (error) {
136
+ throw new Error(`dsh-tui: invalid installed Client plugin registry JSON: ${error.message}`)
137
+ }
138
+ if (!Array.isArray(parsed)) {
139
+ throw new Error('dsh-tui: installed Client plugin registry must be an array')
140
+ }
141
+ return parsed.map((entry, index) => {
142
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
143
+ throw new Error(`dsh-tui: installed Client plugin entry ${index} must be an object`)
144
+ }
145
+ if (typeof entry.id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(entry.id)) {
146
+ throw new Error(`dsh-tui: installed Client plugin entry ${index} has an invalid id`)
147
+ }
148
+ if (!['theme', 'ui-preset'].includes(entry.kind)) {
149
+ throw new Error(`dsh-tui: installed Client plugin entry ${index} has an invalid kind`)
150
+ }
151
+ if (typeof entry.entry !== 'string') {
152
+ throw new Error(`dsh-tui: installed Client plugin entry ${index} has no file entry`)
153
+ }
154
+ let url
155
+ try {
156
+ url = new URL(entry.entry)
157
+ } catch {
158
+ throw new Error(`dsh-tui: installed Client plugin entry ${index} has an invalid file entry`)
159
+ }
160
+ if (url.protocol !== 'file:') {
161
+ throw new Error(`dsh-tui: installed Client plugin entry ${index} must use a file URL`)
162
+ }
163
+ return { id: entry.id, kind: entry.kind, entry: url.href }
164
+ })
165
+ }
166
+
167
+ export function uiSettingsPath(extraArgs = [], env = process.env, userHome = homedir()) {
94
168
  for (let index = 0; index < extraArgs.length; index += 1) {
95
169
  if (extraArgs[index] === '--session-root' && typeof extraArgs[index + 1] === 'string') {
96
- return path.join(extraArgs[index + 1], 'dsh-tui-settings.json')
170
+ return path.join(extraArgs[index + 1], 'settings.json')
171
+ }
172
+ }
173
+ return path.join(marttyHome(env, userHome), 'settings.json')
174
+ }
175
+
176
+ export function legacyUiSettingsPaths(extraArgs = [], userHome = homedir()) {
177
+ const paths = []
178
+ for (let index = 0; index < extraArgs.length; index += 1) {
179
+ if (extraArgs[index] === '--session-root' && typeof extraArgs[index + 1] === 'string') {
180
+ paths.push(path.join(extraArgs[index + 1], 'dsh-tui-settings.json'))
181
+ break
182
+ }
183
+ }
184
+ paths.push(path.join(userHome, '.dsh-tui', 'sessions', 'dsh-tui-settings.json'))
185
+ return [...new Set(paths)]
186
+ }
187
+
188
+ export function migrateLegacyUiSettings(settingsPath, legacyPaths) {
189
+ if (existsSync(settingsPath)) return false
190
+ for (const legacyPath of legacyPaths) {
191
+ if (!existsSync(legacyPath)) continue
192
+ try {
193
+ const value = JSON.parse(readFileSync(legacyPath, 'utf8'))
194
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) continue
195
+ mkdirSync(path.dirname(settingsPath), { recursive: true })
196
+ copyFileSync(legacyPath, settingsPath, constants.COPYFILE_EXCL)
197
+ return true
198
+ } catch {
199
+ // A malformed or racing legacy file must not block TUI startup.
97
200
  }
98
201
  }
99
- return path.join(homedir(), '.dsh-tui', 'sessions', 'dsh-tui-settings.json')
202
+ return false
100
203
  }
101
204
 
102
205
  /**
@@ -2,10 +2,11 @@
2
2
 
3
3
  /** Independent TUI Client Cordis process launched by the Host runner. */
4
4
 
5
- import { bootClient } from './boot.js'
5
+ import { bootClient, parseClientPluginsEnv } from './boot.js'
6
6
 
7
7
  await bootClient({
8
8
  stream: { stdin: process.stdout, stdout: process.stdin },
9
9
  extraArgs: process.argv.slice(2),
10
10
  tty: { stdin: 3, stdout: 4 },
11
+ packagePlugins: parseClientPluginsEnv(process.env.DSH_TUI_CLIENT_PLUGINS_V0),
11
12
  })
package/lib/client-run.js CHANGED
@@ -76,6 +76,16 @@ export async function applyClientHalf(clientCode, env) {
76
76
  }
77
77
  throw error
78
78
  }
79
+ return applyClientPlugin(plugin, env)
80
+ }
81
+
82
+ /**
83
+ * Apply an already imported Client plugin through the same restricted TUI facade as code.client.
84
+ * @param {unknown} plugin
85
+ * @param {{ pluginId?: string, tuiTheme?: object, tuiSlots?: object, tuiCommands?: object, tuiOverlay?: object, acpSessionConfig?: object, acpSessionPlan?: object, acpSessionStats?: object, acpSessionStatus?: object, timer?: object, invoke?: Function }} env
86
+ * @returns {Promise<{ waitingFor: string[], dispose: () => void }>}
87
+ */
88
+ export async function applyClientPlugin(plugin, env) {
79
89
  const evaluated = normalizePlugin(plugin)
80
90
  const inject = evaluated.inject ?? []
81
91
  for (const name of inject) {
@@ -4,9 +4,12 @@
4
4
  */
5
5
 
6
6
  import { readFileSync } from 'node:fs'
7
+ import { createTuiPluginStore } from './tui-plugin-store.js'
7
8
 
8
9
  export const name = 'tui-creator-overlay'
9
- export const inject = ['agentPresets', 'skills', 'systemPrompt', 'loader']
10
+ export const inject = [
11
+ 'agentPresets', 'skills', 'systemPrompt', 'loader', 'tools', 'dynamicCordisRunner',
12
+ ]
10
13
 
11
14
  const SKILL_NAME = 'tui-plugin-development'
12
15
  const ROUTING_PROMPT = `# Dynamic TUI Plugin routing
@@ -16,6 +19,158 @@ Load cordis-plugin-development for every dynamic Plugin; it owns the common Plug
16
19
  - When any requested behavior belongs to the TUI — terminal themes or backgrounds, native TUI slots, local TUI commands, native overlays, or current ACP Session options — also load tui-plugin-development. Its TUI Provider guidance overrides generic browser UI assumptions only for that half.
17
20
  - Browser/Web-only work needs no TUI companion. A mixed Plugin uses the generic skill for common, Host, and Web behavior and the TUI skill for its terminal behavior. Do not translate a TUI request into Web Slots or add Host code unless the requested behavior owns Host data.`
18
21
 
22
+ const jsonOutput = {
23
+ schema: { type: 'json' },
24
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
25
+ }
26
+
27
+ function requireAgent(exec) {
28
+ if (exec?.agent === undefined) {
29
+ throw new Error('TUI artifact tools require an Agent-backed Creator session')
30
+ }
31
+ return exec.agent
32
+ }
33
+
34
+ function registerArtifactTools(ctx, scopedCtx, defineTool, store) {
35
+ const tools = scopedCtx.get('tools')
36
+ if (tools === undefined || typeof tools.register !== 'function') {
37
+ throw new Error('tui-creator-overlay: tools service is unavailable in the preset scope')
38
+ }
39
+
40
+ tools.register(defineTool({
41
+ name: 'tui_plugin_save',
42
+ description:
43
+ 'Persist one successfully activated, Client-only Cordis Package as a durable TUI artifact. '
44
+ + 'Use this only after cordis_run succeeds. UI Presets and Theme Plugins survive restart only '
45
+ + 'after this Tool returns saved. Replacing an existing artifact must be explicit.',
46
+ parameters: {
47
+ artifactId: {
48
+ type: 'string',
49
+ required: true,
50
+ description: 'Stable lowercase artifact id used as its directory name.',
51
+ },
52
+ kind: {
53
+ type: 'string',
54
+ required: true,
55
+ enum: ['ui-preset', 'theme'],
56
+ description: 'Durable TUI contribution kind.',
57
+ },
58
+ pluginId: {
59
+ type: 'string',
60
+ required: true,
61
+ description: 'Dynamic Plugin id returned by cordis_define.',
62
+ },
63
+ packageId: {
64
+ type: 'string',
65
+ required: true,
66
+ description: 'Exact immutable Package id that cordis_run activated successfully.',
67
+ },
68
+ replace: {
69
+ type: 'boolean',
70
+ description: 'Replace an existing artifact with this Package. Defaults to false.',
71
+ },
72
+ },
73
+ output: jsonOutput,
74
+ async execute(args, exec) {
75
+ const source = ctx.dynamicCordisRunner.inspectPackage(
76
+ requireAgent(exec),
77
+ args.pluginId,
78
+ args.packageId,
79
+ )
80
+ if (source.currentPackageId !== args.packageId) {
81
+ throw new Error(
82
+ `tui_plugin_save: ${args.pluginId}/${args.packageId} is not the successful current Package; `
83
+ + 'run it successfully before persisting it',
84
+ )
85
+ }
86
+ if (typeof source.code?.host === 'string') {
87
+ throw new Error(
88
+ 'tui_plugin_save: durable TUI artifacts are client-only; a Package with a Host half '
89
+ + 'must be shipped as a standard profile plugin instead',
90
+ )
91
+ }
92
+ if (typeof source.code?.client !== 'string') {
93
+ throw new Error('tui_plugin_save: Package has no Client half')
94
+ }
95
+ const saved = store.save({
96
+ id: args.artifactId,
97
+ kind: args.kind,
98
+ name: source.name,
99
+ purpose: source.purpose,
100
+ source: { pluginId: args.pluginId, packageId: args.packageId },
101
+ clientCode: source.code.client,
102
+ }, { replace: args.replace === true })
103
+ return {
104
+ status: 'saved',
105
+ artifact: { id: args.artifactId, kind: args.kind },
106
+ path: saved.path,
107
+ recovery: 'The TUI Client discovers this artifact from disk on startup.',
108
+ }
109
+ },
110
+ }))
111
+
112
+ tools.register(defineTool({
113
+ name: 'tui_plugin_list',
114
+ description:
115
+ 'List durable user-authored TUI artifacts from disk, including broken rows that need repair or removal.',
116
+ parameters: {},
117
+ output: jsonOutput,
118
+ execute(_args, exec) {
119
+ requireAgent(exec)
120
+ return Promise.resolve({ root: store.root, artifacts: store.list() })
121
+ },
122
+ isConcurrencySafe() {
123
+ return true
124
+ },
125
+ }))
126
+
127
+ tools.register(defineTool({
128
+ name: 'tui_plugin_read',
129
+ description:
130
+ 'Read one durable Creator-authored TUI artifact including its exact code.client source. '
131
+ + 'Use this before continuing development after a restart; define a new temporary preview '
132
+ + 'Plugin from the source, then save the successful result with replace:true.',
133
+ parameters: {
134
+ artifactId: {
135
+ type: 'string',
136
+ required: true,
137
+ description: 'Exact durable artifact id returned by tui_plugin_list.',
138
+ },
139
+ },
140
+ output: jsonOutput,
141
+ execute(args, exec) {
142
+ requireAgent(exec)
143
+ return Promise.resolve({ artifact: store.resolve(args.artifactId) })
144
+ },
145
+ isConcurrencySafe() {
146
+ return true
147
+ },
148
+ }))
149
+
150
+ tools.register(defineTool({
151
+ name: 'tui_plugin_remove',
152
+ description:
153
+ 'Remove one durable user-authored TUI artifact from disk. This does not undefine the current '
154
+ + 'Session\'s temporary preview Plugin.',
155
+ parameters: {
156
+ artifactId: {
157
+ type: 'string',
158
+ required: true,
159
+ description: 'Exact durable artifact id returned by tui_plugin_list.',
160
+ },
161
+ },
162
+ output: jsonOutput,
163
+ execute(args, exec) {
164
+ requireAgent(exec)
165
+ const removed = store.remove(args.artifactId)
166
+ return Promise.resolve({
167
+ status: removed ? 'removed' : 'absent',
168
+ artifactId: args.artifactId,
169
+ })
170
+ },
171
+ }))
172
+ }
173
+
19
174
  const skillDocument = readFileSync(
20
175
  new URL('../skills/tui-plugin-development/SKILL.md', import.meta.url),
21
176
  'utf8',
@@ -49,6 +204,10 @@ export async function apply(ctx, config = {}) {
49
204
  const { createScope } = scopeModule
50
205
  const overlay = createScope(ctx, key)
51
206
  try {
207
+ const toolsModule = await ctx.loader.import('@deepseek-ai/dsh-tools')
208
+ if (typeof toolsModule?.defineTool !== 'function') {
209
+ throw new Error('tui-creator-overlay: profile dsh-tools module is unavailable')
210
+ }
52
211
  const skills = overlay.ctx.get('skills')
53
212
  if (skills === undefined || typeof skills.register !== 'function') {
54
213
  throw new Error('tui-creator-overlay: skills service is unavailable in the preset scope')
@@ -69,6 +228,12 @@ export async function apply(ctx, config = {}) {
69
228
  order: 116,
70
229
  text: ROUTING_PROMPT,
71
230
  })
231
+ registerArtifactTools(
232
+ ctx,
233
+ overlay.ctx,
234
+ toolsModule.defineTool,
235
+ createTuiPluginStore({ root: config.artifactRoot }),
236
+ )
72
237
  ctx.effect(() => () => overlay.dispose(), `tui-creator-overlay(${JSON.stringify(preset)})`)
73
238
  } catch (error) {
74
239
  await overlay.dispose()
package/lib/index.js CHANGED
@@ -30,16 +30,23 @@ const shellStateKey = Symbol.for('martty/shell-state')
30
30
  * livePainter: null | ({ muxed: boolean } & Awaited<ReturnType<typeof spawnPluginTui>>),
31
31
  * startingPainter: null | Promise<{ muxed: boolean } & Awaited<ReturnType<typeof spawnPluginTui>>>,
32
32
  * refuseRespawn: boolean,
33
+ * processExitListener: null | (() => void),
33
34
  * }}
34
35
  */
35
36
  const shellState = globalThis[shellStateKey] ??= {
36
37
  livePainter: null,
37
38
  startingPainter: null,
38
39
  refuseRespawn: false,
40
+ processExitListener: null,
39
41
  }
42
+ if (shellState.processExitListener === undefined) shellState.processExitListener = null
40
43
 
41
44
  /** Drop the sticky painter so tests can apply() more than once in-process. */
42
45
  export function resetShellForTests() {
46
+ if (shellState.processExitListener !== null) {
47
+ process.off('exit', shellState.processExitListener)
48
+ shellState.processExitListener = null
49
+ }
43
50
  shellState.livePainter = null
44
51
  shellState.startingPainter = null
45
52
  shellState.refuseRespawn = false
@@ -150,6 +157,10 @@ export async function applyShell(ctx, options = {}) {
150
157
  shellState.livePainter = live
151
158
  const { child } = live
152
159
  child.on('exit', (code, signal) => {
160
+ if (shellState.processExitListener === processExitListener) {
161
+ process.off('exit', processExitListener)
162
+ shellState.processExitListener = null
163
+ }
153
164
  try {
154
165
  agent.child?.kill('SIGTERM')
155
166
  } catch {
@@ -165,13 +176,15 @@ export async function applyShell(ctx, options = {}) {
165
176
  console.error(`dsh-tui: ${err.message}`)
166
177
  process.exit(1)
167
178
  })
168
- process.once('exit', () => {
179
+ const processExitListener = () => {
169
180
  try {
170
181
  child.kill('SIGTERM')
171
182
  } catch {
172
183
  // already gone
173
184
  }
174
- })
185
+ }
186
+ shellState.processExitListener = processExitListener
187
+ process.once('exit', processExitListener)
175
188
  return live
176
189
  })()
177
190
  }
package/lib/inspect.js CHANGED
@@ -162,7 +162,7 @@ export function uiPresetInspectProvider(tuiPresets) {
162
162
  'A synchronous callback that mounts multiple UI contributions and returns one disposer.',
163
163
  },
164
164
  selection: '/ui <id>',
165
- persistence: 'The selected id survives restart in dsh-tui-settings.json.',
165
+ persistence: 'The selected id survives restart in $MARTTY_HOME/settings.json.',
166
166
  },
167
167
  }
168
168
  },
@@ -754,6 +754,7 @@ async function mountClientHalf(
754
754
  * acpSessionPlan?: object,
755
755
  * acpSessionStats?: object,
756
756
  * acpSessionStatus?: object,
757
+ * localPlugins?: object,
757
758
  * requestAgent: (method: string, params?: object) => Promise<unknown>,
758
759
  * }} opts
759
760
  * @returns {{ onHost: (message: object) => void }}
@@ -761,7 +762,7 @@ async function mountClientHalf(
761
762
  export function attachTuiClient(opts) {
762
763
  const {
763
764
  ctx, tuiTheme, tuiPresets, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
764
- acpSessionStats, acpSessionStatus,
765
+ acpSessionStats, acpSessionStatus, localPlugins,
765
766
  requestAgent,
766
767
  } = opts
767
768
  const providers = [
@@ -780,6 +781,19 @@ export function attachTuiClient(opts) {
780
781
  const loaded = new Map()
781
782
  const pendingThemeSelections = new Map()
782
783
 
784
+ async function stopOwner(agentId, pluginId) {
785
+ if (localPlugins?.has?.(pluginId) === true) {
786
+ await localPlugins.stop(pluginId)
787
+ return
788
+ }
789
+ const stopped = await requestAgent(CORDIS_METHODS.pluginStop, { agentId, pluginId })
790
+ if (stopped?.ok === false) {
791
+ throw new Error(typeof stopped.message === 'string'
792
+ ? stopped.message
793
+ : `failed to stop theme Plugin "${pluginId}"`)
794
+ }
795
+ }
796
+
783
797
  function sync() {
784
798
  void requestAgent(CORDIS_METHODS.inspectSync, { providers: providers.map((provider) => provider.manifest) }).catch(() => {
785
799
  // Agent has no TUI Client extras (Zed / non-dsh ACP). Stay a standard client.
@@ -905,15 +919,7 @@ export function attachTuiClient(opts) {
905
919
  && previousThemeOwner !== undefined
906
920
  && previousThemeOwner !== selectedThemeOwner) {
907
921
  try {
908
- const stopped = await requestAgent(CORDIS_METHODS.pluginStop, {
909
- agentId: request.agentId,
910
- pluginId: previousThemeOwner,
911
- })
912
- if (stopped?.ok === false) {
913
- throw new Error(typeof stopped.message === 'string'
914
- ? stopped.message
915
- : `failed to stop theme Plugin "${previousThemeOwner}"`)
916
- }
922
+ await stopOwner(request.agentId, previousThemeOwner)
917
923
  } catch (error) {
918
924
  if (loaded.get(request.pluginId) === applied.dispose) {
919
925
  loaded.delete(request.pluginId)
@@ -981,19 +987,31 @@ export function attachTuiClient(opts) {
981
987
  const previousOwner = tuiTheme.owner?.(previousId)
982
988
  const targetOwner = tuiTheme.owner?.(request.id)
983
989
 
990
+ if (targetOwner !== undefined && localPlugins?.has?.(targetOwner) === true) {
991
+ if (localPlugins.isLoaded?.(targetOwner) !== true) {
992
+ await localPlugins.start(targetOwner)
993
+ }
994
+ if (tuiTheme.isLoaded?.(request.id) !== true) {
995
+ throw new Error(`local theme Plugin "${targetOwner}" did not load palette "${request.id}"`)
996
+ }
997
+ tuiTheme.activate(request.id)
998
+ if (previousOwner !== undefined && previousOwner !== targetOwner) {
999
+ try {
1000
+ await stopOwner(request.agentId, previousOwner)
1001
+ } catch (error) {
1002
+ await localPlugins.stop(targetOwner)
1003
+ if (tuiTheme.isLoaded?.(previousId) === true) tuiTheme.activate(previousId)
1004
+ throw error
1005
+ }
1006
+ }
1007
+ return { ok: true, status: 'selected', id: request.id }
1008
+ }
1009
+
984
1010
  if (targetOwner === undefined || loaded.has(targetOwner)) {
985
1011
  tuiTheme.activate(request.id)
986
1012
  if (previousOwner !== undefined && previousOwner !== targetOwner) {
987
1013
  try {
988
- const stopped = await requestAgent(CORDIS_METHODS.pluginStop, {
989
- agentId: request.agentId,
990
- pluginId: previousOwner,
991
- })
992
- if (stopped?.ok === false) {
993
- throw new Error(typeof stopped.message === 'string'
994
- ? stopped.message
995
- : `failed to stop theme Plugin "${previousOwner}"`)
996
- }
1014
+ await stopOwner(request.agentId, previousOwner)
997
1015
  } catch (error) {
998
1016
  if (tuiTheme.isLoaded?.(previousId) === true) tuiTheme.activate(previousId)
999
1017
  throw error
@@ -1043,13 +1061,14 @@ export function apply(ctx) {
1043
1061
  const acpSessionPlan = ctx.acpSessionPlan ?? ctx.get?.('acpSessionPlan')
1044
1062
  const acpSessionStats = ctx.acpSessionStats ?? ctx.get?.('acpSessionStats')
1045
1063
  const acpSessionStatus = ctx.acpSessionStatus ?? ctx.get?.('acpSessionStatus')
1064
+ const localPlugins = ctx.get?.('tuiLocalPlugins')
1046
1065
  let client
1047
1066
  const service = {
1048
1067
  bindTransport(requestAgent) {
1049
1068
  client?.dispose()
1050
1069
  client = attachTuiClient({
1051
1070
  ctx, tuiTheme, tuiPresets, tuiSlots, tuiCommands, tuiOverlay, acpSessionConfig, acpSessionPlan,
1052
- acpSessionStats, acpSessionStatus,
1071
+ acpSessionStats, acpSessionStatus, localPlugins,
1053
1072
  requestAgent,
1054
1073
  })
1055
1074
  const attached = client
package/lib/runner.js CHANGED
@@ -12,7 +12,7 @@ import { createRequire } from 'node:module'
12
12
  import { fileURLToPath } from 'node:url'
13
13
 
14
14
  export const name = 'dsh-tui-runner'
15
- export const inject = ['loader', 'acpServer', 'cmdlineArgs', 'appExit']
15
+ export const inject = ['loader', 'acpServer', 'cmdlineArgs', 'appExit', 'tuiClientPlugins']
16
16
 
17
17
  const clientEntry = fileURLToPath(new URL('./client-process.js', import.meta.url))
18
18
  const requireFromTui = createRequire(import.meta.url)
@@ -44,7 +44,10 @@ export async function apply(ctx) {
44
44
  // ACP owns the Client process's stdin/stdout. Its fd 3/4 retain the
45
45
  // user's terminal for the Rust painter spawned inside that process.
46
46
  stdio: ['pipe', 'pipe', 'inherit', process.stdin, process.stdout],
47
- env: process.env,
47
+ env: {
48
+ ...process.env,
49
+ DSH_TUI_CLIENT_PLUGINS_V0: JSON.stringify(ctx.tuiClientPlugins.list()),
50
+ },
48
51
  },
49
52
  )
50
53
  const agentToClient = child.stdin