@jc_stack/ez-agents 0.1.0-beta.12 → 0.1.0-beta.13

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 (73) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +1 -1
  3. package/AGENTS.md +10 -1
  4. package/CHANGELOG.md +22 -0
  5. package/CONTRIBUTING.md +3 -0
  6. package/README.md +112 -10
  7. package/SECURITY.md +7 -1
  8. package/bin/ezenciel-agents-schedule +2 -0
  9. package/bin/ezenciel-agents-schedule.mjs +16 -0
  10. package/bin/ezenciel-agents-task +2 -0
  11. package/bin/ezenciel-agents-task.mjs +16 -0
  12. package/compose.yaml +2 -1
  13. package/docker/recovery.ts +2 -2
  14. package/docker/run.ts +2 -2
  15. package/docs/architecture/authority-boundaries.md +114 -12
  16. package/docs/architecture/event-sources.md +12 -7
  17. package/docs/channel-backend.md +36 -0
  18. package/docs/local-qa.md +45 -0
  19. package/docs/plugin-catalog.md +54 -0
  20. package/docs/plugin-contributions.md +3 -0
  21. package/docs/plugins.md +49 -0
  22. package/docs/scheduling.md +127 -0
  23. package/docs/selective-monitoring.md +106 -0
  24. package/docs/setup.md +7 -0
  25. package/docs/standalone-cli.md +62 -0
  26. package/package.json +7 -2
  27. package/scripts/smoke-scheduler.ts +90 -0
  28. package/scripts/stage-qa.mjs +42 -0
  29. package/src/channel-backend.ts +46 -0
  30. package/src/codex-session.ts +96 -0
  31. package/src/config.ts +6 -1
  32. package/src/desktop-bridge.ts +29 -11
  33. package/src/execution-authority.ts +24 -0
  34. package/src/executor.ts +66 -15
  35. package/src/host-executor.ts +30 -10
  36. package/src/inbox.ts +4 -0
  37. package/src/index.ts +130 -34
  38. package/src/plugins/exposure.mjs +13 -0
  39. package/src/plugins/manager.mjs +27 -12
  40. package/src/process-tree.ts +33 -0
  41. package/src/runs.ts +50 -17
  42. package/src/schedule-cli.ts +69 -0
  43. package/src/schedule-time.ts +85 -0
  44. package/src/scheduler.ts +121 -0
  45. package/src/source-cli.ts +1 -1
  46. package/src/task-cli.ts +16 -0
  47. package/src/task-executor.ts +63 -0
  48. package/src/task-mcp.ts +36 -0
  49. package/src/task-rpc.ts +45 -0
  50. package/src/task-workspace.ts +22 -0
  51. package/src/tasks.ts +192 -0
  52. package/src/updates/binding.mjs +1 -0
  53. package/src/updates/status.mjs +7 -1
  54. package/templates/agent/TOOLS.md +54 -1
  55. package/templates/standalone-tools.md +20 -0
  56. package/test/channel-backend.test.ts +100 -0
  57. package/test/codex-context.test.ts +36 -1
  58. package/test/codex-session.test.ts +49 -0
  59. package/test/config.test.ts +2 -2
  60. package/test/desktop-bridge.test.ts +19 -0
  61. package/test/event-sources.test.ts +47 -11
  62. package/test/execution-authority.test.ts +42 -0
  63. package/test/executor.test.ts +42 -1
  64. package/test/helpers/owner-run.ts +13 -0
  65. package/test/host-executor.test.ts +9 -3
  66. package/test/local-qa.test.mjs +38 -0
  67. package/test/plugin-manager.test.mjs +70 -1
  68. package/test/schedule-cli.test.ts +49 -0
  69. package/test/scheduler-host.test.ts +55 -0
  70. package/test/scheduler-relay.test.ts +67 -0
  71. package/test/scheduler.test.ts +104 -0
  72. package/test/task-native.test.ts +87 -0
  73. package/test/tasks.test.ts +179 -0
package/src/index.ts CHANGED
@@ -1,5 +1,11 @@
1
+ import { Tasks } from './tasks.js'
2
+ import { taskRequests } from './task-rpc.js'
3
+ import { executionBlockReason } from './execution-authority.js'
4
+ import { dispatchChannel } from './channel-backend.js'
1
5
  import { randomUUID } from 'node:crypto'
2
6
  import { stat } from 'node:fs/promises'
7
+ import { Scheduler } from './scheduler.js'
8
+ import { taskWorkspace } from './task-workspace.js'
3
9
  import { queueUpdateAttention } from './update-attention.js'
4
10
  import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
5
11
  import { dirname, join, basename } from 'node:path'
@@ -29,7 +35,7 @@ import { softwareStatus } from './software-status.js'
29
35
  export const createRelay = (config: Config, launch = startExecutorJob) => {
30
36
  const safeError = (error: unknown): string => {
31
37
  let message = error instanceof Error ? error.message : 'Unknown error'
32
- for (const secret of [config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
38
+ for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
33
39
  if (secret) message = message.replaceAll(secret, '[redacted]')
34
40
  }
35
41
  return message
@@ -40,10 +46,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
40
46
  const runs = new RunStore(config.controlDir)
41
47
  const inbox = new InboxStore(config.controlDir)
42
48
  const sources = new EventSources(config.controlDir)
49
+ const scheduler = new Scheduler(config.controlDir)
50
+ const background = new Map<string, ChildProcess>()
51
+ const tasks = new Tasks(config.controlDir)
52
+ const drainTaskRequests = taskRequests(tasks)
43
53
  const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace)
44
54
  const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
45
55
 
46
56
  let activeTypingTimer: ReturnType<typeof setInterval> | null = null
57
+ let activeBackend = false
47
58
  let activeChild: ChildProcess | null = null
48
59
  let shuttingDown = false
49
60
  let nextSendAt = 0
@@ -91,16 +102,38 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
91
102
 
92
103
  const startJob = async (run: RunRecord): Promise<void> => {
93
104
  await withStartLock(async () => {
94
- if (shuttingDown || activeChild) return
105
+ if (shuttingDown || activeBackend) return
95
106
  // stat uses the effective UID; access uses the relay's isolated real UID.
96
107
  if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
97
108
  if ((await runs.get(run.id))?.status !== 'queued') return
98
- if (await runs.running()) return
109
+ if (!run.scheduled && await runs.running(false)) return
99
110
  const owner = (await control.status()).owner
100
111
  if (!owner || owner.telegramUserId !== run.telegramUserId || owner.telegramChatId !== run.chatId) {
101
112
  await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
102
113
  return
103
114
  }
115
+ if (config.channelBackendUrl) {
116
+ if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); return }
117
+ activeBackend = true
118
+ await runs.patch(run.id, { status: 'running', backendSubmitted: true })
119
+ void dispatchChannel(config, run).then(async reply => {
120
+ if (reply === null) { await runs.patch(run.id, { status: 'queued' }); return }
121
+ if (reply) await runs.enqueueMessage(run.id, reply, { id: `${run.id}_backend`, replyToMessageId: run.messageId })
122
+ await runs.patch(run.id, { status: 'completed', endedAt: new Date().toISOString() })
123
+ }).catch(async error => {
124
+ console.error('Channel backend unavailable', safeError(error))
125
+ // The backend deduplicates the stable run ID. Retry transport, never create another operation.
126
+ await new Promise(resolve => setTimeout(resolve, 5000))
127
+ await runs.patch(run.id, { status: error?.permanent ? 'failed' : 'queued' })
128
+ }).finally(() => { activeBackend = false })
129
+ return
130
+ }
131
+ if (run.scheduled && (!await scheduler.current(run, owner) || await scheduler.cancelled(run.id))) {
132
+ await runs.patch(run.id, {status:'cancelled',endedAt:new Date().toISOString()})
133
+ return
134
+ }
135
+ if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
136
+ if (run.scheduled ? background.size >= 4 : activeChild) return
104
137
  let texts = run.texts
105
138
  if (run.external) {
106
139
  // Availability failures leave durable queued work for a later check.
@@ -112,15 +145,25 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
112
145
  }
113
146
  texts = events.map(event => JSON.stringify(event))
114
147
  }
148
+ if (run.taskId) {
149
+ try { await tasks.authorize(run) } catch {
150
+ await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); return
151
+ }
152
+ }
153
+ const blockReason = run.taskId ? undefined : executionBlockReason(run, owner)
154
+ if (blockReason) {
155
+ await runs.patch(run.id, { status: 'cancelled', blockReason, endedAt: new Date().toISOString() })
156
+ return
157
+ }
115
158
  try {
116
159
  const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
117
- if (!started.execution) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
118
- const session = run.external
160
+ if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
161
+ const session = run.external || run.taskId || run.scheduled
119
162
  ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
120
- : await control.executionSession(started.execution)
121
- const selected = started.execution.preset
163
+ : await control.executionSession(started.execution!)
164
+ const selected = run.taskId ? { cli: 'codex', model: undefined, effort: undefined } : started.execution!.preset
122
165
  const { child, cleanup } = await launch(texts, {
123
- workspace: config.workspace,
166
+ workspace: run.scheduled ? await taskWorkspace(config.workspace,run.id) : config.workspace,
124
167
  timeoutMs: config.executorTimeoutMs,
125
168
  runId: started.id,
126
169
  controlDir: config.controlDir,
@@ -131,9 +174,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
131
174
  sessionId: session.nativeSessionId || session.sessionId,
132
175
  isResume: session.hasStarted,
133
176
  eventSource: run.external?.sourceId,
134
- onSession: run.external ? undefined : (id) => control.saveNativeSession(session.sessionId, id),
177
+ onSession: run.scheduled ? async (id) => { await runs.patch(run.id,{nativeSessionId:id}) } : run.external || run.taskId ? undefined : (id) => control.saveNativeSession(session.sessionId, id),
135
178
  })
136
- activeChild = child
179
+ if (run.scheduled) background.set(run.id,child)
180
+ else activeChild = child
137
181
  const finished = new Promise<number | null>((resolve) => {
138
182
  if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
139
183
  else child.once('close', resolve)
@@ -147,8 +191,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
147
191
  isResume: session.hasStarted,
148
192
  })
149
193
 
150
- if (activeTypingTimer) clearInterval(activeTypingTimer)
151
- if (!run.external) activeTypingTimer = setInterval(() => {
194
+ if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
195
+ if (!run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
152
196
  void bot.api.sendChatAction(run.chatId, 'typing').catch(() => {})
153
197
  }, 4000)
154
198
 
@@ -160,24 +204,27 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
160
204
  await withStartLock(async () => {
161
205
  try {
162
206
  await cleanup()
163
- if (code === 0 && !run.external) await control.markSessionStarted(session.sessionId)
164
- await runs.patch(started.id, { status: code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString() })
207
+ if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
208
+ await runs.patch(started.id, { status: run.scheduled && await scheduler.cancelled(run.id) ? 'cancelled' : code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString() })
165
209
  } catch (error) {
166
210
  await runs.patch(started.id, { status: 'failed', endedAt: new Date().toISOString() })
167
211
  console.error('Session completion failed', safeError(error))
168
212
  } finally {
169
- activeChild = null
170
- if (activeTypingTimer) clearInterval(activeTypingTimer)
171
- activeTypingTimer = null
213
+ if (run.scheduled) background.delete(run.id)
214
+ else {
215
+ activeChild = null
216
+ if (activeTypingTimer) clearInterval(activeTypingTimer)
217
+ activeTypingTimer = null
218
+ }
172
219
  }
173
220
  })
174
221
  console.info('run ended', { run_id: started.id, code })
175
- const next = await runs.nextQueued()
222
+ const next = await runs.nextQueued(false)
176
223
  if (next && !shuttingDown) await startJob(next)
177
224
  })().catch((error) => console.error('Run completion failed', error.message))
178
225
  })
179
226
  } catch (error) {
180
- if (activeTypingTimer) {
227
+ if (!run.scheduled && activeTypingTimer) {
181
228
  clearInterval(activeTypingTimer)
182
229
  activeTypingTimer = null
183
230
  }
@@ -186,7 +233,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
186
233
  await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
187
234
  setImmediate(() => {
188
235
  void runs
189
- .nextQueued()
236
+ .nextQueued(false)
190
237
  .then((next) => next && startJob(next))
191
238
  .catch(console.error)
192
239
  })
@@ -202,8 +249,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
202
249
  if (shuttingDown) return
203
250
  const owner = (await control.status()).owner
204
251
  if (!owner) return
252
+ if (!config.channelBackendUrl) {
253
+ await drainTaskRequests()
254
+ for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active') {
255
+ try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
256
+ }
205
257
  await queueUpdateAttention(config.controlDir,owner,runs,await control.captureChoice(aiMenu.initial))
206
- for (const source of await sources.available(owner)) {
258
+ }
259
+ for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
207
260
  try {
208
261
  const batch = await sources.batch(source)
209
262
  unavailableSources.delete(source.id)
@@ -213,18 +266,27 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
213
266
  await sources.remember(source, batch)
214
267
  const groups = new Map<string, SourceEvent[]>()
215
268
  for (const event of batch.events) groups.set(event.conversationId, [...(groups.get(event.conversationId) || []), event])
216
- for (const events of groups.values()) await runs.create({
269
+ for (const events of groups.values()) {
270
+ const task = await tasks.match(source.id, source.bindingId, events)
271
+ await runs.create({
272
+ taskId: task?.id,
217
273
  id: eventRunId(source, events), chatId: owner.telegramChatId, telegramUserId: owner.telegramUserId,
218
274
  texts: [], execution: await control.captureChoice(aiMenu.initial),
219
275
  external: { sourceId: source.id, bindingId: source.bindingId, eventIds: events.map(e => e.id) },
220
276
  })
277
+ }
221
278
  // Acknowledgement follows durable run creation; replay uses the saved batch.
222
279
  await sources.advance(source, batch.cursor)
223
280
  })
224
281
  } catch { unavailableSources.add(source.id) }
225
282
  }
283
+ await runs.running(true)
284
+ if (!config.channelBackendUrl) await scheduler.tick(owner,runs)
285
+ for (const [id,child] of background) {
286
+ if (await scheduler.cancelled(id)) terminateJob(child)
287
+ }
226
288
  for (const run of (await runs.list()).filter(r => r.status === 'queued')) {
227
- if (activeChild || shuttingDown) break
289
+ if (shuttingDown) break
228
290
  await startJob(run)
229
291
  }
230
292
  })().finally(() => { sourceWork = undefined })
@@ -239,6 +301,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
239
301
  let intakeWork: Promise<void> | undefined
240
302
  const collectItem = (item: IncomingItem) => {
241
303
  const message = normalizing?.message
304
+ item.sentAt = message?.date
305
+ item.caption = message?.caption
306
+ item.albumId = message?.media_group_id
242
307
  if (message?.media_group_id) item.text = `[Telegram album: ${message.media_group_id}]\n${item.text}`
243
308
  if (message && !message.text && message.reply_to_message) {
244
309
  const quoted = message.reply_to_message
@@ -284,6 +349,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
284
349
  telegramUserId: first.fromId,
285
350
  messageId: first.messageId,
286
351
  texts: collected.map((item) => item.text),
352
+ items: collected,
287
353
  execution: batch.entries[0].execution,
288
354
  })
289
355
  await inbox.finish(batch.id)
@@ -328,6 +394,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
328
394
  !owner ||
329
395
  origin.telegramUserId !== owner.telegramUserId ||
330
396
  origin.chatId !== owner.telegramChatId ||
397
+ (origin.scheduled && origin.scheduled.pairedAt !== owner.pairedAt) ||
331
398
  item.chatId !== origin.chatId
332
399
  )
333
400
  throw new Error('Outbox ownership mismatch')
@@ -342,7 +409,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
342
409
  ])
343
410
  console.info('run reaction sent', { run_id: item.runId, emoji })
344
411
  } else if (item.type === 'document' && item.documentPath) {
345
- const docPath = await workspaceFile(config.workspace, item.documentPath)
412
+ const docPath = await workspaceFile(origin.scheduled ? await taskWorkspace(config.workspace,origin.id) : config.workspace, item.documentPath)
346
413
  await paceSend()
347
414
  attemptedDelivery = true
348
415
  const sent = await bot.api.sendDocument(item.chatId, new InputFile(docPath), {
@@ -424,7 +491,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
424
491
  const commands = mainCommands
425
492
  const controlCommand = (text?: string) => text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
426
493
  const statusText = async () => {
427
- const running = await runs.running()
494
+ const running = await runs.running(false)
428
495
  const all = await runs.list()
429
496
  const incoming = await inbox.status()
430
497
  const delivery = await runs.deliveryStatus()
@@ -437,8 +504,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
437
504
  `Default: ${ai.presets.find((p) => p.id === ai.defaultId)!.name}`,
438
505
  `Session: ${session?.sessionId.slice(0, 8) || 'none'}`,
439
506
  `Work: ${running ? `running ${running.id}` : 'idle'}`,
507
+ `Background: ${all.filter(r => r.scheduled && r.status === 'running').map(r=>r.id).join(', ') || 'idle'}`,
440
508
  `Queue: ${all.filter((r) => r.status === 'queued').length} runs; ${incoming.pending} incoming messages`,
441
509
  `Failed: ${all.filter((r) => r.status === 'failed').length} runs; ${incoming.failed} incoming batches`,
510
+ `Blocked: ${all.filter((r) => r.blockReason === 'external-execution-unavailable').length} external runs (isolated execution unavailable)`,
442
511
  `Delivery: ${delivery.failed} failed; ${delivery.unknown} unknown/in-flight (inspect before retrying)`,
443
512
  ...(unavailableSources.size ? [`Unavailable event sources: ${[...unavailableSources].join(', ')}`] : []),
444
513
  '/stop stops active work only. /cancel clears pending work only.',
@@ -454,7 +523,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
454
523
  await withStartLock(async () => {
455
524
  count += await inbox.cancel()
456
525
  for (const run of await runs.list()) {
457
- if (run.status !== 'queued') continue
526
+ if (run.status !== 'queued' || run.backendSubmitted) continue
458
527
  await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() })
459
528
  count++
460
529
  }
@@ -505,12 +574,22 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
505
574
  return
506
575
  }
507
576
 
577
+ if (config.channelBackendUrl && ['/new', '/ai', '/settings'].includes(text ?? '')) {
578
+ await ctx.reply('Conversation and model settings are managed in the connected application.')
579
+ return
580
+ }
581
+
508
582
  // Steering & session commands
583
+ if (text === '/stop' && config.channelBackendUrl) {
584
+ await ctx.reply('This channel uses an application backend. Stopping its active job is not supported here; check the application. /cancel removes only pending relay work.')
585
+ return
586
+ }
509
587
  if (text === '/stop') {
510
- const running = await runs.running()
511
- if (running && running.pid) {
588
+ const running = await runs.running(false)
589
+ if ((running && running.pid) || background.size) {
512
590
  try {
513
591
  if (activeChild) terminateJob(activeChild)
592
+ for (const [id,child] of background) { await scheduler.cancel(id); terminateJob(child) }
514
593
  } catch {}
515
594
  if (activeTypingTimer) {
516
595
  clearInterval(activeTypingTimer)
@@ -586,6 +665,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
586
665
  const prompt = `[Attached image staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
587
666
  collectItem({
588
667
  text: prompt,
668
+ attachment: { path: staged.relativePath, type: staged.fileType },
589
669
  messageId: ctx.message.message_id,
590
670
  updateId: ctx.update.update_id,
591
671
  chatId: ctx.chat.id,
@@ -614,6 +694,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
614
694
  const prompt = `[Attached document staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
615
695
  collectItem({
616
696
  text: prompt,
697
+ attachment: { path: staged.relativePath, type: staged.fileType },
617
698
  messageId: ctx.message.message_id,
618
699
  updateId: ctx.update.update_id,
619
700
  chatId: ctx.chat.id,
@@ -693,6 +774,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
693
774
  const original = ctx.callbackQuery.message?.text || ''
694
775
  const updated = `${escapeHtml(original)}\n\n<b>Decision:</b> ${isApproved ? 'Approved ✅' : 'Denied ❌'}`
695
776
  await ctx.editMessageText(updated, { parse_mode: 'HTML' }).catch(() => {})
777
+ if (await tasks.decide(actionId)) { void drainSources(); return }
696
778
  collectItem({
697
779
  text: JSON.stringify({ event: 'approval_decision', actionId, decision, prompt: request?.prompt }),
698
780
  messageId: ctx.callbackQuery.message?.message_id,
@@ -702,6 +784,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
702
784
  })
703
785
  console.info('Approval decision recorded', { actionId, decision: isApproved ? 'approved' : 'denied' })
704
786
  }
787
+ } else if (config.channelBackendUrl && ['menu:new', 'menu:ai', 'menu:settings'].includes(data)) {
788
+ await ctx.answerCallbackQuery()
789
+ await ctx.reply('Conversation and model settings are managed in the connected application.')
705
790
  } else if (await aiMenu.handle(ctx)) {
706
791
  return
707
792
  } else if (data.startsWith('menu:')) {
@@ -728,10 +813,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
728
813
  } else if (action === 'cancel') {
729
814
  await ctx.answerCallbackQuery()
730
815
  await ctx.reply(await cancelPending())
816
+ } else if (action === 'stop' && config.channelBackendUrl) {
817
+ await ctx.answerCallbackQuery()
818
+ await ctx.reply('This channel uses an application backend. Stopping its active job is not supported here; check the application.')
731
819
  } else if (action === 'stop') {
732
- const running = await runs.running()
733
- if (running && running.pid) {
820
+ const running = await runs.running(false)
821
+ if ((running && running.pid) || background.size) {
734
822
  if (activeChild) terminateJob(activeChild)
823
+ for (const [id,child] of background) { await scheduler.cancel(id); terminateJob(child) }
735
824
  await ctx.answerCallbackQuery({ text: 'Run stopped' })
736
825
  await ctx.reply(
737
826
  '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.',
@@ -755,6 +844,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
755
844
  if (intakeTimer) clearTimeout(intakeTimer)
756
845
  if (sourceTimer) clearInterval(sourceTimer)
757
846
  if (activeChild) terminateJob(activeChild)
847
+ for (const child of background.values()) terminateJob(child)
758
848
  if (activeTypingTimer) clearInterval(activeTypingTimer)
759
849
  if (sourceWork) await sourceWork.catch(() => {})
760
850
  if (bot.isRunning()) await bot.stop()
@@ -762,9 +852,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
762
852
 
763
853
  const start = async () => {
764
854
  await initializeWorkspace(config.workspace)
855
+ await scheduler.recover(runs)
765
856
  sourceTimer = setInterval(() => {
766
857
  void drainSources().catch(error => console.error('Event-source drain failed', safeError(error)))
767
858
  }, 1000)
859
+ const taskTimer = setInterval(() => { void drainTaskRequests().catch(console.error) }, 250)
768
860
  const drainTimer = setInterval(() => {
769
861
  void drainOutbox().catch((error) => console.error('Outbox drain failed', error.message))
770
862
  }, 250)
@@ -773,12 +865,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
773
865
  console.log(`ezenciel-agents listening with workspace ${config.workspace}`)
774
866
  console.log(`Authority control state: ${config.controlDir}`)
775
867
  console.log(`CLI executor: ${config.executorCli}`)
776
- await aiMenu.refresh()
868
+ if (!config.channelBackendUrl) await aiMenu.refresh()
777
869
 
778
870
  // Reconcile stale runs and start any queued run on boot
779
- const currentRunning = await runs.running()
871
+ if (config.channelBackendUrl) {
872
+ for (const run of await runs.list()) if (run.status === 'running' && !run.pid) await runs.patch(run.id, { status: 'queued' })
873
+ }
874
+ const currentRunning = await runs.running(false)
780
875
  if (!currentRunning) {
781
- const pendingRun = await runs.nextQueued()
876
+ const pendingRun = await runs.nextQueued(false)
782
877
  if (pendingRun) {
783
878
  console.info('Processing queued run on startup:', pendingRun.id)
784
879
  void startJob(pendingRun)
@@ -796,10 +891,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
796
891
  })
797
892
  } finally {
798
893
  clearInterval(drainTimer)
894
+ clearInterval(taskTimer)
799
895
  if (sourceTimer) clearInterval(sourceTimer)
800
896
  }
801
897
  }
802
- return { bot, start, stop, drainOutbox, drainInbox, drainSources }
898
+ return { bot, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests }
803
899
  }
804
900
 
805
901
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -0,0 +1,13 @@
1
+ // Self-reported exposure is discovery metadata, never an authority grant.
2
+ const fields = ['receivesExternalContent', 'sendsExternally', 'changesRecords', 'requiresReview'];
3
+ export function exposure(value) {
4
+ if (value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value) ||
5
+ Object.keys(value).some(key => !fields.includes(key)) ||
6
+ Object.values(value).some(item => typeof item !== 'boolean')))
7
+ throw new Error('Invalid plugin exposure declaration');
8
+ return Object.fromEntries(fields.map(key => [key, value?.[key] ?? true]));
9
+ }
10
+ export function commandExposure(manifest) {
11
+ return Object.fromEntries(Object.entries(manifest.commands).map(([name, command]) =>
12
+ [name, { declared: command.exposure !== undefined, ...exposure(command.exposure) }]));
13
+ }
@@ -1,3 +1,4 @@
1
+ import { exposure, commandExposure } from './exposure.mjs';
1
2
  import * as fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -83,7 +84,7 @@ export function validate(m,d,files) {
83
84
  if(JSON.stringify(Object.keys(m.commands).sort())!==JSON.stringify(Object.keys(d.commands).sort())) throw Error('Command bindings must match manifest');
84
85
  for(const [alias,c] of Object.entries(m.commands)) {
85
86
  id(alias); if(reserved.has(alias)) throw Error('Reserved alias');
86
- keys(c,['executable','args']); strings(c.args);
87
+ keys(c,['executable','args','exposure']); strings(c.args); exposure(c.exposure);
87
88
  if(!files.has(c.executable)) throw Error('Missing package executable');
88
89
  const b=d.commands[alias];keys(b,['service','argv','suffix']);
89
90
  if(!d.services[b.service] || !strings(b.argv).length) throw Error('Invalid command service'); strings(b.suffix||[]);
@@ -118,14 +119,22 @@ function dockerEnv() {
118
119
  export function run(argv,{capture=false,container}={}) {
119
120
  return new Promise((resolve,reject)=>{
120
121
  const child=spawn('docker',argv,{env:dockerEnv(),stdio:capture?['ignore','pipe','pipe']:['inherit','inherit','inherit']});
121
- let stdout='',stderr='',cancelled=false;
122
+ let stdout='',stderr='',cancelled=false,killTimer;
122
123
  if(capture) {child.stdout.on('data',b=>stdout+=b);child.stderr.on('data',b=>stderr+=b);}
123
- const cancel=signal=>{cancelled=true;child.kill(signal);};
124
+ const cancel=signal=>{cancelled=true;child.kill(signal);killTimer??=setTimeout(()=>child.kill('SIGKILL'),2000);};
124
125
  const term=()=>cancel('SIGTERM'),int=()=>cancel('SIGINT');
125
126
  process.on('SIGTERM',term);process.on('SIGINT',int);
126
- child.once('error',reject);
127
+ child.once('error',error=>{clearTimeout(killTimer);process.off('SIGTERM',term);process.off('SIGINT',int);reject(error);});
127
128
  child.once('close',async(code,signal)=>{process.off('SIGTERM',term);process.off('SIGINT',int);
128
- if(cancelled&&container) await run(['rm','-f',container],{capture:true});
129
+ clearTimeout(killTimer);
130
+ if(cancelled&&container) {
131
+ try {
132
+ const cleanup=await run(['container','rm','--force',container],{capture:true});
133
+ // Compose --rm may already have removed this exact command container.
134
+ if(cleanup.code!==0&&!cleanup.stderr.includes(`No such container: ${container}`))
135
+ return reject(Error(`Cancelled command container cleanup failed: ${cleanup.stderr||cleanup.stdout}`));
136
+ } catch(error) {return reject(error);}
137
+ }
129
138
  resolve({code:cancelled?130:code??(signal?130:1),stdout,stderr});});
130
139
  });
131
140
  }
@@ -142,7 +151,8 @@ async function registry(home) {
142
151
  for(const [alias,plugin] of Object.entries(r.commands)) if(!r.plugins[plugin]?.deployment?.commands?.[alias]) throw Error('Corrupt command registry');
143
152
  return r;
144
153
  }
145
- export async function init(home,workspace,catalogFile,hostConfig) {
154
+ export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
155
+ if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
146
156
  if(typeof home!=='string'||typeof workspace!=='string'||!path.isAbsolute(home)||!path.isAbsolute(workspace)||/[\r\n\0$:,]/.test(home+workspace)) throw Error('Explicit absolute home/workspace required');
147
157
  workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);
148
158
  if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
@@ -171,7 +181,7 @@ export async function init(home,workspace,catalogFile,hostConfig) {
171
181
  }
172
182
  });
173
183
  const index=path.join(workspace,'TOOLS.md');
174
- const prior=await fs.readFile(index,'utf8').catch(e=>{if(e.code==='ENOENT')return fs.readFile(new URL('../../templates/agent/TOOLS.md',import.meta.url),'utf8');throw e;});
184
+ const prior=await fs.readFile(index,'utf8').catch(e=>{if(e.code==='ENOENT')return fs.readFile(new URL(standalone?'../../templates/standalone-tools.md':'../../templates/agent/TOOLS.md',import.meta.url),'utf8');throw e;});
175
185
  await fs.writeFile(index,prior+'\n## Registered plugins\n\nUse `'+path.join(home,'bin','ez')+'` for this agent only.\nDiscover reviewed packages with `ez plugins available`; inspect with `ez plugins inspect <id>`.\nOn an authorized installation request, run `ez plugins install <id>`, then `ez plugins start <id>`.\nRead the installed skill paths from `ez plugins list` before onboarding or provider operations.\nUse `ez tools list` for aliases and `ez <alias> --help` for native commands.\nInstallation does not grant send authority. The registry is the only plugin installation, command and lifecycle authority. Do not create standalone provider launchers or deployments.\n',{mode:0o600});
176
186
  if(hostConfig && path.basename(hostConfig)==='host-executor.json') await (await import('../updates/binding.mjs')).bindUpdates(home,hostConfig);
177
187
  return {ok:true,launcher:path.join(home,'bin','ez'),workspace};
@@ -214,22 +224,27 @@ export async function main(args) {
214
224
  // Only the fixed launcher may supply the leading home binding. Never consume plugin arguments here.
215
225
  let home;if(args[0]==='--home') {home=args[1];args=args.slice(2);}
216
226
  if(args[0]==='enable-updates') {args.shift();const h=take('--home'),host=take('--host-config');if(args.length||!h||!host)throw Error('Supply --home and --host-config');return emit(await (await import('../updates/binding.mjs')).bindUpdates(h,host));}
217
- if(args[0]==='init') {args.shift();const options=[take('--home'),take('--workspace'),take('--catalog'),take('--host-config')];if(args.length)throw Error('Unknown init arguments');return emit(await init(...options));}
227
+ if(!home && (args.length===0 || (args.length===1 && ['--help','-h'].includes(args[0])))) return emit({usage:'ezenciel-agents-tools init --standalone --home /absolute/tools --workspace /absolute/workspace',relay:'Omit --standalone and supply --host-config for a relay binding',discovery:'Use the returned launcher from any local executor; read workspace/TOOLS.md'});
228
+ if(args[0]==='init') {args.shift();const standalone=args.includes('--standalone');if(standalone)args.splice(args.indexOf('--standalone'),1);const options=[take('--home'),take('--workspace'),take('--catalog'),take('--host-config')];if(args.length)throw Error('Unknown init arguments');return emit(await init(...options,standalone));}
218
229
  if(!home || !path.isAbsolute(home)) throw Error('Use the agent-bound launcher, or init --home /absolute/tools --workspace /absolute/mind --catalog /absolute/catalog.json');
219
230
  home=await fs.realpath(home);
220
231
  const config=await json(path.join(home,'config.json'));
221
232
  if(config.schemaVersion!==1 || !path.isAbsolute(config.workspace)) throw Error('Invalid binding');
222
233
  const [group,action,...rest]=args;
223
- if(group==='status'){if(args.length!==1)throw Error('Use status without arguments');return emit(await (await import('../updates/status.mjs')).status(home));}
234
+ if(group==='status'){if(args.length!==1)throw Error('Use status without arguments');await registry(home);return emit(await (await import('../updates/status.mjs')).status(home));}
224
235
  if(group==='updates')return emit(await (await import('../updates/control.mjs')).command(home,args.slice(1)));
225
- if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list','<registered CLI> ...'],scope:home});
236
+ if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list|exposure','<registered CLI> ...'],scope:home});
226
237
  if(group==='plugins'&&(!action||args.includes('--help'))) return emit({commands:['available','list','inspect <id>','install <id>','start <id>','stop <id>','status <id>','logs <id>','uninstall <id>','catalog-add <id> --source PATH --revision HASH','export <id> <artifact> --output PATH'],uninstall:'Stops and removes containers/network and unregisters aliases; retains all volumes and secrets. No data deletion flag.',scope:home});
227
238
  if(group==='plugins'||group==='tools') {
228
239
  args=rest;args=args.filter(a=>a!=='--json');
229
240
  if(action==='available'&&group==='plugins') return emit(config.catalog);
230
241
  const r=await registry(home);
231
242
  if(action==='list') return emit(group==='tools'?r.commands:r.plugins);
232
- if(group==='tools') throw Error('Only tools list is supported; installation registers CLI bindings');
243
+ if(group==='tools' && action==='exposure') {
244
+ if(args.length) throw Error('Use tools exposure without arguments');
245
+ return emit(Object.fromEntries(Object.entries(r.plugins).map(([name, record]) => [name, commandExposure(record.manifest)])));
246
+ }
247
+ if(group==='tools') throw Error('Use tools list or tools exposure; installation registers CLI bindings');
233
248
  const name=args.shift();id(name);
234
249
  if(action==='inspect'||action==='install'||action==='catalog-add') {
235
250
  const source=take('--source')||config.catalog[name]?.source,revision=take('--revision')||config.catalog[name]?.revision;
@@ -238,7 +253,7 @@ export async function main(args) {
238
253
  const p=await snapshot(source);if(p.manifest.id!==name||p.revision!==revision)throw Error('Inspect and pin the exact catalog package first');
239
254
  return locked(home,async()=>{const latest=await json(path.join(home,'config.json'));latest.catalog[name]={source:p.source,revision:p.revision};await atomic(path.join(home,'config.json'),latest);emit({ok:true,plugin:name,revision:p.revision,installed:false});});
240
255
  }
241
- if(action==='inspect') {const p=await snapshot(source);return emit({id:p.manifest.id,source:p.source,revision:p.revision,catalogRevision:config.catalog[name]?.revision??null,catalogMatches:config.catalog[name]?.source===p.source&&config.catalog[name]?.revision===p.revision,inspection:'Read-only; does not update the catalog pin. After review, pass --revision to install or use catalog-add --source --revision.',manifest:p.manifest,deployment:p.deployment});}
256
+ if(action==='inspect') {const p=await snapshot(source);return emit({id:p.manifest.id,source:p.source,revision:p.revision,catalogRevision:config.catalog[name]?.revision??null,catalogMatches:config.catalog[name]?.source===p.source&&config.catalog[name]?.revision===p.revision,inspection:'Read-only; does not update the catalog pin. After review, pass --revision to install or use catalog-add --source --revision.',manifest:p.manifest,exposure:commandExposure(p.manifest),deployment:p.deployment});}
242
257
  return emit(await install(home,config,name,source,revision));
243
258
  }
244
259
  const record=r.plugins[name];if(!record)throw Error('Plugin not installed');
@@ -0,0 +1,33 @@
1
+ import { readdir, readFile } from 'node:fs/promises'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+
5
+ type ProcessInfo = {parent: number; birth: string}
6
+ type Snapshot = Map<number, ProcessInfo>
7
+
8
+ export const matchingProcessIds = (original: Snapshot, current: Snapshot): number[] =>
9
+ [...original].filter(([pid, info]) => info.birth && current.get(pid)?.birth === info.birth).map(([pid]) => pid)
10
+
11
+ export const processSnapshot = async (): Promise<Snapshot> => {
12
+ if (process.platform === 'win32') return new Map()
13
+ if (process.platform !== 'linux') {
14
+ const {stdout} = await promisify(execFile)('/bin/ps', ['-axo', 'pid=,ppid=,lstart='], {timeout: 2000})
15
+ return new Map(stdout.trim().split('\n').flatMap(line => {
16
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/)
17
+ return match ? [[Number(match[1]), {parent:Number(match[2]),birth:match[3]}] as const] : []
18
+ }))
19
+ }
20
+ const scan = async (): Promise<Snapshot> => new Map(await Promise.all(
21
+ (await readdir('/proc')).filter(id => /^\d+$/.test(id)).map(async id => {
22
+ const stat = await readFile(`/proc/${id}/stat`, {encoding:'utf8',signal:AbortSignal.timeout(2000)}).catch(() => '')
23
+ const fields = stat.slice(stat.lastIndexOf(')')+2).split(' ')
24
+ return [Number(id), {parent:Number(fields[1]),birth:fields[19] || ''}] as const
25
+ }),
26
+ ))
27
+ let timer: ReturnType<typeof setTimeout> | undefined
28
+ try {
29
+ return await Promise.race([scan(), new Promise<never>((_,reject) => {
30
+ timer = setTimeout(() => reject(new Error('Process inspection timed out')),2000)
31
+ })])
32
+ } finally { clearTimeout(timer) }
33
+ }