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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.dockerignore +2 -0
  2. package/.env.example +3 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +38 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/Dockerfile +18 -10
  7. package/THIRD_PARTY_NOTICES.md +6 -3
  8. package/bin/ez +7 -0
  9. package/docker/broker-readiness.mjs +54 -0
  10. package/docker/broker-update-smoke.mjs +86 -0
  11. package/docker/engine-pnpm-workspace.yaml +4 -0
  12. package/docker/run.ts +1 -1
  13. package/docker/smoke.mjs +1 -1
  14. package/docs/application-channel.md +32 -1
  15. package/docs/architecture/ai-selection.md +26 -5
  16. package/docs/architecture/authority-boundaries.md +2 -2
  17. package/docs/development-and-testing.md +1 -1
  18. package/docs/docker-runtime.md +39 -0
  19. package/docs/isolated-plugin-update-readiness.md +66 -0
  20. package/docs/plugin-guide.md +1 -1
  21. package/docs/plugins.md +1 -1
  22. package/docs/releasing.md +5 -1
  23. package/docs/scheduling.md +117 -9
  24. package/docs/selective-monitoring.md +13 -0
  25. package/docs/upgrades.md +38 -1
  26. package/docs/work-preflight.md +9 -0
  27. package/package.json +4 -3
  28. package/pnpm-workspace.yaml +7 -0
  29. package/scripts/smoke-scheduler.ts +4 -1
  30. package/src/ai.ts +41 -4
  31. package/src/application-channel.ts +43 -3
  32. package/src/application-cli.ts +7 -1
  33. package/src/application-origin.ts +2 -0
  34. package/src/audio.ts +37 -22
  35. package/src/client-defaults.ts +3 -2
  36. package/src/config.ts +9 -0
  37. package/src/control-state.ts +3 -1
  38. package/src/delivery-socket-client.d.mts +2 -0
  39. package/src/delivery-socket-client.mjs +2 -0
  40. package/src/delivery-socket.ts +38 -1
  41. package/src/event-sources.ts +2 -2
  42. package/src/executor.ts +72 -1
  43. package/src/files.ts +47 -3
  44. package/src/host-executor.ts +40 -26
  45. package/src/index.ts +42 -17
  46. package/src/install-tools.mjs +18 -9
  47. package/src/menu.ts +4 -2
  48. package/src/model-policy.ts +4 -0
  49. package/src/plugin-broker.mjs +47 -4
  50. package/src/plugins/manager.mjs +11 -4
  51. package/src/run-evidence.ts +78 -0
  52. package/src/runs.ts +16 -2
  53. package/src/schedule-cli.ts +80 -10
  54. package/src/schedule-preflight.ts +23 -0
  55. package/src/scheduled-tasks.ts +16 -12
  56. package/src/scheduler.ts +102 -16
  57. package/src/scripts.ts +167 -0
  58. package/src/setup.ts +2 -2
  59. package/src/task-capability.ts +17 -1
  60. package/src/task-executor.ts +11 -8
  61. package/src/task-mcp.ts +20 -8
  62. package/src/tasks.ts +89 -9
  63. package/src/updates/runtime.mjs +53 -11
  64. package/src/updates/supervisor.mjs +23 -13
  65. package/test/ai.test.ts +85 -5
  66. package/test/application-channel.test.ts +16 -1
  67. package/test/audio.test.ts +19 -0
  68. package/test/channel-owner.test.ts +2 -2
  69. package/test/config.test.ts +8 -0
  70. package/test/desktop-bridge.test.ts +2 -1
  71. package/test/executor.test.ts +37 -0
  72. package/test/failure.test.ts +4 -4
  73. package/test/files.test.ts +44 -0
  74. package/test/host-executor.test.ts +49 -6
  75. package/test/install-json.test.mjs +21 -0
  76. package/test/install-tools.test.mjs +6 -5
  77. package/test/native-tasks.test.ts +1 -1
  78. package/test/plugin-broker.test.mjs +64 -0
  79. package/test/plugin-manager.test.mjs +20 -0
  80. package/test/private-speech-config.test.ts +22 -0
  81. package/test/run-evidence.test.ts +78 -0
  82. package/test/schedule-cli.test.ts +47 -3
  83. package/test/schedule-preflight.test.ts +49 -0
  84. package/test/scheduled-tasks.test.ts +2 -2
  85. package/test/scheduler-host.test.ts +21 -3
  86. package/test/scheduler-relay.test.ts +84 -4
  87. package/test/scheduler.test.ts +21 -1
  88. package/test/script-schedules.test.ts +219 -0
  89. package/test/task-native.test.ts +50 -12
  90. package/test/tasks.test.ts +97 -4
  91. package/test/updates.test.mjs +77 -18
package/src/audio.ts CHANGED
@@ -6,6 +6,10 @@ export type AudioOptions = {
6
6
  geminiApiKey?: string
7
7
  openaiApiKey?: string
8
8
  voice?: string
9
+ speechProvider?: 'gemini' | 'openrouter'
10
+ speechModel?: string
11
+ openrouterApiKey?: string
12
+ signal?: AbortSignal
9
13
  }
10
14
 
11
15
  export class SpeechCreditsDepletedError extends Error {
@@ -32,27 +36,12 @@ export const pcmToWav = (pcm: Buffer, sampleRate = 24000, channels = 1): Buffer
32
36
  return Buffer.concat([header, pcm])
33
37
  }
34
38
 
35
- export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Promise<Buffer> => {
39
+ const convertSpeech = (audio: Buffer, input: 's16le' | 'mp3', format: 'wav' | 'ogg', sampleRate = 24000, channels = 1): Promise<Buffer> => {
36
40
  return new Promise((resolve, reject) => {
37
41
  const invocation = executorInvocation(
38
42
  'ffmpeg',
39
- [
40
- '-f',
41
- 's16le',
42
- '-ar',
43
- String(sampleRate),
44
- '-ac',
45
- String(channels),
46
- '-i',
47
- '-',
48
- '-c:a',
49
- 'libopus',
50
- '-b:a',
51
- '32k',
52
- '-f',
53
- 'ogg',
54
- '-',
55
- ],
43
+ [...(input === 's16le' ? ['-f', 's16le', '-ar', String(sampleRate), '-ac', String(channels)] : ['-f', 'mp3']),
44
+ '-i', '-', ...(format === 'ogg' ? ['-c:a', 'libopus', '-b:a', '32k', '-f', 'ogg'] : ['-ar', '24000', '-ac', '1', '-f', 's16le']), '-'],
56
45
  )
57
46
  const child = spawn(invocation.command, invocation.args,
58
47
  { env: executorEnvironment(), stdio: ['pipe', 'pipe', 'pipe'] })
@@ -60,7 +49,8 @@ export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Pr
60
49
  const output: Buffer[] = []
61
50
  let error = ''
62
51
 
63
- child.stdout.on('data', (chunk) => output.push(chunk))
52
+ let outputBytes = 0
53
+ child.stdout.on('data', (chunk) => { outputBytes += chunk.length; if(outputBytes > 16 * 1024 * 1024) { child.kill('SIGKILL'); reject(new Error('Encoded speech exceeds limit')) } else output.push(chunk) })
64
54
  child.stderr.on('data', (chunk) => {
65
55
  error = (error + chunk.toString()).slice(-2048)
66
56
  })
@@ -70,16 +60,18 @@ export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Pr
70
60
 
71
61
  child.on('close', (code) => {
72
62
  if (code === 0 && output.length > 0) {
73
- resolve(Buffer.concat(output))
63
+ resolve(format === 'wav' ? pcmToWav(Buffer.concat(output)) : Buffer.concat(output))
74
64
  } else {
75
65
  reject(new Error(`Voice encoding failed (${code}): ${error}`))
76
66
  }
77
67
  })
78
68
 
79
- child.stdin.end(pcm)
69
+ child.stdin.end(audio)
80
70
  })
81
71
  }
82
72
 
73
+ export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1) => convertSpeech(pcm, 's16le', 'ogg', sampleRate, channels)
74
+
83
75
  export const transcribeAudio = async (
84
76
  audioBytes: Buffer,
85
77
  mimeType: string,
@@ -146,6 +138,29 @@ export const synthesizeSpeech = async (
146
138
  const trimmed = text.trim()
147
139
  if (!trimmed) throw new Error('Cannot synthesize speech for empty text')
148
140
 
141
+ if ((options.speechProvider ?? process.env.EZ_SPEECH_PROVIDER) === 'openrouter') {
142
+ const key = (options.openrouterApiKey ?? process.env.OPENROUTER_API_KEY)?.trim()
143
+ if (!key) throw new Error('OpenRouter speech is not configured')
144
+ const response = await fetch('https://openrouter.ai/api/v1/audio/speech', {
145
+ method: 'POST', signal: options.signal ?? AbortSignal.timeout(60000),
146
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
147
+ body: JSON.stringify({ model: options.speechModel ?? process.env.EZ_SPEECH_MODEL ?? 'fish-audio/s2.1-pro-free:free',
148
+ input: trimmed, response_format: 'mp3', ...(options.voice ? { voice: options.voice } : {}) })
149
+ })
150
+ if (!response.ok) { await response.body?.cancel(); throw new Error(`OpenRouter speech synthesis error ${response.status}`) }
151
+ const chunks: Buffer[] = []; let size = 0
152
+ const reader = response.body?.getReader()
153
+ if (!reader) throw new Error('OpenRouter returned no audio')
154
+ try {
155
+ for (;;) { const {done,value} = await reader.read(); if(done) break
156
+ size += value.length; if(size > 16 * 1024 * 1024) throw new Error('Speech audio exceeds limit')
157
+ chunks.push(Buffer.from(value)) }
158
+ } finally { await reader.cancel() }
159
+ if (!size) throw new Error('OpenRouter returned no audio')
160
+ const buffer = await convertSpeech(Buffer.concat(chunks), 'mp3', options.format ?? 'ogg')
161
+ return { buffer, mimeType: options.format === 'wav' ? 'audio/wav' : 'audio/ogg' }
162
+ }
163
+
149
164
  const geminiKey = (options.geminiApiKey ?? process.env.GEMINI_API_KEY)?.trim()
150
165
  if (geminiKey) {
151
166
  const url =
@@ -154,7 +169,7 @@ export const synthesizeSpeech = async (
154
169
  const response = await fetch(url, {
155
170
  method: 'POST',
156
171
  headers: { 'content-type': 'application/json', 'x-goog-api-key': geminiKey },
157
- signal: AbortSignal.timeout(60_000),
172
+ signal: options.signal ?? AbortSignal.timeout(60_000),
158
173
  body: JSON.stringify({
159
174
  contents: [{ parts: [{ text: trimmed }] }],
160
175
  generationConfig: {
@@ -78,7 +78,7 @@ export const grokSettings = (text: string): { model?: string; effort?: string }
78
78
  }
79
79
 
80
80
  export const discoverDefaults = async (cwd: string, options: {
81
- home?: string; codexHome?: string; nativeCodexFallback?: boolean; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
81
+ home?: string; codexHome?: string; claudeHome?: string; nativeCodexFallback?: boolean; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
82
82
  } = {}): Promise<AiPreset[]> => {
83
83
  const home = options.home ?? homedir()
84
84
  const available = options.available ?? installed
@@ -99,7 +99,8 @@ export const discoverDefaults = async (cwd: string, options: {
99
99
  model = value(config.model); effort = value(config.effort)
100
100
  } else if (cli === 'claude') {
101
101
  // Match Claude's documented user -> project -> local settings precedence.
102
- const settings = Object.assign({}, await json(join(home, '.claude/settings.json')),
102
+ // User settings come from the agent-bound CLAUDE_CONFIG_DIR when bound.
103
+ const settings = Object.assign({}, await json(join(options.claudeHome ?? join(home, '.claude'), 'settings.json')),
103
104
  await json(join(cwd, '.claude/settings.json')), await json(join(cwd, '.claude/settings.local.json')))
104
105
  model = value(settings.model); effort = value(settings.effortLevel)
105
106
  } else if (cli === 'opencode') {
package/src/config.ts CHANGED
@@ -19,12 +19,16 @@ export type Config = ControlConfig & {
19
19
  isolation?: IsolationClass
20
20
  codexSandbox?: 'external'
21
21
  codexAutoCompactTokens?: number
22
+ scheduledConcurrency?: number
22
23
  executorCli: string
23
24
  channelBackendUrl?: string
24
25
  channelBackendToken?: string
25
26
  applicationPort?: number
26
27
  applicationHost?: string
27
28
  geminiApiKey?: string
29
+ speechProvider?: 'gemini' | 'openrouter'
30
+ speechModel?: string
31
+ openrouterApiKey?: string
28
32
  speechVoiceEn?: string
29
33
  speechVoiceEs?: string
30
34
  openaiApiKey?: string
@@ -69,6 +73,7 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
69
73
  const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
70
74
  if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
71
75
  if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || transport !== 'local')) throw new Error('External Codex sandbox requires native local execution')
76
+ if (env.EZ_SPEECH_PROVIDER && !['gemini','openrouter'].includes(env.EZ_SPEECH_PROVIDER)) throw new Error('Invalid EZ_SPEECH_PROVIDER')
72
77
  return {
73
78
  ...loadControlConfig(env),
74
79
  telegramEnabled,
@@ -80,12 +85,16 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
80
85
  isolation,
81
86
  codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
82
87
  codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
88
+ scheduledConcurrency: positiveInteger(env.EZ_SCHEDULED_CONCURRENCY, 'EZ_SCHEDULED_CONCURRENCY', 6),
83
89
  executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'codex',
84
90
  applicationPort: env.EZ_APPLICATION_PORT ? positiveInteger(env.EZ_APPLICATION_PORT, 'EZ_APPLICATION_PORT') : undefined,
85
91
  applicationHost: env.EZ_APPLICATION_HOST?.trim() || '127.0.0.1',
86
92
  channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
87
93
  channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
88
94
  geminiApiKey: env.GEMINI_API_KEY?.trim(),
95
+ speechProvider: env.EZ_SPEECH_PROVIDER as Config['speechProvider'],
96
+ speechModel: env.EZ_SPEECH_MODEL?.trim(),
97
+ openrouterApiKey: env.OPENROUTER_API_KEY?.trim(),
89
98
  speechVoiceEn: env.EZ_SPEECH_VOICE_EN?.trim(),
90
99
  speechVoiceEs: env.EZ_SPEECH_VOICE_ES?.trim(),
91
100
  openaiApiKey: env.OPENAI_API_KEY?.trim(),
@@ -755,7 +755,9 @@ export class ControlStore {
755
755
  const state = await this.readState()
756
756
  await requireControlGuard(state, guard)
757
757
  if (!state.ai) throw new Error('AI settings not initialized')
758
- if (state.ai.presets.length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
758
+ // Auto-detected client defaults come and go with installed clients; only
759
+ // owner-saved choices count toward the cap.
760
+ if (state.ai.presets.filter((p) => !p.id.startsWith('detected_')).length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
759
761
  throw new Error('Keep it small: at most 12 saved AIs.')
760
762
  state.ai.presets = [...state.ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
761
763
  await this.writeState(state)
@@ -2,3 +2,5 @@ export type DeliverySocketRequest = { op: string; payload?: unknown }
2
2
  export function deliverySocketPath(controlDir: string): string
3
3
  export function callDeliverySocket(socketPath: string, op: DeliverySocketRequest, timeoutMs?: number): Promise<unknown>
4
4
  export function deliverySocketAlive(socketPath: string, timeoutMs?: number): Promise<boolean>
5
+
6
+ export function callLocalSocket(socketPath:string, envelope:Record<string,unknown>, timeoutMs?:number):Promise<unknown>
@@ -67,6 +67,8 @@ const exchange = (connect, envelope, timeoutMs, target) => new Promise((resolve,
67
67
  socket.write(encode(envelope))
68
68
  })
69
69
 
70
+ export const callLocalSocket = (socketPath, envelope, timeoutMs = 130000) => exchange(() => createConnection(socketPath), envelope, timeoutMs, socketPath)
71
+
70
72
  const readEndpoint = async controlDir => {
71
73
  try {
72
74
  const value = JSON.parse(await readFile(path.join(controlDir, endpointName), 'utf8'))
@@ -1,3 +1,5 @@
1
+ import { sameOwner } from './control-state.js'
2
+ import { ApplicationBindings } from './application-channel.js'
1
3
  import { createServer, type Server, type Socket } from 'node:net'
2
4
  import { randomBytes, timingSafeEqual } from 'node:crypto'
3
5
  import { rename, rm, writeFile } from 'node:fs/promises'
@@ -8,6 +10,9 @@ import { ApprovalStore } from './approval.js'
8
10
  import { deliveredMessages } from './message-history.js'
9
11
  import { readOnlyOwner, requireOwnerExecution } from './execution-authority.js'
10
12
  import { telegramOwner } from './control-state.js'
13
+ import { Scheduler } from './scheduler.js'
14
+ import {Tasks} from './tasks.js'
15
+ import { runEvidence, type EvidenceOptions } from './run-evidence.js'
11
16
 
12
17
  // Stateless pipe transport. The relay owns the runs/outbox ledger in process
13
18
  // memory; engine children, plugin children and the host executor reach it
@@ -27,15 +32,19 @@ export { deliverySocketPath }
27
32
  export type DeliverySocketOp =
28
33
  | { op: 'ping' }
29
34
  | { op: 'status' }
35
+ | { op: 'schedulePreflight'; payload?: Record<string, unknown> }
36
+ | { op: 'triggerSchedule'; payload: { scheduleId: string; revision: string; key: string; callerRunId?: string } }
30
37
  | { op: 'enqueue'; payload: Record<string, unknown> }
31
38
  | { op: 'wait'; payload: { id: string; timeoutMs?: number } }
32
39
  | { op: 'get'; payload: { runId: string } }
33
40
  | { op: 'list'; payload?: Record<string, never> }
34
41
  | { op: 'patch'; payload: { runId: string; change: Record<string, unknown> } }
42
+ | { op: 'taskApplication'; payload: {runId:string;ticketHash:string} }
35
43
  | { op: 'authorize'; payload: { runId: string } }
36
44
  | { op: 'receipt'; payload: { context: unknown; id: string } }
37
45
  | { op: 'history'; payload: { runId: string; limit?: number; messageId?: number } }
38
46
  | { op: 'deliveryStatus'; payload?: Record<string, never> }
47
+ | { op: 'evidence'; payload: EvidenceOptions }
39
48
  | { op: 'approvalCheck'; payload: { actionId: string } }
40
49
  | { op: 'latestReply'; payload?: Record<string, never> }
41
50
 
@@ -208,6 +217,30 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
208
217
  queued: all.filter((run) => run.status === 'queued').length,
209
218
  }
210
219
  }
220
+ case 'schedulePreflight': {
221
+ const {scheduleId,revision}=op.payload ?? {}
222
+ if(!nonEmptyString(scheduleId) || !nonEmptyString(revision)) throw Error('Schedule preflight identity required')
223
+ const scheduler=new Scheduler(controlDir),s=await scheduler.get(scheduleId),owner=await readOnlyOwner(controlDir)
224
+ if(!owner || !sameOwner(owner,s.owner) || !s.enabled || s.revision!==revision || !s.preflight) throw Error('Schedule preflight binding unavailable')
225
+ if(s.delivery) {
226
+ const binding=(await new ApplicationBindings(controlDir).list()).find(b=>b.bindingId===s.delivery!.bindingId)
227
+ if(!binding || !sameOwner(owner,binding.owner)) throw Error('Schedule preflight delivery revoked')
228
+ }
229
+ return s.preflight
230
+ }
231
+ case 'triggerSchedule': {
232
+ const {scheduleId,revision,key,callerRunId}=op.payload ?? {}
233
+ if (!nonEmptyString(scheduleId) || !nonEmptyString(revision) || !nonEmptyString(key)) throw new Error('Task ID, revision and request key are required')
234
+ if (callerRunId !== undefined) {
235
+ const caller=await requireOwnerExecution(controlDir,callerRunId)
236
+ if (caller.replyOnly || caller.script || (caller.scheduled && caller.scheduled.pairedAt !== (await readOnlyOwner(controlDir))?.pairedAt)) throw new Error('Scheduling requires an active owner-authorized run')
237
+ }
238
+ const owner=await readOnlyOwner(controlDir)
239
+ if (!owner) throw new Error('Pair an owner before scheduling')
240
+ const run=await new Scheduler(controlDir).trigger(scheduleId,revision,key,owner,runs)
241
+ hooks.wake()
242
+ return run
243
+ }
211
244
  case 'enqueue': {
212
245
  const envelope = (op.payload ?? {}) as Record<string, unknown>
213
246
  if (envelope.kind === 'owner') {
@@ -287,6 +320,10 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
287
320
  if (!payload.change || typeof payload.change !== 'object') throw new Error('Patch change is required')
288
321
  return runs.patch(payload.runId, payload.change as never)
289
322
  }
323
+ case 'taskApplication': {
324
+ const p=op.payload as {runId:string;ticketHash:string}
325
+ return new Tasks(controlDir).authorizeApplicationLaunch(p.runId,p.ticketHash)
326
+ }
290
327
  case 'authorize': {
291
328
  const runId = (op.payload as { runId?: unknown } | undefined)?.runId
292
329
  if (typeof runId !== 'string' || !runId) throw new Error('Run ID is required')
@@ -306,6 +343,7 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
306
343
  ...(payload.messageId === undefined ? {} : { messageId: payload.messageId as number }),
307
344
  })
308
345
  }
346
+ case 'evidence': return runEvidence(controlDir, op.payload ?? {})
309
347
  case 'deliveryStatus': return runs.deliveryStatus()
310
348
  case 'approvalCheck': {
311
349
  const actionId = (op.payload as { actionId?: unknown } | undefined)?.actionId
@@ -351,4 +389,3 @@ export const deliverySocketAlive = (socketPath: string, timeoutMs = 2000): Promi
351
389
  // closed DeliverySocketOp union.
352
390
  export const callDeliverySocket = (socketPath: string, op: DeliverySocketOp, timeoutMs = 130_000): Promise<unknown> =>
353
391
  callSocket(socketPath, op, timeoutMs)
354
-
@@ -27,11 +27,11 @@ async function atomic(path: string, data: unknown) {
27
27
  try { await fd.writeFile(JSON.stringify(data)); await fd.sync() } finally { await fd.close() }
28
28
  await rename(tmp, path)
29
29
  }
30
- export function sourceCall(socketPath: string, command: string, args = {}): Promise<any> {
30
+ export function sourceCall(socketPath: string, command: string, args = {}, maxResponseBytes = 256000): Promise<any> {
31
31
  return new Promise((resolve, reject) => {
32
32
  const req = request({ socketPath, path: '/', method: 'POST', timeout: 3000 }, res => {
33
33
  let body = ''
34
- res.on('data', chunk => { body += chunk; if (Buffer.byteLength(body) > 256000) req.destroy(new Error('Event-source response too large')) })
34
+ res.on('data', chunk => { body += chunk; if (Buffer.byteLength(body) > maxResponseBytes) req.destroy(new Error('Event-source response too large')) })
35
35
  res.on('error', () => reject(new Error('Event-source response interrupted')))
36
36
  res.on('end', () => {
37
37
  try { const result = JSON.parse(body); if (res.statusCode !== 200 || result.ok !== true) throw new Error(); resolve(result.data) }
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
+ }
@@ -13,6 +13,7 @@ import { readModels, validateSelection, validateOpencodeProviders, type ModelCho
13
13
  import type { ChildProcess } from 'node:child_process'
14
14
  import { packageVersion } from './version.js'
15
15
  import { installedPluginVersions } from './software-status.js'
16
+ import { assertScheduledModel } from './model-policy.js'
16
17
  import { processSnapshot } from './process-tree.js'
17
18
 
18
19
  export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
@@ -51,7 +52,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
51
52
  let catalogRefresh: Promise<void> | undefined
52
53
  const locks: string[] = []
53
54
  const sharedWorkspaces = new Map<HostBinding, string>()
54
- const activeWorkspaces = new Map<HostBinding, { lanes: Set<boolean>; release?: () => Promise<void> }>()
55
+ const activeWorkspaces = new Map<HostBinding, { lanes: Map<string, boolean>; release?: () => Promise<void> }>()
55
56
  const additionalWorkspaces = new Map<HostBinding, string[]>()
56
57
  const catalog = async (agent: HostBinding) => {
57
58
  const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
@@ -160,18 +161,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
160
161
  const base=path.join(directory,id)
161
162
  const scheduled = Boolean(run?.scheduled)
162
163
  let workspace = activeWorkspaces.get(agent)
163
- if (workspace?.lanes.has(scheduled)) continue
164
+ if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
164
165
  if (!workspace) {
165
166
  const release = agent.toolsHome
166
167
  ? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
167
168
  if (agent.toolsHome && !release) continue
168
- workspace = { lanes: new Set(), release }
169
+ workspace = { lanes: new Map(), release }
169
170
  activeWorkspaces.set(agent, workspace)
170
171
  }
171
- workspace.lanes.add(scheduled)
172
- // 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.
173
174
  const releaseWorkspace = async () => {
174
- workspace.lanes.delete(scheduled)
175
+ workspace.lanes.delete(id)
175
176
  if (!workspace.lanes.size) {
176
177
  activeWorkspaces.delete(agent)
177
178
  await workspace.release?.()
@@ -194,26 +195,39 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
194
195
  await new Tasks(agent.controlDir).authorize(run, false)
195
196
  } else await authorizeRun(agent.controlDir, path.basename(base))
196
197
  const opts=request.options as ExecutorOptions
197
- const cli = opts.cli || installation.cli
198
- resolveExecutor(cli)
199
- const provider = cli === 'codex' && opts.provider
200
- ? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
201
- : undefined
202
- if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
203
- const model = opts.model
204
- if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
205
- if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
206
- if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
207
- throw new Error('Selected Codex provider is required for this model')
208
- // Admission and execution must use the same catalog snapshot. A
209
- // transient native-client probe failure must not reject a model
210
- // that the host just advertised to the relay and application.
211
- 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')))
212
- const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
213
- runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
214
- sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
215
- promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
216
- job=await launch(request.texts,options)
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)
230
+ }
217
231
  active.set(base,job.child)
218
232
  await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
219
233
  if(signal.aborted)terminateJob(job.child)