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

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 (80) hide show
  1. package/.env.example +3 -0
  2. package/AGENTS.md +2 -2
  3. package/CHANGELOG.md +30 -0
  4. package/Dockerfile +5 -1
  5. package/THIRD_PARTY_NOTICES.md +6 -3
  6. package/bin/ez +7 -0
  7. package/docker/broker-readiness.mjs +54 -0
  8. package/docker/broker-update-smoke.mjs +100 -0
  9. package/docker/run.ts +1 -1
  10. package/docker/upgrade-smoke.mjs +1 -1
  11. package/docs/application-channel.md +32 -1
  12. package/docs/architecture/ai-selection.md +23 -4
  13. package/docs/docker-runtime.md +57 -0
  14. package/docs/isolated-plugin-update-readiness.md +66 -0
  15. package/docs/releasing.md +4 -0
  16. package/docs/scheduling.md +141 -6
  17. package/docs/selective-monitoring.md +13 -0
  18. package/docs/upgrades.md +33 -4
  19. package/docs/work-preflight.md +9 -0
  20. package/package.json +1 -1
  21. package/src/ai-cli.ts +28 -15
  22. package/src/ai.ts +98 -15
  23. package/src/application-channel.ts +52 -12
  24. package/src/application-cli.ts +7 -1
  25. package/src/application-origin.ts +2 -0
  26. package/src/audio.ts +37 -22
  27. package/src/auth-profile.ts +53 -0
  28. package/src/client-defaults.ts +3 -2
  29. package/src/codex-session.ts +14 -3
  30. package/src/config.ts +9 -0
  31. package/src/control-state.ts +31 -22
  32. package/src/delivery-socket-client.d.mts +2 -0
  33. package/src/delivery-socket-client.mjs +2 -0
  34. package/src/delivery-socket.ts +26 -1
  35. package/src/event-sources.ts +2 -2
  36. package/src/executor.ts +81 -7
  37. package/src/files.ts +47 -3
  38. package/src/host-executor.ts +68 -34
  39. package/src/index.ts +227 -110
  40. package/src/install-tools.mjs +8 -5
  41. package/src/menu.ts +9 -7
  42. package/src/plugin-broker.mjs +50 -14
  43. package/src/plugins/manager.mjs +34 -8
  44. package/src/run-evidence.ts +78 -0
  45. package/src/runs.ts +24 -2
  46. package/src/schedule-cli.ts +100 -13
  47. package/src/schedule-preflight.ts +23 -0
  48. package/src/scheduled-tasks.ts +16 -12
  49. package/src/scheduler.ts +85 -19
  50. package/src/scripts.ts +167 -0
  51. package/src/setup-fallback.ts +122 -0
  52. package/src/setup.ts +2 -2
  53. package/src/task-capability.ts +17 -1
  54. package/src/task-executor.ts +13 -9
  55. package/src/task-mcp.ts +20 -8
  56. package/src/tasks.ts +89 -9
  57. package/src/updates/runtime.mjs +107 -30
  58. package/src/updates/supervisor.mjs +27 -6
  59. package/test/ai.test.ts +40 -0
  60. package/test/application-channel.test.ts +16 -1
  61. package/test/audio.test.ts +19 -0
  62. package/test/auth-profiles.test.ts +187 -0
  63. package/test/config.test.ts +8 -0
  64. package/test/executor.test.ts +37 -0
  65. package/test/files.test.ts +44 -0
  66. package/test/host-executor.test.ts +45 -2
  67. package/test/install-json.test.mjs +21 -0
  68. package/test/plugin-broker.test.mjs +67 -0
  69. package/test/plugin-manager.test.mjs +44 -0
  70. package/test/private-speech-config.test.ts +22 -0
  71. package/test/run-evidence.test.ts +78 -0
  72. package/test/schedule-preflight.test.ts +49 -0
  73. package/test/scheduler-host.test.ts +7 -0
  74. package/test/scheduler-relay.test.ts +38 -3
  75. package/test/scheduler.test.ts +1 -1
  76. package/test/script-schedules.test.ts +219 -0
  77. package/test/setup-fallback.test.ts +245 -0
  78. package/test/task-native.test.ts +50 -12
  79. package/test/tasks.test.ts +97 -4
  80. package/test/updates.test.mjs +100 -11
@@ -7,9 +7,11 @@ import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath }
7
7
  import path from 'node:path'
8
8
  import { isHostRunId } from './host-executor-protocol.js'
9
9
  import { fileURLToPath } from 'node:url'
10
- import { startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
10
+ import { CliUnavailableError, startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
11
+ import { activeSetup } from './runs.js'
12
+ import { startupLine, startupObserver } from './setup-fallback.js'
11
13
  import { parseIsolationClass, type IsolationClass } from './isolation.js'
12
- import { readModels, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
14
+ import { readModels, sameEngine, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
13
15
  import type { ChildProcess } from 'node:child_process'
14
16
  import { packageVersion } from './version.js'
15
17
  import { installedPluginVersions } from './software-status.js'
@@ -48,11 +50,13 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
48
50
  new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
49
51
  throw new Error('Each agent requires a separate workspace and control directory')
50
52
  const active = new Map<string, ChildProcess>()
53
+ // A fallback attempt reuses its run ID; claim it only after the prior attempt's cleanup.
54
+ const claimed = new Set<string>()
51
55
  const tasks = new Set<Promise<void>>()
52
56
  let catalogRefresh: Promise<void> | undefined
53
57
  const locks: string[] = []
54
58
  const sharedWorkspaces = new Map<HostBinding, string>()
55
- const activeWorkspaces = new Map<HostBinding, { lanes: Set<boolean>; release?: () => Promise<void> }>()
59
+ const activeWorkspaces = new Map<HostBinding, { lanes: Map<string, boolean>; release?: () => Promise<void> }>()
56
60
  const additionalWorkspaces = new Map<HostBinding, string[]>()
57
61
  const catalog = async (agent: HostBinding) => {
58
62
  const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
@@ -148,6 +152,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
148
152
  for (const file of await readdir(directory)) {
149
153
  if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
150
154
  const id=file.slice(0,-13)
155
+ if (claimed.has(path.join(directory,id))) continue
151
156
  let run
152
157
  try {
153
158
  run = await readRun(agent.controlDir, id)
@@ -161,29 +166,31 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
161
166
  const base=path.join(directory,id)
162
167
  const scheduled = Boolean(run?.scheduled)
163
168
  let workspace = activeWorkspaces.get(agent)
164
- if (workspace?.lanes.has(scheduled)) continue
169
+ if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
165
170
  if (!workspace) {
166
171
  const release = agent.toolsHome
167
172
  ? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
168
173
  if (agent.toolsHome && !release) continue
169
- workspace = { lanes: new Set(), release }
174
+ workspace = { lanes: new Map(), release }
170
175
  activeWorkspaces.set(agent, workspace)
171
176
  }
172
- workspace.lanes.add(scheduled)
173
- // Keep plugin workspace invocations excluded until both streams finish.
177
+ workspace.lanes.set(id, scheduled)
178
+ claimed.add(base)
179
+ // Keep plugin workspace invocations excluded until all native jobs finish.
174
180
  const releaseWorkspace = async () => {
175
- workspace.lanes.delete(scheduled)
181
+ workspace.lanes.delete(id)
176
182
  if (!workspace.lanes.size) {
177
183
  activeWorkspaces.delete(agent)
178
184
  await workspace.release?.()
179
185
  }
180
186
  }
181
187
  try { await rename(base+'.request.json',base+'.running.json') }
182
- catch (error) { await releaseWorkspace(); throw error }
188
+ catch (error) { claimed.delete(base); await releaseWorkspace(); throw error }
183
189
  const task=(async()=>{
184
190
  let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
185
191
  let cancellation: ReturnType<typeof setInterval> | undefined
186
192
  let writes=Promise.resolve()
193
+ let observeClaude=false
187
194
  const emit=(event:unknown)=>{writes=writes.then(()=>appendFile(base+'.events',JSON.stringify(event)+'\n',{mode:0o600}))}
188
195
  try {
189
196
  try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
@@ -195,34 +202,58 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
195
202
  await new Tasks(agent.controlDir).authorize(run, false)
196
203
  } else await authorizeRun(agent.controlDir, path.basename(base))
197
204
  const opts=request.options as ExecutorOptions
198
- if (run?.scheduled) {
199
- assertScheduledModel(run.execution?.preset.model)
200
- if (opts.model !== run.execution!.preset.model) throw new Error('Scheduled run model does not match its saved task model')
205
+ // Script occurrences come from the authorized run record, never the request.
206
+ if (run?.script) {
207
+ 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),
209
+ runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
210
+ script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
211
+ job=await launch(request.texts,options)
212
+ } else {
213
+ const cli = opts.cli || installation.cli
214
+ if (run?.scheduled) {
215
+ const setup = activeSetup(run)
216
+ assertScheduledModel(setup?.model)
217
+ if (opts.model !== setup!.model || cli !== setup!.cli) throw new Error('Scheduled run model does not match its saved task model')
218
+ observeClaude = cli === 'claude'
219
+ }
220
+ resolveExecutor(cli)
221
+ // The credential home comes from the authorized run's captured
222
+ // choice; a request can never move it to another account.
223
+ const captured = run?.scheduled ? activeSetup(run) : run?.execution?.preset
224
+ if ((opts.authProfile !== undefined || (captured?.cli === cli && captured.authProfile !== undefined)) &&
225
+ !(captured && sameEngine(captured, {cli, authProfile: opts.authProfile})))
226
+ throw new Error('Run auth profile does not match its captured AI selection')
227
+ const provider = cli === 'codex' && opts.provider
228
+ ? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
229
+ : undefined
230
+ if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
231
+ const model = opts.model
232
+ if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
233
+ if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
234
+ if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
235
+ throw new Error('Selected Codex provider is required for this model')
236
+ // Admission and execution must use the same catalog snapshot. A
237
+ // transient native-client probe failure must not reject a model
238
+ // that the host just advertised to the relay and application.
239
+ 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,
241
+ runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
242
+ sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,authProfile:opts.authProfile,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
243
+ promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
244
+ job=await launch(request.texts,options)
201
245
  }
202
- const cli = opts.cli || installation.cli
203
- resolveExecutor(cli)
204
- const provider = cli === 'codex' && opts.provider
205
- ? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
206
- : undefined
207
- if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
208
- const model = opts.model
209
- if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
210
- if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
211
- if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
212
- throw new Error('Selected Codex provider is required for this model')
213
- // Admission and execution must use the same catalog snapshot. A
214
- // transient native-client probe failure must not reject a model
215
- // that the host just advertised to the relay and application.
216
- if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
217
- const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
218
- runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
219
- sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
220
- promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
221
- job=await launch(request.texts,options)
222
246
  active.set(base,job.child)
223
247
  await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
224
248
  if(signal.aborted)terminateJob(job.child)
225
- job.child.stdout?.on('data',chunk=>emit({stream:'stdout',text:chunk.toString()}))
249
+ // Scheduled Claude streams every event; forward only the startup summary.
250
+ const startup = observeClaude ? startupObserver() : undefined
251
+ let summary = ''
252
+ job.child.stdout?.setEncoding('utf8').on('data',(chunk:string)=>{
253
+ if (!startup) return emit({stream:'stdout',text:chunk})
254
+ startup.write(chunk)
255
+ if (startup.summary() !== summary) { summary = startup.summary(); emit({stream:'stdout',text:summary+'\n'}) }
256
+ })
226
257
  job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
227
258
  cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{})},250)
228
259
  const code=await new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
@@ -230,7 +261,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
230
261
  await job.cleanup()
231
262
  job = undefined
232
263
  emit({stream:'exit',code})
233
- } catch (error) { emit({stream:'stderr',text:'Host CLI execution failed: '+redactFailure(error instanceof Error ? error.message : 'Unknown error')+'\n'}); emit({stream:'exit',code:1}) }
264
+ } catch (error) {
265
+ if (error instanceof CliUnavailableError) emit({stream:'stdout',text:startupLine({workBegan:false,rejection:{category:'cli-unavailable'}})+'\n'})
266
+ emit({stream:'stderr',text:'Host CLI execution failed: '+redactFailure(error instanceof Error ? error.message : 'Unknown error')+'\n'}); emit({stream:'exit',code:1}) }
234
267
  finally {
235
268
  if(cancellation)clearInterval(cancellation)
236
269
  await job?.cleanup().catch(() => {})
@@ -240,6 +273,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
240
273
  await rm(base+'.cancel',{force:true})
241
274
  active.delete(base)
242
275
  await releaseWorkspace()
276
+ claimed.delete(base)
243
277
  }
244
278
  })()
245
279
  tasks.add(task); void task.finally(()=>tasks.delete(task))
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ import { dispatchChannel } from './channel-backend.js'
11
11
  import { randomUUID } from 'node:crypto'
12
12
  import { stat } from 'node:fs/promises'
13
13
  import { Scheduler } from './scheduler.js'
14
+ import { Scripts } from './scripts.js'
15
+ import { redactFailure } from './failure.js'
14
16
  import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
15
17
  import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
16
18
  import {removeTaskAttachments} from './task-attachments.js'
@@ -25,7 +27,8 @@ import { InboxStore, type IncomingItem } from './inbox.js'
25
27
  import { loadConfig, type Config } from './config.js'
26
28
  import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
27
29
  import { ApprovalStore } from './approval.js'
28
- import { startExecutorJob, terminateJob, opencodeDataHome } from './executor.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'
29
32
  import { assertScheduledModel } from './model-policy.js'
30
33
  import { RunStore, type RunRecord } from './runs.js'
31
34
  import { splitTelegramText } from './reply.js'
@@ -36,7 +39,7 @@ import { normalizeReactionEmoji } from './reaction.js'
36
39
  import { downloadTelegramFile } from './read-request.js'
37
40
  import { createConversationMenu } from './conversation-menu.js'
38
41
  import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
39
- import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
42
+ import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset, type AiPreset } from './ai.js'
40
43
  import { discoverDefaults } from './client-defaults.js'
41
44
  import { runPromptSuffix } from './prompt-suffix.js'
42
45
  import { softwareStatus } from './software-status.js'
@@ -44,7 +47,7 @@ import { softwareStatus } from './software-status.js'
44
47
  export const createRelay = (config: Config, launch = startExecutorJob) => {
45
48
  const safeError = (error: unknown): string => {
46
49
  let message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
47
- for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
50
+ for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey, config.openrouterApiKey]) {
48
51
  if (secret) message = message.replaceAll(secret, '[redacted]')
49
52
  }
50
53
  return message
@@ -60,7 +63,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
60
63
  const scheduler = new Scheduler(config.controlDir)
61
64
  const background = new Map<string, ChildProcess>()
62
65
  const completions = new Set<Promise<void>>()
63
- const tasks = new Tasks(config.controlDir)
66
+ const speechOptions = { geminiApiKey: config.geminiApiKey, openrouterApiKey: config.openrouterApiKey, speechProvider: config.speechProvider, speechModel: config.speechModel }
67
+ const speechConfigured = config.speechProvider === 'openrouter' ? Boolean(config.openrouterApiKey) : Boolean(config.geminiApiKey)
68
+ const tasks = new Tasks(config.controlDir, speechConfigured ? (text) => synthesizeSpeech(text, { ...speechOptions, voice: config.speechVoiceEn, signal: AbortSignal.timeout(20000) }) : undefined)
64
69
  const processTaskRequests = taskRequests(tasks)
65
70
  let taskWork: Promise<void> | undefined
66
71
  const drainTaskRequests = (): Promise<void> => {
@@ -103,8 +108,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
103
108
  const applicationChannel = new ApplicationChannel({
104
109
  controlDir: config.controlDir, workspace: config.workspace, isolation: config.isolation, initial: aiMenu.initial,
105
110
  aiControls: aiMenu,
106
- speech: config.geminiApiKey ? (text, language) => synthesizeSpeech(text, {
107
- geminiApiKey: config.geminiApiKey,
111
+ speech: speechConfigured ? (text, language) => synthesizeSpeech(text, {
112
+ ...speechOptions,
108
113
  voice: language === 'es' ? config.speechVoiceEs : config.speechVoiceEn,
109
114
  format: 'wav',
110
115
  }) : undefined,
@@ -207,8 +212,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
207
212
  }
208
213
  if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
209
214
  // A long scheduled turn must not occupy the owner's chat stream.
210
- // Each stream remains serial, with separate native sessions.
211
- if (run.scheduled ? background.size > 0 : activeChild !== null) return
215
+ // Foreground stays serial; scheduled jobs have independent native sessions.
216
+ if (run.scheduled ? background.size >= (config.scheduledConcurrency ?? 6) : activeChild !== null) return
212
217
  let texts = run.texts
213
218
  if (run.external) {
214
219
  // Availability failures leave durable queued work for a later check.
@@ -232,111 +237,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
232
237
  await releaseExternal(run)
233
238
  return
234
239
  }
235
- try {
236
- const launchStarted = performance.now()
237
- const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
238
- if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
239
- const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
240
- const session = startsOwnSession
241
- ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
242
- : await control.executionSession(started.execution!)
243
- const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
244
- if (run.scheduled) assertScheduledModel(selected.model)
245
- const { child, cleanup } = await launch(texts, {
246
- workspace: config.workspace,
247
- timeoutMs: config.executorTimeoutMs,
248
- repairEnabled: config.repairEnabled,
249
- runId: started.id,
250
- controlDir: config.controlDir,
251
- binDir,
252
- cli: selected.cli,
253
- model: selected.model,
254
- effort: selected.effort,
255
- provider: selected.provider,
256
- codexAutoCompactTokens: config.codexAutoCompactTokens,
257
- codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
258
- sessionId: session.nativeSessionId || session.sessionId,
259
- isResume: session.hasStarted,
260
- promptSuffix: runPromptSuffix(started),
261
- taskRun: Boolean(run.taskId),
262
- nativeSession: Boolean(run.scheduled),
263
- onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
264
- })
265
- const executionStarted = performance.now()
266
- // Attach before disk writes: a fast child can close while PID persistence
267
- // is pending, and Node drains its remaining pipes during process close.
268
- let failureReason = 'executor-exit', errorTail = '', interrupted = false
269
- child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
270
- errorTail = (errorTail + chunk).slice(-16384)
271
- if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
272
- if (chunk.includes('Host executor client interrupted by')) {
273
- failureReason = 'host-executor-transport-interrupted'
274
- interrupted = true
275
- }
276
- if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
277
- })
278
- console.info('run timing', { run_id: run.id, phase: 'launch',
279
- queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
280
- startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
281
- if (run.scheduled) background.set(run.id,child)
282
- else activeChild = child
283
- const finished = new Promise<number | null>((resolve) => {
284
- if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
285
- else child.once('close', resolve)
286
- })
287
- await runs.patch(started.id, { pid: child.pid })
288
- console.info('run started', {
289
- run_id: started.id,
290
- pid: child.pid,
291
- cli: selected.cli,
292
- session: session.sessionId,
293
- isResume: session.hasStarted,
294
- })
295
-
296
- if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
297
- if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
298
- if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
299
- }, 4000)
300
-
301
- const completion = finished.then((code) => {
302
- console.info('run timing', { run_id: run.id, phase: 'execution',
303
- execution_ms: Math.round(performance.now() - executionStarted), exit_code: code })
304
- return (async () => {
305
- await withStartLock(async () => {
306
- try {
307
- await cleanup()
308
- if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
309
- const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
310
- await runs.patch(started.id, { status: cancelled ? 'cancelled' : code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString(), exitCode: code, ...(code !== 0 && !cancelled ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
311
- await releaseExternal(run)
312
- } catch (error) {
313
- await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
314
- console.error('Session completion failed', safeError(error))
315
- } finally {
316
- if (run.scheduled) background.delete(run.id)
317
- else {
318
- activeChild = null
319
- if (activeTypingTimer) clearInterval(activeTypingTimer)
320
- activeTypingTimer = null
321
- }
322
- }
323
- })
324
- console.info('run ended', { run_id: started.id, code })
325
- const next = await runs.nextQueued()
326
- if (next && !shuttingDown) await startJob(next)
327
- })().catch((error) => console.error('Run completion failed', error.message))
328
- })
329
- completions.add(completion)
330
- void completion.finally(() => completions.delete(completion))
331
- } catch (error) {
240
+ // Shared by a first launch and a fallback launch that cannot start.
241
+ const startFailed = async (error: unknown) => {
332
242
  if (!run.scheduled && activeTypingTimer) {
333
243
  clearInterval(activeTypingTimer)
334
244
  activeTypingTimer = null
335
245
  }
336
- await runs.patch(run.id, { status: 'failed', failureReason: 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
246
+ if (run.scheduled) background.delete(run.id)
247
+ const unavailable = error instanceof SetupsUnavailableError
248
+ await runs.patch(run.id, { status: 'failed', failureReason: unavailable ? 'setups-unavailable' : 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
337
249
  await releaseExternal(run)
338
250
  console.error('run start failed', run.id, safeError(error))
339
- if (bot && !run.application && !run.delivery && run.chatId !== undefined) await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
251
+ if (bot && !run.application && !run.delivery && run.chatId !== undefined)
252
+ await sendChat(run.chatId, unavailable ? `Run ${run.id} could not start. ${safeError(error)}` : `Run ${run.id} failed to start. Check the local relay log.`)
340
253
  setImmediate(() => {
341
254
  void runs
342
255
  .nextQueued()
@@ -344,6 +257,210 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
344
257
  .catch(console.error)
345
258
  })
346
259
  }
260
+ try {
261
+ const launchStarted = performance.now()
262
+ const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
263
+ // A registered script occurrence: no model, prompt or native session.
264
+ const script = run.script && run.scheduled
265
+ ? { registration: await new Scripts(config.controlDir).owned(run.script.id, owner), ref: run.script, scheduled: run.scheduled }
266
+ : undefined
267
+ if (run.script && !script) throw new Error('Script runs require a scheduled occurrence')
268
+ if (!started.execution && !run.taskId && !script) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
269
+ const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
270
+ const session = startsOwnSession
271
+ ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
272
+ : await control.executionSession(started.execution!)
273
+ const selected = script ? initialPreset('codex') : run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
274
+ if (run.scheduled && !script) assertScheduledModel(selected.model)
275
+ // A scheduled occurrence may name ordered fallback setups. They share this
276
+ // run, workspace and concurrency slot; each is tried at most once.
277
+ const candidates = run.scheduled && !script ? setupCandidates(started.execution!) : [selected]
278
+ const ordered = candidates.length > 1
279
+ let attempts: SetupAttempt[] = []
280
+ const settle = (change: Partial<SetupAttempt>) => {
281
+ attempts = [...attempts.slice(0, -1), { ...attempts[attempts.length - 1], ...change, endedAt: new Date().toISOString() }]
282
+ }
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.
285
+ const advance = async (): Promise<AiPreset | undefined> => {
286
+ let preset: AiPreset | undefined
287
+ const current = !shuttingDown && !await scheduler.cancelled(run.id) ? (await control.status()).owner : undefined
288
+ if (current && ownsRun(current, run) && await scheduler.current(run, current)) {
289
+ const next = nextSetup(candidates, attempts)
290
+ attempts = [...attempts, ...next.skipped]
291
+ if (!next.preset) {
292
+ await runs.patch(run.id, { attempts })
293
+ throw new SetupsUnavailableError(unavailableSummary(attempts))
294
+ }
295
+ preset = next.preset
296
+ }
297
+ await runs.patch(run.id, { attempts })
298
+ return preset
299
+ }
300
+ // Each attempt is a fresh native session; a rejected Claude attempt already claimed its ID.
301
+ const launchSetup = async (preset: AiPreset): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; preset: AiPreset }> => {
302
+ for (;;) {
303
+ const sessionId = attempts.length ? randomUUID() : session.nativeSessionId || session.sessionId
304
+ if (ordered) {
305
+ attempts = [...attempts, { preset, quotaScope: quotaScope(preset), outcome: 'running', startedAt: new Date().toISOString() }]
306
+ await runs.patch(run.id, { attempts })
307
+ }
308
+ try {
309
+ return { ...await launch(texts, script ? {
310
+ workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
311
+ controlDir: config.controlDir, binDir,
312
+ script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
313
+ } : {
314
+ workspace: config.workspace,
315
+ timeoutMs: config.executorTimeoutMs,
316
+ repairEnabled: config.repairEnabled,
317
+ runId: started.id,
318
+ controlDir: config.controlDir,
319
+ binDir,
320
+ cli: preset.cli,
321
+ model: preset.model,
322
+ effort: preset.effort,
323
+ provider: preset.provider,
324
+ authProfile: preset.authProfile,
325
+ codexAutoCompactTokens: config.codexAutoCompactTokens,
326
+ codexSandbox: !run.taskId && preset.cli === 'codex' ? config.codexSandbox : undefined,
327
+ sessionId,
328
+ isResume: session.hasStarted,
329
+ promptSuffix: runPromptSuffix(started),
330
+ taskRun: Boolean(run.taskId),
331
+ nativeSession: Boolean(run.scheduled),
332
+ onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
333
+ }), preset }
334
+ } catch (error) {
335
+ if (ordered && !(error instanceof CliUnavailableError)) {
336
+ settle({ outcome: 'failed', category: 'uncertain' })
337
+ await runs.patch(run.id, { attempts })
338
+ }
339
+ if (!ordered || !(error instanceof CliUnavailableError)) throw error
340
+ settle({ outcome: 'failed', category: 'cli-unavailable' })
341
+ const next = await advance()
342
+ if (!next) throw error
343
+ preset = next
344
+ }
345
+ }
346
+ }
347
+ const watch = async ({ child, cleanup, preset }: Awaited<ReturnType<typeof launchSetup>>): Promise<void> => {
348
+ const executionStarted = performance.now()
349
+ // Attach before disk writes: a fast child can close while PID persistence
350
+ // is pending, and Node drains its remaining pipes during process close.
351
+ let failureReason = 'executor-exit', errorTail = '', interrupted = false, outputTail = '', timedOut = false
352
+ // Script output is bounded diagnostics for run inspection, never chat.
353
+ if (script) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { outputTail = (outputTail + chunk).slice(-16384) })
354
+ // Structured startup events decide whether a failed setup may advance.
355
+ const startup = ordered ? startupObserver() : undefined
356
+ if (startup) child.stdout?.setEncoding('utf8').on('data', startup.write)
357
+ const scriptTimer = script ? setTimeout(() => { timedOut = true; terminateJob(child) }, script.registration.timeoutSeconds * 1000) : undefined
358
+ child.once('close', () => clearTimeout(scriptTimer))
359
+ child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
360
+ errorTail = (errorTail + chunk).slice(-16384)
361
+ if (script) outputTail = (outputTail + chunk).slice(-16384)
362
+ if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
363
+ if (chunk.includes('Host executor client interrupted by')) {
364
+ failureReason = 'host-executor-transport-interrupted'
365
+ interrupted = true
366
+ }
367
+ if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
368
+ })
369
+ console.info('run timing', { run_id: run.id, phase: 'launch',
370
+ queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
371
+ startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
372
+ if (run.scheduled) background.set(run.id,child)
373
+ else activeChild = child
374
+ const finished = new Promise<number | null>((resolve) => {
375
+ if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
376
+ else child.once('close', resolve)
377
+ })
378
+ await runs.patch(started.id, { pid: child.pid })
379
+ console.info('run started', {
380
+ run_id: started.id,
381
+ pid: child.pid,
382
+ cli: script ? 'script' : preset.cli,
383
+ ...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
384
+ ...(ordered ? { setup: attempts.length } : {}),
385
+ session: session.sessionId,
386
+ isResume: session.hasStarted,
387
+ })
388
+
389
+ if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
390
+ if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
391
+ if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
392
+ }, 4000)
393
+
394
+ const free = () => {
395
+ if (run.scheduled) background.delete(run.id)
396
+ else {
397
+ activeChild = null
398
+ if (activeTypingTimer) clearInterval(activeTypingTimer)
399
+ activeTypingTimer = null
400
+ }
401
+ }
402
+ const finalize = async (code: number | null, cancelled: boolean, failed: boolean) => {
403
+ await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', endedAt: new Date().toISOString(), exitCode: code,
404
+ ...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
405
+ ...(failed ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(timedOut ? `Script exceeded its ${script!.registration.timeoutSeconds}s timeout and was terminated` : errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
406
+ await releaseExternal(run)
407
+ }
408
+ const completion = finished.then((code) => {
409
+ console.info('run timing', { run_id: run.id, phase: 'execution',
410
+ execution_ms: Math.round(performance.now() - executionStarted), exit_code: code })
411
+ return (async () => {
412
+ let eligible = false
413
+ await withStartLock(async () => {
414
+ try {
415
+ await cleanup()
416
+ if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
417
+ const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
418
+ if (timedOut) failureReason = 'script-timeout'
419
+ const observed = startup?.result()
420
+ const rejected = observed?.rejection && !observed.workBegan ? observed.rejection : undefined
421
+ // A rejection before work is a failure even when the CLI exits 0.
422
+ const failed = !cancelled && (code !== 0 || timedOut || Boolean(rejected))
423
+ 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 } : {}) })
427
+ await runs.patch(run.id, { attempts })
428
+ eligible = failed && !interrupted && advances(rejected)
429
+ }
430
+ if (!eligible) await finalize(code, cancelled, failed)
431
+ } catch (error) {
432
+ eligible = false
433
+ await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
434
+ console.error('Session completion failed', safeError(error))
435
+ } finally {
436
+ // An advancing occurrence keeps its concurrency slot for the next setup.
437
+ if (!eligible) free()
438
+ }
439
+ })
440
+ console.info('run ended', { run_id: started.id, code, ...(eligible ? { advancing: true } : {}) })
441
+ // Re-check cancellation and shutdown under the start lock, so a stop
442
+ // that already snapshotted children never misses the next setup.
443
+ if (eligible) return withStartLock(async () => {
444
+ try {
445
+ const next = await advance()
446
+ if (next) return await watch(await launchSetup(next))
447
+ const cancelled = await scheduler.cancelled(run.id)
448
+ await finalize(code, cancelled, !cancelled)
449
+ free()
450
+ setImmediate(() => { void runs.nextQueued().then(queued => { if (queued && !shuttingDown) return startJob(queued) }).catch(console.error) })
451
+ } catch (error) { await startFailed(error) }
452
+ })
453
+ const next = await runs.nextQueued()
454
+ if (next && !shuttingDown) await startJob(next)
455
+ })().catch((error) => console.error('Run completion failed', error.message))
456
+ })
457
+ completions.add(completion)
458
+ void completion.finally(() => completions.delete(completion))
459
+ }
460
+ await watch(await launchSetup(selected))
461
+ } catch (error) {
462
+ await startFailed(error)
463
+ }
347
464
  })
348
465
  }
349
466
 
@@ -587,8 +704,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
587
704
  } else if (item.type === 'voice' && item.voiceText) {
588
705
  await bot!.api.sendChatAction(item.chatId, 'record_voice')
589
706
  const { buffer } = await synthesizeSpeech(item.voiceText, {
590
- geminiApiKey: config.geminiApiKey,
591
- openaiApiKey: config.openaiApiKey,
707
+ ...speechOptions,
592
708
  voice: config.speechVoiceEn,
593
709
  })
594
710
  await paceSend()
@@ -719,7 +835,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
719
835
  const delivery = await runs.deliveryStatus()
720
836
  const ai = await control.aiState(aiMenu.initial)
721
837
  const selected = ai.presets.find((p) => p.id === ai.selectedId)!
722
- const discovered = await discoverDefaults(config.workspace, { codexHome, nativeCodexFallback: true })
838
+ const discovered = await discoverDefaults(config.workspace, { codexHome, claudeHome: claudeConfigHome(config.controlDir), nativeCodexFallback: true })
723
839
  const displayedSelected = statusPreset(selected, discovered)
724
840
  const scheduled = all.filter(run => run.scheduled && run.status === 'running').length
725
841
  const queued = all.filter(run => run.status === 'queued').length
@@ -743,6 +859,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
743
859
  '',
744
860
  'Work',
745
861
  `Current: ${running ? (waitingForHost ? 'waiting for the workspace' : 'running') : 'idle'}`,
862
+ `Scheduled limit: ${config.scheduledConcurrency ?? 6}`,
746
863
  `Background: ${scheduled ? `${scheduled} scheduled task${scheduled === 1 ? '' : 's'} running` : 'none'}`,
747
864
  `Queue: ${queued || incoming.pending ? `${queued} run${queued === 1 ? '' : 's'}; ${incoming.pending} incoming message${incoming.pending === 1 ? '' : 's'}` : 'empty'}`,
748
865
  ...(attention.length ? ['', 'Needs attention', ...attention] : []),
@@ -16,13 +16,14 @@ export const hostEnvironment=()=>Object.fromEntries(['HOME','PATH','LANG','LC_AL
16
16
  async function atomic(file,value) {
17
17
  const tmp=file+'.'+randomUUID()+'.tmp';await fs.writeFile(tmp,JSON.stringify(value)+'\n',{mode:0o600,flag:'wx'});await fs.rename(tmp,file);
18
18
  }
19
- export function run(command,args,{cwd,log,timeout=15000}={}) {
19
+ export function run(command,args,{cwd,log,timeout=15000,stdoutOnly=false}={}) {
20
20
  return new Promise((resolve,reject)=>{
21
- const child=spawn(command,args,{cwd,env:hostEnvironment(),stdio:['ignore',log??'pipe',log??'pipe']});let output='';
22
- for(const stream of [child.stdout,child.stderr])stream?.on('data',b=>output=(output+b).slice(-200000));
21
+ const child=spawn(command,args,{cwd,env:hostEnvironment(),stdio:['ignore',log??'pipe',log??'pipe']});let output='',stdout='';
22
+ child.stdout?.on('data',b=>{output=(output+b).slice(-200000);if(stdoutOnly)stdout=(stdout+b).slice(-200000);});
23
+ child.stderr?.on('data',b=>output=(output+b).slice(-200000));
23
24
  const timer=setTimeout(()=>child.kill('SIGTERM'),timeout);
24
25
  child.once('error',e=>{clearTimeout(timer);reject(e);});
25
- child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(Error(`${command} failed (${code})${log===undefined?': '+output.trim():''}`));else resolve(output.trim());});
26
+ child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(Error(`${command} failed (${code})${log===undefined?': '+output.trim():''}`));else resolve((stdoutOnly?stdout:output).trim());});
26
27
  });
27
28
  }
28
29
  export const defaultHome=()=>path.join(process.env.XDG_DATA_HOME||path.join(homedir(),'.local/share'),'ez');
@@ -115,7 +116,9 @@ export async function migrateLedger({ deployment } = {}) {
115
116
  }
116
117
 
117
118
  export async function latestCodexVersion(invoke,manager={command:"npm",args:[]}) {
118
- const version=JSON.parse(await invoke(manager.command,[...manager.args,'view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/']));
119
+ const output=await invoke(manager.command,[...manager.args,'view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/'],{stdoutOnly:true});
120
+ let version;
121
+ try {version=JSON.parse(output);}catch {throw Error('Codex version discovery returned invalid JSON on stdout');}
119
122
  if(typeof version!=='string'||!/^\d+\.\d+\.\d+$/.test(version))throw Error('Invalid latest Codex version');
120
123
  return version;
121
124
  }