@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
package/src/menu.ts CHANGED
@@ -4,8 +4,9 @@ import path from 'node:path'
4
4
  import { randomBytes } from 'node:crypto'
5
5
  import { InlineKeyboard, type Context } from 'grammy'
6
6
  import { ControlStore, type ControlGuard } from './control-state.js'
7
- import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
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
+ import { claudeConfigHome } from './executor.js'
9
10
 
10
11
  export const mainCommands = [
11
12
  { command: 'new', description: 'New conversation' },
@@ -24,7 +25,7 @@ export const mainKeyboard = () => new InlineKeyboard()
24
25
  const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
25
26
 
26
27
  const matchesModel = (preset: AiPreset, model: ModelChoice) =>
27
- model.cli === preset.cli && model.provider === preset.provider && (preset.model === undefined || model.model === preset.model) &&
28
+ sameEngine(model, preset) && model.provider === preset.provider && (preset.model === undefined || model.model === preset.model) &&
28
29
  (preset.effort === undefined || model.efforts.includes(preset.effort))
29
30
 
30
31
  // Short-lived opaque button IDs: no model names or executable arguments from callbacks.
@@ -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)
@@ -64,7 +66,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
64
66
  const state = (await control.status()).ai
65
67
  if (!state) throw new Error('AI settings not initialized')
66
68
  const current = state.presets.find((p) => p.id === state.selectedId)!
67
- const fresh = current.cli !== preset.cli || presetProvider(current) !== presetProvider(preset) || Boolean(session && !session.cli)
69
+ const fresh = !sameEngine(current, preset) || presetProvider(current) !== presetProvider(preset) || Boolean(session && !session.cli)
68
70
  if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
69
71
  return { preset, fresh }
70
72
  }
@@ -125,9 +127,9 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
125
127
  const state = (await control.status()).ai
126
128
  if (!state) throw new Error('AI settings not initialized')
127
129
  const candidate: AiPreset = { id: randomBytes(8).toString('hex'),
128
- name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, model: model.model, effort }
130
+ name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, ...(model.authProfile ? {authProfile:model.authProfile} : {}), model: model.model, effort }
129
131
  const stored = persistedPreset(candidate)
130
- const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.provider === stored.provider && preset.model === stored.model && preset.effort === stored.effort)
132
+ const existing = state.presets.find((preset) => sameChoice(preset, stored))
131
133
  const preset = existing ?? candidate
132
134
  await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
133
135
  // Private scopes persist their selection on the session, not the owner menu.
@@ -1,10 +1,12 @@
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';
5
7
  import { spawn } from 'node:child_process';
6
8
  import { fileURLToPath } from 'node:url';
7
- import { prepareCommand, registry, run as dockerRun, stewardOwned } from './plugins/manager.mjs';
9
+ import { prepareCommand, registry, run as dockerRun, stewardOwned, publishBrokerPlugins } from './plugins/manager.mjs';
8
10
 
9
11
  const MAX_FRAME = 4 * 1024 * 1024;
10
12
  const MAX_ARGS = 100;
@@ -163,15 +165,7 @@ const writeReceipt = async (binding, receipt) => {
163
165
  // Installed plugin versions for relay status. Rewritten on broker start and
164
166
  // after every management call; the relay reads it without Docker access.
165
167
  // Failures stay local so a status snapshot never breaks plugin operations.
166
- const refreshBrokerPlugins = async binding => {
167
- const r = await registry(binding.home);
168
- const plugins = Object.values(r.plugins).map(record => ({ id: record.manifest.id, version: record.manifest.version }));
169
- const file = path.join(binding.controlDir, 'plugin-broker-plugins.json');
170
- const temporary = `${file}.${randomUUID()}.tmp`;
171
- await fs.writeFile(temporary, JSON.stringify({ version: 1, at: new Date().toISOString(), plugins }) + '\n', { mode: 0o600, flag: 'wx' });
172
- await fs.rename(temporary, file);
173
- await stewardOwned(file);
174
- };
168
+ const refreshBrokerPlugins = binding => publishBrokerPlugins(binding.home,binding.controlDir);
175
169
 
176
170
  const response = (socket, value) => {
177
171
  if (socket.destroyed || !socket.writable) return;
@@ -223,23 +217,38 @@ const managerCommand = async (binding, args, signal, runId) => new Promise((reso
223
217
  });
224
218
  });
225
219
 
226
- const invokePlugin = async (binding, request, signal) => {
220
+ const authorizeTaskLaunch=async(binding,request)=>{
221
+ const input=JSON.parse(request.stdin)
222
+ 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')
223
+ const ticketHash=createHash('sha256').update(input.taskToken).digest('hex')
224
+ const grant=await callDeliverySocket(deliverySocketPath(binding.controlDir),{op:'taskApplication',payload:{runId:request.runId,ticketHash}})
225
+ if(input.taskId!==grant.taskId || input.expiresAt!==grant.expiresAt)throw Error('Task launch authority mismatch')
226
+ return grant.launch
227
+ }
228
+ const invokePlugin = async (binding, request, signal, scheduledPreflight = false) => {
227
229
  const startedAt = new Date().toISOString();
228
230
  let plugin = request.alias;
229
231
  let revision = request.revision;
230
232
  let result;
231
233
  let failure;
232
234
  try {
233
- await authorize(binding, request.runId);
235
+ if (!scheduledPreflight) {
236
+ if(request.operation==='task-launch') {
237
+ const launch=await authorizeTaskLaunch(binding,request)
238
+ if(launch.command!==request.alias || JSON.stringify(launch.args)!==JSON.stringify(request.args))throw Error('Task launch command changed')
239
+ }else await authorize(binding, request.runId);
240
+ }
234
241
  const current = await registry(binding.home);
235
242
  const selected = recordForAlias(current, request.alias);
243
+ 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
244
  plugin = selected.plugin;
237
245
  revision = selected.record.revision;
238
246
  if (selected.record.revision !== request.revision) throw Error('Plugin changed; discover again');
239
247
  const command = await prepareCommand(binding.home, request.alias, request.args, {
240
248
  revision: request.revision,
241
249
  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) },
250
+ signal,
251
+ environment: { EZ_CONTROL_DIR: binding.controlDir, ...(scheduledPreflight ? {} : {EZ_RUN_ID: request.runId}), EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
243
252
  });
244
253
  try {
245
254
  result = await dockerRun(command.argv, {
@@ -282,8 +291,17 @@ const invokePlugin = async (binding, request, signal) => {
282
291
 
283
292
  export async function validateBrokerRequest(request, workspace) {
284
293
  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');
294
+ if(request.operation === 'preflight') {
295
+ validId(request.scheduleId,RUN_ID,'schedule ID'); validId(request.revision,/^[a-f0-9-]{36}$/,'schedule revision');
296
+ if(Object.keys(request).some(k=>!['version','id','operation','scheduleId','revision'].includes(k))) throw Error('Unexpected schedule preflight field');
297
+ return request;
298
+ }
299
+ if (!['resolve', 'invoke', 'manager','task-launch'].includes(request.operation)) throw Error('Unknown plugin broker operation');
286
300
  validId(request.runId, RUN_ID, 'plugin run ID');
301
+ if(request.operation==='task-launch') {
302
+ if(Object.keys(request).some(k=>!['version','id','operation','runId','stdin'].includes(k)))throw Error('Unexpected task launch field')
303
+ boundedString(request.stdin,1024,'task launch stdin');return request
304
+ }
287
305
  if (request.operation === 'manager') {
288
306
  await validateManagerArgs(request.args, workspace);
289
307
  if (Object.keys(request).some(key => !['version', 'id', 'operation', 'runId', 'args'].includes(key))) throw Error('Unexpected broker management field');
@@ -328,6 +346,18 @@ export async function servePluginBroker(binding, signal = new AbortController().
328
346
  (async () => {
329
347
  try {
330
348
  await validateBrokerRequest(request, binding.workspace);
349
+ if (request.operation === 'preflight') {
350
+ const condition=await (await import('./delivery-socket-client.mjs')).callDeliverySocket(deliverySocketPath(binding.controlDir),{op:'schedulePreflight',payload:{scheduleId:request.scheduleId,revision:request.revision}});
351
+ const values=[];
352
+ for(const check of condition.checks) {
353
+ const selected=recordForAlias(await registry(binding.home),check.alias);
354
+ const result=await invokePlugin(binding,{alias:check.alias,args:check.args,revision:selected.record.revision,runId:`preflight_${request.scheduleId}`,id:randomUUID()},abort.signal,true);
355
+ if(!result.ok || result.code!==0) throw Error('Scheduled plugin preflight unavailable');
356
+ try { values.push(JSON.parse(result.stdout)); } catch { throw Error('Scheduled plugin preflight returned invalid output'); }
357
+ }
358
+ response(socket,{version:1,id:request.id,ok:true,result:combinePreflight(values)});
359
+ return;
360
+ }
331
361
  if (request.operation === 'resolve') {
332
362
  await authorize(binding, request.runId);
333
363
  const selected = recordForAlias(await registry(binding.home), request.alias);
@@ -338,6 +368,12 @@ export async function servePluginBroker(binding, signal = new AbortController().
338
368
  response(socket, { version: 1, id: request.id, ok: true, plugin: selected.plugin, alias: request.alias, revision: selected.record.revision, capability });
339
369
  return;
340
370
  }
371
+ if(request.operation==='task-launch') {
372
+ const launch=await authorizeTaskLaunch(binding,request)
373
+ const selected=recordForAlias(await registry(binding.home),launch.command)
374
+ const result=await invokePlugin(binding,{...request,alias:launch.command,args:launch.args,revision:selected.record.revision},abort.signal)
375
+ response(socket,{version:1,id:request.id,...result});return
376
+ }
341
377
  if (request.operation === 'manager') {
342
378
  await authorize(binding, request.runId);
343
379
  const result = await managerCommand(binding, request.args, abort.signal, request.runId);
@@ -78,7 +78,8 @@ async function activeInvocations(home) {
78
78
  const directory=invocationDirectory(home),files=await fs.readdir(directory).catch(error=>{if(error.code==='ENOENT')return [];throw error;});
79
79
  for(const file of files) {
80
80
  if(!/^[0-9a-f-]{36}\.json$/.test(file))throw Error('Invalid command invocation lease; inspect before recovery');
81
- const lease=await json(path.join(directory,file));
81
+ const lease=await json(path.join(directory,file)).catch(error=>{if(error.code==='ENOENT')return null;throw error;});
82
+ if(!lease)continue;
82
83
  if(!Number.isSafeInteger(lease.pid)||lease.pid<1||typeof lease.container!=='string'||!lease.container)throw Error('Invalid command invocation lease; inspect before recovery');
83
84
  try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH'&&error.code!=='EPERM')throw error;}
84
85
  const state=await run(['container','inspect',lease.container],{capture:true});
@@ -94,14 +95,31 @@ async function invocationLease(home,container) {
94
95
  await stewardOwned(file);
95
96
  return async()=>{await fs.rm(file,{force:true});};
96
97
  }
97
- export async function locked(home, fn, {allowInvocations=false}={}) {
98
+ export async function locked(home, fn, {allowInvocations=false, waitMs=0, upgradeWaitMs=0, drainInvocations=false, signal}={}) {
98
99
  const lock = path.join(home,'registry.lock');
99
100
  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; }
101
+ const started=Date.now();
102
+ while(!handle) {
103
+ signal?.throwIfAborted();
104
+ try { handle=await fs.open(lock,'wx',0o600); }
105
+ catch(error) {
106
+ if(error.code!=='EEXIST')throw error;
107
+ const owner=await json(lock).catch(()=>null);
108
+ const limit=owner?.kind==='plugin-upgrade'&&upgradeWaitMs?upgradeWaitMs:waitMs;
109
+ if(Date.now()-started>=limit)throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager');
110
+ await new Promise(resolve=>setTimeout(resolve,25));
111
+ }
112
+ }
102
113
  try {
103
- await handle.writeFile(JSON.stringify({pid:process.pid}));
104
- if(!allowInvocations&&await activeInvocations(home))throw Error('Plugin commands are active; retry the registry or lifecycle change after they finish');
114
+ await handle.writeFile(JSON.stringify({pid:process.pid,...(drainInvocations?{kind:'plugin-upgrade'}:{})}));
115
+ // Keep admission closed while already-started calls release their leases.
116
+ // This is a lifecycle barrier, not an agent-turn drain or a command retry.
117
+ while(!allowInvocations&&await activeInvocations(home)) {
118
+ if(!drainInvocations)throw Error('Plugin commands are active; retry the registry or lifecycle change after they finish');
119
+ signal?.throwIfAborted();
120
+ await new Promise(resolve=>setTimeout(resolve,25));
121
+ }
122
+ signal?.throwIfAborted();
105
123
  return await fn();
106
124
  }
107
125
  finally { await handle.close(); await fs.rm(lock); }
@@ -467,8 +485,16 @@ export async function registry(home) {
467
485
  for(const [alias,plugin] of Object.entries(r.commands)) if(!r.plugins[plugin]?.deployment?.commands?.[alias]) throw Error('Corrupt command registry');
468
486
  return r;
469
487
  }
488
+ // Relay status reads this generated inventory without Docker/registry access.
489
+ export async function publishBrokerPlugins(home,controlDir) {
490
+ const r=await registry(home);
491
+ const plugins=Object.values(r.plugins).map(record=>({id:record.manifest.id,version:record.manifest.version}));
492
+ const file=path.join(controlDir,'plugin-broker-plugins.json');
493
+ await atomic(file,{version:1,at:new Date().toISOString(),plugins});
494
+ await stewardOwned(file);
495
+ }
470
496
  // The lock protects admission and compose refresh, never a persistent connection.
471
- export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false,environment=process.env}={}) {
497
+ export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false,environment=process.env,signal}={}) {
472
498
  strings(args);
473
499
  return locked(home,async()=>{
474
500
  const config=await json(path.join(home,'config.json')),r=await registry(home);
@@ -498,7 +524,7 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish,i
498
524
  const invocationRelease=invocation?await invocationLease(home,container):undefined;
499
525
  const release=(invocationRelease||contextFile)?(async()=>{try{if(contextFile)await fs.rm(contextFile,{force:true});}finally{await invocationRelease?.();}}):undefined;
500
526
  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});
527
+ },{allowInvocations:true,waitMs:5000,upgradeWaitMs:invocation?300000:0,signal});
502
528
  }
503
529
  export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
504
530
  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
@@ -6,7 +6,9 @@ import { normalizeReactionEmoji } from './reaction.js'
6
6
  import { validScheduledOrigin, type ScheduledOrigin } from './scheduler.js'
7
7
  import type { IncomingItem } from './inbox.js'
8
8
  import { assertId } from './identity.js'
9
- import { isExecutionChoice, type ExecutionChoice } from './ai.js'
9
+ import { isExecutionChoice, type AiPreset, type ExecutionChoice } from './ai.js'
10
+ import type { SetupAttempt } from './setup-fallback.js'
11
+ import type { ScriptRunRef } from './scripts.js'
10
12
  import {authorizeDeliveryContext,currentDeliveryOwner,type DeliveryContext} from './delivery-context.mjs'
11
13
 
12
14
  export type RunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
@@ -31,6 +33,8 @@ export type RunRecord = {
31
33
  pid?: number
32
34
  blockReason?: string
33
35
  execution?: ExecutionChoice
36
+ // Ordered task setups tried for this occurrence; the last one is active.
37
+ attempts?: SetupAttempt[]
34
38
  replyOnly?: boolean
35
39
  exitCode?: number | null
36
40
  failureReason?: string
@@ -44,6 +48,11 @@ export type RunRecord = {
44
48
  application?: ApplicationOrigin
45
49
  telegramApplication?: ApplicationOrigin
46
50
  delivery?: { bindingId: string; scope: string }
51
+ // Registered script occurrence (no execution preset or native session).
52
+ script?: ScriptRunRef
53
+ // Bounded, redacted script stdout/stderr tail; diagnostic, never delivered.
54
+ output?: string
55
+ timedOut?: boolean
47
56
  }
48
57
 
49
58
  export type OutboxItemType = 'message' | 'reaction' | 'document' | 'voice' | 'approval'
@@ -71,6 +80,9 @@ export type StoredOutboxItem = OutboxItem & { state?: 'queued' | 'sending' | 'se
71
80
  export const sentOutbox = (controlDir: string): StoredOutboxItem[] =>
72
81
  [...outboxFor(controlDir).values()].filter(item => item.state === 'sent')
73
82
 
83
+ // Explicit operational evidence reads; the projection verifies owner/run scope.
84
+ export const retainedOutbox = (controlDir: string): StoredOutboxItem[] => [...outboxFor(controlDir).values()]
85
+
74
86
  // Stateless pipe: the relay owns the ledger in process memory. Cross-process
75
87
  // producers (engine children, plugin children, host executor) reach it through
76
88
  // the delivery socket (see delivery-socket.ts), never through control/ files.
@@ -120,6 +132,14 @@ const pruneTerminals = (controlDir: string): void => {
120
132
  }
121
133
  }
122
134
 
135
+ // The setup the current attempt runs with: the latest recorded attempt, else the
136
+ // saved choice. Only a setup saved on the task itself is ever admitted.
137
+ export const activeSetup = (run: Pick<RunRecord, 'attempts' | 'execution'>): AiPreset | undefined => {
138
+ if (!run.execution) return undefined
139
+ const preset = [...run.attempts ?? []].reverse().find(attempt => attempt.outcome !== 'skipped')?.preset ?? run.execution.preset
140
+ return [run.execution.preset, ...run.execution.fallbacks ?? []].find(candidate => JSON.stringify(candidate) === JSON.stringify(preset))
141
+ }
142
+
123
143
  export const newRunId = (): string => `r_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
124
144
 
125
145
  export class RunStore {
@@ -141,6 +161,7 @@ export class RunStore {
141
161
  taskId?: string
142
162
  application?: ApplicationOrigin
143
163
  delivery?: { bindingId: string; scope: string }
164
+ script?: ScriptRunRef
144
165
  }, exclusiveSchedule = false): Promise<RunRecord> {
145
166
  const store = runsFor(this.controlDir)
146
167
  if (input.id) {
@@ -172,6 +193,7 @@ export class RunStore {
172
193
  application: input.application,
173
194
  delivery: input.delivery,
174
195
  scheduled: input.scheduled,
196
+ ...(input.script ? { script: input.script } : {}),
175
197
  status: 'queued',
176
198
  createdAt: new Date().toISOString(),
177
199
  }
@@ -187,7 +209,7 @@ export class RunStore {
187
209
 
188
210
  async patch(
189
211
  id: string,
190
- change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased'>>,
212
+ change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut' | 'attempts'>>,
191
213
  ): Promise<RunRecord> {
192
214
  const store = runsFor(this.controlDir)
193
215
  const run = store.get(id)
@@ -1,38 +1,80 @@
1
1
  import { needsFailureReview, failureStamp, redactFailure } from './failure.js'
2
2
  import { parseArgs } from 'node:util'
3
3
  import { readFile } from 'node:fs/promises'
4
+ import { resolve } from 'node:path'
4
5
  import { randomUUID } from 'node:crypto'
5
6
  import { loadControlConfig } from './config.js'
6
7
  import { ControlStore, sameOwner } from './control-state.js'
7
8
  import { ApplicationBindings } from './application-channel.js'
8
9
  import type { RunRecord } from './runs.js'
9
10
  import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
10
- import { initialPreset, isPreset } from './ai.js'
11
+ import { initialPreset, isPreset, presetLabel, MAX_FALLBACKS, type AiPreset } from './ai.js'
11
12
  import { executionOverrides } from './model-policy.js'
12
- import { holdsSchedule, Scheduler } from './scheduler.js'
13
+ import { holdsSchedule, Scheduler, executionType } from './scheduler.js'
14
+ import { Scripts, fileSha256, workspaceEntry, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
15
+ import { executorEnvironment } from './executor.js'
16
+ import { provisionedHome } from './auth-profile.js'
13
17
  import { ownsRun } from './identity.js'
14
18
  import { type Trigger } from './schedule-time.js'
15
19
 
20
+ // `cli=claude,model=sonnet,effort=medium` names one fallback setup.
21
+ const fallbackSetup = (spec: string, index: number): AiPreset => {
22
+ const fields = Object.fromEntries(spec.split(',').map(part => {
23
+ const at = part.indexOf('=')
24
+ if (at < 1) throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
25
+ return [part.slice(0, at), part.slice(at + 1)]
26
+ }))
27
+ if (Object.keys(fields).some(key => !['cli','model','effort','provider'].includes(key)) || !fields.cli || !fields.model)
28
+ throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
29
+ const base = initialPreset(fields.cli)
30
+ const preset = executionOverrides(base.cli, {...base, ...(fields.provider ? {provider: fields.provider} : {})}, fields.model, fields.effort)
31
+ const named = {...preset, id: `fallback-${index + 1}`, name: presetLabel(preset).slice(0, 80)}
32
+ if (!isPreset(named)) throw new Error('Invalid fallback setup')
33
+ return named
34
+ }
35
+
16
36
  async function main() {
17
- const { values:v, positionals:[action='list',id] } = parseArgs({allowPositionals:true,options:{
18
- cli:{type:'string'}, model:{type:'string'}, effort:{type:'string'},
19
- all:{type:'boolean'}, limit:{type:'string'}, key:{type:'string'}, when:{type:'string'}, status:{type:'string'}, diagnosis:{type:'string'}, recovery:{type:'string'}, outcome:{type:'string'}, 'failed-at':{type:'string'},
20
- name:{type:'string'}, text:{type:'string'}, 'text-file':{type:'string'}, at:{type:'string'}, now:{type:'boolean'},
37
+ const { values:v, positionals:[action='list',id,scriptId] } = parseArgs({allowPositionals:true,options:{
38
+ script:{type:'string'}, arg:{type:'string',multiple:true}, file:{type:'string'}, interpreter:{type:'string'}, 'timeout-seconds':{type:'string'},
39
+ cli:{type:'string'}, 'auth-profile':{type:'string'}, model:{type:'string'}, effort:{type:'string'}, fallback:{type:'string',multiple:true}, 'clear-fallbacks':{type:'boolean'},
40
+ all:{type:'boolean'}, limit:{type:'string'}, offset:{type:'string'}, expected:{type:'string'}, key:{type:'string'}, when:{type:'string'}, status:{type:'string'}, diagnosis:{type:'string'}, recovery:{type:'string'}, outcome:{type:'string'}, 'failed-at':{type:'string'},
41
+ 'preflight-file':{type:'string'},'clear-preflight':{type:'boolean'},name:{type:'string'}, text:{type:'string'}, 'text-file':{type:'string'}, at:{type:'string'}, now:{type:'boolean'},
21
42
  cron:{type:'string'}, timezone:{type:'string'}, 'every-seconds':{type:'string'}, start:{type:'string'}, until:{type:'string'}, help:{type:'boolean'},
22
43
  }})
23
44
  if(v.help){console.log(`ezenciel-agents-schedule list | runs | show ID | pause ID | resume ID | remove ID | cancel RUN_ID
24
45
  failures [--all] [--limit N] | run RUN_ID | context
46
+ evidence [RUN_ID] [--offset N --limit N --expected SNAPSHOT_SHA256]
25
47
  trigger SCHEDULE_ID --key REQUEST_KEY
26
48
  review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT
27
- create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE)
49
+ create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE | --script SCRIPT_ID [--arg=VALUE ...])
28
50
  --now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
29
- [--cli EXECUTOR] [--model MODEL] [--effort <native-effort>]
51
+ [--cli EXECUTOR] [--auth-profile NAME] [--model MODEL] [--effort <native-effort>]
52
+ [--fallback cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID] ... | --clear-fallbacks]
53
+ [--preflight-file FILE | --clear-preflight]
30
54
  [--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
55
+ script list | show SCRIPT_ID | remove SCRIPT_ID
56
+ script register|update SCRIPT_ID --file WORKSPACE_PATH --interpreter COMMAND [--arg=VALUE ...] [--timeout-seconds N]
57
+ Execution types: agent (prompt, engine, auth profile, model, effort) or script (registered script ID and saved arguments).
58
+ --auth-profile runs a Codex/Claude task under that provisioned login (ezenciel-agents-ai profiles); --cli alone uses the default login.
59
+ A script registration references an entry point inside the agent workspace and records its SHA-256. Core invokes
60
+ the installed interpreter with the entry point and arguments directly, without a shell or a model. Changed entry-point
61
+ bytes refuse to run until an explicit script update. The hash covers that file only, not imported dependencies.
62
+ Script runs receive EZ_RUN_ID, EZ_SCHEDULE_ID, EZ_SCHEDULE_REVISION, EZ_DUE_AT, EZ_SCRIPT_ID, EZ_SCRIPT_REVISION and
63
+ EZ_SCRIPT_SHA256 plus the normal executor allowlist. Stdout/stderr are bounded diagnostics in run, never owner messages.
64
+ Script runs have a timeout (default ${DEFAULT_SCRIPT_TIMEOUT_SECONDS}s) and are never retried automatically.
31
65
  Context reads the current run only.
66
+ Evidence exports sanitized current-owner Telegram operational metadata only.
67
+ Follow nextOffset with --expected; a changed snapshot requires restarting.
68
+ Relay retention is incomplete. Content hashes identify submitted text, not recipient content.
69
+ Native sessions, tool/delegation events and failure/review prose are not exported.
32
70
  Failures default to unreviewed owner runs. Review records a diagnosis; it never changes execution status or retries work.
33
71
  A conditional review schedule consumes no model run when there are no unreviewed failures.
34
72
  New tasks capture the selected engine settings; edit preserves existing settings unless overridden.
35
73
  Every task requires a concrete saved model. Supply --model if the selected settings have none; existing tasks never fall back to chat or client defaults.
74
+ Fallbacks (at most ${MAX_FALLBACKS}, in order) run only when the previous setup's CLI is unavailable or its provider
75
+ rejected the turn before any work began. Access denial, unknown errors, cancellation and failure after work began stop
76
+ the chain; nothing is replayed. A quota rejection skips later setups of the same client or provider (quota scope),
77
+ even with different credentials. The run records each attempt, setup, failure category and any reported reset time.
36
78
  Trigger runs an existing task once with its saved instructions, AI and delivery binding in a fresh session. Reuse the request key after an uncertain result; the regular schedule is unchanged.
37
79
  Creates a scheduled task. Instructions are text, never shell commands.
38
80
  Use --now to run once. Run completion is not delivery proof.
@@ -66,6 +108,10 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
66
108
  if(process.env.EZ_RUN_ID && (!caller || caller.status!=='running' || caller.external || caller.taskId || caller.replyOnly ||
67
109
  !ownsRun(owner, caller) ||
68
110
  (caller.scheduled && caller.scheduled.pairedAt!==owner.pairedAt)))throw new Error('Scheduling requires an active owner-authorized run')
111
+ // A script occurrence may read state and deliver through bound plugins; it
112
+ // cannot change registrations or schedules, including its own.
113
+ const readOnly=['list','runs','show','run','context','failures','evidence'].includes(action) || (action==='script' && ['list','show'].includes(id ?? ''))
114
+ if(caller?.script && !readOnly)throw new Error('Script runs cannot change scripts or schedules')
69
115
  const owned=(s:{owner:typeof owner})=>sameOwner(s.owner,owner)
70
116
  const ownsFailureRun=(r:RunRecord | null)=>r && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt)
71
117
  const show=async(s:Awaited<ReturnType<Scheduler['get']>>)=>{
@@ -73,11 +119,38 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
73
119
  const interruptedRunIds=held.filter(r=>r.interrupted).map(r=>r.id)
74
120
  const failedReviewRunIds=held.filter(r=>!r.interrupted).map(r=>r.id)
75
121
  const next=s.enabled && !held.length ? await scheduler.pendingOccurrence(s) : null
76
- return {...s,interruptedRunIds,failedReviewRunIds,nextEligibleAt:next===null ? null : new Date(next).toISOString(),
122
+ return {...s,executionType:executionType(s),preflightReceipt:await scheduler.preflightReceipt(s),interruptedRunIds,failedReviewRunIds,nextEligibleAt:next===null ? null : new Date(next).toISOString(),
77
123
  ...(held.length ? {recovery:'Inspect the failed run and explicitly edit this schedule to resume; pause/resume does not clear the stop.'} : {})}
78
124
  }
125
+ const scripts=new Scripts(config.controlDir)
79
126
  let result:unknown
80
- if(action==='context'){
127
+ if(action==='script'){
128
+ const workspace=process.env.EZ_AGENT_WORKSPACE?.trim() || process.cwd()
129
+ if(id==='list')result=await scripts.list(owner)
130
+ else if(!scriptId)throw new Error('Script ID required')
131
+ else if(id==='show'){
132
+ const registration=await scripts.owned(scriptId,owner)
133
+ let current:string|null=null
134
+ try{current=await fileSha256((await workspaceEntry(workspace,registration.entry)).file)}catch{}
135
+ result={...registration,currentSha256:current,matchesRegistration:current===registration.sha256,
136
+ schedules:(await scheduler.list()).filter(s=>owned(s) && s.script?.id===scriptId).map(s=>s.id)}
137
+ }else if(id==='remove'){
138
+ const users=(await scheduler.list()).filter(s=>owned(s) && s.script?.id===scriptId).map(s=>s.id)
139
+ if(users.length)throw new Error(`Script is used by schedules ${users.join(', ')}; edit or remove them first`)
140
+ await scripts.remove(scriptId,owner);result={removed:scriptId}
141
+ }else if(id==='register' || id==='update'){
142
+ const previous=id==='update' ? await scripts.owned(scriptId,owner) : undefined
143
+ if(id==='register' && (!v.file || !v.interpreter))throw new Error('Register requires --file and --interpreter')
144
+ const timeout=v['timeout-seconds']===undefined ? previous?.timeoutSeconds ?? DEFAULT_SCRIPT_TIMEOUT_SECONDS : Number(v['timeout-seconds'])
145
+ result=await scripts.save({id:scriptId,owner,workspace,entry:v.file ? resolve(v.file) : previous!.entry,interpreter:v.interpreter ?? previous!.interpreter,
146
+ args:v.arg ?? previous?.args ?? [],timeoutSeconds:timeout,pathValue:executorEnvironment().PATH},id==='register')
147
+ }else throw new Error('Unknown script action; use --help')
148
+ }else if(action==='evidence'){
149
+ result=await callDeliverySocket(socketPath,{op:'evidence',payload:{
150
+ ...(caller ? {callerRunId:caller.id} : {}), ...(id ? {runId:id} : {}),
151
+ offset:Number(v.offset ?? 0),limit:Number(v.limit ?? 100),expected:v.expected,
152
+ }})
153
+ }else if(action==='context'){
81
154
  if(!caller)throw new Error('Context requires an active owner run')
82
155
  const origin=caller.scheduled?.originRunId ? await runs.get(caller.scheduled.originRunId) : null
83
156
  if(origin && !ownsFailureRun(origin))throw new Error('Source context is outside this owner binding')
@@ -108,23 +181,37 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
108
181
  if(action==='edit' && (!id || !owned(await scheduler.get(id))))throw new Error('Unknown schedule')
109
182
  if(action==='create' && id && (await scheduler.list()).some(s=>s.id===id))throw new Error('Schedule exists; use edit')
110
183
  if([v.now,v.at,v.cron,v['every-seconds']].filter(Boolean).length!==1)throw new Error('Choose exactly one trigger')
111
- if(Boolean(v.text)===Boolean(v['text-file']))throw new Error('Choose --text or --text-file')
184
+ if([v.text,v['text-file'],v.script].filter(Boolean).length!==1)throw new Error('Choose --text, --text-file or --script')
185
+ if(v.script && (v.cli || v['auth-profile'] || v.model || v.effort || v.fallback || v['clear-fallbacks']))throw new Error('Script schedules run no model; --cli, --auth-profile, --model, --effort and --fallback do not apply')
186
+ if(v.fallback && v['clear-fallbacks'])throw new Error('Choose --fallback or --clear-fallbacks')
187
+ if(v.arg && !v.script)throw new Error('--arg applies to script schedules only')
112
188
  const start=v.start || new Date(Date.now()+1000).toISOString()
113
189
  const trigger:Trigger=v.now ? {at:new Date(Date.now()+1000).toISOString()} : v.at ? {at:v.at} :
114
190
  v.cron ? {cron:v.cron,timezone:v.timezone!,start,until:v.until} : {everySeconds:Number(v['every-seconds']),start,until:v.until}
191
+ if(v['clear-preflight'] && v['preflight-file']) throw Error('Choose one preflight operation')
115
192
  const previousSchedule = action === 'edit' ? await scheduler.get(id!) : undefined
193
+ const preflight = v['clear-preflight'] ? undefined : v['preflight-file'] ? JSON.parse(await readFile(v['preflight-file'],'utf8')) : previousSchedule?.preflight
116
194
  const origin = caller?.application ?? caller?.delivery
117
195
  const delivery = previousSchedule ? previousSchedule.delivery : (origin ? {bindingId:origin.bindingId,scope:origin.scope} : undefined)
118
196
  if (!delivery && !owner.telegramChatId) throw new Error('Create the schedule from an authenticated channel turn to bind its reply destination')
197
+ if (v.script) {
198
+ result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || v.script,preflight,
199
+ originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
200
+ script:{id:v.script,args:v.arg ?? []}},action==='create'))
201
+ console.log(JSON.stringify(result,null,2));return
202
+ }
119
203
  const previous = previousSchedule?.execution
120
204
  const state = await control.status()
121
205
  const selected = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
122
206
  const base = v.cli ? initialPreset(v.cli) : previous?.preset || selected || initialPreset(process.env.EZ_EXECUTOR_CLI || 'codex')
123
- const preset = executionOverrides(base.cli, base, v.model, v.effort)
207
+ const preset = executionOverrides(base.cli, v['auth-profile'] === undefined ? base : {...base, authProfile:v['auth-profile']}, v.model, v.effort)
124
208
  if (!isPreset(preset)) throw new Error('Invalid task AI selection')
209
+ const fallbacks = v['clear-fallbacks'] ? undefined : v.fallback ? v.fallback.map(fallbackSetup) : previous?.fallbacks
210
+ if (preset.authProfile !== undefined) await provisionedHome(config.controlDir, preset.cli, preset.authProfile)
125
211
  result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
212
+ preflight,
126
213
  originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
127
- execution:{sessionId:previous?.sessionId || randomUUID(),preset}},action==='create'))
214
+ execution:{sessionId:previous?.sessionId || randomUUID(),preset,...(fallbacks ? {fallbacks} : {})}},action==='create'))
128
215
  }else{
129
216
  if(!id)throw new Error('ID required')
130
217
  if(action==='cancel'){