martty 0.2.32 → 0.2.33

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/bin/martty.js CHANGED
@@ -14,11 +14,19 @@ const vendorDir = path.join(__dirname, '..', 'vendor')
14
14
  const binaryName = process.platform === 'win32' ? 'martty.exe' : 'martty'
15
15
  const packaged = path.join(vendorDir, platformKey, binaryName)
16
16
  const configuredBin = process.env.MARTTY_BIN || process.env.DSH_TUI_BIN
17
- const binaryPath = typeof configuredBin === 'string'
18
- && configuredBin.length > 0
19
- && fs.existsSync(configuredBin)
20
- ? configuredBin
21
- : packaged
17
+ let binaryPath = packaged
18
+ if (typeof configuredBin === 'string' && configuredBin.length > 0) {
19
+ if (fs.existsSync(configuredBin)) {
20
+ binaryPath = configuredBin
21
+ } else {
22
+ // Never silently fall back: the user believes they are running their
23
+ // own build (devlocalinstall.sh has caught this confusion before).
24
+ console.error(
25
+ `martty: ${configuredBin} (from ${process.env.MARTTY_BIN ? 'MARTTY_BIN' : 'DSH_TUI_BIN'})`
26
+ + ' does not exist — falling back to the bundled binary',
27
+ )
28
+ }
29
+ }
22
30
 
23
31
  const argv = process.argv.slice(2)
24
32
  const wantDemoSkin = argv.includes('--demo-skin')
@@ -74,9 +82,14 @@ if (!fs.existsSync(binaryPath)) {
74
82
  }
75
83
 
76
84
  function exitFromSpawn(result) {
85
+ if (result.error) {
86
+ // A failed spawn (ENOENT, EACCES) sets status/signal to null; without
87
+ // a diagnostic the process would just exit 1 with no explanation.
88
+ console.error('martty: failed to launch the native binary: ' + result.error.message)
89
+ }
77
90
  process.exit(
78
- result.status ??
79
- (result.signal === 'SIGINT' ? 130 : result.signal === 'SIGTERM' ? 143 : 1),
91
+ result.status
92
+ ?? (result.signal === 'SIGINT' ? 130 : result.signal === 'SIGTERM' ? 143 : 1),
80
93
  )
81
94
  }
82
95
 
@@ -14,6 +14,7 @@ class AcpClientEventsService extends Service {
14
14
  register(observer) { return this.core.register(this.ctx, observer) }
15
15
  observeClient(message) { return this.core.observeClient(message) }
16
16
  observeAgent(message) { return this.core.observeAgent(message) }
17
+ selectSession(sessionId) { return this.core.selectSession(sessionId) }
17
18
  }
18
19
 
19
20
  export function installAcpClientEvents(ctx) {
@@ -48,13 +49,18 @@ export function installAcpClientEvents(ctx) {
48
49
  for (const observer of [...observers]) observer.observeAgent?.(message)
49
50
  }
50
51
 
51
- const core = { register, observeClient, observeAgent }
52
+ function selectSession(sessionId) {
53
+ for (const observer of [...observers]) observer.selectSession?.(sessionId)
54
+ }
55
+
56
+ const core = { register, observeClient, observeAgent, selectSession }
52
57
  const service = typeof ctx.provide === 'function'
53
58
  ? new AcpClientEventsService(ctx, core)
54
59
  : {
55
60
  register(observer) { return register(ctx, observer) },
56
61
  observeClient,
57
62
  observeAgent,
63
+ selectSession,
58
64
  }
59
65
  if (typeof ctx.provide !== 'function') ctx.acpClientEvents = service
60
66
  return service
package/lib/acp-client.js CHANGED
@@ -23,6 +23,14 @@ export const inject = []
23
23
  /** @type {null | { command: string, args: string[], child: import('node:child_process').ChildProcess, stdin: import('node:stream').Writable, stdout: import('node:stream').Readable, kind: 'spawn' }} */
24
24
  let liveAgent = null
25
25
 
26
+ /**
27
+ * One kill-on-exit hook per process, replaced (not accumulated) whenever a
28
+ * new agent is spawned. Registering `process.once('exit', …)` per apply()
29
+ * would stack listeners and kill every past child again on exit.
30
+ * @type {null | (() => void)}
31
+ */
32
+ let liveAgentExitHook = null
33
+
26
34
  /**
27
35
  * @typedef {{ command: string, args?: string[], env?: Record<string, string> }} AgentSpec
28
36
  */
@@ -115,13 +123,17 @@ export function apply(ctx, config = {}) {
115
123
  }
116
124
  liveAgent = service
117
125
  provide(ctx, service)
118
- process.once('exit', () => {
126
+ if (liveAgentExitHook !== null) {
127
+ process.removeListener('exit', liveAgentExitHook)
128
+ }
129
+ liveAgentExitHook = () => {
119
130
  try {
120
131
  child.kill('SIGTERM')
121
132
  } catch {
122
133
  // already gone
123
134
  }
124
- })
135
+ }
136
+ process.once('exit', liveAgentExitHook)
125
137
  }
126
138
 
127
139
  function provide(ctx, service) {
package/lib/acp-host.js CHANGED
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module'
4
4
  import { pathToFileURL } from 'node:url'
5
5
 
6
6
  export const name = 'dsh-tui-acp-host'
7
- export const inject = ['loader']
7
+ export const inject = ['loader', 'userQuestions', 'permissionPresets']
8
8
 
9
9
  function resolvedModule(ctx, specifier) {
10
10
  for (const anchor of [ctx.baseUrl, import.meta.url]) {
@@ -18,7 +18,130 @@ function resolvedModule(ctx, specifier) {
18
18
  return specifier
19
19
  }
20
20
 
21
+ function resolvedHostModule(ctx, specifier) {
22
+ if (typeof ctx.baseUrl !== 'string') return undefined
23
+ try {
24
+ return pathToFileURL(createRequire(ctx.baseUrl).resolve(specifier)).href
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ /**
31
+ * dsh 0.1.2 replaced the single user-question provider slot with a scoped
32
+ * waterfall. Keep the ACP dependency's older provider seam working while it
33
+ * remains protocol-compatible with both Host generations.
34
+ */
35
+ export function installUserQuestionsCompatibility(ctx) {
36
+ const service = ctx.get?.('userQuestions')
37
+ if (service === undefined || typeof service.registerProvider === 'function') return false
38
+ const registerProvider = (provider) => {
39
+ if (provider === null || typeof provider !== 'object' || typeof provider.ask !== 'function') {
40
+ throw new TypeError('userQuestions.registerProvider: provider must expose ask()')
41
+ }
42
+ return ctx.on('user-questions/request', (request) => provider.ask(request))
43
+ }
44
+ Object.defineProperty(service, 'registerProvider', {
45
+ configurable: true,
46
+ value: registerProvider,
47
+ })
48
+ ctx.effect(() => () => {
49
+ if (service.registerProvider === registerProvider) delete service.registerProvider
50
+ }, 'dsh-tui.user-questions-compatibility')
51
+ return true
52
+ }
53
+
54
+ /**
55
+ * dsh 0.1.2 replaced Session.events with snapshotEvents(). ACP 0.4.x only
56
+ * reads the old immutable snapshot, so restore that getter until ACP can
57
+ * require the new Host API directly.
58
+ */
59
+ export function installSessionEventsCompatibility(Session) {
60
+ const prototype = Session?.prototype
61
+ if (prototype === undefined || 'events' in prototype) return false
62
+ if (typeof prototype.snapshotEvents !== 'function') return false
63
+ Object.defineProperty(prototype, 'events', {
64
+ configurable: true,
65
+ get() {
66
+ if (typeof this.snapshotEvents === 'function') return this.snapshotEvents()
67
+ // Cordis may hand an older plugin a remote Session reference whose
68
+ // declared surface predates snapshotEvents(). The 0.1.2 reference still
69
+ // exposes the scalar seq/eventAt pair, which yields the same snapshot.
70
+ if (typeof this.eventAt === 'function' && Number.isInteger(this.seq)) {
71
+ const events = []
72
+ for (let seq = 0; seq < this.seq; seq += 1) {
73
+ const event = this.eventAt(seq)
74
+ if (event !== undefined) events.push(event)
75
+ }
76
+ return Object.freeze(events)
77
+ }
78
+ return Object.freeze([])
79
+ },
80
+ })
81
+ return true
82
+ }
83
+
84
+ /** Accept ACP 0.4.x's event-array read alongside dsh 0.1.2's Session read. */
85
+ export function installPermissionPresetsCompatibility(permissionPresets) {
86
+ if (permissionPresets === undefined) return false
87
+ const current = permissionPresets.current
88
+ if (typeof current !== 'function' || current.dshTuiAcceptsEventArrays === true) return false
89
+ const compatibleCurrent = function (target) {
90
+ if (!Array.isArray(target)) return current.call(this, target)
91
+ let state = { preset: null, sandbox: null, approval: null, seeded: false }
92
+ for (const event of target) {
93
+ switch (event?.type) {
94
+ case 'permission/preset':
95
+ state = { ...state, preset: event.data?.preset ?? null }
96
+ break
97
+ case 'sandbox/mode':
98
+ state = { ...state, sandbox: event.data?.mode ?? null }
99
+ break
100
+ case 'approval/policy':
101
+ state = { ...state, approval: event.data?.policy ?? null }
102
+ break
103
+ case 'session/end-seed':
104
+ state = { ...state, seeded: true }
105
+ break
106
+ }
107
+ }
108
+ return this.derive(state)
109
+ }
110
+ Object.defineProperty(compatibleCurrent, 'dshTuiAcceptsEventArrays', { value: true })
111
+ Object.defineProperty(permissionPresets, 'current', {
112
+ configurable: true,
113
+ value: compatibleCurrent,
114
+ })
115
+ return true
116
+ }
117
+
118
+ async function mountHostCompatibility(ctx) {
119
+ // The service can come from the active Host even when module resolution
120
+ // below lands on ACP's older peer copy, so adapt the live instance directly.
121
+ installPermissionPresetsCompatibility(ctx.permissionPresets ?? ctx.get?.('permissionPresets'))
122
+
123
+ const sessionModule = resolvedHostModule(ctx, '@deepseek-ai/dsh-session')
124
+ if (sessionModule !== undefined) {
125
+ const exports = await ctx.loader.import(sessionModule)
126
+ installSessionEventsCompatibility(exports.Session)
127
+ }
128
+
129
+ // New dsh presets opt the delegation tool into Host-owned model selection.
130
+ // Older hosts neither export nor require this service, so resolution is
131
+ // deliberately anchored only at the active Host profile.
132
+ if (ctx.get?.('subagentModelSelection') !== undefined) return
133
+ const resolved = resolvedHostModule(
134
+ ctx,
135
+ '@deepseek-ai/dsh-tool-subagent/model-selection-settings',
136
+ )
137
+ if (resolved === undefined) return
138
+ const exports = await ctx.loader.import(resolved)
139
+ await ctx.plugin(ctx.loader.unwrapExports(exports))
140
+ }
141
+
21
142
  export async function apply(ctx, config) {
143
+ await mountHostCompatibility(ctx)
144
+ installUserQuestionsCompatibility(ctx)
22
145
  const specifier = '@openma/deepseek-harness-acp/plugin'
23
146
  const resolved = resolvedModule(ctx, specifier)
24
147
  let exports
@@ -12,7 +12,7 @@ import { CORDIS_METHODS, CORDIS_PROTOCOL } from './cordis-protocol.js'
12
12
  export const name = 'acp-session-config'
13
13
  export const inject = []
14
14
 
15
- const SETUP_METHODS = new Set(['session/new', 'session/load'])
15
+ const SETUP_METHODS = new Set(['session/new', 'session/load', 'session/resume'])
16
16
 
17
17
  class AcpSessionConfigService extends Service {
18
18
  constructor(ctx, core) {
@@ -55,25 +55,35 @@ class AcpSessionConfigService extends Service {
55
55
  observeAgent(message) {
56
56
  return this.core.observeAgent(message)
57
57
  }
58
+
59
+ selectSession(sessionId) {
60
+ return this.core.selectSession(sessionId)
61
+ }
58
62
  }
59
63
 
60
64
  /** Install the `ctx.acpSessionConfig` standard ACP state service. */
61
65
  export function installAcpSessionConfig(ctx) {
62
66
  let sessionId
63
- let options = []
67
+ let selectionKnown = false
68
+ const optionsBySession = new Map()
64
69
  let requestTui
65
70
  const pending = new Map()
66
71
  const listeners = new Set()
67
72
 
73
+ function listFor(targetSessionId) {
74
+ return cloneJson(optionsBySession.get(targetSessionId) ?? [])
75
+ }
76
+
68
77
  function list() {
69
- return cloneJson(options)
78
+ return listFor(sessionId)
70
79
  }
71
80
 
72
81
  function current(id) {
73
82
  if (typeof id !== 'string' || id.length === 0) {
74
83
  throw new Error('acpSessionConfig.current: id must be a non-empty string')
75
84
  }
76
- const option = options.find((candidate) => candidate?.id === id)
85
+ const option = (optionsBySession.get(sessionId) ?? [])
86
+ .find((candidate) => candidate?.id === id)
77
87
  return cloneJson(option?.currentValue ?? option?.current_value)
78
88
  }
79
89
 
@@ -81,23 +91,36 @@ export function installAcpSessionConfig(ctx) {
81
91
  if (typeof category !== 'string' || category.length === 0) {
82
92
  throw new Error('acpSessionConfig.byCategory: category must be a non-empty string')
83
93
  }
84
- return cloneJson(options.filter((candidate) => candidate?.category === category))
94
+ return cloneJson(
95
+ (optionsBySession.get(sessionId) ?? [])
96
+ .filter((candidate) => candidate?.category === category),
97
+ )
85
98
  }
86
99
 
87
100
  function snapshot() {
88
101
  return { sessionId, options: list() }
89
102
  }
90
103
 
91
- function publish(nextSessionId, nextOptions) {
92
- if (typeof nextSessionId === 'string' && nextSessionId.length > 0) {
93
- sessionId = nextSessionId
94
- }
95
- if (!Array.isArray(nextOptions)) return
96
- options = cloneJson(nextOptions)
104
+ function publish() {
97
105
  const next = snapshot()
98
106
  for (const listener of [...listeners]) listener(next)
99
107
  }
100
108
 
109
+ function updateSession(targetSessionId, nextOptions) {
110
+ if (typeof targetSessionId !== 'string' || targetSessionId.length === 0
111
+ || !Array.isArray(nextOptions)) return
112
+ optionsBySession.set(targetSessionId, cloneJson(nextOptions))
113
+ if (targetSessionId === sessionId) publish()
114
+ }
115
+
116
+ function selectSession(nextSessionId) {
117
+ selectionKnown = true
118
+ sessionId = typeof nextSessionId === 'string' && nextSessionId.length > 0
119
+ ? nextSessionId
120
+ : undefined
121
+ publish()
122
+ }
123
+
101
124
  function subscribe(effectCtx, listener) {
102
125
  if (typeof listener !== 'function') {
103
126
  throw new Error('acpSessionConfig.subscribe: listener must be a function')
@@ -120,7 +143,7 @@ export function installAcpSessionConfig(ctx) {
120
143
  function observeClient(message) {
121
144
  if (!isObject(message) || message.id === undefined || typeof message.method !== 'string') return
122
145
  if (SETUP_METHODS.has(message.method)) {
123
- const requested = message.method === 'session/load'
146
+ const requested = message.method !== 'session/new'
124
147
  ? readString(message.params, 'sessionId', 'session_id')
125
148
  : undefined
126
149
  pending.set(message.id, { kind: 'setup', sessionId: requested })
@@ -143,21 +166,21 @@ export function installAcpSessionConfig(ctx) {
143
166
  const bound = readString(message.result, 'sessionId', 'session_id') ?? tracked.sessionId
144
167
  const next = readOptions(message.result)
145
168
  if (tracked.kind === 'setup') {
146
- sessionId = bound
147
- publish(bound, next ?? [])
148
- } else if (bound === undefined || bound === sessionId) {
149
- publish(bound, next)
169
+ if (!selectionKnown) sessionId = bound
170
+ updateSession(bound, next ?? [])
171
+ } else {
172
+ updateSession(bound, next)
150
173
  }
151
174
  return
152
175
  }
153
176
  if (message.method !== 'session/update' || !isObject(message.params)) return
154
177
  const updatedSession = readString(message.params, 'sessionId', 'session_id')
155
- if (sessionId !== undefined && updatedSession !== sessionId) return
156
178
  const update = message.params.update
157
179
  if (!isObject(update)) return
158
180
  const type = readString(update, 'sessionUpdate', 'session_update')
159
181
  if (type !== 'config_option_update') return
160
- publish(updatedSession, readOptions(update))
182
+ if (!selectionKnown && sessionId === undefined) sessionId = updatedSession
183
+ updateSession(updatedSession, readOptions(update))
161
184
  }
162
185
 
163
186
  function bindTransport(nextRequestTui) {
@@ -170,37 +193,47 @@ export function installAcpSessionConfig(ctx) {
170
193
  }
171
194
  }
172
195
 
173
- async function set(id, value) {
196
+ async function setForSession(targetSessionId, id, value) {
174
197
  if (typeof id !== 'string' || id.length === 0) {
175
198
  throw new Error('acpSessionConfig.set: id must be a non-empty string')
176
199
  }
177
- if (sessionId === undefined) {
200
+ if (targetSessionId === undefined) {
178
201
  throw new Error('acpSessionConfig.set: no ACP Session is active')
179
202
  }
180
203
  if (typeof requestTui !== 'function') {
181
204
  throw new Error('acpSessionConfig.set: native ACP transport is unavailable')
182
205
  }
183
- const option = options.find((candidate) => candidate?.id === id)
206
+ const option = (optionsBySession.get(targetSessionId) ?? [])
207
+ .find((candidate) => candidate?.id === id)
184
208
  if (option === undefined) {
185
209
  throw new Error(`acpSessionConfig.set: option "${id}" is not advertised by the current Session`)
186
210
  }
187
211
  validateValue(option, value)
188
212
  const result = await requestTui(CORDIS_METHODS.sessionConfigSet, {
189
213
  protocol: CORDIS_PROTOCOL,
190
- sessionId,
214
+ sessionId: targetSessionId,
191
215
  configId: id,
192
216
  value,
193
217
  })
194
218
  if (!isObject(result)) {
195
219
  throw new Error('acpSessionConfig.set: native ACP client returned an invalid response')
196
220
  }
197
- const resultSession = readString(result, 'sessionId', 'session_id') ?? sessionId
198
- publish(resultSession, readOptions(result))
199
- return list()
221
+ const resultSession = readString(result, 'sessionId', 'session_id') ?? targetSessionId
222
+ updateSession(resultSession, readOptions(result))
223
+ return listFor(targetSessionId)
224
+ }
225
+
226
+ async function set(id, value) {
227
+ return setForSession(sessionId, id, value)
200
228
  }
201
229
 
202
230
  function transaction(selector) {
203
- const option = resolveTransactionOption(selector, options)
231
+ const transactionSessionId = sessionId
232
+ if (transactionSessionId === undefined) {
233
+ throw new Error('acpSessionConfig.transaction: no ACP Session is active')
234
+ }
235
+ const transactionOptions = optionsBySession.get(transactionSessionId) ?? []
236
+ const option = resolveTransactionOption(selector, transactionOptions)
204
237
  const original = cloneJson(option.currentValue ?? option.current_value)
205
238
  if (original === undefined) {
206
239
  throw new Error(
@@ -210,18 +243,18 @@ export function installAcpSessionConfig(ctx) {
210
243
 
211
244
  let desired = cloneJson(original)
212
245
  let applied = cloneJson(original)
213
- let chain = Promise.resolve(list())
246
+ let chain = Promise.resolve(listFor(transactionSessionId))
214
247
  let finalizing
215
248
  let settled = false
216
249
 
217
250
  const write = async (value) => {
218
- const result = await set(option.id, value)
251
+ const result = await setForSession(transactionSessionId, option.id, value)
219
252
  applied = cloneJson(value)
220
253
  return result
221
254
  }
222
255
 
223
256
  const preview = (value) => {
224
- if (settled) return Promise.resolve(list())
257
+ if (settled) return Promise.resolve(listFor(transactionSessionId))
225
258
  if (finalizing) return finalizing
226
259
  if (Object.is(value, desired)) return chain
227
260
  desired = cloneJson(value)
@@ -232,13 +265,13 @@ export function installAcpSessionConfig(ctx) {
232
265
  }
233
266
 
234
267
  const finish = (value) => {
235
- if (settled) return finalizing ?? Promise.resolve(list())
268
+ if (settled) return finalizing ?? Promise.resolve(listFor(transactionSessionId))
236
269
  if (finalizing) return finalizing
237
270
  desired = cloneJson(value)
238
271
  const settle = async () => {
239
272
  if (!Object.is(applied, desired)) await write(desired)
240
273
  settled = true
241
- return list()
274
+ return listFor(transactionSessionId)
242
275
  }
243
276
  finalizing = chain.then(settle, settle).catch((error) => {
244
277
  finalizing = undefined
@@ -273,6 +306,7 @@ export function installAcpSessionConfig(ctx) {
273
306
  bindTransport,
274
307
  observeClient,
275
308
  observeAgent,
309
+ selectSession,
276
310
  }
277
311
  const service = typeof ctx.provide === 'function'
278
312
  ? new AcpSessionConfigService(ctx, core)
@@ -288,6 +322,7 @@ export function installAcpSessionConfig(ctx) {
288
322
  bindTransport,
289
323
  observeClient,
290
324
  observeAgent,
325
+ selectSession,
291
326
  }
292
327
  if (typeof ctx.provide !== 'function') ctx.acpSessionConfig = service
293
328
  return service
@@ -5,7 +5,7 @@ import { Service } from '@deepseek-ai/cordis'
5
5
  export const name = 'acp-session-plan'
6
6
  export const inject = []
7
7
 
8
- const SETUP_METHODS = new Set(['session/new', 'session/load'])
8
+ const SETUP_METHODS = new Set(['session/new', 'session/load', 'session/resume'])
9
9
 
10
10
  class AcpSessionPlanService extends Service {
11
11
  constructor(ctx, core) {
@@ -32,21 +32,30 @@ class AcpSessionPlanService extends Service {
32
32
  observeAgent(message) {
33
33
  return this.core.observeAgent(message)
34
34
  }
35
+
36
+ selectSession(sessionId) {
37
+ return this.core.selectSession(sessionId)
38
+ }
35
39
  }
36
40
 
37
41
  /** Install `ctx.acpSessionPlan`, backed only by standard ACP traffic. */
38
42
  export function installAcpSessionPlan(ctx) {
39
43
  let sessionId
40
- const plans = new Map()
44
+ let selectionKnown = false
45
+ const plansBySession = new Map()
41
46
  const pending = new Map()
42
47
  const listeners = new Set()
43
48
 
49
+ function activePlans() {
50
+ return plansBySession.get(sessionId) ?? new Map()
51
+ }
52
+
44
53
  function list() {
45
- return cloneJson([...plans.values()])
54
+ return cloneJson([...activePlans().values()])
46
55
  }
47
56
 
48
57
  function current() {
49
- return cloneJson([...plans.values()].at(-1) ?? null)
58
+ return cloneJson([...activePlans().values()].at(-1) ?? null)
50
59
  }
51
60
 
52
61
  function snapshot() {
@@ -58,9 +67,17 @@ export function installAcpSessionPlan(ctx) {
58
67
  for (const listener of [...listeners]) listener(next)
59
68
  }
60
69
 
61
- function reset(nextSessionId) {
62
- sessionId = nextSessionId
63
- plans.clear()
70
+ function reset(targetSessionId) {
71
+ if (typeof targetSessionId !== 'string' || targetSessionId.length === 0) return
72
+ plansBySession.set(targetSessionId, new Map())
73
+ if (targetSessionId === sessionId) publish()
74
+ }
75
+
76
+ function selectSession(nextSessionId) {
77
+ selectionKnown = true
78
+ sessionId = typeof nextSessionId === 'string' && nextSessionId.length > 0
79
+ ? nextSessionId
80
+ : undefined
64
81
  publish()
65
82
  }
66
83
 
@@ -86,7 +103,7 @@ export function installAcpSessionPlan(ctx) {
86
103
  function observeClient(message) {
87
104
  if (!isObject(message) || message.id === undefined || !SETUP_METHODS.has(message.method)) return
88
105
  pending.set(message.id, {
89
- sessionId: message.method === 'session/load'
106
+ sessionId: message.method !== 'session/new'
90
107
  ? readString(message.params, 'sessionId', 'session_id')
91
108
  : undefined,
92
109
  })
@@ -98,13 +115,20 @@ export function installAcpSessionPlan(ctx) {
98
115
  const tracked = pending.get(message.id)
99
116
  pending.delete(message.id)
100
117
  if (message.error !== undefined || !isObject(message.result)) return
101
- reset(readString(message.result, 'sessionId', 'session_id') ?? tracked.sessionId)
118
+ const bound = readString(message.result, 'sessionId', 'session_id') ?? tracked.sessionId
119
+ if (!selectionKnown) sessionId = bound
120
+ reset(bound)
102
121
  return
103
122
  }
104
123
  if (message.method !== 'session/update' || !isObject(message.params)) return
105
124
  const updatedSession = readString(message.params, 'sessionId', 'session_id')
106
- if (sessionId !== undefined && updatedSession !== sessionId) return
107
- if (sessionId === undefined && updatedSession !== undefined) sessionId = updatedSession
125
+ if (updatedSession === undefined) return
126
+ if (!selectionKnown && sessionId === undefined) sessionId = updatedSession
127
+ let plans = plansBySession.get(updatedSession)
128
+ if (plans === undefined) {
129
+ plans = new Map()
130
+ plansBySession.set(updatedSession, plans)
131
+ }
108
132
  const update = message.params.update
109
133
  if (!isObject(update)) return
110
134
  const type = readString(update, 'sessionUpdate', 'session_update')
@@ -112,7 +136,7 @@ export function installAcpSessionPlan(ctx) {
112
136
  const id = readString(update, 'planId', 'plan_id')
113
137
  if (id === undefined) plans.clear()
114
138
  else plans.delete(id)
115
- publish()
139
+ if (updatedSession === sessionId) publish()
116
140
  return
117
141
  }
118
142
  if (type !== 'plan' && type !== 'plan_update') return
@@ -124,10 +148,10 @@ export function installAcpSessionPlan(ctx) {
124
148
  plans.delete(plan.id)
125
149
  plans.set(plan.id, plan)
126
150
  }
127
- publish()
151
+ if (updatedSession === sessionId) publish()
128
152
  }
129
153
 
130
- const core = { list, current, subscribe, observeClient, observeAgent }
154
+ const core = { list, current, subscribe, observeClient, observeAgent, selectSession }
131
155
  const service = typeof ctx.provide === 'function'
132
156
  ? new AcpSessionPlanService(ctx, core)
133
157
  : {
@@ -138,6 +162,7 @@ export function installAcpSessionPlan(ctx) {
138
162
  },
139
163
  observeClient,
140
164
  observeAgent,
165
+ selectSession,
141
166
  }
142
167
  if (typeof ctx.provide !== 'function') ctx.acpSessionPlan = service
143
168
  return service
@@ -5,7 +5,7 @@ import { Service } from '@deepseek-ai/cordis'
5
5
  export const name = 'acp-session-stats'
6
6
  export const inject = []
7
7
 
8
- const SETUP_METHODS = new Set(['session/new', 'session/load'])
8
+ const SETUP_METHODS = new Set(['session/new', 'session/load', 'session/resume'])
9
9
 
10
10
  class AcpSessionStatsService extends Service {
11
11
  constructor(ctx, core) {
@@ -17,6 +17,7 @@ class AcpSessionStatsService extends Service {
17
17
  subscribe(listener) { return this.core.subscribe(this.ctx, listener) }
18
18
  observeClient(message) { return this.core.observeClient(message) }
19
19
  observeAgent(message) { return this.core.observeAgent(message) }
20
+ selectSession(sessionId) { return this.core.selectSession(sessionId) }
20
21
  }
21
22
 
22
23
  function zero(sessionId) {
@@ -44,20 +45,51 @@ export function installAcpSessionStats(ctx, options = {}) {
44
45
  const pendingPrompts = new Map()
45
46
  const activePrompts = new Map()
46
47
  const toolStarts = new Map()
47
- let value = zero(undefined)
48
+ const values = new Map()
49
+ let sessionId
50
+ let selectionKnown = false
48
51
 
49
- function current() { return structuredClone(value) }
52
+ function stateFor(targetSessionId) {
53
+ if (typeof targetSessionId !== 'string' || targetSessionId.length === 0) {
54
+ return zero(undefined)
55
+ }
56
+ let value = values.get(targetSessionId)
57
+ if (value === undefined) {
58
+ value = zero(targetSessionId)
59
+ values.set(targetSessionId, value)
60
+ }
61
+ return value
62
+ }
63
+
64
+ function current() { return structuredClone(stateFor(sessionId)) }
50
65
 
51
66
  function publish() {
52
67
  const snapshot = current()
53
68
  for (const listener of [...listeners]) listener(snapshot)
54
69
  }
55
70
 
56
- function reset(sessionId) {
57
- value = zero(sessionId)
58
- activePrompts.clear()
59
- pendingPrompts.clear()
60
- toolStarts.clear()
71
+ function publishIf(targetSessionId) {
72
+ if (targetSessionId === sessionId) publish()
73
+ }
74
+
75
+ function reset(targetSessionId) {
76
+ if (typeof targetSessionId !== 'string' || targetSessionId.length === 0) return
77
+ values.set(targetSessionId, zero(targetSessionId))
78
+ activePrompts.delete(targetSessionId)
79
+ for (const [id, prompt] of pendingPrompts) {
80
+ if (prompt.sessionId === targetSessionId) pendingPrompts.delete(id)
81
+ }
82
+ for (const key of toolStarts.keys()) {
83
+ if (key.startsWith(`${targetSessionId}\u0000`)) toolStarts.delete(key)
84
+ }
85
+ publishIf(targetSessionId)
86
+ }
87
+
88
+ function selectSession(nextSessionId) {
89
+ selectionKnown = true
90
+ sessionId = typeof nextSessionId === 'string' && nextSessionId.length > 0
91
+ ? nextSessionId
92
+ : undefined
61
93
  publish()
62
94
  }
63
95
 
@@ -84,22 +116,26 @@ export function installAcpSessionStats(ctx, options = {}) {
84
116
  if (!object(message) || message.id === undefined || typeof message.method !== 'string') return
85
117
  if (SETUP_METHODS.has(message.method)) {
86
118
  pendingSetup.set(message.id, {
87
- sessionId: message.method === 'session/load'
119
+ sessionId: message.method !== 'session/new'
88
120
  ? readString(message.params, 'sessionId', 'session_id')
89
121
  : undefined,
90
122
  })
91
123
  return
92
124
  }
93
125
  if (message.method !== 'session/prompt') return
94
- const sessionId = readString(message.params, 'sessionId', 'session_id')
95
- if (sessionId === undefined) return
96
- if (value.sessionId === undefined) value.sessionId = sessionId
97
- if (value.sessionId !== sessionId) return
98
- const prompt = { sessionId, started: now(), firstToken: undefined, toolMillis: 0 }
126
+ const promptSessionId = readString(message.params, 'sessionId', 'session_id')
127
+ if (promptSessionId === undefined) return
128
+ if (!selectionKnown && sessionId === undefined) sessionId = promptSessionId
129
+ const prompt = {
130
+ sessionId: promptSessionId,
131
+ started: now(),
132
+ firstToken: undefined,
133
+ toolMillis: 0,
134
+ }
99
135
  pendingPrompts.set(message.id, prompt)
100
- activePrompts.set(sessionId, prompt)
101
- value.stats.turns += 1
102
- publish()
136
+ activePrompts.set(promptSessionId, prompt)
137
+ stateFor(promptSessionId).stats.turns += 1
138
+ publishIf(promptSessionId)
103
139
  }
104
140
 
105
141
  function observeAgent(message) {
@@ -108,32 +144,36 @@ export function installAcpSessionStats(ctx, options = {}) {
108
144
  const tracked = pendingSetup.get(message.id)
109
145
  pendingSetup.delete(message.id)
110
146
  if (message.error !== undefined || !object(message.result)) return
111
- reset(readString(message.result, 'sessionId', 'session_id') ?? tracked.sessionId)
147
+ const bound = readString(message.result, 'sessionId', 'session_id') ?? tracked.sessionId
148
+ if (!selectionKnown) sessionId = bound
149
+ reset(bound)
112
150
  return
113
151
  }
114
152
  if (message.id !== undefined && pendingPrompts.has(message.id)) {
115
153
  const prompt = pendingPrompts.get(message.id)
116
154
  pendingPrompts.delete(message.id)
117
155
  activePrompts.delete(prompt.sessionId)
156
+ const value = stateFor(prompt.sessionId)
118
157
  if (message.error === undefined && object(message.result)) {
119
- addUsage(message.result.usage)
158
+ addUsage(value, message.result.usage)
120
159
  }
121
160
  const elapsed = Math.max(0, now() - prompt.started)
122
161
  value.stats.llmMillis += Math.max(0, elapsed - prompt.toolMillis)
123
- publish()
162
+ publishIf(prompt.sessionId)
124
163
  return
125
164
  }
126
165
  if (message.method !== 'session/update' || !object(message.params)) return
127
- const sessionId = readString(message.params, 'sessionId', 'session_id')
128
- if (value.sessionId !== undefined && sessionId !== value.sessionId) return
129
- if (value.sessionId === undefined) value.sessionId = sessionId
166
+ const updateSessionId = readString(message.params, 'sessionId', 'session_id')
167
+ if (updateSessionId === undefined) return
168
+ if (!selectionKnown && sessionId === undefined) sessionId = updateSessionId
169
+ const value = stateFor(updateSessionId)
130
170
  const update = message.params.update
131
171
  if (!object(update)) return
132
172
 
133
173
  const replay = update?._meta?.dsh
134
174
  if (object(replay) && replay.event === 'prompt/usage') {
135
175
  value.usage = usageOf(replay.usage)
136
- publish()
176
+ publishIf(updateSessionId)
137
177
  return
138
178
  }
139
179
 
@@ -147,11 +187,11 @@ export function installAcpSessionStats(ctx, options = {}) {
147
187
  const size = number(update.size)
148
188
  if (size > 0) {
149
189
  value.context = { used: number(update.used), size }
150
- publish()
190
+ publishIf(updateSessionId)
151
191
  }
152
192
  return
153
193
  }
154
- const prompt = sessionId === undefined ? undefined : activePrompts.get(sessionId)
194
+ const prompt = activePrompts.get(updateSessionId)
155
195
  if (prompt !== undefined && prompt.firstToken === undefined
156
196
  && (type === 'agent_message_chunk' || type === 'agent_thought_chunk')
157
197
  && textOf(update.content).length > 0) {
@@ -162,28 +202,28 @@ export function installAcpSessionStats(ctx, options = {}) {
162
202
  if (type === 'agent_message_chunk'
163
203
  && update?._meta?.dsh?.event === 'assistant_message') {
164
204
  value.stats.steps += 1
165
- publish()
205
+ publishIf(updateSessionId)
166
206
  return
167
207
  }
168
208
  const callId = readString(update, 'toolCallId', 'tool_call_id')
169
209
  if (type === 'tool_call' && callId !== undefined) {
170
- toolStarts.set(`${sessionId ?? ''}\u0000${callId}`, now())
210
+ toolStarts.set(`${updateSessionId}\u0000${callId}`, now())
171
211
  return
172
212
  }
173
213
  if (type === 'tool_call_update' && callId !== undefined
174
214
  && ['completed', 'failed'].includes(update.status)) {
175
- const key = `${sessionId ?? ''}\u0000${callId}`
215
+ const key = `${updateSessionId}\u0000${callId}`
176
216
  const started = toolStarts.get(key)
177
217
  toolStarts.delete(key)
178
218
  if (started === undefined) return
179
219
  const duration = Math.max(0, now() - started)
180
220
  value.stats.toolMillis += duration
181
221
  if (prompt !== undefined) prompt.toolMillis += duration
182
- publish()
222
+ publishIf(updateSessionId)
183
223
  }
184
224
  }
185
225
 
186
- function addUsage(usage) {
226
+ function addUsage(value, usage) {
187
227
  const next = usageOf(usage)
188
228
  value.usage.input += next.input
189
229
  value.usage.output += next.output
@@ -193,7 +233,7 @@ export function installAcpSessionStats(ctx, options = {}) {
193
233
  value.usage.reasoning += next.reasoning
194
234
  }
195
235
 
196
- const core = { current, subscribe, observeClient, observeAgent }
236
+ const core = { current, subscribe, observeClient, observeAgent, selectSession }
197
237
  const service = typeof ctx.provide === 'function'
198
238
  ? new AcpSessionStatsService(ctx, core)
199
239
  : {
@@ -201,6 +241,7 @@ export function installAcpSessionStats(ctx, options = {}) {
201
241
  subscribe(listener) { return subscribe(ctx, listener) },
202
242
  observeClient,
203
243
  observeAgent,
244
+ selectSession,
204
245
  }
205
246
  if (typeof ctx.provide !== 'function') ctx.acpSessionStats = service
206
247
  return service
@@ -14,7 +14,7 @@ export const name = 'acp-session-status'
14
14
  export const inject = ['acpClientEvents', 'acpSessionConfig']
15
15
 
16
16
  const AUTH_REQUIRED_CODE = -32000
17
- const SETUP_METHODS = new Set(['session/new', 'session/load'])
17
+ const SETUP_METHODS = new Set(['session/new', 'session/load', 'session/resume'])
18
18
  const RUNNING_UPDATE_TYPES = new Set([
19
19
  'user_message_chunk',
20
20
  'agent_message_chunk',
@@ -37,20 +37,20 @@ class AcpSessionStatusService extends Service {
37
37
  subscribe(listener) { return this.core.subscribe(this.ctx, listener) }
38
38
  observeClient(message) { return this.core.observeClient(message) }
39
39
  observeAgent(message) { return this.core.observeAgent(message) }
40
+ selectSession(sessionId) { return this.core.selectSession(sessionId) }
40
41
  }
41
42
 
42
- function zero() {
43
+ function zeroSession(sessionId, bound = sessionId !== undefined) {
43
44
  return {
44
45
  state: 'idle',
45
- connection: 'connecting',
46
- server: undefined,
47
- auth: { status: undefined, method: undefined },
48
- session: { sessionId: undefined, bound: false },
46
+ session: { sessionId, bound },
49
47
  model: undefined,
50
48
  effort: undefined,
51
49
  permission: undefined,
52
50
  plan: undefined,
53
51
  agent: undefined,
52
+ permissionPreset: undefined,
53
+ sandboxMode: undefined,
54
54
  }
55
55
  }
56
56
 
@@ -60,21 +60,55 @@ export function installAcpSessionStatus(ctx, options = {}) {
60
60
  ?? ctx.acpSessionConfig ?? ctx.get?.('acpSessionConfig')
61
61
 
62
62
  const listeners = new Set()
63
- let value = zero()
63
+ const sessions = new Map()
64
+ const fallback = zeroSession(undefined, false)
65
+ let sessionId
66
+ let selectionKnown = false
67
+ let connection = 'connecting'
68
+ let server
69
+ const auth = { status: undefined, method: undefined }
64
70
  let initializeId
65
- let pendingAuthenticate = new Set()
71
+ const pendingAuthenticate = new Set()
66
72
  const pendingSetup = new Map()
67
73
  const pendingPrompts = new Map()
68
- let permissionPreset
69
- let sandboxMode
70
74
 
71
- function current() { return structuredClone(value) }
75
+ function stateFor(targetSessionId, bound = targetSessionId !== undefined) {
76
+ if (typeof targetSessionId !== 'string' || targetSessionId.length === 0) {
77
+ return fallback
78
+ }
79
+ let value = sessions.get(targetSessionId)
80
+ if (value === undefined) {
81
+ value = zeroSession(targetSessionId, bound)
82
+ sessions.set(targetSessionId, value)
83
+ } else if (bound) {
84
+ value.session.bound = true
85
+ }
86
+ return value
87
+ }
88
+
89
+ function current() {
90
+ const value = stateFor(sessionId)
91
+ const { permissionPreset: _permissionPreset, sandboxMode: _sandboxMode, ...visible } = value
92
+ return structuredClone({ connection, server, auth, ...visible })
93
+ }
72
94
 
73
95
  function publish() {
74
96
  const snapshot = current()
75
97
  for (const listener of [...listeners]) listener(snapshot)
76
98
  }
77
99
 
100
+ function publishIf(targetSessionId) {
101
+ if (targetSessionId === sessionId) publish()
102
+ }
103
+
104
+ function selectSession(nextSessionId) {
105
+ selectionKnown = true
106
+ sessionId = typeof nextSessionId === 'string' && nextSessionId.length > 0
107
+ ? nextSessionId
108
+ : undefined
109
+ publish()
110
+ }
111
+
78
112
  function subscribe(effectCtx, listener) {
79
113
  if (typeof listener !== 'function') {
80
114
  throw new Error('acpSessionStatus.subscribe: listener must be a function')
@@ -102,27 +136,28 @@ export function installAcpSessionStatus(ctx, options = {}) {
102
136
  }
103
137
  if (message.method === 'authenticate' && message.id !== undefined) {
104
138
  pendingAuthenticate.add(message.id)
105
- value.auth.status = 'signing in'
139
+ auth.status = 'signing in'
106
140
  publish()
107
141
  return
108
142
  }
109
143
  if (SETUP_METHODS.has(message.method) && message.id !== undefined) {
110
144
  pendingSetup.set(
111
145
  message.id,
112
- message.method === 'session/load'
146
+ message.method !== 'session/new'
113
147
  ? readString(message.params, 'sessionId', 'session_id')
114
148
  : undefined,
115
149
  )
116
150
  return
117
151
  }
118
152
  if (message.method === 'session/prompt' && message.id !== undefined) {
119
- pendingPrompts.set(
120
- message.id,
121
- readString(message.params, 'sessionId', 'session_id'),
122
- )
153
+ const promptSessionId = readString(message.params, 'sessionId', 'session_id')
154
+ if (promptSessionId === undefined) return
155
+ if (!selectionKnown && sessionId === undefined) sessionId = promptSessionId
156
+ pendingPrompts.set(message.id, promptSessionId)
157
+ const value = stateFor(promptSessionId)
123
158
  if (value.state === 'idle') {
124
159
  value.state = 'starting'
125
- publish()
160
+ publishIf(promptSessionId)
126
161
  }
127
162
  }
128
163
  }
@@ -131,14 +166,14 @@ export function installAcpSessionStatus(ctx, options = {}) {
131
166
  if (!object(message)) return
132
167
  if (message.id !== undefined && message.id === initializeId) {
133
168
  initializeId = undefined
134
- value.connection = 'attached'
169
+ connection = 'attached'
135
170
  const result = object(message.result) ? message.result : undefined
136
- value.server = readString(result?.agentInfo, 'name')
171
+ server = readString(result?.agentInfo, 'name')
137
172
  const methods = Array.isArray(result?.authMethods) ? result.authMethods : []
138
173
  const method = methods.find((candidate) => object(candidate)
139
174
  && typeof candidate.id === 'string' && candidate.id.length > 0)
140
- if (method !== undefined && value.auth.status === undefined) {
141
- value.auth.method = readString(method, 'name', 'label') ?? method.id
175
+ if (method !== undefined && auth.status === undefined) {
176
+ auth.method = readString(method, 'name', 'label') ?? method.id
142
177
  }
143
178
  publish()
144
179
  return
@@ -146,11 +181,11 @@ export function installAcpSessionStatus(ctx, options = {}) {
146
181
  if (message.id !== undefined && pendingAuthenticate.has(message.id)) {
147
182
  pendingAuthenticate.delete(message.id)
148
183
  if (message.error === undefined) {
149
- value.auth.status = 'configured'
184
+ auth.status = 'configured'
150
185
  } else if (isAuthRequired(message.error)) {
151
- value.auth.status = 'needs sign-in'
186
+ auth.status = 'needs sign-in'
152
187
  } else {
153
- value.auth.status = undefined
188
+ auth.status = undefined
154
189
  }
155
190
  publish()
156
191
  return
@@ -159,82 +194,99 @@ export function installAcpSessionStatus(ctx, options = {}) {
159
194
  const requested = pendingSetup.get(message.id)
160
195
  pendingSetup.delete(message.id)
161
196
  if (message.error !== undefined) {
162
- if (isAuthRequired(message.error)) value.auth.status = 'needs sign-in'
163
- value.session = { sessionId: requested, bound: false }
197
+ if (isAuthRequired(message.error)) auth.status = 'needs sign-in'
198
+ if (requested !== undefined) {
199
+ stateFor(requested, false).session.bound = false
200
+ }
201
+ if (!selectionKnown) sessionId = requested
164
202
  } else {
165
- const sessionId = readString(message.result, 'sessionId', 'session_id') ?? requested
166
- value.session = { sessionId, bound: sessionId !== undefined }
203
+ const bound = readString(message.result, 'sessionId', 'session_id') ?? requested
204
+ if (bound !== undefined) stateFor(bound).session = { sessionId: bound, bound: true }
205
+ if (!selectionKnown) sessionId = bound
167
206
  }
168
207
  publish()
169
208
  return
170
209
  }
171
210
  if (response(message) && pendingPrompts.has(message.id)) {
211
+ const promptSessionId = pendingPrompts.get(message.id)
172
212
  pendingPrompts.delete(message.id)
173
213
  let changed = false
174
214
  if (message.error !== undefined && isAuthRequired(message.error)
175
- && value.auth.status !== 'needs sign-in') {
176
- value.auth.status = 'needs sign-in'
215
+ && auth.status !== 'needs sign-in') {
216
+ auth.status = 'needs sign-in'
177
217
  changed = true
178
218
  }
179
- if (pendingPrompts.size === 0 && value.state !== 'idle') {
219
+ const value = stateFor(promptSessionId)
220
+ if (!hasPendingPrompt(promptSessionId) && value.state !== 'idle') {
180
221
  value.state = 'idle'
181
222
  changed = true
182
223
  }
183
- if (changed) publish()
224
+ if (changed && (promptSessionId === sessionId || auth.status === 'needs sign-in')) publish()
184
225
  return
185
226
  }
186
227
  if (message.error !== undefined && isAuthRequired(message.error)) {
187
- value.auth.status = 'needs sign-in'
228
+ auth.status = 'needs sign-in'
188
229
  publish()
189
230
  return
190
231
  }
191
232
 
192
233
  if (message.method === 'session.status' && object(message.params)) {
234
+ const statusSessionId = readString(message.params, 'sessionId', 'session_id') ?? sessionId
235
+ if (statusSessionId === undefined) return
236
+ if (!selectionKnown && sessionId === undefined) sessionId = statusSessionId
193
237
  const status = readString(message.params, 'status')
194
- if (status === 'running' || (status === 'idle' && pendingPrompts.size === 0)) {
238
+ const value = stateFor(statusSessionId)
239
+ if (status === 'running' || (status === 'idle' && !hasPendingPrompt(statusSessionId))) {
195
240
  value.state = status
196
- publish()
241
+ publishIf(statusSessionId)
197
242
  }
198
243
  return
199
244
  }
200
245
  if (message.method === 'session.event' && object(message.params)) {
246
+ const eventSessionId = readString(message.params, 'sessionId', 'session_id') ?? sessionId
247
+ if (eventSessionId === undefined) return
248
+ if (!selectionKnown && sessionId === undefined) sessionId = eventSessionId
249
+ const value = stateFor(eventSessionId)
201
250
  const event = object(message.params.event) ? message.params.event : undefined
202
251
  const type = readString(event, 'type')
203
252
  const data = object(event?.data) ? event.data : undefined
204
253
  if (type === 'permission/preset') {
205
254
  const preset = readString(data, 'preset')
206
255
  if (preset !== undefined) {
207
- permissionPreset = preset
208
- value.permission = permissionPreset ?? sandboxMode
209
- publish()
256
+ value.permissionPreset = preset
257
+ value.permission = value.permissionPreset ?? value.sandboxMode
258
+ publishIf(eventSessionId)
210
259
  }
211
260
  } else if (type === 'sandbox/mode') {
212
261
  const mode = readString(data, 'mode')
213
262
  if (mode !== undefined) {
214
- sandboxMode = mode
215
- value.permission = permissionPreset ?? sandboxMode
216
- publish()
263
+ value.sandboxMode = mode
264
+ value.permission = value.permissionPreset ?? value.sandboxMode
265
+ publishIf(eventSessionId)
217
266
  }
218
267
  } else if (type === 'plan/mode' && typeof data?.active === 'boolean') {
219
268
  value.plan = data.active
220
- publish()
269
+ publishIf(eventSessionId)
221
270
  } else if (type === 'agent-preset/selected') {
222
271
  const preset = readString(data, 'agentPreset')
223
272
  if (preset !== undefined) {
224
273
  value.agent = preset
225
- publish()
274
+ publishIf(eventSessionId)
226
275
  }
227
276
  }
228
277
  return
229
278
  }
230
279
  if (message.method !== 'session/update' || !object(message.params)) return
231
- const sessionId = readString(message.params, 'sessionId', 'session_id')
280
+ const updateSessionId = readString(message.params, 'sessionId', 'session_id')
281
+ if (updateSessionId === undefined) return
282
+ if (!selectionKnown && sessionId === undefined) sessionId = updateSessionId
232
283
  const update = object(message.params.update) ? message.params.update : undefined
233
284
  const type = readString(update, 'sessionUpdate', 'session_update')
234
- if (value.state !== 'running' && hasPendingPrompt(sessionId)
285
+ const value = stateFor(updateSessionId)
286
+ if (value.state !== 'running' && hasPendingPrompt(updateSessionId)
235
287
  && RUNNING_UPDATE_TYPES.has(type)) {
236
288
  value.state = 'running'
237
- publish()
289
+ publishIf(updateSessionId)
238
290
  }
239
291
  }
240
292
 
@@ -248,13 +300,13 @@ export function installAcpSessionStatus(ctx, options = {}) {
248
300
 
249
301
  function onConfigSnapshot(snapshot) {
250
302
  if (snapshot === null || typeof snapshot !== 'object') return
251
- if (typeof snapshot.sessionId === 'string' && snapshot.sessionId.length > 0
252
- && value.session.sessionId === undefined) {
253
- value.session = { sessionId: snapshot.sessionId, bound: true }
254
- }
303
+ const configSessionId = typeof snapshot.sessionId === 'string' && snapshot.sessionId.length > 0
304
+ ? snapshot.sessionId
305
+ : sessionId
306
+ const value = stateFor(configSessionId)
255
307
  value.model = optionValue(snapshot.options, 'model') ?? value.model
256
308
  value.effort = optionValue(snapshot.options, 'effort') ?? value.effort
257
- publish()
309
+ publishIf(configSessionId)
258
310
  }
259
311
 
260
312
  function optionValue(options, id) {
@@ -265,7 +317,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
265
317
  return typeof raw === 'string' ? raw : undefined
266
318
  }
267
319
 
268
- const core = { current, subscribe, observeClient, observeAgent }
320
+ const core = { current, subscribe, observeClient, observeAgent, selectSession }
269
321
  const service = typeof ctx.provide === 'function'
270
322
  ? new AcpSessionStatusService(ctx, core)
271
323
  : {
@@ -273,6 +325,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
273
325
  subscribe(listener) { return subscribe(ctx, listener) },
274
326
  observeClient,
275
327
  observeAgent,
328
+ selectSession,
276
329
  }
277
330
  if (typeof ctx.provide !== 'function') ctx.acpSessionStatus = service
278
331
 
@@ -286,7 +339,7 @@ export function installAcpSessionStatus(ctx, options = {}) {
286
339
  if (typeof events?.register === 'function') {
287
340
  // register(observer) only: the service scopes the subscription to its
288
341
  // own Context, so pass the folding object alone.
289
- events.register({ observeClient, observeAgent })
342
+ events.register({ observeClient, observeAgent, selectSession })
290
343
  }
291
344
 
292
345
  return service
@@ -37,6 +37,7 @@ export const CORDIS_METHODS = Object.freeze({
37
37
  agentsUpdate: '_dsh/cordis/tui/agents/update',
38
38
  agentsSelect: '_dsh/cordis/tui/agents/select',
39
39
  agentsNavigate: '_dsh/cordis/tui/agents/navigate',
40
+ sessionActive: '_dsh/cordis/tui/session/active',
40
41
  sessionConfigSet: '_dsh/cordis/tui/session-config/set',
41
42
  })
42
43
 
package/lib/harnesses.js CHANGED
@@ -28,14 +28,28 @@ function readSettings(settingsPath) {
28
28
  try {
29
29
  value = JSON.parse(readFileSync(settingsPath, 'utf8'))
30
30
  } catch (error) {
31
- throw new Error(`invalid Martty settings: ${error.message}`)
31
+ // A corrupt settings file must never block boot (boot.js: "must not
32
+ // block boot"). Park the unreadable file for diagnosis and start
33
+ // over — the same resilience tui-theme/tui-presets apply.
34
+ quarantineSettings(settingsPath, `invalid Martty settings: ${error.message}`)
35
+ return {}
32
36
  }
33
37
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
34
- throw new Error('invalid Martty settings: root must be an object')
38
+ quarantineSettings(settingsPath, 'invalid Martty settings: root must be an object')
39
+ return {}
35
40
  }
36
41
  return value
37
42
  }
38
43
 
44
+ function quarantineSettings(settingsPath, reason) {
45
+ process.stderr.write(`martty: ${reason} — moving it aside and starting fresh\n`)
46
+ try {
47
+ renameSync(settingsPath, `${settingsPath}.corrupt-${Date.now()}`)
48
+ } catch {
49
+ // Best effort only; the fresh settings write below still proceeds.
50
+ }
51
+ }
52
+
39
53
  function writeSettings(settingsPath, value) {
40
54
  mkdirSync(path.dirname(settingsPath), { recursive: true })
41
55
  const temporary = `${settingsPath}.${process.pid}.${Date.now()}.tmp`
package/lib/index.js CHANGED
@@ -132,9 +132,10 @@ export async function applyShell(ctx, options = {}) {
132
132
  }
133
133
  const clientEvents = ctx.acpClientEvents ?? ctx.get?.('acpClientEvents')
134
134
  if (clientEvents === undefined || typeof clientEvents.observeClient !== 'function'
135
- || typeof clientEvents.observeAgent !== 'function') {
135
+ || typeof clientEvents.observeAgent !== 'function'
136
+ || typeof clientEvents.selectSession !== 'function') {
136
137
  throw new Error(
137
- 'dsh-tui-shell: ctx.acpClientEvents must expose ACP observers',
138
+ 'dsh-tui-shell: ctx.acpClientEvents must expose ACP observers and Session selection',
138
139
  )
139
140
  }
140
141
  const queue = ctx.tuiQueue ?? ctx.get?.('tuiQueue')
@@ -180,7 +181,9 @@ export async function applyShell(ctx, options = {}) {
180
181
  if (!shouldQuitOnPainterExit(signal)) return
181
182
  shellState.refuseRespawn = true
182
183
  if (shellState.livePainter?.child === child) shellState.livePainter = null
183
- process.exit(code ?? (signal === 'SIGINT' ? 130 : 0))
184
+ // Signal exits must not read as success: 130/143 match the
185
+ // bin/martty.js convention; any other signal is a failure.
186
+ process.exit(code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1))
184
187
  })
185
188
  child.on('error', (err) => {
186
189
  console.error(`dsh-tui: ${err.message}`)
@@ -241,6 +244,13 @@ export async function applyShell(ctx, options = {}) {
241
244
  if (message.method === CORDIS_METHODS.agentsUpdate) {
242
245
  return { ok: agents.observe(message.params) }
243
246
  }
247
+ if (message.method === CORDIS_METHODS.sessionActive) {
248
+ const active = message.params?.sessionId
249
+ clientEvents.selectSession(
250
+ typeof active === 'string' && active.length > 0 ? active : undefined,
251
+ )
252
+ return { ok: true }
253
+ }
244
254
  if (message.method === CORDIS_METHODS.approvalRespond) {
245
255
  return clientRunner.respondApproval(message.params)
246
256
  }
package/lib/mux.js CHANGED
@@ -24,6 +24,7 @@ const COMPOSITOR_METHODS = Object.freeze(new Set([
24
24
  CORDIS_METHODS.agentsUpdate,
25
25
  CORDIS_METHODS.agentsSelect,
26
26
  CORDIS_METHODS.agentsNavigate,
27
+ CORDIS_METHODS.sessionActive,
27
28
  CORDIS_METHODS.sessionConfigSet,
28
29
  CORDIS_METHODS.approvalRespond,
29
30
  CORDIS_METHODS.uiSelected,
package/lib/runner.js CHANGED
@@ -79,7 +79,11 @@ export async function apply(ctx) {
79
79
  void release()
80
80
  })
81
81
  child.once('exit', (code, signal) => {
82
- requestExit(code ?? (signal === 'SIGINT' ? 130 : 1))
82
+ // Profile patch watching can recompose the Host tree immediately after
83
+ // startup. In that path our disposer deliberately terminates this Client
84
+ // process; its resulting SIGTERM belongs to the old fiber and must not
85
+ // tear down the replacement tree through appExit(1).
86
+ if (!released) requestExit(code ?? (signal === 'SIGINT' ? 130 : 1))
83
87
  void release()
84
88
  })
85
89
  process.once('exit', onProcessExit)
package/lib/tui-theme.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * flushed from `bindNotify`.
8
8
  */
9
9
 
10
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
11
11
  import { dirname, isAbsolute } from 'node:path'
12
12
  import { CORDIS_METHODS } from './cordis-protocol.js'
13
13
 
@@ -92,7 +92,12 @@ function writePreferred(settingsPath, id) {
92
92
  const settings = readSettings(settingsPath)
93
93
  settings.theme = id
94
94
  mkdirSync(dirname(settingsPath), { recursive: true })
95
- writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
95
+ // Atomic write (temp + rename), matching harnesses.writeSettings: a
96
+ // crash mid-write must never leave a truncated settings.json behind —
97
+ // every later launch would otherwise start from an unreadable file.
98
+ const temporary = `${settingsPath}.${process.pid}.${Date.now()}.tmp`
99
+ writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`)
100
+ renameSync(temporary, settingsPath)
96
101
  }
97
102
 
98
103
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "martty",
3
- "version": "0.2.32",
3
+ "version": "0.2.33",
4
4
  "description": "Terminal-native ACP client UI; Cordis client tree, any ACP agent",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file