@jc_stack/ez-agents 0.1.0-beta.49 → 0.1.0-beta.51

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.
Files changed (58) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +9 -5
  4. package/bin/ez +16 -7
  5. package/docs/architecture/ai-selection.md +4 -0
  6. package/docs/docker-runtime.md +28 -2
  7. package/docs/plugin-guide.md +3 -3
  8. package/docs/plugins.md +13 -10
  9. package/docs/scheduling.md +62 -22
  10. package/docs/setup.md +2 -2
  11. package/docs/standalone-cli.md +4 -4
  12. package/docs/upgrades.md +16 -15
  13. package/package.json +4 -2
  14. package/src/agent-guidance.ts +2 -2
  15. package/src/ai-cli.ts +4 -2
  16. package/src/ai.ts +17 -5
  17. package/src/application-channel.ts +12 -8
  18. package/src/control-state.ts +10 -4
  19. package/src/delivery-socket-client.mjs +15 -1
  20. package/src/delivery-socket.ts +18 -4
  21. package/src/executor.ts +63 -10
  22. package/src/host-executor-client.ts +23 -8
  23. package/src/host-executor.ts +63 -32
  24. package/src/index.ts +157 -35
  25. package/src/menu.ts +3 -2
  26. package/src/plugins/manager.mjs +43 -40
  27. package/src/plugins/workspace-lease.mjs +1 -1
  28. package/src/runs.ts +67 -3
  29. package/src/schedule-cli.ts +24 -15
  30. package/src/scheduled-tasks.ts +53 -17
  31. package/src/scheduler.ts +7 -8
  32. package/src/session-close.ts +17 -0
  33. package/src/setup-fallback.ts +62 -25
  34. package/src/tools-line.d.mts +1 -0
  35. package/src/tools-line.mjs +3 -0
  36. package/src/updates/binding.mjs +4 -10
  37. package/src/updates/control.mjs +3 -2
  38. package/src/updates/runtime.mjs +25 -9
  39. package/src/updates/supervisor.mjs +105 -16
  40. package/src/workspace.ts +12 -1
  41. package/test/ai-cli.test.ts +23 -1
  42. package/test/ai.test.ts +72 -4
  43. package/test/application-channel.test.ts +24 -0
  44. package/test/application-controls.test.ts +4 -1
  45. package/test/delivery-transport.test.ts +41 -2
  46. package/test/executor.test.ts +32 -4
  47. package/test/host-executor.test.ts +70 -3
  48. package/test/intake-relay.test.ts +71 -3
  49. package/test/model-policy.test.ts +1 -1
  50. package/test/plugin-broker.test.mjs +48 -1
  51. package/test/plugin-manager.test.mjs +52 -38
  52. package/test/run-handoff.test.ts +136 -0
  53. package/test/schedule-cli.test.ts +24 -1
  54. package/test/scheduled-tasks.test.ts +45 -15
  55. package/test/session-close.test.ts +17 -0
  56. package/test/setup-fallback.test.ts +194 -16
  57. package/test/updates.test.mjs +55 -12
  58. package/test/workspace.test.ts +6 -0
@@ -3,7 +3,7 @@ import { Tasks } from './tasks.js'
3
3
  import { ControlStore } from './control-state.js'
4
4
  import { authorizeRun, readRun } from './delivery-socket.js'
5
5
  import { runPromptSuffix } from './prompt-suffix.js'
6
- import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
6
+ import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath, utimes, stat } from 'node:fs/promises'
7
7
  import path from 'node:path'
8
8
  import { isHostRunId } from './host-executor-protocol.js'
9
9
  import { fileURLToPath } from 'node:url'
@@ -42,13 +42,16 @@ export const refreshAgentCatalog = async (agent: HostBinding,
42
42
  const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
43
43
 
44
44
  export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob,
45
- readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]>) => {
45
+ readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]>, retire?: AbortSignal) => {
46
46
  if (installation.isolation !== undefined) parseIsolationClass(installation.isolation)
47
- if (installation.isolation === 'isolated') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
47
+ if (installation.isolation === 'isolated' && process.env.EZ_CONTAINER_EXECUTOR !== '1') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
48
48
  resolveExecutor(installation.cli)
49
49
  if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
50
50
  new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
51
51
  throw new Error('Each agent requires a separate workspace and control directory')
52
+ const containerId = process.env.EZ_CONTAINER_EXECUTOR === '1' ? process.env.HOSTNAME : undefined
53
+ const hostStarted = await processStart(process.pid)
54
+ let retiring = false
52
55
  const active = new Map<string, ChildProcess>()
53
56
  // A fallback attempt reuses its run ID; claim it only after the prior attempt's cleanup.
54
57
  const claimed = new Set<string>()
@@ -74,9 +77,29 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
74
77
  const freshCatalog = readCatalog ?? catalog
75
78
  const beat = async (agent: HostBinding) => {
76
79
  const directory = path.join(agent.controlDir, 'host-executor')
77
- await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
80
+ await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, ...(process.env.EZ_CONTAINER_EXECUTOR === '1' ? {containerExecutor:true,containerId} : {}), plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
78
81
  await rename(path.join(directory, 'heartbeat.tmp'), path.join(directory, 'heartbeat.json'))
79
82
  }
83
+ const recoverClaims = async (agent: HostBinding) => {
84
+ const directory=path.join(agent.controlDir,'host-executor')
85
+ for (const file of await readdir(directory)) if (file.endsWith('.running.json') && isHostRunId(file.slice(0,-13))) {
86
+ const base=path.join(directory,file.slice(0,-13))
87
+ if (claimed.has(base)) continue
88
+ let prior: {pid?:number;started?:string;hostPid?:number;hostStarted?:string;containerId?:string} | undefined
89
+ try { prior=JSON.parse(await readFile(base+'.process.json','utf8')) } catch(error) { if ((error as NodeJS.ErrnoException).code!=='ENOENT') throw error }
90
+ if(prior?.containerId && prior.containerId!==containerId){
91
+ const touched=await stat(base+'.events').then(s=>s.mtimeMs,()=>0)
92
+ if(Date.now()-touched<15000)continue
93
+ }
94
+ if (!prior?.containerId && prior?.hostPid && prior.hostStarted && await processStart(prior.hostPid)===prior.hostStarted) continue
95
+ if (!prior?.containerId && prior?.pid && await processStart(prior.pid)) {
96
+ if (!prior.hostPid) throw Error('Previous host CLI is still running; stop it before recovery')
97
+ if (prior.started && await processStart(prior.pid)===prior.started) terminateJob({pid:prior.pid,exitCode:null,signalCode:null} as ChildProcess)
98
+ }
99
+ await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
100
+ await rm(base+'.process.json',{force:true});await rm(base+'.running.json',{force:true})
101
+ }
102
+ }
80
103
  try {
81
104
  for (const agent of installation.agents) {
82
105
  if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
@@ -89,7 +112,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
89
112
  if (!Array.isArray(agent.additionalWorkspaces) || agent.additionalWorkspaces.some(workspace=>typeof workspace!=='string' || !path.isAbsolute(workspace))) throw new Error('Additional workspaces require absolute paths')
90
113
  additionalWorkspaces.set(agent,await Promise.all(agent.additionalWorkspaces.map(workspace=>realpath(workspace))))
91
114
  }
92
- if (agent.toolsHome) {
115
+ if (agent.toolsHome && !containerId) {
93
116
  if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
94
117
  const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
95
118
  if (config.schemaVersion!==1 || config.workspace!==await realpath(agent.workspace)) throw new Error('Plugin registry belongs to another workspace')
@@ -99,6 +122,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
99
122
  const lock=path.join(directory,'worker.lock')
100
123
  try {
101
124
  const prior=JSON.parse(await readFile(lock,'utf8'))
125
+ if(prior.containerId&&prior.containerId!==containerId)throw Error('Previous isolated worker still owns the claim lock')
102
126
  const currentStart=await processStart(prior.pid)
103
127
  if (!prior.started || !currentStart || prior.started===currentStart) {
104
128
  try { process.kill(prior.pid,0); throw new Error('Host executor already running') }
@@ -107,34 +131,29 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
107
131
  await rm(lock)
108
132
  }
109
133
  catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
110
- await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
134
+ await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid),...(containerId ? {containerId} : {})}),{mode:0o600,flag:'wx'})
111
135
  locks.push(lock)
112
136
  const models = await freshCatalog(agent)
113
137
  await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
114
138
  const modelsFile=path.join(directory,'models.json')
115
139
  await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
116
- // A host crash is terminal for a claimed job. Never replay an action.
117
- for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
118
- const base=path.join(directory,file.slice(0,-13))
119
- try {
120
- const prior=JSON.parse(await readFile(base+'.process.json','utf8'))
121
- try { process.kill(prior.pid,0); throw new Error('Previous host CLI is still running; stop it before recovery') }
122
- catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
123
- } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
124
- await rm(base+'.process.json',{force:true})
125
- await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
126
- await rm(path.join(directory,file))
127
- }
128
- if (agent.toolsHome) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
140
+ await recoverClaims(agent)
141
+ if (agent.toolsHome && !containerId) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
129
142
  }
130
143
  let catalogAt=Date.now()
131
144
  const catalogSeen=new Map<HostBinding,string>()
132
145
  while (!signal.aborted) {
133
146
  const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
134
- if(parent) { try { process.kill(parent,0) } catch { break } }
147
+ if(parent && !retiring) { try { process.kill(parent,0) } catch { break } }
148
+ if (retire?.aborted && !retiring) {
149
+ retiring=true
150
+ await catalogRefresh
151
+ for (const lock of locks.splice(0)) await rm(lock,{force:true})
152
+ }
153
+ if (retiring && !tasks.size) break
135
154
  // Keep transport liveness independent of native catalog I/O.
136
- for (const agent of installation.agents) await beat(agent)
137
- if(!catalogRefresh && Date.now()-catalogAt>30000){
155
+ if (!retiring) for (const agent of installation.agents) await beat(agent)
156
+ if(!retiring && !catalogRefresh && Date.now()-catalogAt>30000){
138
157
  catalogAt=Date.now()
139
158
  catalogRefresh=(async()=>{
140
159
  for(const agent of installation.agents){
@@ -149,6 +168,8 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
149
168
  }
150
169
  for (const agent of installation.agents) {
151
170
  const directory=path.join(agent.controlDir,'host-executor')
171
+ if (retiring) continue
172
+ await recoverClaims(agent)
152
173
  for (const file of await readdir(directory)) {
153
174
  if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
154
175
  const id=file.slice(0,-13)
@@ -168,9 +189,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
168
189
  let workspace = activeWorkspaces.get(agent)
169
190
  if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
170
191
  if (!workspace) {
171
- const release = agent.toolsHome
192
+ const release = agent.toolsHome && !containerId
172
193
  ? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
173
- if (agent.toolsHome && !release) continue
194
+ if (agent.toolsHome && !containerId && !release) continue
174
195
  workspace = { lanes: new Map(), release }
175
196
  activeWorkspaces.set(agent, workspace)
176
197
  }
@@ -184,7 +205,11 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
184
205
  await workspace.release?.()
185
206
  }
186
207
  }
187
- try { await rename(base+'.request.json',base+'.running.json') }
208
+ try {
209
+ await writeFile(base+'.process.json',JSON.stringify({hostPid:process.pid,hostStarted,...(containerId ? {containerId} : {})}),{mode:0o600})
210
+ await appendFile(base+'.events','',{mode:0o600})
211
+ await rename(base+'.request.json',base+'.running.json')
212
+ }
188
213
  catch (error) { claimed.delete(base); await releaseWorkspace(); throw error }
189
214
  const task=(async()=>{
190
215
  let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
@@ -205,7 +230,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
205
230
  // Script occurrences come from the authorized run record, never the request.
206
231
  if (run?.script) {
207
232
  if (!run.scheduled) throw new Error('Script runs require a scheduled occurrence')
208
- const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
233
+ const options:ExecutorOptions={catalogTransport:containerId?'local':'host',workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
209
234
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
210
235
  script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
211
236
  job=await launch(request.texts,options)
@@ -237,14 +262,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
237
262
  // transient native-client probe failure must not reject a model
238
263
  // that the host just advertised to the relay and application.
239
264
  if (cli !== installation.cli || opts.authProfile !== undefined) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,authProfile:opts.authProfile,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
240
- const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
265
+ const options:ExecutorOptions={catalogTransport:containerId?'local':'host',workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
241
266
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
242
267
  sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,authProfile:opts.authProfile,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
243
268
  promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
244
269
  job=await launch(request.texts,options)
245
270
  }
246
271
  active.set(base,job.child)
247
- await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
272
+ // Observe completion before process metadata I/O: a fast CLI may
273
+ // already have exited when launch returns, or while that I/O waits.
274
+ const closed=job.child.exitCode!==null||job.child.signalCode!==null
275
+ ? Promise.resolve(job.child.exitCode??1)
276
+ : new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
248
277
  if(signal.aborted)terminateJob(job.child)
249
278
  // Scheduled Claude streams every event; forward only the startup summary.
250
279
  const startup = observeClaude ? startupObserver() : undefined
@@ -255,8 +284,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
255
284
  if (startup.summary() !== summary) { summary = startup.summary(); emit({stream:'stdout',text:summary+'\n'}) }
256
285
  })
257
286
  job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
258
- cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{})},250)
259
- const code=await new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
287
+ await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid,started:job.child.pid ? await processStart(job.child.pid) : undefined,hostPid:process.pid,hostStarted,...(containerId ? {containerId} : {})}),{mode:0o600})
288
+ cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{}); const now=new Date();void utimes(base+'.events',now,now).catch(()=>{})},250)
289
+ const code=await closed
260
290
  if (cancellation) clearInterval(cancellation)
261
291
  await job.cleanup()
262
292
  job = undefined
@@ -290,8 +320,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
290
320
  }
291
321
 
292
322
  if (process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
293
- const abort=new AbortController()
323
+ const abort=new AbortController(),retire=new AbortController()
324
+ process.once('SIGUSR2',()=>retire.abort())
294
325
  for(const signal of ['SIGTERM','SIGINT'] as const)process.once(signal,()=>abort.abort())
295
326
  const config=JSON.parse(await readFile(process.argv[2],'utf8'))
296
- await serveHostExecutor(config,abort.signal)
327
+ await serveHostExecutor(config,abort.signal,undefined,undefined,retire.signal)
297
328
  }
package/src/index.ts CHANGED
@@ -9,9 +9,9 @@ import { packageVersion } from './version.js'
9
9
  import {authorizeDeliveryContext} from './delivery-context.mjs'
10
10
  import { dispatchChannel } from './channel-backend.js'
11
11
  import { randomUUID } from 'node:crypto'
12
- import { stat } from 'node:fs/promises'
12
+ import { mkdir, stat, writeFile } from 'node:fs/promises'
13
13
  import { Scheduler } from './scheduler.js'
14
- import { Scripts } from './scripts.js'
14
+ import { Scripts, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
15
15
  import { redactFailure } from './failure.js'
16
16
  import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
17
17
  import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
@@ -25,12 +25,12 @@ import { isOwner, ownsRun } from './identity.js'
25
25
  import type { Update } from 'grammy/types'
26
26
  import { InboxStore, type IncomingItem } from './inbox.js'
27
27
  import { loadConfig, type Config } from './config.js'
28
- import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
28
+ import { ControlStore, telegramOwner, ownerId, ownerEpoch, type SessionState } from './control-state.js'
29
29
  import { ApprovalStore } from './approval.js'
30
- import { CliUnavailableError, startExecutorJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
31
- import { advances, nextSetup, quotaScope, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
30
+ import { CliUnavailableError, startExecutorJob, attachHostJob, terminateJob, opencodeDataHome, claudeConfigHome, usesFileTransport } from './executor.js'
31
+ import { attemptFailure, continuationLine, nextSetup, quotaScope, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
32
32
  import { assertScheduledModel } from './model-policy.js'
33
- import { RunStore, type RunRecord } from './runs.js'
33
+ import { RunStore, saveRunHandoff, takeRunHandoff, type RunRecord } from './runs.js'
34
34
  import { splitTelegramText } from './reply.js'
35
35
  import { markdownToTelegramHtml, escapeHtml } from './format.js'
36
36
  import { sanitizeFileName, stageChatAttachment, workspaceFile } from './files.js'
@@ -38,6 +38,7 @@ import { transcribeAudio, synthesizeSpeech } from './audio.js'
38
38
  import { normalizeReactionEmoji } from './reaction.js'
39
39
  import { downloadTelegramFile } from './read-request.js'
40
40
  import { createConversationMenu } from './conversation-menu.js'
41
+ import { queueSessionClose } from './session-close.js'
41
42
  import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
42
43
  import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset, type AiPreset } from './ai.js'
43
44
  import { discoverDefaults } from './client-defaults.js'
@@ -77,13 +78,27 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
77
78
  const codexHome = join(config.controlDir, 'cli', 'codex')
78
79
  const conversationMenu = createConversationMenu(control)
79
80
  const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome,
80
- undefined, opencodeDataHome(config.controlDir))
81
+ undefined, opencodeDataHome(config.controlDir), (session) => closeSession(session))
81
82
  const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
82
83
  const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
84
+ // Queue the replaced chat session's silent close turn (session-close.ts).
85
+ // A busy relay starts it after the active run, ahead of newer chat input.
86
+ const closeSession = async (session: SessionState | null): Promise<void> => {
87
+ if (config.channelBackendUrl) return
88
+ // The replacement is already saved; a failed close must not fail that reply.
89
+ try {
90
+ const run = await queueSessionClose(runs, (await control.status()).owner, session)
91
+ if (run) void startJob(run).catch((error) => console.error('Conversation close failed to start', safeError(error)))
92
+ } catch (error) { console.error('Conversation close failed to queue', safeError(error)) }
93
+ }
83
94
 
84
95
  let activeTypingTimer: ReturnType<typeof setInterval> | null = null
85
96
  let activeBackend = false
86
97
  const ownerStopped = new WeakSet<ChildProcess>()
98
+ // Host-transport executors outlive this relay; a graceful stop hands them off.
99
+ const hostTransport = async () => !config.channelBackendUrl && await usesFileTransport(config.controlDir)
100
+ const childRuns = new WeakMap<ChildProcess, string>()
101
+ const handedOff = new WeakSet<ChildProcess>()
87
102
  let activeChild: ChildProcess | null = null
88
103
  let runtimeStarted = false
89
104
  let shuttingDown = false
@@ -114,6 +129,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
114
129
  format: 'wav',
115
130
  }) : undefined,
116
131
  wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
132
+ closeSession: (session) => closeSession(session),
117
133
  createTelegramPairing: async (bindingId, owner) => {
118
134
  const username = bot?.botInfo?.username
119
135
  if (!username || !/^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(username))
@@ -178,7 +194,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
178
194
  // stat uses the effective UID; access uses the relay's isolated real UID.
179
195
  if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
180
196
  if ((await runs.get(run.id))?.status !== 'queued') return
181
- if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled && !run.external) return
197
+ if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled && !run.external && !run.sessionClose) return
182
198
  if (!run.scheduled && await runs.running(false)) return
183
199
  const owner = (await control.status()).owner
184
200
  if (!owner || !ownsRun(owner, run)) {
@@ -280,8 +296,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
280
296
  const settle = (change: Partial<SetupAttempt>) => {
281
297
  attempts = [...attempts.slice(0, -1), { ...attempts[attempts.length - 1], ...change, endedAt: new Date().toISOString() }]
282
298
  }
283
- // Advance only after an eligible pre-work failure of a current, uncancelled
284
- // occurrence. Setups in an exhausted quota scope are skipped and recorded.
299
+ // Advance only after an eligible failure (see attemptFailure) of a current,
300
+ // uncancelled occurrence. Setups in an exhausted scope are skipped and recorded.
285
301
  const advance = async (): Promise<AiPreset | undefined> => {
286
302
  let preset: AiPreset | undefined
287
303
  const current = !shuttingDown && !await scheduler.cancelled(run.id) ? (await control.status()).owner : undefined
@@ -297,16 +313,18 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
297
313
  await runs.patch(run.id, { attempts })
298
314
  return preset
299
315
  }
300
- // Each attempt is a fresh native session; a rejected Claude attempt already claimed its ID.
316
+ // Each attempt is a fresh native session: a rejected Claude attempt already
317
+ // claimed its ID, and a session cannot resume under another login's home.
301
318
  const launchSetup = async (preset: AiPreset): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; preset: AiPreset }> => {
302
319
  for (;;) {
303
320
  const sessionId = attempts.length ? randomUUID() : session.nativeSessionId || session.sessionId
321
+ const continuation = continuationLine(started.id, attempts)
304
322
  if (ordered) {
305
- attempts = [...attempts, { preset, quotaScope: quotaScope(preset), outcome: 'running', startedAt: new Date().toISOString() }]
323
+ attempts = [...attempts, { preset, quotaScope: quotaScope(preset), outcome: 'running', sessionId, startedAt: new Date().toISOString() }]
306
324
  await runs.patch(run.id, { attempts })
307
325
  }
308
326
  try {
309
- return { ...await launch(texts, script ? {
327
+ return { ...await launch(continuation ? [...texts, continuation] : texts, script ? {
310
328
  workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
311
329
  controlDir: config.controlDir, binDir,
312
330
  script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
@@ -371,6 +389,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
371
389
  startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
372
390
  if (run.scheduled) background.set(run.id,child)
373
391
  else activeChild = child
392
+ childRuns.set(child, run.id)
374
393
  const finished = new Promise<number | null>((resolve) => {
375
394
  if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
376
395
  else child.once('close', resolve)
@@ -413,6 +432,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
413
432
  await withStartLock(async () => {
414
433
  try {
415
434
  await cleanup()
435
+ // The next relay records a handed-off run's result.
436
+ if (handedOff.has(child)) return
416
437
  if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
417
438
  const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
418
439
  if (timedOut) failureReason = 'script-timeout'
@@ -421,11 +442,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
421
442
  // A rejection before work is a failure even when the CLI exits 0.
422
443
  const failed = !cancelled && (code !== 0 || timedOut || Boolean(rejected))
423
444
  if (startup) {
424
- settle({ outcome: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', exitCode: code,
425
- ...(failed ? { category: rejected?.category ?? (observed!.workBegan ? 'after-work-began' : 'uncertain') } : {}),
426
- ...(failed && rejected?.resetAt ? { resetAt: rejected.resetAt } : {}) })
445
+ const { continues, ...failure } = failed ? attemptFailure(observed!) : { continues: false }
446
+ settle({ outcome: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', exitCode: code, ...failure })
427
447
  await runs.patch(run.id, { attempts })
428
- eligible = failed && !interrupted && advances(rejected)
448
+ eligible = continues && !interrupted
429
449
  }
430
450
  if (!eligible) await finalize(code, cancelled, failed)
431
451
  } catch (error) {
@@ -648,8 +668,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
648
668
  }
649
669
 
650
670
  let outboxWork: Promise<void> | undefined
651
- const drainOutbox = (onlyRunId?: string): Promise<void> => {
652
- if (shuttingDown) return outboxWork ?? Promise.resolve()
671
+ const drainOutbox = (onlyRunId?: string, stopping = false): Promise<void> => {
672
+ if (shuttingDown && !stopping) return outboxWork ?? Promise.resolve()
653
673
  return outboxWork ?? (outboxWork = (async () => {
654
674
  for (const item of await runs.pendingOutbox()) {
655
675
  if (onlyRunId && item.runId !== onlyRunId) continue
@@ -753,6 +773,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
753
773
  // children, host executor, CLIs) enqueue and query the memory ledger
754
774
  // through the shared handler in delivery-socket.ts. The pump below stays
755
775
  // the single delivery executor; the socket only admits work into it.
776
+ let drainingHandoff=false
756
777
  let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
757
778
  const handleDeliveryOp = createLedgerHandler(config.controlDir, {
758
779
  wake: () => { void drainOutbox() },
@@ -815,7 +836,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
815
836
  const owner = (await control.status()).owner
816
837
  if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
817
838
  // This intentionally uses the reader that does not create a schedules directory.
818
- const active = await scheduler.listActiveReadOnly(await runs.list())
839
+ const active = await scheduler.listTasksReadOnly(await runs.list())
819
840
  return { owner, active, text: scheduledTasksText(active, owner) }
820
841
  }
821
842
  const replyScheduledTasks = async (ctx: Context, number?: number) => {
@@ -872,6 +893,18 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
872
893
  : []),
873
894
  ].join('\n')
874
895
  }
896
+ const stopActiveWork = async (): Promise<boolean> => {
897
+ const children: [string | undefined, ChildProcess][] = [...background]
898
+ if (activeChild) children.unshift([undefined, activeChild])
899
+ let requested = true
900
+ for (const [id, child] of children) {
901
+ if (!await terminateJob(child)) { requested = false; continue }
902
+ ownerStopped.add(child)
903
+ if (id) await scheduler.cancel(id)
904
+ }
905
+ return requested
906
+ }
907
+
875
908
  const cancelPending = async () => {
876
909
  let count = 0
877
910
  await withStartLock(async () => {
@@ -1010,16 +1043,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1010
1043
  if (text === '/stop') {
1011
1044
  const running = await runs.running(false)
1012
1045
  if ((running && running.pid) || background.size) {
1013
- try {
1014
- if (activeChild) { ownerStopped.add(activeChild); terminateJob(activeChild) }
1015
- for (const [id,child] of background) { await scheduler.cancel(id); ownerStopped.add(child); terminateJob(child) }
1016
- } catch {}
1017
- if (activeTypingTimer) {
1046
+ let requested = false
1047
+ try { requested = await stopActiveWork() } catch {}
1048
+ if (requested && activeTypingTimer) {
1018
1049
  clearInterval(activeTypingTimer)
1019
1050
  activeTypingTimer = null
1020
1051
  }
1021
1052
  await ctx.reply(
1022
- '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.',
1053
+ requested ? '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.'
1054
+ : 'A stop request failed; some work remains active.',
1023
1055
  )
1024
1056
  } else {
1025
1057
  await ctx.reply('No active run in progress.')
@@ -1029,7 +1061,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1029
1061
 
1030
1062
  if (text === '/new') {
1031
1063
  await control.aiState(aiMenu.initial)
1064
+ const previous = await control.getActiveSession()
1032
1065
  const next = await control.resetSession()
1066
+ await closeSession(previous)
1033
1067
  await ctx.reply(
1034
1068
  `🔄 Started fresh conversation session (<code>${next.sessionId.slice(0, 8)}</code>). Agent files and memory preserved.`,
1035
1069
  { parse_mode: 'HTML' },
@@ -1217,7 +1251,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1217
1251
  await ctx.reply(id ? `Incoming batch ${id} queued for retry.` : 'No failed incoming batch to retry.')
1218
1252
  } else if (action === 'new') {
1219
1253
  await control.aiState(aiMenu.initial)
1254
+ const previous = await control.getActiveSession()
1220
1255
  const next = await control.resetSession()
1256
+ await closeSession(previous)
1221
1257
  await ctx.answerCallbackQuery({ text: 'New session started' })
1222
1258
  await ctx.reply(
1223
1259
  `🔄 Started fresh conversation session (<code>${next.sessionId.slice(0, 8)}</code>).`,
@@ -1238,11 +1274,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1238
1274
  } else if (action === 'stop') {
1239
1275
  const running = await runs.running(false)
1240
1276
  if ((running && running.pid) || background.size) {
1241
- if (activeChild) { ownerStopped.add(activeChild); terminateJob(activeChild) }
1242
- for (const [id,child] of background) { await scheduler.cancel(id); ownerStopped.add(child); terminateJob(child) }
1243
- await ctx.answerCallbackQuery({ text: 'Run stopped' })
1277
+ let requested = false
1278
+ try { requested = await stopActiveWork() } catch {}
1279
+ await ctx.answerCallbackQuery({ text: requested ? 'Stop requested' : 'Stop request failed' })
1244
1280
  await ctx.reply(
1245
- '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.',
1281
+ requested ? '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.'
1282
+ : 'A stop request failed; some work remains active.',
1246
1283
  )
1247
1284
  } else {
1248
1285
  await ctx.answerCallbackQuery({ text: 'No run active' })
@@ -1259,12 +1296,95 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1259
1296
  })
1260
1297
  }
1261
1298
 
1299
+ // Watch runs a previous relay handed off until they finish, with the normal
1300
+ // receipts. Never relaunch; existing loops still revoke and cancel them. An
1301
+ // unknown outcome is recorded interrupted, which holds its schedule.
1302
+ const adoptHandoff = async (): Promise<void> => {
1303
+ const handed = await takeRunHandoff(config.controlDir)
1304
+ if (!await hostTransport()) { if (handed.length) console.error('Ignoring run handoff without host transport', handed.length); return }
1305
+ // Admit every record before the first attach, so no trigger races a hold.
1306
+ const admitted = (await Promise.all(handed.map(run => runs.adopt(run).catch(() => null)))).filter((run): run is RunRecord => run !== null)
1307
+ for (const run of admitted) await withStartLock(async () => {
1308
+ let child: ChildProcess
1309
+ try {
1310
+ // The foreground lane is serial; a second foreground handoff is corrupt.
1311
+ if (!run.scheduled && activeChild) throw new Error('A second handed-off foreground run was cancelled; its outcome is unknown')
1312
+ child = await attachHostJob(config.controlDir, run.id)
1313
+ } catch (error) {
1314
+ // Never leave an unobserved executor: ask the host to stop it.
1315
+ await mkdir(join(config.controlDir, 'host-executor'), { recursive: true, mode: 0o700 })
1316
+ .then(() => writeFile(join(config.controlDir, 'host-executor', run.id + '.cancel'), '', { mode: 0o600 })).catch(() => {})
1317
+ await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString(), interrupted: true, failureReason: 'handoff-outcome-unknown', failure: await failureEvidence(config.controlDir, safeError(error)) })
1318
+ return
1319
+ }
1320
+ // Listen before any await so a fast exit cannot be missed.
1321
+ const closed = new Promise<number | null>(resolve => child.once('close', resolve))
1322
+ let errorTail = '', outputTail = '', timedOut = false
1323
+ child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { if (run.script) outputTail = (outputTail + chunk).slice(-16384) })
1324
+ child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
1325
+ errorTail = (errorTail + chunk).slice(-16384)
1326
+ if (run.script) outputTail = (outputTail + chunk).slice(-16384)
1327
+ })
1328
+ if (run.scheduled) background.set(run.id, child)
1329
+ else activeChild = child
1330
+ childRuns.set(child, run.id)
1331
+ await runs.patch(run.id, { pid: child.pid })
1332
+ console.info('run adopted', { run_id: run.id, pid: child.pid })
1333
+ // A script keeps its registered bound from its original start.
1334
+ const limit = run.script ? await new Scripts(config.controlDir).get(run.script.id).then(script => script.timeoutSeconds, () => DEFAULT_SCRIPT_TIMEOUT_SECONDS) : 0
1335
+ const timer = limit ? setTimeout(() => { timedOut = true; terminateJob(child) }, Math.max(0, Date.parse(run.startedAt ?? run.createdAt) + limit * 1000 - Date.now())) : undefined
1336
+ const completion = closed.then(code => withStartLock(async () => {
1337
+ clearTimeout(timer)
1338
+ if (run.scheduled) background.delete(run.id)
1339
+ else if (activeChild === child) activeChild = null
1340
+ if (handedOff.has(child)) return
1341
+ const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
1342
+ const failed = !cancelled && code !== 0
1343
+ const status = cancelled ? 'cancelled' : failed ? 'failed' : 'completed'
1344
+ const unknown = errorTail.includes('outcome is unknown'), transport = errorTail.includes('Host executor client interrupted by')
1345
+ const failureReason = timedOut ? 'script-timeout' : unknown ? 'handoff-outcome-unknown' : transport ? 'host-executor-transport-interrupted'
1346
+ : errorTail.includes('Host CLI executor is offline') ? 'host-executor-offline' : 'executor-exit'
1347
+ if (code === 0 && run.execution && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(run.execution.sessionId)
1348
+ const at = new Date().toISOString(), last = run.attempts?.at(-1)
1349
+ await runs.patch(run.id, { status, endedAt: at, exitCode: code,
1350
+ ...(last?.outcome === 'running' ? { attempts: [...run.attempts!.slice(0, -1), { ...last, outcome: status, exitCode: code, endedAt: at }] } : {}),
1351
+ ...(run.script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
1352
+ ...(failed ? { failureReason, interrupted: unknown || transport, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
1353
+ await releaseExternal(run)
1354
+ console.info('run ended', { run_id: run.id, code, adopted: true })
1355
+ })).then(async () => {
1356
+ const next = await runs.nextQueued()
1357
+ if (next && !shuttingDown) await startJob(next)
1358
+ }).catch(error => console.error('Adopted run completion failed', safeError(error)))
1359
+ completions.add(completion)
1360
+ void completion.finally(() => completions.delete(completion))
1361
+ }).catch(error => console.error('Run adoption failed', run.id, safeError(error)))
1362
+ }
1363
+
1262
1364
  let stopWork: Promise<void> | undefined
1263
1365
  const stop = (): Promise<void> => stopWork ?? (stopWork = (async () => {
1264
1366
  shuttingDown = true
1265
1367
  runtimeStarted = false
1266
1368
  pollingAbort.abort()
1267
- await deliveryServer?.stop().catch(() => {})
1369
+ let handedRecords:RunRecord[]=[]
1370
+ // Hand off running host work before any wait can outlast the stop grace
1371
+ // period. The lock lets an in-flight launch register; later ones see shutdown.
1372
+ if (await hostTransport()) await withStartLock(async () => {
1373
+ const handoff = new Map<ChildProcess, RunRecord>()
1374
+ for (const child of [...(activeChild ? [activeChild] : []), ...background.values()]) {
1375
+ const run = childRuns.has(child) ? await runs.get(childRuns.get(child)!) : null
1376
+ if (run?.status === 'running' && !ownerStopped.has(child) && child.exitCode === null && child.signalCode === null) handoff.set(child, run)
1377
+ }
1378
+ if (!handoff.size) return
1379
+ try { handedRecords=[...handoff.values()];await saveRunHandoff(config.controlDir, handedRecords) }
1380
+ catch (error) { console.error('Run handoff failed; stopping host work', safeError(error)); return }
1381
+ // SIGKILL leaves no cancel marker, so the host keeps each executor running.
1382
+ for (const child of handoff.keys()) { handedOff.add(child); child.kill('SIGKILL') }
1383
+ })
1384
+ drainingHandoff=handedRecords.length>0
1385
+ if(deliveryServer)await deliveryServer.stop()
1386
+ else if(drainingHandoff){await outboxWork?.catch(() => {});await drainOutbox(undefined,true)}
1387
+ if(handedRecords.length)await saveRunHandoff(config.controlDir,handedRecords)
1268
1388
  deliveryServer = null
1269
1389
  await applicationChannel.stop()
1270
1390
  wakePollRetry?.()
@@ -1275,8 +1395,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1275
1395
  await Promise.all([sourceWork, intakeWork].map(work => work?.catch(() => {})))
1276
1396
  // Finish registering in-flight launches before taking the child snapshot.
1277
1397
  await withStartLock(async () => {
1278
- if (activeChild) terminateJob(activeChild)
1279
- for (const child of background.values()) terminateJob(child)
1398
+ for (const child of [...(activeChild ? [activeChild] : []), ...background.values()]) if (!handedOff.has(child)) terminateJob(child)
1280
1399
  if (activeTypingTimer) clearInterval(activeTypingTimer)
1281
1400
  })
1282
1401
  await Promise.all(completions)
@@ -1300,13 +1419,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1300
1419
  throw new Error(`Another relay owns ${config.controlDir}; stop it before starting a second one`)
1301
1420
  const deliveryTcpPort = Number(process.env.EZ_DELIVERY_TCP_PORT ?? '')
1302
1421
  deliveryServer = await serveDeliverySocket(config.controlDir, handleDeliveryOp, {
1422
+ beforeClose:async()=>{if(drainingHandoff){await outboxWork?.catch(()=>{});await drainOutbox(undefined,true)}},
1303
1423
  ...(Number.isSafeInteger(deliveryTcpPort) && deliveryTcpPort >= 1024 && deliveryTcpPort <= 65535 ? { tcpPort: deliveryTcpPort } : {}),
1304
1424
  })
1425
+ // Reconcile, then adopt handed-off runs, before any intake can launch work.
1426
+ await scheduler.recover(runs)
1427
+ await adoptHandoff()
1305
1428
  if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
1306
1429
  runtimeStarted = true
1307
1430
  const owner = (await control.status()).owner
1308
1431
  if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
1309
- await scheduler.recover(runs)
1310
1432
  sourceTimer = setInterval(() => {
1311
1433
  void drainSources().catch(error => console.error('Event-source drain failed', safeError(error)))
1312
1434
  }, 1000)
@@ -1380,7 +1502,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1380
1502
  }
1381
1503
  }
1382
1504
  }
1383
- return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
1505
+ return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, adoptHandoff, applicationChannel }
1384
1506
  }
1385
1507
 
1386
1508
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
package/src/menu.ts CHANGED
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { randomBytes } from 'node:crypto'
5
5
  import { InlineKeyboard, type Context } from 'grammy'
6
- import { ControlStore, type ControlGuard } from './control-state.js'
6
+ import { ControlStore, type ControlGuard, type SessionState } from './control-state.js'
7
7
  import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, sameChoice, sameEngine, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
8
8
  import { discoverDefaults } from './client-defaults.js'
9
9
  import { claudeConfigHome } from './executor.js'
@@ -31,7 +31,7 @@ const matchesModel = (preset: AiPreset, model: ModelChoice) =>
31
31
  // Short-lived opaque button IDs: no model names or executable arguments from callbacks.
32
32
  // These are operational settings, not a second conversational/agent loop.
33
33
  export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string,
34
- isInstalled = installed, opencodeDataHome?: string) => {
34
+ isInstalled = installed, opencodeDataHome?: string, closeSession?: (session: SessionState | null) => Promise<void>) => {
35
35
  const initial = chatPreset(cli)
36
36
  const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
37
37
  if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
@@ -68,6 +68,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
68
68
  const current = state.presets.find((p) => p.id === state.selectedId)!
69
69
  const fresh = !sameEngine(current, preset) || presetProvider(current) !== presetProvider(preset) || Boolean(session && !session.cli)
70
70
  if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
71
+ if (fresh) await closeSession?.(session)
71
72
  return { preset, fresh }
72
73
  }
73
74
  const choose = async (ctx: Context, preset: AiPreset) => {