@jc_stack/ez-agents 0.1.0-beta.47 → 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 (72) hide show
  1. package/.env.example +3 -0
  2. package/AGENTS.md +2 -2
  3. package/CHANGELOG.md +21 -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 +86 -0
  9. package/docker/run.ts +1 -1
  10. package/docs/application-channel.md +32 -1
  11. package/docs/architecture/ai-selection.md +18 -3
  12. package/docs/docker-runtime.md +29 -0
  13. package/docs/isolated-plugin-update-readiness.md +66 -0
  14. package/docs/releasing.md +4 -0
  15. package/docs/scheduling.md +100 -6
  16. package/docs/selective-monitoring.md +13 -0
  17. package/docs/upgrades.md +35 -0
  18. package/docs/work-preflight.md +9 -0
  19. package/package.json +1 -1
  20. package/src/ai.ts +22 -3
  21. package/src/application-channel.ts +43 -3
  22. package/src/application-cli.ts +7 -1
  23. package/src/application-origin.ts +2 -0
  24. package/src/audio.ts +37 -22
  25. package/src/client-defaults.ts +3 -2
  26. package/src/config.ts +9 -0
  27. package/src/control-state.ts +3 -1
  28. package/src/delivery-socket-client.d.mts +2 -0
  29. package/src/delivery-socket-client.mjs +2 -0
  30. package/src/delivery-socket.ts +24 -1
  31. package/src/event-sources.ts +2 -2
  32. package/src/executor.ts +72 -1
  33. package/src/files.ts +47 -3
  34. package/src/host-executor.ts +38 -29
  35. package/src/index.ts +41 -18
  36. package/src/install-tools.mjs +8 -5
  37. package/src/menu.ts +4 -2
  38. package/src/plugin-broker.mjs +47 -4
  39. package/src/plugins/manager.mjs +11 -4
  40. package/src/run-evidence.ts +78 -0
  41. package/src/runs.ts +12 -1
  42. package/src/schedule-cli.ts +69 -8
  43. package/src/schedule-preflight.ts +23 -0
  44. package/src/scheduled-tasks.ts +16 -12
  45. package/src/scheduler.ts +79 -18
  46. package/src/scripts.ts +167 -0
  47. package/src/setup.ts +2 -2
  48. package/src/task-capability.ts +17 -1
  49. package/src/task-executor.ts +10 -7
  50. package/src/task-mcp.ts +20 -8
  51. package/src/tasks.ts +89 -9
  52. package/src/updates/runtime.mjs +46 -7
  53. package/test/ai.test.ts +40 -0
  54. package/test/application-channel.test.ts +16 -1
  55. package/test/audio.test.ts +19 -0
  56. package/test/config.test.ts +8 -0
  57. package/test/executor.test.ts +37 -0
  58. package/test/files.test.ts +44 -0
  59. package/test/host-executor.test.ts +45 -2
  60. package/test/install-json.test.mjs +21 -0
  61. package/test/plugin-broker.test.mjs +64 -0
  62. package/test/plugin-manager.test.mjs +20 -0
  63. package/test/private-speech-config.test.ts +22 -0
  64. package/test/run-evidence.test.ts +78 -0
  65. package/test/schedule-preflight.test.ts +49 -0
  66. package/test/scheduler-host.test.ts +7 -0
  67. package/test/scheduler-relay.test.ts +38 -3
  68. package/test/scheduler.test.ts +1 -1
  69. package/test/script-schedules.test.ts +219 -0
  70. package/test/task-native.test.ts +50 -12
  71. package/test/tasks.test.ts +97 -4
  72. package/test/updates.test.mjs +45 -9
package/src/executor.ts CHANGED
@@ -11,6 +11,7 @@ import { createInterface } from 'node:readline'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
13
13
  import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
14
+ import { Scripts, type ScriptRunRef } from './scripts.js'
14
15
 
15
16
  export type ExecutorOptions = {
16
17
  repairEnabled?: boolean
@@ -38,8 +39,12 @@ export type ExecutorOptions = {
38
39
  codexAutoCompactTokens?: number
39
40
  codexProvider?: CodexProviderBinding
40
41
  onSession?: (id: string) => Promise<void>
42
+ // Registered script occurrence: invoke it directly, never a native CLI.
43
+ script?: ScriptLaunch
41
44
  }
42
45
 
46
+ export type ScriptLaunch = ScriptRunRef & { scheduleId: string; scheduleRevision: string; dueAt: string }
47
+
43
48
  export type CodexProviderBinding = {
44
49
  id: string
45
50
  name: string
@@ -71,7 +76,7 @@ export const executorEnvironment = (environment: NodeJS.ProcessEnv = process.env
71
76
  }
72
77
 
73
78
  export const executorJobEnv = (
74
- options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'>,
79
+ options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'> & { workspace?: string },
75
80
  environment: NodeJS.ProcessEnv = process.env,
76
81
  ): NodeJS.ProcessEnv => {
77
82
  const base = executorEnvironment(environment)
@@ -81,6 +86,7 @@ export const executorJobEnv = (
81
86
  PATH: pathValue,
82
87
  EZ_RUN_ID: options.runId,
83
88
  EZ_CONTROL_DIR: options.controlDir,
89
+ ...(options.workspace ? { EZ_AGENT_WORKSPACE: options.workspace } : {}),
84
90
  EZ_DELIVERY_SOCKET: deliverySocketPath(options.controlDir),
85
91
  EZ_REPAIR_ENABLED: String(options.repairEnabled !== false),
86
92
  ...(options.toolsHome ? {BUILDX_CONFIG:path.join(options.toolsHome,'buildx')} : {}),
@@ -155,6 +161,8 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
155
161
  args.push('--session-id', opts.sessionId)
156
162
  }
157
163
  args.push('--print', '--dangerously-skip-permissions')
164
+ for (const directory of [opts.controlDir, opts.sharedWorkspace, ...(opts.additionalWorkspaces ?? []), opts.toolsHome])
165
+ if (directory) args.push('--add-dir', directory)
158
166
  if (opts.model) args.push('--model', opts.model)
159
167
  if (opts.effort) args.push('--effort', opts.effort)
160
168
  return args
@@ -285,6 +293,9 @@ export const opencodeDataHome = (controlDir: string): string | undefined => {
285
293
  } catch { return undefined }
286
294
  }
287
295
 
296
+ // Agent-bound Claude Code configuration directory (CLAUDE_CONFIG_DIR).
297
+ export const claudeConfigHome = (controlDir: string): string => path.join(controlDir, 'cli', 'claude')
298
+
288
299
  // Pi resolves its provider/model catalog from an agent dir (settings.json,
289
300
  // auth.json). Only a path is ever returned, never a secret.
290
301
  export const piAgentDir = (controlDir: string): string | undefined => {
@@ -323,6 +334,7 @@ export const startExecutorJob = async (
323
334
  texts: string[],
324
335
  options: ExecutorOptions,
325
336
  ): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
337
+ if (options.script && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
326
338
  options = executionDefaults(executorKey(options.cli), options)
327
339
  // Routing is caller-owned: a restricted task run goes to the task runner
328
340
  // unless the host transport must ship it across the boundary first.
@@ -387,6 +399,32 @@ export const startExecutorJob = async (
387
399
  }
388
400
  environment.CODEX_HOME = home
389
401
  }
402
+ if (!host && !gui && key === 'claude') {
403
+ // Mirror the Codex binding: agent-bound settings, memory, plugins and
404
+ // sessions; never the user's personal ~/.claude configuration.
405
+ const home = claudeConfigHome(options.controlDir)
406
+ await mkdir(home, {recursive:true,mode:0o700})
407
+ environment.CLAUDE_CONFIG_DIR = home
408
+ // Host-capable agents share the installer login only. An empty secure
409
+ // storage dir makes Claude use its default credential store (macOS
410
+ // Keychain entry or ~/.claude/.credentials.json) in place, so token
411
+ // refreshes stay shared rather than diverging through a copy. Isolated
412
+ // agents keep their own login inside CLAUDE_CONFIG_DIR. The host executor
413
+ // runs unlabelled, so this matches the Codex auth-link check, not
414
+ // resolveIsolation().
415
+ if (process.env.EZ_ISOLATION !== 'isolated') environment.CLAUDE_SECURESTORAGE_CONFIG_DIR = ''
416
+ // Optional owner-provisioned long-lived token (`claude setup-token`). It
417
+ // never refreshes, so one file can be copied to every agent without the
418
+ // agents invalidating each other's OAuth session.
419
+ const token = await readFile(path.join(home, 'oauth-token'), 'utf8').then(value => value.trim(), (error: NodeJS.ErrnoException) => {
420
+ if (error.code === 'ENOENT') return undefined
421
+ throw error
422
+ })
423
+ if (token !== undefined) {
424
+ if (!/^[A-Za-z0-9._-]{20,512}$/.test(token)) throw new Error('Invalid Claude token binding')
425
+ environment.CLAUDE_CODE_OAUTH_TOKEN = token
426
+ }
427
+ }
390
428
  if (!host && !gui && key === 'opencode') {
391
429
  // Point OpenCode at the agent-bound data home (cli/opencode/auth.json)
392
430
  // when the owner provisioned it. Host runs keep the installer login;
@@ -459,6 +497,39 @@ export const startExecutorJob = async (
459
497
 
460
498
  export const startGrokJob = startExecutorJob
461
499
 
500
+ // Spawn a verified registered script with the executor identity, environment
501
+ // allowlist and workspace confinement. No shell, model, prompt or native session.
502
+ export const startScriptJob = async (
503
+ options: ExecutorOptions & { script: ScriptLaunch },
504
+ ): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
505
+ const { script } = options
506
+ for (const value of [options.runId, script.scheduleId, script.scheduleRevision]) if (!/^[a-zA-Z0-9_-]+$/.test(value)) throw new Error('Invalid script run binding')
507
+ if (!Number.isFinite(Date.parse(script.dueAt))) throw new Error('Invalid script due time')
508
+ const environment: NodeJS.ProcessEnv = {
509
+ ...executorJobEnv(options),
510
+ EZ_SCHEDULE_ID: script.scheduleId, EZ_SCHEDULE_REVISION: script.scheduleRevision, EZ_DUE_AT: script.dueAt,
511
+ EZ_SCRIPT_ID: script.id, EZ_SCRIPT_REVISION: script.revision, EZ_SCRIPT_SHA256: script.sha256,
512
+ }
513
+ const verified = await new Scripts(options.controlDir).verifiedInvocation(script, options.workspace, environment.PATH)
514
+ let invocation = executorInvocation(verified.command, verified.args)
515
+ let profileDirectory: string | undefined
516
+ const denies = process.platform === 'darwin' ? workspaceSiblingDenies(options.workspace) : []
517
+ if (denies.length) {
518
+ profileDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
519
+ const profile = path.join(profileDirectory, 'workspace.sb')
520
+ await writeFile(profile, macosWorkspaceProfile(denies, [options.controlDir, options.binDir, options.toolsHome, options.sharedWorkspace,
521
+ ...(options.additionalWorkspaces ?? [])].filter((value): value is string => Boolean(value))), { mode: 0o600 })
522
+ invocation = { command: 'sandbox-exec', args: ['-f', profile, invocation.command, ...invocation.args] }
523
+ }
524
+ const cleanup = async () => { if (profileDirectory) await rm(profileDirectory, { recursive: true, force: true }) }
525
+ const child = spawn(invocation.command, invocation.args, {
526
+ cwd: options.workspace, env: environment, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', shell: false,
527
+ })
528
+ await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
529
+ .catch(async (error) => { await cleanup(); throw error })
530
+ return { child, cleanup, stdout: '' }
531
+ }
532
+
462
533
  // Structured client events only. Model text is never interpreted or sent to chat.
463
534
  export const nativeSessionId = (cli: string, line: string): string | undefined => {
464
535
  try {
package/src/files.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { mkdir, readFile, writeFile, realpath, stat } from 'node:fs/promises'
1
+ import { mkdir, readFile, writeFile, realpath, stat, mkdtemp, readdir, rm, unlink } from 'node:fs/promises'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
2
4
  import { randomUUID } from 'node:crypto'
3
5
  import path from 'node:path'
4
6
 
5
- export type DetectedFileType = 'pdf' | 'jpeg' | 'png' | 'webp' | 'text' | 'unknown'
7
+ export type DetectedFileType = 'pdf' | 'jpeg' | 'png' | 'webp' | 'text' | 'mp4' | 'unknown'
6
8
 
7
9
  export const detectFileType = (buffer: Buffer): DetectedFileType => {
8
10
  if (buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
@@ -31,6 +33,9 @@ export const detectFileType = (buffer: Buffer): DetectedFileType => {
31
33
  ) {
32
34
  return 'webp'
33
35
  }
36
+ // ISO-BMFF also carries HEIF/AVIF stills and audio-only M4A; only video brands are MP4.
37
+ if (buffer.length >= 12 && buffer.subarray(4,8).toString('ascii') === 'ftyp' &&
38
+ !/^(hei[cmsx]|hev[cmsx]|mif1|msf1|avi[fs]|M4[ABP] |F4A )$/.test(buffer.subarray(8,12).toString('latin1'))) return 'mp4'
34
39
  try {
35
40
  new TextDecoder('utf-8', { fatal: true }).decode(buffer)
36
41
  if (!buffer.includes(0)) return 'text'
@@ -108,7 +113,46 @@ export const stageChatAttachment = async (stagingRoot: string, name: string, byt
108
113
  if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || type === 'unknown' ||
109
114
  (type === 'text' && !/\.(txt|md|markdown)$/i.test(name))) throw new Error('Invalid application attachment: unsupported type or size')
110
115
  const staged = await stageIncomingFile(stagingRoot, name, bytes)
111
- const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : 'document'
116
+ const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : staged.fileType === 'mp4' ? 'video' : 'document'
112
117
  return { text: `[Attached ${kind} staged at ${staged.fullPath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
113
118
  attachment: {path: staged.fullPath, type: staged.fileType}, fullPath: staged.fullPath }
114
119
  }
120
+
121
+ // Shared inbound staging/validation with Telegram; restricted correspondence
122
+ // can read only this already-authorized input, never arbitrary workspace paths.
123
+ export async function readChatAttachment(stagingRoot: string, name: string, bytes: Buffer) {
124
+ const staged = await stageChatAttachment(stagingRoot, name, bytes, '')
125
+ // Untrusted media parsers never inherit relay secrets.
126
+ const execute = (command: string, args: string[], options: {timeout:number;maxBuffer:number}) =>
127
+ promisify(execFile)(command, args, {...options, env: {PATH: process.env.PATH ?? '/usr/bin:/bin'}})
128
+ try {
129
+ if (['jpeg','png','webp'].includes(staged.attachment.type)) return {name:sanitizeFileName(name),type:staged.attachment.type,bytes:bytes.length,image:{data:bytes.toString('base64'),mimeType:`image/${staged.attachment.type}`}}
130
+ if (staged.attachment.type === 'mp4') {
131
+ const directory = await mkdtemp(path.join(stagingRoot,'video-'))
132
+ try {
133
+ const probe = JSON.parse((await execute('ffprobe',['-v','error','-protocol_whitelist','file,pipe','-show_entries','format=duration:stream=codec_type','-of','json',staged.fullPath],{timeout:3000,maxBuffer:8000})).stdout)
134
+ const durationSeconds = Number(probe.format?.duration)
135
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0 || durationSeconds > 120 || !probe.streams?.some((stream:{codec_type:string})=>stream.codec_type==='video')) throw Error('Video must contain a visual track and be at most 120 seconds')
136
+ await execute('ffmpeg',['-v','error','-nostdin','-protocol_whitelist','file,pipe','-enable_drefs','0','-use_absolute_path','0','-i',staged.fullPath,'-an','-vf',`fps=fps=${8/durationSeconds}:start_time=0,scale=640:-2`,'-frames:v','8','-q:v','3',path.join(directory,'frame-%02d.jpg')],{timeout:20000,maxBuffer:8000})
137
+ const names=(await readdir(directory)).filter(name=>/^frame-\d{2}\.jpg$/.test(name)).sort()
138
+ const images=[];let total=0
139
+ for (const frame of names) {
140
+ const image=await readFile(path.join(directory,frame));total+=image.length
141
+ if (total>1024*1024 || detectFileType(image)!=='jpeg') throw Error('Video frames exceed the bounded image limit')
142
+ images.push({data:image.toString('base64'),mimeType:'image/jpeg'})
143
+ }
144
+ if (!images.length) throw Error('Video has no decodable frames')
145
+ return {name:sanitizeFileName(name),type:'mp4',bytes:bytes.length,durationSeconds,frames:images.map((_,index)=>({frame:index+1,seconds:index*durationSeconds/8})),images}
146
+ } finally { await rm(directory,{recursive:true,force:true}) }
147
+ }
148
+ const limit = 256000
149
+ const text = staged.attachment.type === 'text' ? new TextDecoder('utf-8', {fatal:true}).decode(bytes)
150
+ : (await execute('pdftotext', ['-enc','UTF-8','-layout',staged.fullPath,'-'], {timeout:20000,maxBuffer:limit + 1}).catch(error => {
151
+ if ((error as {code?:unknown}).code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') throw Error('Extracted document exceeds the 256000-byte text limit')
152
+ throw error
153
+ })).stdout
154
+ if (!text.trim()) throw Error('Document has no extractable text; scanned PDFs require OCR')
155
+ if (Buffer.byteLength(text) > limit) throw Error('Extracted document exceeds the 256000-byte text limit')
156
+ return { name: sanitizeFileName(name), type: staged.attachment.type, bytes: bytes.length, text }
157
+ } finally { await unlink(staged.fullPath) }
158
+ }
@@ -52,7 +52,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
52
52
  let catalogRefresh: Promise<void> | undefined
53
53
  const locks: string[] = []
54
54
  const sharedWorkspaces = new Map<HostBinding, string>()
55
- const activeWorkspaces = new Map<HostBinding, { lanes: Set<boolean>; release?: () => Promise<void> }>()
55
+ const activeWorkspaces = new Map<HostBinding, { lanes: Map<string, boolean>; release?: () => Promise<void> }>()
56
56
  const additionalWorkspaces = new Map<HostBinding, string[]>()
57
57
  const catalog = async (agent: HostBinding) => {
58
58
  const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
@@ -161,18 +161,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
161
161
  const base=path.join(directory,id)
162
162
  const scheduled = Boolean(run?.scheduled)
163
163
  let workspace = activeWorkspaces.get(agent)
164
- if (workspace?.lanes.has(scheduled)) continue
164
+ if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
165
165
  if (!workspace) {
166
166
  const release = agent.toolsHome
167
167
  ? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
168
168
  if (agent.toolsHome && !release) continue
169
- workspace = { lanes: new Set(), release }
169
+ workspace = { lanes: new Map(), release }
170
170
  activeWorkspaces.set(agent, workspace)
171
171
  }
172
- workspace.lanes.add(scheduled)
173
- // Keep plugin workspace invocations excluded until both streams finish.
172
+ workspace.lanes.set(id, scheduled)
173
+ // Keep plugin workspace invocations excluded until all native jobs finish.
174
174
  const releaseWorkspace = async () => {
175
- workspace.lanes.delete(scheduled)
175
+ workspace.lanes.delete(id)
176
176
  if (!workspace.lanes.size) {
177
177
  activeWorkspaces.delete(agent)
178
178
  await workspace.release?.()
@@ -195,30 +195,39 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
195
195
  await new Tasks(agent.controlDir).authorize(run, false)
196
196
  } else await authorizeRun(agent.controlDir, path.basename(base))
197
197
  const opts=request.options as ExecutorOptions
198
- if (run?.scheduled) {
199
- assertScheduledModel(run.execution?.preset.model)
200
- if (opts.model !== run.execution!.preset.model) throw new Error('Scheduled run model does not match its saved task model')
198
+ // Script occurrences come from the authorized run record, never the request.
199
+ if (run?.script) {
200
+ if (!run.scheduled) throw new Error('Script runs require a scheduled occurrence')
201
+ const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
202
+ runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
203
+ script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
204
+ job=await launch(request.texts,options)
205
+ } else {
206
+ if (run?.scheduled) {
207
+ assertScheduledModel(run.execution?.preset.model)
208
+ if (opts.model !== run.execution!.preset.model) throw new Error('Scheduled run model does not match its saved task model')
209
+ }
210
+ const cli = opts.cli || installation.cli
211
+ resolveExecutor(cli)
212
+ const provider = cli === 'codex' && opts.provider
213
+ ? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
214
+ : undefined
215
+ if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
216
+ const model = opts.model
217
+ if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
218
+ if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
219
+ if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
220
+ throw new Error('Selected Codex provider is required for this model')
221
+ // Admission and execution must use the same catalog snapshot. A
222
+ // transient native-client probe failure must not reject a model
223
+ // that the host just advertised to the relay and application.
224
+ if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
225
+ const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
226
+ runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
227
+ sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
228
+ promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
229
+ job=await launch(request.texts,options)
201
230
  }
202
- const cli = opts.cli || installation.cli
203
- resolveExecutor(cli)
204
- const provider = cli === 'codex' && opts.provider
205
- ? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
206
- : undefined
207
- if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
208
- const model = opts.model
209
- if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
210
- if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
211
- if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
212
- throw new Error('Selected Codex provider is required for this model')
213
- // Admission and execution must use the same catalog snapshot. A
214
- // transient native-client probe failure must not reject a model
215
- // that the host just advertised to the relay and application.
216
- if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
217
- const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
218
- runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
219
- sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
220
- promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
221
- job=await launch(request.texts,options)
222
231
  active.set(base,job.child)
223
232
  await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
224
233
  if(signal.aborted)terminateJob(job.child)
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,7 @@ 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'
29
31
  import { assertScheduledModel } from './model-policy.js'
30
32
  import { RunStore, type RunRecord } from './runs.js'
31
33
  import { splitTelegramText } from './reply.js'
@@ -44,7 +46,7 @@ import { softwareStatus } from './software-status.js'
44
46
  export const createRelay = (config: Config, launch = startExecutorJob) => {
45
47
  const safeError = (error: unknown): string => {
46
48
  let message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
47
- for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
49
+ for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey, config.openrouterApiKey]) {
48
50
  if (secret) message = message.replaceAll(secret, '[redacted]')
49
51
  }
50
52
  return message
@@ -60,7 +62,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
60
62
  const scheduler = new Scheduler(config.controlDir)
61
63
  const background = new Map<string, ChildProcess>()
62
64
  const completions = new Set<Promise<void>>()
63
- 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)
64
68
  const processTaskRequests = taskRequests(tasks)
65
69
  let taskWork: Promise<void> | undefined
66
70
  const drainTaskRequests = (): Promise<void> => {
@@ -103,8 +107,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
103
107
  const applicationChannel = new ApplicationChannel({
104
108
  controlDir: config.controlDir, workspace: config.workspace, isolation: config.isolation, initial: aiMenu.initial,
105
109
  aiControls: aiMenu,
106
- speech: config.geminiApiKey ? (text, language) => synthesizeSpeech(text, {
107
- geminiApiKey: config.geminiApiKey,
110
+ speech: speechConfigured ? (text, language) => synthesizeSpeech(text, {
111
+ ...speechOptions,
108
112
  voice: language === 'es' ? config.speechVoiceEs : config.speechVoiceEn,
109
113
  format: 'wav',
110
114
  }) : undefined,
@@ -207,8 +211,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
207
211
  }
208
212
  if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
209
213
  // A long scheduled turn must not occupy the owner's chat stream.
210
- // Each stream remains serial, with separate native sessions.
211
- if (run.scheduled ? background.size > 0 : activeChild !== null) return
214
+ // Foreground stays serial; scheduled jobs have independent native sessions.
215
+ if (run.scheduled ? background.size >= (config.scheduledConcurrency ?? 6) : activeChild !== null) return
212
216
  let texts = run.texts
213
217
  if (run.external) {
214
218
  // Availability failures leave durable queued work for a later check.
@@ -235,14 +239,23 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
235
239
  try {
236
240
  const launchStarted = performance.now()
237
241
  const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
238
- if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
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.')
239
248
  const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
240
249
  const session = startsOwnSession
241
250
  ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
242
251
  : await control.executionSession(started.execution!)
243
- const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
244
- if (run.scheduled) assertScheduledModel(selected.model)
245
- const { child, cleanup } = await launch(texts, {
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
+ } : {
246
259
  workspace: config.workspace,
247
260
  timeoutMs: config.executorTimeoutMs,
248
261
  repairEnabled: config.repairEnabled,
@@ -265,9 +278,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
265
278
  const executionStarted = performance.now()
266
279
  // Attach before disk writes: a fast child can close while PID persistence
267
280
  // is pending, and Node drains its remaining pipes during process close.
268
- 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))
269
286
  child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
270
287
  errorTail = (errorTail + chunk).slice(-16384)
288
+ if (script) outputTail = (outputTail + chunk).slice(-16384)
271
289
  if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
272
290
  if (chunk.includes('Host executor client interrupted by')) {
273
291
  failureReason = 'host-executor-transport-interrupted'
@@ -288,7 +306,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
288
306
  console.info('run started', {
289
307
  run_id: started.id,
290
308
  pid: child.pid,
291
- cli: selected.cli,
309
+ cli: script ? 'script' : selected.cli,
310
+ ...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
292
311
  session: session.sessionId,
293
312
  isResume: session.hasStarted,
294
313
  })
@@ -306,8 +325,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
306
325
  try {
307
326
  await cleanup()
308
327
  if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
309
- const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
310
- await runs.patch(started.id, { status: cancelled ? 'cancelled' : code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString(), exitCode: code, ...(code !== 0 && !cancelled ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
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}`}`)) } : {}) })
311
334
  await releaseExternal(run)
312
335
  } catch (error) {
313
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() })
@@ -587,8 +610,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
587
610
  } else if (item.type === 'voice' && item.voiceText) {
588
611
  await bot!.api.sendChatAction(item.chatId, 'record_voice')
589
612
  const { buffer } = await synthesizeSpeech(item.voiceText, {
590
- geminiApiKey: config.geminiApiKey,
591
- openaiApiKey: config.openaiApiKey,
613
+ ...speechOptions,
592
614
  voice: config.speechVoiceEn,
593
615
  })
594
616
  await paceSend()
@@ -719,7 +741,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
719
741
  const delivery = await runs.deliveryStatus()
720
742
  const ai = await control.aiState(aiMenu.initial)
721
743
  const selected = ai.presets.find((p) => p.id === ai.selectedId)!
722
- const discovered = await discoverDefaults(config.workspace, { codexHome, nativeCodexFallback: true })
744
+ const discovered = await discoverDefaults(config.workspace, { codexHome, claudeHome: claudeConfigHome(config.controlDir), nativeCodexFallback: true })
723
745
  const displayedSelected = statusPreset(selected, discovered)
724
746
  const scheduled = all.filter(run => run.scheduled && run.status === 'running').length
725
747
  const queued = all.filter(run => run.status === 'queued').length
@@ -743,6 +765,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
743
765
  '',
744
766
  'Work',
745
767
  `Current: ${running ? (waitingForHost ? 'waiting for the workspace' : 'running') : 'idle'}`,
768
+ `Scheduled limit: ${config.scheduledConcurrency ?? 6}`,
746
769
  `Background: ${scheduled ? `${scheduled} scheduled task${scheduled === 1 ? '' : 's'} running` : 'none'}`,
747
770
  `Queue: ${queued || incoming.pending ? `${queued} run${queued === 1 ? '' : 's'}; ${incoming.pending} incoming message${incoming.pending === 1 ? '' : 's'}` : 'empty'}`,
748
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');
@@ -115,7 +116,9 @@ export async function migrateLedger({ deployment } = {}) {
115
116
  }
116
117
 
117
118
  export async function latestCodexVersion(invoke,manager={command:"npm",args:[]}) {
118
- const version=JSON.parse(await invoke(manager.command,[...manager.args,'view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/']));
119
+ const output=await invoke(manager.command,[...manager.args,'view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/'],{stdoutOnly:true});
120
+ let version;
121
+ try {version=JSON.parse(output);}catch {throw Error('Codex version discovery returned invalid JSON on stdout');}
119
122
  if(typeof version!=='string'||!/^\d+\.\d+\.\d+$/.test(version))throw Error('Invalid latest Codex version');
120
123
  return version;
121
124
  }
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)
@@ -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);