@jc_stack/ez-agents 0.1.0-beta.46 → 0.1.0-beta.48

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 (91) hide show
  1. package/.dockerignore +2 -0
  2. package/.env.example +3 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +38 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/Dockerfile +18 -10
  7. package/THIRD_PARTY_NOTICES.md +6 -3
  8. package/bin/ez +7 -0
  9. package/docker/broker-readiness.mjs +54 -0
  10. package/docker/broker-update-smoke.mjs +86 -0
  11. package/docker/engine-pnpm-workspace.yaml +4 -0
  12. package/docker/run.ts +1 -1
  13. package/docker/smoke.mjs +1 -1
  14. package/docs/application-channel.md +32 -1
  15. package/docs/architecture/ai-selection.md +26 -5
  16. package/docs/architecture/authority-boundaries.md +2 -2
  17. package/docs/development-and-testing.md +1 -1
  18. package/docs/docker-runtime.md +39 -0
  19. package/docs/isolated-plugin-update-readiness.md +66 -0
  20. package/docs/plugin-guide.md +1 -1
  21. package/docs/plugins.md +1 -1
  22. package/docs/releasing.md +5 -1
  23. package/docs/scheduling.md +117 -9
  24. package/docs/selective-monitoring.md +13 -0
  25. package/docs/upgrades.md +38 -1
  26. package/docs/work-preflight.md +9 -0
  27. package/package.json +4 -3
  28. package/pnpm-workspace.yaml +7 -0
  29. package/scripts/smoke-scheduler.ts +4 -1
  30. package/src/ai.ts +41 -4
  31. package/src/application-channel.ts +43 -3
  32. package/src/application-cli.ts +7 -1
  33. package/src/application-origin.ts +2 -0
  34. package/src/audio.ts +37 -22
  35. package/src/client-defaults.ts +3 -2
  36. package/src/config.ts +9 -0
  37. package/src/control-state.ts +3 -1
  38. package/src/delivery-socket-client.d.mts +2 -0
  39. package/src/delivery-socket-client.mjs +2 -0
  40. package/src/delivery-socket.ts +38 -1
  41. package/src/event-sources.ts +2 -2
  42. package/src/executor.ts +72 -1
  43. package/src/files.ts +47 -3
  44. package/src/host-executor.ts +40 -26
  45. package/src/index.ts +42 -17
  46. package/src/install-tools.mjs +18 -9
  47. package/src/menu.ts +4 -2
  48. package/src/model-policy.ts +4 -0
  49. package/src/plugin-broker.mjs +47 -4
  50. package/src/plugins/manager.mjs +11 -4
  51. package/src/run-evidence.ts +78 -0
  52. package/src/runs.ts +16 -2
  53. package/src/schedule-cli.ts +80 -10
  54. package/src/schedule-preflight.ts +23 -0
  55. package/src/scheduled-tasks.ts +16 -12
  56. package/src/scheduler.ts +102 -16
  57. package/src/scripts.ts +167 -0
  58. package/src/setup.ts +2 -2
  59. package/src/task-capability.ts +17 -1
  60. package/src/task-executor.ts +11 -8
  61. package/src/task-mcp.ts +20 -8
  62. package/src/tasks.ts +89 -9
  63. package/src/updates/runtime.mjs +53 -11
  64. package/src/updates/supervisor.mjs +23 -13
  65. package/test/ai.test.ts +85 -5
  66. package/test/application-channel.test.ts +16 -1
  67. package/test/audio.test.ts +19 -0
  68. package/test/channel-owner.test.ts +2 -2
  69. package/test/config.test.ts +8 -0
  70. package/test/desktop-bridge.test.ts +2 -1
  71. package/test/executor.test.ts +37 -0
  72. package/test/failure.test.ts +4 -4
  73. package/test/files.test.ts +44 -0
  74. package/test/host-executor.test.ts +49 -6
  75. package/test/install-json.test.mjs +21 -0
  76. package/test/install-tools.test.mjs +6 -5
  77. package/test/native-tasks.test.ts +1 -1
  78. package/test/plugin-broker.test.mjs +64 -0
  79. package/test/plugin-manager.test.mjs +20 -0
  80. package/test/private-speech-config.test.ts +22 -0
  81. package/test/run-evidence.test.ts +78 -0
  82. package/test/schedule-cli.test.ts +47 -3
  83. package/test/schedule-preflight.test.ts +49 -0
  84. package/test/scheduled-tasks.test.ts +2 -2
  85. package/test/scheduler-host.test.ts +21 -3
  86. package/test/scheduler-relay.test.ts +84 -4
  87. package/test/scheduler.test.ts +21 -1
  88. package/test/script-schedules.test.ts +219 -0
  89. package/test/task-native.test.ts +50 -12
  90. package/test/tasks.test.ts +97 -4
  91. package/test/updates.test.mjs +77 -18
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 { startExecutorJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
31
+ import { assertScheduledModel } from './model-policy.js'
29
32
  import { RunStore, type RunRecord } from './runs.js'
30
33
  import { splitTelegramText } from './reply.js'
31
34
  import { markdownToTelegramHtml, escapeHtml } from './format.js'
@@ -43,7 +46,7 @@ import { softwareStatus } from './software-status.js'
43
46
  export const createRelay = (config: Config, launch = startExecutorJob) => {
44
47
  const safeError = (error: unknown): string => {
45
48
  let message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
46
- for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
49
+ for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey, config.openrouterApiKey]) {
47
50
  if (secret) message = message.replaceAll(secret, '[redacted]')
48
51
  }
49
52
  return message
@@ -59,7 +62,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
59
62
  const scheduler = new Scheduler(config.controlDir)
60
63
  const background = new Map<string, ChildProcess>()
61
64
  const completions = new Set<Promise<void>>()
62
- const tasks = new Tasks(config.controlDir)
65
+ const speechOptions = { geminiApiKey: config.geminiApiKey, openrouterApiKey: config.openrouterApiKey, speechProvider: config.speechProvider, speechModel: config.speechModel }
66
+ const speechConfigured = config.speechProvider === 'openrouter' ? Boolean(config.openrouterApiKey) : Boolean(config.geminiApiKey)
67
+ const tasks = new Tasks(config.controlDir, speechConfigured ? (text) => synthesizeSpeech(text, { ...speechOptions, voice: config.speechVoiceEn, signal: AbortSignal.timeout(20000) }) : undefined)
63
68
  const processTaskRequests = taskRequests(tasks)
64
69
  let taskWork: Promise<void> | undefined
65
70
  const drainTaskRequests = (): Promise<void> => {
@@ -102,8 +107,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
102
107
  const applicationChannel = new ApplicationChannel({
103
108
  controlDir: config.controlDir, workspace: config.workspace, isolation: config.isolation, initial: aiMenu.initial,
104
109
  aiControls: aiMenu,
105
- speech: config.geminiApiKey ? (text, language) => synthesizeSpeech(text, {
106
- geminiApiKey: config.geminiApiKey,
110
+ speech: speechConfigured ? (text, language) => synthesizeSpeech(text, {
111
+ ...speechOptions,
107
112
  voice: language === 'es' ? config.speechVoiceEs : config.speechVoiceEn,
108
113
  format: 'wav',
109
114
  }) : undefined,
@@ -206,8 +211,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
206
211
  }
207
212
  if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
208
213
  // A long scheduled turn must not occupy the owner's chat stream.
209
- // Each stream remains serial, with separate native sessions.
210
- if (run.scheduled ? background.size > 0 : activeChild !== null) return
214
+ // Foreground stays serial; scheduled jobs have independent native sessions.
215
+ if (run.scheduled ? background.size >= (config.scheduledConcurrency ?? 6) : activeChild !== null) return
211
216
  let texts = run.texts
212
217
  if (run.external) {
213
218
  // Availability failures leave durable queued work for a later check.
@@ -234,13 +239,23 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
234
239
  try {
235
240
  const launchStarted = performance.now()
236
241
  const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
237
- if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
242
+ // A registered script occurrence: no model, prompt or native session.
243
+ const script = run.script && run.scheduled
244
+ ? { registration: await new Scripts(config.controlDir).owned(run.script.id, owner), ref: run.script, scheduled: run.scheduled }
245
+ : undefined
246
+ if (run.script && !script) throw new Error('Script runs require a scheduled occurrence')
247
+ if (!started.execution && !run.taskId && !script) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
238
248
  const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
239
249
  const session = startsOwnSession
240
250
  ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
241
251
  : await control.executionSession(started.execution!)
242
- const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
243
- const { child, cleanup } = await launch(texts, {
252
+ const selected = script ? initialPreset('codex') : run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
253
+ if (run.scheduled && !script) assertScheduledModel(selected.model)
254
+ const { child, cleanup } = await launch(texts, script ? {
255
+ workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
256
+ controlDir: config.controlDir, binDir,
257
+ script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
258
+ } : {
244
259
  workspace: config.workspace,
245
260
  timeoutMs: config.executorTimeoutMs,
246
261
  repairEnabled: config.repairEnabled,
@@ -263,9 +278,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
263
278
  const executionStarted = performance.now()
264
279
  // Attach before disk writes: a fast child can close while PID persistence
265
280
  // is pending, and Node drains its remaining pipes during process close.
266
- let failureReason = 'executor-exit', errorTail = '', interrupted = false
281
+ let failureReason = 'executor-exit', errorTail = '', interrupted = false, outputTail = '', timedOut = false
282
+ // Script output is bounded diagnostics for run inspection, never chat.
283
+ if (script) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { outputTail = (outputTail + chunk).slice(-16384) })
284
+ const scriptTimer = script ? setTimeout(() => { timedOut = true; terminateJob(child) }, script.registration.timeoutSeconds * 1000) : undefined
285
+ child.once('close', () => clearTimeout(scriptTimer))
267
286
  child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
268
287
  errorTail = (errorTail + chunk).slice(-16384)
288
+ if (script) outputTail = (outputTail + chunk).slice(-16384)
269
289
  if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
270
290
  if (chunk.includes('Host executor client interrupted by')) {
271
291
  failureReason = 'host-executor-transport-interrupted'
@@ -286,7 +306,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
286
306
  console.info('run started', {
287
307
  run_id: started.id,
288
308
  pid: child.pid,
289
- cli: selected.cli,
309
+ cli: script ? 'script' : selected.cli,
310
+ ...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
290
311
  session: session.sessionId,
291
312
  isResume: session.hasStarted,
292
313
  })
@@ -304,8 +325,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
304
325
  try {
305
326
  await cleanup()
306
327
  if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
307
- const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
308
- 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}`}`)) } : {}) })
328
+ const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
329
+ if (timedOut) failureReason = 'script-timeout'
330
+ const failed = !cancelled && (code !== 0 || timedOut)
331
+ await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', endedAt: new Date().toISOString(), exitCode: code,
332
+ ...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
333
+ ...(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}`}`)) } : {}) })
309
334
  await releaseExternal(run)
310
335
  } catch (error) {
311
336
  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() })
@@ -585,8 +610,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
585
610
  } else if (item.type === 'voice' && item.voiceText) {
586
611
  await bot!.api.sendChatAction(item.chatId, 'record_voice')
587
612
  const { buffer } = await synthesizeSpeech(item.voiceText, {
588
- geminiApiKey: config.geminiApiKey,
589
- openaiApiKey: config.openaiApiKey,
613
+ ...speechOptions,
590
614
  voice: config.speechVoiceEn,
591
615
  })
592
616
  await paceSend()
@@ -717,7 +741,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
717
741
  const delivery = await runs.deliveryStatus()
718
742
  const ai = await control.aiState(aiMenu.initial)
719
743
  const selected = ai.presets.find((p) => p.id === ai.selectedId)!
720
- const discovered = await discoverDefaults(config.workspace, { codexHome, nativeCodexFallback: true })
744
+ const discovered = await discoverDefaults(config.workspace, { codexHome, claudeHome: claudeConfigHome(config.controlDir), nativeCodexFallback: true })
721
745
  const displayedSelected = statusPreset(selected, discovered)
722
746
  const scheduled = all.filter(run => run.scheduled && run.status === 'running').length
723
747
  const queued = all.filter(run => run.status === 'queued').length
@@ -741,6 +765,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
741
765
  '',
742
766
  'Work',
743
767
  `Current: ${running ? (waitingForHost ? 'waiting for the workspace' : 'running') : 'idle'}`,
768
+ `Scheduled limit: ${config.scheduledConcurrency ?? 6}`,
744
769
  `Background: ${scheduled ? `${scheduled} scheduled task${scheduled === 1 ? '' : 's'} running` : 'none'}`,
745
770
  `Queue: ${queued || incoming.pending ? `${queued} run${queued === 1 ? '' : 's'}; ${incoming.pending} incoming message${incoming.pending === 1 ? '' : 's'}` : 'empty'}`,
746
771
  ...(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');
@@ -114,6 +115,13 @@ export async function migrateLedger({ deployment } = {}) {
114
115
  return { ok: true, deployment: directory, changed: true, port, overlay };
115
116
  }
116
117
 
118
+ export async function latestCodexVersion(invoke,manager={command:"npm",args:[]}) {
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');}
122
+ if(typeof version!=='string'||!/^\d+\.\d+\.\d+$/.test(version))throw Error('Invalid latest Codex version');
123
+ return version;
124
+ }
117
125
  async function fingerprintOf(source,invoke) {
118
126
  const listing=JSON.parse(await invoke('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:source}));
119
127
  const hash=createHash('sha256');
@@ -144,8 +152,9 @@ async function buildIdentity(source,label,invoke) {
144
152
  export async function build({home=defaultHome(),source=root,label},invoke=run) {
145
153
  source=await fs.realpath(source);
146
154
  const identity=await buildIdentity(source,label,invoke);
155
+ const codexVersion=await latestCodexVersion(invoke);
147
156
  const fingerprint=await fingerprintOf(source,invoke);
148
- const artifact=createHash('sha256').update(JSON.stringify([fingerprint,identity.label??'',identity.sha??''])).digest('hex');
157
+ const artifact=createHash('sha256').update(JSON.stringify([fingerprint,identity.label??'',identity.sha??'',codexVersion])).digest('hex');
149
158
  const image='ezenciel-agents:install-'+artifact.slice(0,24);
150
159
  const dir=path.join(path.resolve(home),'builds',artifact);await fs.mkdir(dir,{recursive:true,mode:0o700});
151
160
  const lock=path.join(dir,'lock'),receipt=path.join(dir,'status.json'),log=path.join(dir,'build.log');
@@ -155,14 +164,14 @@ export async function build({home=defaultHome(),source=root,label},invoke=run) {
155
164
  await handle.writeFile(JSON.stringify({pid:process.pid,source,image,...identity}));
156
165
  const prior=await read(receipt).catch(absent);
157
166
  if(prior?.state==='completed')try{if(await invoke('docker',['image','inspect','--format','{{.Id}}',image])===prior.imageId)return {...prior,reused:true};}catch{/* Image was removed; rebuild under the same exclusive lock. */}
158
- await atomic(receipt,{state:'building',pid:process.pid,source,image,log,...identity});
159
- console.error(JSON.stringify({state:'building',image,log,...identity}));
160
- const buildArgs=identity.label?['--build-arg',`BUILD_TAG=${identity.label}`,'--build-arg',`BUILD_SHA=${identity.sha}`]:[];
167
+ await atomic(receipt,{state:'building',pid:process.pid,source,image,log,codexVersion,...identity});
168
+ console.error(JSON.stringify({state:'building',image,log,codexVersion,...identity}));
169
+ const buildArgs=['--build-arg',`CODEX_CLI_VERSION=${codexVersion}`,...(identity.label?['--build-arg',`BUILD_TAG=${identity.label}`,'--build-arg',`BUILD_SHA=${identity.sha}`]:[])];
161
170
  const tags=[image,...(identity.label?[`ezenciel-agents:${identity.label}`]:[])];
162
171
  const output=await fs.open(log,'a',0o600);
163
172
  try{await invoke('docker',['build','--progress','plain','--target','runtime',...buildArgs,...tags.flatMap(t=>['-t',t]),source],{log:output.fd,timeout:3600000});}finally{await output.close();}
164
173
  if(await fingerprintOf(source,invoke)!==fingerprint)throw Error('Package changed during build; keep the source stable and retry');
165
- const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log,...identity};await atomic(receipt,result);return result;
174
+ const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log,codexVersion,...identity};await atomic(receipt,result);return result;
166
175
  }catch(e){await atomic(receipt,{state:'failed',source,image,log,error:e.message,...identity});throw e;}
167
176
  finally{await handle.close();await fs.rm(lock);}
168
177
  }
package/src/menu.ts CHANGED
@@ -6,6 +6,7 @@ import { InlineKeyboard, type Context } from 'grammy'
6
6
  import { ControlStore, type ControlGuard } from './control-state.js'
7
7
  import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
8
8
  import { discoverDefaults } from './client-defaults.js'
9
+ import { claudeConfigHome } from './executor.js'
9
10
 
10
11
  export const mainCommands = [
11
12
  { command: 'new', description: 'New conversation' },
@@ -38,14 +39,15 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
38
39
  // directory, but the default catalog probed the process home instead. Bind it
39
40
  // to the same agent-bound home so /ai lists the models Codex can actually run.
40
41
  else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome, undefined, opencodeDataHome, undefined, process.env.EZ_CONTROL_DIR)
41
- const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome }))
42
+ const claudeHome = process.env.EZ_CONTROL_DIR ? claudeConfigHome(process.env.EZ_CONTROL_DIR) : undefined
43
+ const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome, claudeHome }))
42
44
  const validate = async (preset: AiPreset) => {
43
45
  assertEffort(preset.effort, preset.model, preset.cli)
44
46
  if (preset.id === initial.id) return
45
47
  if (preset.id.startsWith('detected_')) {
46
48
  if (host) await validateSelection(preset, await catalog(), async name => (await catalog()).some(model => model.cli === name))
47
49
  else {
48
- const detected = await discoverDefaults(workspace, { codexHome })
50
+ const detected = await discoverDefaults(workspace, { codexHome, claudeHome })
49
51
  if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
50
52
  }
51
53
  } else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
@@ -3,6 +3,10 @@ export const allowedEffort = (effort?: string, _model?: string, _cli?: string) =
3
3
  export function assertEffort(effort?: string, model?: string, cli?: string) {
4
4
  if (!allowedEffort(effort,model,cli)) throw new Error('Invalid reasoning effort')
5
5
  }
6
+ export function assertScheduledModel(model: unknown): asserts model is string {
7
+ if (typeof model !== 'string' || !/^[a-zA-Z0-9_./:-]{1,160}$/.test(model))
8
+ throw new Error('Scheduled tasks require a saved explicit model; configure the task with --model')
9
+ }
6
10
  export function executionDefaults<T extends { model?: string; effort?: string }>(cli: string, options: T): T {
7
11
  assertEffort(options.effort,options.model,cli)
8
12
  return options
@@ -1,4 +1,6 @@
1
+ import { combinePreflight } from './schedule-preflight.js';
1
2
  import * as fs from 'node:fs/promises';
3
+ import {callDeliverySocket} from './delivery-socket-client.mjs';
2
4
  import path from 'node:path';
3
5
  import { createServer } from 'node:net';
4
6
  import { randomBytes, randomUUID, createHash } from 'node:crypto';
@@ -223,23 +225,37 @@ const managerCommand = async (binding, args, signal, runId) => new Promise((reso
223
225
  });
224
226
  });
225
227
 
226
- const invokePlugin = async (binding, request, signal) => {
228
+ const authorizeTaskLaunch=async(binding,request)=>{
229
+ const input=JSON.parse(request.stdin)
230
+ if(Object.keys(input).some(k=>!['taskId','taskToken','expiresAt'].includes(k)) || typeof input.taskToken!=='string' || !/^[a-f0-9]{64}$/.test(input.taskToken))throw Error('Invalid task launch input')
231
+ const ticketHash=createHash('sha256').update(input.taskToken).digest('hex')
232
+ const grant=await callDeliverySocket(deliverySocketPath(binding.controlDir),{op:'taskApplication',payload:{runId:request.runId,ticketHash}})
233
+ if(input.taskId!==grant.taskId || input.expiresAt!==grant.expiresAt)throw Error('Task launch authority mismatch')
234
+ return grant.launch
235
+ }
236
+ const invokePlugin = async (binding, request, signal, scheduledPreflight = false) => {
227
237
  const startedAt = new Date().toISOString();
228
238
  let plugin = request.alias;
229
239
  let revision = request.revision;
230
240
  let result;
231
241
  let failure;
232
242
  try {
233
- await authorize(binding, request.runId);
243
+ if (!scheduledPreflight) {
244
+ if(request.operation==='task-launch') {
245
+ const launch=await authorizeTaskLaunch(binding,request)
246
+ if(launch.command!==request.alias || JSON.stringify(launch.args)!==JSON.stringify(request.args))throw Error('Task launch command changed')
247
+ }else await authorize(binding, request.runId);
248
+ }
234
249
  const current = await registry(binding.home);
235
250
  const selected = recordForAlias(current, request.alias);
251
+ if (scheduledPreflight && (!selected.command.exposure || selected.command.exposure.changesRecords !== false || selected.command.exposure.sendsExternally !== false)) throw Error('Scheduled preflight requires an explicitly read-only plugin command');
236
252
  plugin = selected.plugin;
237
253
  revision = selected.record.revision;
238
254
  if (selected.record.revision !== request.revision) throw Error('Plugin changed; discover again');
239
255
  const command = await prepareCommand(binding.home, request.alias, request.args, {
240
256
  revision: request.revision,
241
257
  invocation: true,
242
- environment: { EZ_CONTROL_DIR: binding.controlDir, EZ_RUN_ID: request.runId, EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
258
+ environment: { EZ_CONTROL_DIR: binding.controlDir, ...(scheduledPreflight ? {} : {EZ_RUN_ID: request.runId}), EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
243
259
  });
244
260
  try {
245
261
  result = await dockerRun(command.argv, {
@@ -282,8 +298,17 @@ const invokePlugin = async (binding, request, signal) => {
282
298
 
283
299
  export async function validateBrokerRequest(request, workspace) {
284
300
  if (!request || typeof request !== 'object' || request.version !== 1 || !REQUEST_ID.test(request.id)) throw Error('Invalid plugin broker request');
285
- if (!['resolve', 'invoke', 'manager'].includes(request.operation)) throw Error('Unknown plugin broker operation');
301
+ if(request.operation === 'preflight') {
302
+ validId(request.scheduleId,RUN_ID,'schedule ID'); validId(request.revision,/^[a-f0-9-]{36}$/,'schedule revision');
303
+ if(Object.keys(request).some(k=>!['version','id','operation','scheduleId','revision'].includes(k))) throw Error('Unexpected schedule preflight field');
304
+ return request;
305
+ }
306
+ if (!['resolve', 'invoke', 'manager','task-launch'].includes(request.operation)) throw Error('Unknown plugin broker operation');
286
307
  validId(request.runId, RUN_ID, 'plugin run ID');
308
+ if(request.operation==='task-launch') {
309
+ if(Object.keys(request).some(k=>!['version','id','operation','runId','stdin'].includes(k)))throw Error('Unexpected task launch field')
310
+ boundedString(request.stdin,1024,'task launch stdin');return request
311
+ }
287
312
  if (request.operation === 'manager') {
288
313
  await validateManagerArgs(request.args, workspace);
289
314
  if (Object.keys(request).some(key => !['version', 'id', 'operation', 'runId', 'args'].includes(key))) throw Error('Unexpected broker management field');
@@ -328,6 +353,18 @@ export async function servePluginBroker(binding, signal = new AbortController().
328
353
  (async () => {
329
354
  try {
330
355
  await validateBrokerRequest(request, binding.workspace);
356
+ if (request.operation === 'preflight') {
357
+ const condition=await (await import('./delivery-socket-client.mjs')).callDeliverySocket(deliverySocketPath(binding.controlDir),{op:'schedulePreflight',payload:{scheduleId:request.scheduleId,revision:request.revision}});
358
+ const values=[];
359
+ for(const check of condition.checks) {
360
+ const selected=recordForAlias(await registry(binding.home),check.alias);
361
+ const result=await invokePlugin(binding,{alias:check.alias,args:check.args,revision:selected.record.revision,runId:`preflight_${request.scheduleId}`,id:randomUUID()},abort.signal,true);
362
+ if(!result.ok || result.code!==0) throw Error('Scheduled plugin preflight unavailable');
363
+ values.push(JSON.parse(result.stdout));
364
+ }
365
+ response(socket,{version:1,id:request.id,ok:true,result:combinePreflight(values)});
366
+ return;
367
+ }
331
368
  if (request.operation === 'resolve') {
332
369
  await authorize(binding, request.runId);
333
370
  const selected = recordForAlias(await registry(binding.home), request.alias);
@@ -338,6 +375,12 @@ export async function servePluginBroker(binding, signal = new AbortController().
338
375
  response(socket, { version: 1, id: request.id, ok: true, plugin: selected.plugin, alias: request.alias, revision: selected.record.revision, capability });
339
376
  return;
340
377
  }
378
+ if(request.operation==='task-launch') {
379
+ const launch=await authorizeTaskLaunch(binding,request)
380
+ const selected=recordForAlias(await registry(binding.home),launch.command)
381
+ const result=await invokePlugin(binding,{...request,alias:launch.command,args:launch.args,revision:selected.record.revision},abort.signal)
382
+ response(socket,{version:1,id:request.id,...result});return
383
+ }
341
384
  if (request.operation === 'manager') {
342
385
  await authorize(binding, request.runId);
343
386
  const result = await managerCommand(binding, request.args, abort.signal, request.runId);
@@ -94,11 +94,18 @@ async function invocationLease(home,container) {
94
94
  await stewardOwned(file);
95
95
  return async()=>{await fs.rm(file,{force:true});};
96
96
  }
97
- export async function locked(home, fn, {allowInvocations=false}={}) {
97
+ export async function locked(home, fn, {allowInvocations=false, waitMs=0}={}) {
98
98
  const lock = path.join(home,'registry.lock');
99
99
  let handle;
100
- try { handle=await fs.open(lock,'wx',0o600); }
101
- catch(error) { if(error.code==='EEXIST') throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager'); throw error; }
100
+ const deadline=Date.now()+waitMs;
101
+ while(!handle) {
102
+ try { handle=await fs.open(lock,'wx',0o600); }
103
+ catch(error) {
104
+ if(error.code!=='EEXIST')throw error;
105
+ if(Date.now()>=deadline)throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager');
106
+ await new Promise(resolve=>setTimeout(resolve,25));
107
+ }
108
+ }
102
109
  try {
103
110
  await handle.writeFile(JSON.stringify({pid:process.pid}));
104
111
  if(!allowInvocations&&await activeInvocations(home))throw Error('Plugin commands are active; retry the registry or lifecycle change after they finish');
@@ -498,7 +505,7 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish,i
498
505
  const invocationRelease=invocation?await invocationLease(home,container):undefined;
499
506
  const release=(invocationRelease||contextFile)?(async()=>{try{if(contextFile)await fs.rm(contextFile,{force:true});}finally{await invocationRelease?.();}}):undefined;
500
507
  return {container,plugin:record.manifest.id,revision:record.revision,contextFile,argv:[...composeArgs(record),...(contextFile?['--file',contextFile]:[]),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])],release};
501
- },{allowInvocations:true});
508
+ },{allowInvocations:true,waitMs:5000});
502
509
  }
503
510
  export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
504
511
  if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
@@ -0,0 +1,78 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readOnlyOwner, requireOwnerExecution } from './execution-authority.js'
3
+ import { ownsRun, assertId } from './identity.js'
4
+ import { RunStore, retainedOutbox, type RunRecord, type StoredOutboxItem } from './runs.js'
5
+ import type { Owner } from './control-state.js'
6
+
7
+ const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')
8
+ const version = (value?: string) => value && /^[0-9A-Za-z.+-]{1,80}$/.test(value) ? value : undefined
9
+
10
+ function delivery(item: StoredOutboxItem) {
11
+ const receipt = item.receipt as { messageIds?: unknown; deliveredAt?: unknown } | undefined
12
+ const valid = item.state === 'sent' && !item.deliveryUnknown &&
13
+ Array.isArray(receipt?.messageIds) && receipt.messageIds.length > 0 &&
14
+ receipt.messageIds.every(id => Number.isSafeInteger(id) && id > 0) &&
15
+ typeof receipt.deliveredAt === 'string' && Number.isFinite(Date.parse(receipt.deliveredAt))
16
+ const field = typeof item.text === 'string' ? 'text' : typeof item.voiceText === 'string' ? 'voiceText' : undefined
17
+ const content = field === 'text' ? item.text : field === 'voiceText' ? item.voiceText : undefined
18
+ return { id: item.id, createdAt: item.createdAt, type: item.type ?? 'message',
19
+ state: item.deliveryUnknown ? 'unknown' : item.state ?? 'queued', receiptVerified: Boolean(valid),
20
+ ...(valid ? { deliveredAt: receipt!.deliveredAt, messageCount: (receipt!.messageIds as number[]).length } : {}),
21
+ ...(content === undefined ? {} : { content: { field, sha256: sha256(content), bytes: Buffer.byteLength(content), representation: 'submitted UTF-8' } }),
22
+ recipientContentReadback: 'unavailable', attachmentContent: 'not exported' }
23
+ }
24
+
25
+ // Projection only: never read native sessions, tool arguments/results or model text.
26
+ export function projectRunEvidence(runs: RunRecord[], outbox: StoredOutboxItem[], owner: Owner) {
27
+ return runs.filter(run => ownsRun(owner, run) && Date.parse(run.createdAt) >= Date.parse(owner.pairedAt) &&
28
+ !run.external && !run.taskId && !run.application && !run.delivery &&
29
+ (!run.scheduled || run.scheduled.pairedAt === owner.pairedAt))
30
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
31
+ .map(run => ({ id: run.id, status: run.status, createdAt: run.createdAt,
32
+ startedAt: run.startedAt, endedAt: run.endedAt, exitCode: run.exitCode, interrupted: run.interrupted,
33
+ scheduled: run.scheduled ? { id: run.scheduled.id, revision: run.scheduled.revision, dueAt: run.scheduled.dueAt } : undefined,
34
+ executionType: run.script ? 'script' : 'agent',
35
+ script: run.script ? { id: run.script.id, revision: run.script.revision, sha256: run.script.sha256, timedOut: Boolean(run.timedOut),
36
+ outputSha256: run.output ? sha256(run.output) : undefined } : undefined,
37
+ preset: run.execution ? { cli: run.execution.preset.cli, model: run.execution.preset.model, effort: run.execution.preset.effort } : undefined,
38
+ failure: run.failure ? { recorded: true, errorSha256: sha256(run.failure.error), relayVersion: version(run.failure.relayVersion), hostVersion: version(run.failure.hostVersion) } : undefined,
39
+ failureReasonRecorded: Boolean(run.failureReason), blockReasonRecorded: Boolean(run.blockReason),
40
+ failureReview: run.failureReview ? { failedAt: run.failureReview.failedAt, reviewedAt: run.failureReview.reviewedAt, status: run.failureReview.status } : undefined,
41
+ nativeSessionRecorded: Boolean(run.nativeSessionId),
42
+ toolDelegationMetadata: { state: 'unavailable', reason: 'The relay does not retain native tool or delegation events.' },
43
+ deliveries: outbox.filter(item => item.runId === run.id && item.chatId === owner.telegramChatId &&
44
+ Date.parse(item.createdAt) >= Date.parse(owner.telegramLinkedAt ?? owner.pairedAt))
45
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).map(delivery),
46
+ }))
47
+ }
48
+
49
+ export type EvidenceOptions = { callerRunId?: string; runId?: string; offset?: number; limit?: number; expected?: string }
50
+
51
+ export async function runEvidence(controlDir: string, options: EvidenceOptions = {}) {
52
+ const readStartedAt = new Date().toISOString()
53
+ const offset = options.offset ?? 0, limit = options.limit ?? 100
54
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
55
+ throw new Error('Offset must be nonnegative; limit must be 1..100')
56
+ if (offset > 0 && !options.expected) throw new Error('Continuation requires --expected snapshot SHA-256')
57
+ if (options.expected !== undefined && !/^[a-f0-9]{64}$/.test(options.expected)) throw new Error('Invalid expected snapshot SHA-256')
58
+ if (options.callerRunId !== undefined) {
59
+ const caller = await requireOwnerExecution(controlDir, options.callerRunId)
60
+ if (caller.application || caller.delivery || caller.replyOnly) throw new Error('Evidence requires Telegram owner execution or the bound operator')
61
+ }
62
+ const owner = await readOnlyOwner(controlDir)
63
+ if (!owner) throw new Error('No authorized owner')
64
+ const records = projectRunEvidence(await new RunStore(controlDir).list(), retainedOutbox(controlDir), owner)
65
+ .filter(run => options.runId === undefined || run.id === assertId(options.runId))
66
+ if (options.runId !== undefined && !records.length) throw new Error('Unknown owner run')
67
+ const snapshotSha256 = sha256(JSON.stringify(records))
68
+ if (options.expected !== undefined && options.expected !== snapshotSha256) throw new Error('Evidence changed; restart at offset zero')
69
+ if (offset > records.length) throw new Error('Offset exceeds retained coverage')
70
+ const page = records.slice(offset, offset + limit)
71
+ return { schemaVersion: 1, readStartedAt, observedAt: new Date().toISOString(), snapshotSha256,
72
+ coverage: { scope: 'current-owner Telegram foreground and scheduled runs', total: records.length, offset, limit, returned: page.length,
73
+ nextOffset: offset + page.length < records.length ? offset + page.length : null,
74
+ history: 'relay memory; resets on restart; at most 2000 terminal runs and 2000 terminal outbox items retained',
75
+ completeness: 'retained records only; older history and unassociated deliveries unknown',
76
+ excluded: ['external tasks', 'isolated tasks', 'application/channel runs', 'native sessions', 'tool/delegation events', 'failure/review prose', 'message content', 'recipient-side content readback'] },
77
+ runs: page }
78
+ }
package/src/runs.ts CHANGED
@@ -7,6 +7,7 @@ import { validScheduledOrigin, type ScheduledOrigin } from './scheduler.js'
7
7
  import type { IncomingItem } from './inbox.js'
8
8
  import { assertId } from './identity.js'
9
9
  import { isExecutionChoice, type ExecutionChoice } from './ai.js'
10
+ import type { ScriptRunRef } from './scripts.js'
10
11
  import {authorizeDeliveryContext,currentDeliveryOwner,type DeliveryContext} from './delivery-context.mjs'
11
12
 
12
13
  export type RunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
@@ -44,6 +45,11 @@ export type RunRecord = {
44
45
  application?: ApplicationOrigin
45
46
  telegramApplication?: ApplicationOrigin
46
47
  delivery?: { bindingId: string; scope: string }
48
+ // Registered script occurrence (no execution preset or native session).
49
+ script?: ScriptRunRef
50
+ // Bounded, redacted script stdout/stderr tail; diagnostic, never delivered.
51
+ output?: string
52
+ timedOut?: boolean
47
53
  }
48
54
 
49
55
  export type OutboxItemType = 'message' | 'reaction' | 'document' | 'voice' | 'approval'
@@ -71,6 +77,9 @@ export type StoredOutboxItem = OutboxItem & { state?: 'queued' | 'sending' | 'se
71
77
  export const sentOutbox = (controlDir: string): StoredOutboxItem[] =>
72
78
  [...outboxFor(controlDir).values()].filter(item => item.state === 'sent')
73
79
 
80
+ // Explicit operational evidence reads; the projection verifies owner/run scope.
81
+ export const retainedOutbox = (controlDir: string): StoredOutboxItem[] => [...outboxFor(controlDir).values()]
82
+
74
83
  // Stateless pipe: the relay owns the ledger in process memory. Cross-process
75
84
  // producers (engine children, plugin children, host executor) reach it through
76
85
  // the delivery socket (see delivery-socket.ts), never through control/ files.
@@ -141,7 +150,8 @@ export class RunStore {
141
150
  taskId?: string
142
151
  application?: ApplicationOrigin
143
152
  delivery?: { bindingId: string; scope: string }
144
- }): Promise<RunRecord> {
153
+ script?: ScriptRunRef
154
+ }, exclusiveSchedule = false): Promise<RunRecord> {
145
155
  const store = runsFor(this.controlDir)
146
156
  if (input.id) {
147
157
  assertId(input.id)
@@ -152,6 +162,9 @@ export class RunStore {
152
162
  return existing
153
163
  }
154
164
  }
165
+ if (exclusiveSchedule && input.scheduled && [...store.values()].some(run =>
166
+ run.scheduled?.id === input.scheduled!.id && ['queued','running'].includes(run.status)))
167
+ throw new Error('Schedule already has a queued or running occurrence')
155
168
  const run: RunRecord = {
156
169
  version: input.taskId ? 2 : 1,
157
170
  taskId: input.taskId,
@@ -169,6 +182,7 @@ export class RunStore {
169
182
  application: input.application,
170
183
  delivery: input.delivery,
171
184
  scheduled: input.scheduled,
185
+ ...(input.script ? { script: input.script } : {}),
172
186
  status: 'queued',
173
187
  createdAt: new Date().toISOString(),
174
188
  }
@@ -184,7 +198,7 @@ export class RunStore {
184
198
 
185
199
  async patch(
186
200
  id: string,
187
- change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased'>>,
201
+ change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut'>>,
188
202
  ): Promise<RunRecord> {
189
203
  const store = runsFor(this.controlDir)
190
204
  const run = store.get(id)