martty 0.2.32 → 0.2.34

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.
@@ -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,30 @@ 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
  })
123
+ if (message.method === 'session/load') reset(readString(message.params, 'sessionId', 'session_id'))
91
124
  return
92
125
  }
93
126
  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 }
127
+ const promptSessionId = readString(message.params, 'sessionId', 'session_id')
128
+ if (promptSessionId === undefined) return
129
+ if (!selectionKnown && sessionId === undefined) sessionId = promptSessionId
130
+ const prompt = {
131
+ sessionId: promptSessionId,
132
+ primary: !activePrompts.has(promptSessionId),
133
+ started: now(),
134
+ firstToken: undefined,
135
+ toolMillis: 0,
136
+ }
99
137
  pendingPrompts.set(message.id, prompt)
100
- activePrompts.set(sessionId, prompt)
101
- value.stats.turns += 1
102
- publish()
138
+ if (prompt.primary) {
139
+ activePrompts.set(promptSessionId, prompt)
140
+ stateFor(promptSessionId).stats.turns += 1
141
+ }
142
+ publishIf(promptSessionId)
103
143
  }
104
144
 
105
145
  function observeAgent(message) {
@@ -108,32 +148,38 @@ export function installAcpSessionStats(ctx, options = {}) {
108
148
  const tracked = pendingSetup.get(message.id)
109
149
  pendingSetup.delete(message.id)
110
150
  if (message.error !== undefined || !object(message.result)) return
111
- reset(readString(message.result, 'sessionId', 'session_id') ?? tracked.sessionId)
151
+ const bound = readString(message.result, 'sessionId', 'session_id') ?? tracked.sessionId
152
+ if (!selectionKnown) sessionId = bound
153
+ // Replay notifications precede the setup response. Preserve their state.
154
+ if (!values.has(bound)) reset(bound)
155
+ else if (bound === sessionId) publish()
112
156
  return
113
157
  }
114
158
  if (message.id !== undefined && pendingPrompts.has(message.id)) {
115
159
  const prompt = pendingPrompts.get(message.id)
116
160
  pendingPrompts.delete(message.id)
117
- activePrompts.delete(prompt.sessionId)
161
+ if (activePrompts.get(prompt.sessionId) === prompt) activePrompts.delete(prompt.sessionId)
162
+ const value = stateFor(prompt.sessionId)
118
163
  if (message.error === undefined && object(message.result)) {
119
- addUsage(message.result.usage)
164
+ addUsage(value, message.result.usage)
120
165
  }
121
166
  const elapsed = Math.max(0, now() - prompt.started)
122
- value.stats.llmMillis += Math.max(0, elapsed - prompt.toolMillis)
123
- publish()
167
+ if (prompt.primary) value.stats.llmMillis += Math.max(0, elapsed - prompt.toolMillis)
168
+ publishIf(prompt.sessionId)
124
169
  return
125
170
  }
126
171
  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
172
+ const updateSessionId = readString(message.params, 'sessionId', 'session_id')
173
+ if (updateSessionId === undefined) return
174
+ if (!selectionKnown && sessionId === undefined) sessionId = updateSessionId
175
+ const value = stateFor(updateSessionId)
130
176
  const update = message.params.update
131
177
  if (!object(update)) return
132
178
 
133
179
  const replay = update?._meta?.dsh
134
180
  if (object(replay) && replay.event === 'prompt/usage') {
135
181
  value.usage = usageOf(replay.usage)
136
- publish()
182
+ publishIf(updateSessionId)
137
183
  return
138
184
  }
139
185
 
@@ -147,11 +193,11 @@ export function installAcpSessionStats(ctx, options = {}) {
147
193
  const size = number(update.size)
148
194
  if (size > 0) {
149
195
  value.context = { used: number(update.used), size }
150
- publish()
196
+ publishIf(updateSessionId)
151
197
  }
152
198
  return
153
199
  }
154
- const prompt = sessionId === undefined ? undefined : activePrompts.get(sessionId)
200
+ const prompt = activePrompts.get(updateSessionId)
155
201
  if (prompt !== undefined && prompt.firstToken === undefined
156
202
  && (type === 'agent_message_chunk' || type === 'agent_thought_chunk')
157
203
  && textOf(update.content).length > 0) {
@@ -162,28 +208,28 @@ export function installAcpSessionStats(ctx, options = {}) {
162
208
  if (type === 'agent_message_chunk'
163
209
  && update?._meta?.dsh?.event === 'assistant_message') {
164
210
  value.stats.steps += 1
165
- publish()
211
+ publishIf(updateSessionId)
166
212
  return
167
213
  }
168
214
  const callId = readString(update, 'toolCallId', 'tool_call_id')
169
215
  if (type === 'tool_call' && callId !== undefined) {
170
- toolStarts.set(`${sessionId ?? ''}\u0000${callId}`, now())
216
+ toolStarts.set(`${updateSessionId}\u0000${callId}`, now())
171
217
  return
172
218
  }
173
219
  if (type === 'tool_call_update' && callId !== undefined
174
220
  && ['completed', 'failed'].includes(update.status)) {
175
- const key = `${sessionId ?? ''}\u0000${callId}`
221
+ const key = `${updateSessionId}\u0000${callId}`
176
222
  const started = toolStarts.get(key)
177
223
  toolStarts.delete(key)
178
224
  if (started === undefined) return
179
225
  const duration = Math.max(0, now() - started)
180
226
  value.stats.toolMillis += duration
181
227
  if (prompt !== undefined) prompt.toolMillis += duration
182
- publish()
228
+ publishIf(updateSessionId)
183
229
  }
184
230
  }
185
231
 
186
- function addUsage(usage) {
232
+ function addUsage(value, usage) {
187
233
  const next = usageOf(usage)
188
234
  value.usage.input += next.input
189
235
  value.usage.output += next.output
@@ -193,7 +239,7 @@ export function installAcpSessionStats(ctx, options = {}) {
193
239
  value.usage.reasoning += next.reasoning
194
240
  }
195
241
 
196
- const core = { current, subscribe, observeClient, observeAgent }
242
+ const core = { current, subscribe, observeClient, observeAgent, selectSession }
197
243
  const service = typeof ctx.provide === 'function'
198
244
  ? new AcpSessionStatsService(ctx, core)
199
245
  : {
@@ -201,6 +247,7 @@ export function installAcpSessionStats(ctx, options = {}) {
201
247
  subscribe(listener) { return subscribe(ctx, listener) },
202
248
  observeClient,
203
249
  observeAgent,
250
+ selectSession,
204
251
  }
205
252
  if (typeof ctx.provide !== 'function') ctx.acpSessionStats = service
206
253
  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)