martty 0.2.36 → 0.2.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/acp-host.js CHANGED
@@ -115,10 +115,101 @@ export function installPermissionPresetsCompatibility(permissionPresets) {
115
115
  return true
116
116
  }
117
117
 
118
+ function snapshotRowHeader(row) {
119
+ if (row === null || typeof row !== 'object') return undefined
120
+ const header = row.header
121
+ if (header === null || typeof header !== 'object' || typeof header.id !== 'string') {
122
+ return undefined
123
+ }
124
+ return header
125
+ }
126
+
127
+ /**
128
+ * One legacy `list()` row: the header itself, plus the snapshot fields a newer
129
+ * Host consumer reads off the same row (`header`, `revision`, sizes).
130
+ */
131
+ function legacyListRow(snapshot) {
132
+ const header = snapshotRowHeader(snapshot)
133
+ if (header === undefined) return undefined
134
+ const row = { ...header }
135
+ for (const key of ['header', 'revision', 'eventCount', 'sizeBytes']) {
136
+ if (snapshot[key] === undefined) continue
137
+ Object.defineProperty(row, key, {
138
+ configurable: true,
139
+ value: key === 'header' ? header : snapshot[key],
140
+ })
141
+ }
142
+ return row
143
+ }
144
+
145
+ /**
146
+ * dsh 0.1.5 folded `listSnapshots()` into `list()` and replaced `inspect()`
147
+ * with per-session handles: `list()` now resolves to `{ header, revision }`
148
+ * snapshots, and one stored log is read through `open(id, 'read')`. ACP 0.4.x
149
+ * still filters listed rows by the row's own `cwd` and replays titles and
150
+ * resume history through `inspect()`, so project the removed header/inspection
151
+ * surface back onto the live service until ACP can require the handle API.
152
+ *
153
+ * Rows stay snapshot-compatible, so a newer Host consumer reading `.header`
154
+ * keeps working while ACP reads the header fields directly.
155
+ */
156
+ export function installSessionPersistenceCompatibility(ctx) {
157
+ const service = ctx?.get?.('sessionPersistence')
158
+ if (service === undefined || service === null) return false
159
+ const list = service.list
160
+ if (typeof list !== 'function' || typeof service.open !== 'function') return false
161
+ if (list.dshTuiReturnsHeaders === true) return false
162
+
163
+ const compatibleList = async function (options) {
164
+ const listed = await list.call(this, options)
165
+ if (!Array.isArray(listed)) return listed
166
+ return listed.map((row) => legacyListRow(row) ?? row)
167
+ }
168
+ Object.defineProperty(compatibleList, 'dshTuiReturnsHeaders', { value: true })
169
+
170
+ // The class methods live on the prototype: restore by deleting the own
171
+ // property when the patch shadowed one instead of replacing an own value.
172
+ const listDescriptor = Object.getOwnPropertyDescriptor(service, 'list')
173
+ Object.defineProperty(service, 'list', { configurable: true, value: compatibleList })
174
+
175
+ let compatibleInspect
176
+ if (typeof service.inspect !== 'function') {
177
+ compatibleInspect = async function (id, signal) {
178
+ const options = signal === undefined ? undefined : { signal }
179
+ const handle = await service.open(id, 'read', options)
180
+ try {
181
+ const { events } = await handle.read(0, undefined, options)
182
+ const header = handle.header
183
+ // `meta` is the old coordinator's field; `header` is what ACP reads.
184
+ return Object.freeze({
185
+ meta: header,
186
+ header,
187
+ inheritedEventCount: handle.inheritedEventCount,
188
+ events,
189
+ })
190
+ } finally {
191
+ await handle.close().catch(() => {})
192
+ }
193
+ }
194
+ Object.defineProperty(service, 'inspect', { configurable: true, value: compatibleInspect })
195
+ }
196
+
197
+ ctx.effect?.(() => () => {
198
+ if (service.list !== compatibleList) return
199
+ if (listDescriptor === undefined) delete service.list
200
+ else Object.defineProperty(service, 'list', listDescriptor)
201
+ if (service.inspect === compatibleInspect) delete service.inspect
202
+ }, 'dsh-tui.session-persistence-compatibility')
203
+ return true
204
+ }
205
+
118
206
  async function mountHostCompatibility(ctx) {
119
207
  // The service can come from the active Host even when module resolution
120
208
  // below lands on ACP's older peer copy, so adapt the live instance directly.
121
209
  installPermissionPresetsCompatibility(ctx.permissionPresets ?? ctx.get?.('permissionPresets'))
210
+ // Not injected here on purpose: the profile's base bundle owns persistence,
211
+ // and `ctx.get` reads the live service without adding a mount dependency.
212
+ installSessionPersistenceCompatibility(ctx)
122
213
 
123
214
  const sessionModule = resolvedHostModule(ctx, '@deepseek-ai/dsh-session')
124
215
  if (sessionModule !== undefined) {
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { readFileSync } from 'node:fs'
7
+ import { installSessionPersistenceCompatibility } from './acp-host.js'
7
8
  import { createTuiPluginStore } from './tui-plugin-store.js'
8
9
 
9
10
  export const name = 'tui-creator-overlay'
@@ -192,6 +193,11 @@ const skillContent = skillDocument.slice(skillFrontmatter[0].length)
192
193
  * @param {{ preset?: string }} [config]
193
194
  */
194
195
  export async function apply(ctx, config = {}) {
196
+ // Standalone launches mount this overlay directly into the ACP Host tree,
197
+ // which never loads martty/acp-host; both trees need the ACP compatibility
198
+ // surface, and the installer is idempotent when the profile path already
199
+ // applied it on this service instance.
200
+ installSessionPersistenceCompatibility(ctx)
195
201
  const preset = config.preset ?? 'cordis'
196
202
  if (typeof preset !== 'string' || preset.length === 0) {
197
203
  throw new Error('tui-creator-overlay: preset must be a non-empty string')
package/lib/mux.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * off the painter.
10
10
  */
11
11
 
12
+ import { StringDecoder } from 'node:string_decoder'
12
13
  import { CORDIS_CAPABILITY, CORDIS_METHODS, readCordisCapability } from './cordis-protocol.js'
13
14
 
14
15
  const COMPOSITOR_METHODS = Object.freeze(new Set([
@@ -95,13 +96,20 @@ export function writeJsonLine(dest, value) {
95
96
 
96
97
  /**
97
98
  * Pipe NDJSON, invoking `onLine` for each complete line (trimmed, non-empty).
99
+ *
100
+ * Frames are UTF-8: decoding each chunk with `chunk.toString('utf8')` would
101
+ * turn any multi-byte character split across a chunk boundary into U+FFFD
102
+ * replacement characters (silent corruption in both directions). A
103
+ * `StringDecoder` carries the incomplete tail across chunks, and `end()`
104
+ * flushes a final frame that arrived without a trailing newline.
105
+ *
98
106
  * @param {import('node:stream').Readable} source
99
107
  * @param {(line: string) => void} onLine
100
108
  */
101
109
  export function onJsonLines(source, onLine) {
102
110
  let buffer = ''
103
- source.on('data', (chunk) => {
104
- buffer += chunk.toString('utf8')
111
+ const decoder = new StringDecoder('utf8')
112
+ const drain = () => {
105
113
  for (;;) {
106
114
  const newline = buffer.indexOf('\n')
107
115
  if (newline < 0) break
@@ -110,6 +118,17 @@ export function onJsonLines(source, onLine) {
110
118
  if (line.length === 0) continue
111
119
  onLine(line)
112
120
  }
121
+ }
122
+ source.on('data', (chunk) => {
123
+ buffer += typeof chunk === 'string' ? chunk : decoder.write(chunk)
124
+ drain()
125
+ })
126
+ source.on('end', () => {
127
+ buffer += decoder.end()
128
+ drain()
129
+ const line = buffer.trim()
130
+ buffer = ''
131
+ if (line.length > 0) onLine(line)
113
132
  })
114
133
  }
115
134
 
@@ -1,6 +1,6 @@
1
1
  /** UI Presets compose several UI plugin contributions into one saved choice. */
2
2
 
3
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { Service } from '@deepseek-ai/cordis'
6
6
  import { CORDIS_METHODS } from './cordis-protocol.js'
@@ -72,7 +72,13 @@ function writePreferred(settingsPath, id) {
72
72
  const settings = readSettings(settingsPath)
73
73
  settings.uiPreset = id
74
74
  mkdirSync(path.dirname(settingsPath), { recursive: true })
75
- writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
75
+ // Atomic write (temp + rename), matching tui-theme.js: a crash mid-write
76
+ // must never leave a truncated settings.json behind — every later launch
77
+ // would otherwise start from an unreadable file that also carries the
78
+ // painter's language/themeMode keys.
79
+ const temporary = `${settingsPath}.${process.pid}.${Date.now()}.tmp`
80
+ writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`)
81
+ renameSync(temporary, settingsPath)
76
82
  }
77
83
 
78
84
  function releaseOf(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martty",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "Terminal-native ACP client UI; Cordis client tree, any ACP agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -76,12 +76,12 @@
76
76
  },
77
77
  "dependencies": {
78
78
  "@deepseek-ai/cordis": "^4.0.1",
79
- "@openma/deepseek-harness-acp": "0.4.29",
79
+ "@openma/deepseek-harness-acp": "0.4.31",
80
80
  "cross-spawn": "^7.0.6",
81
81
  "node-downloader-helper": "2.1.11"
82
82
  },
83
83
  "devDependencies": {
84
- "@deepseek-ai/dsh": "0.1.1-rc.2"
84
+ "@deepseek-ai/dsh": "0.1.5-rc.1"
85
85
  },
86
86
  "engines": {
87
87
  "node": ">=18"
Binary file
Binary file
Binary file
Binary file
Binary file