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.
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,10 +103,11 @@ 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
  })
110
+ if (message.method === 'session/load') reset(readString(message.params, 'sessionId', 'session_id'))
93
111
  }
94
112
 
95
113
  function observeAgent(message) {
@@ -98,13 +116,22 @@ export function installAcpSessionPlan(ctx) {
98
116
  const tracked = pending.get(message.id)
99
117
  pending.delete(message.id)
100
118
  if (message.error !== undefined || !isObject(message.result)) return
101
- reset(readString(message.result, 'sessionId', 'session_id') ?? tracked.sessionId)
119
+ const bound = readString(message.result, 'sessionId', 'session_id') ?? tracked.sessionId
120
+ if (!selectionKnown) sessionId = bound
121
+ // Replay notifications precede the setup response. Preserve their state.
122
+ if (!plansBySession.has(bound)) reset(bound)
123
+ else if (bound === sessionId) publish()
102
124
  return
103
125
  }
104
126
  if (message.method !== 'session/update' || !isObject(message.params)) return
105
127
  const updatedSession = readString(message.params, 'sessionId', 'session_id')
106
- if (sessionId !== undefined && updatedSession !== sessionId) return
107
- if (sessionId === undefined && updatedSession !== undefined) sessionId = updatedSession
128
+ if (updatedSession === undefined) return
129
+ if (!selectionKnown && sessionId === undefined) sessionId = updatedSession
130
+ let plans = plansBySession.get(updatedSession)
131
+ if (plans === undefined) {
132
+ plans = new Map()
133
+ plansBySession.set(updatedSession, plans)
134
+ }
108
135
  const update = message.params.update
109
136
  if (!isObject(update)) return
110
137
  const type = readString(update, 'sessionUpdate', 'session_update')
@@ -112,7 +139,7 @@ export function installAcpSessionPlan(ctx) {
112
139
  const id = readString(update, 'planId', 'plan_id')
113
140
  if (id === undefined) plans.clear()
114
141
  else plans.delete(id)
115
- publish()
142
+ if (updatedSession === sessionId) publish()
116
143
  return
117
144
  }
118
145
  if (type !== 'plan' && type !== 'plan_update') return
@@ -124,10 +151,10 @@ export function installAcpSessionPlan(ctx) {
124
151
  plans.delete(plan.id)
125
152
  plans.set(plan.id, plan)
126
153
  }
127
- publish()
154
+ if (updatedSession === sessionId) publish()
128
155
  }
129
156
 
130
- const core = { list, current, subscribe, observeClient, observeAgent }
157
+ const core = { list, current, subscribe, observeClient, observeAgent, selectSession }
131
158
  const service = typeof ctx.provide === 'function'
132
159
  ? new AcpSessionPlanService(ctx, core)
133
160
  : {
@@ -138,6 +165,7 @@ export function installAcpSessionPlan(ctx) {
138
165
  },
139
166
  observeClient,
140
167
  observeAgent,
168
+ selectSession,
141
169
  }
142
170
  if (typeof ctx.provide !== 'function') ctx.acpSessionPlan = service
143
171
  return service