@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.36

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 (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +42 -5
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. package/test/workforce-watch.test.ts +0 -180
package/src/index.ts CHANGED
@@ -4,14 +4,15 @@ import { TelegramSource } from './telegram-source.js'
4
4
  import { Tasks } from './tasks.js'
5
5
  import { taskRequests } from './task-rpc.js'
6
6
  import { executionBlockReason } from './execution-authority.js'
7
+ import { serveDeliverySocket, createLedgerHandler, deliverySocketAlive, deliverySocketPath } from './delivery-socket.js'
8
+ import { packageVersion } from './version.js'
7
9
  import {authorizeDeliveryContext} from './delivery-context.mjs'
8
10
  import { dispatchChannel } from './channel-backend.js'
9
11
  import { randomUUID } from 'node:crypto'
10
12
  import { stat } from 'node:fs/promises'
11
13
  import { Scheduler } from './scheduler.js'
12
- import { scheduledTasksText } from './scheduled-tasks.js'
14
+ import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
13
15
  import { taskWorkspace } from './task-workspace.js'
14
- import { queueUpdateAttention } from './update-attention.js'
15
16
  import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
16
17
  import {removeTaskAttachments} from './task-attachments.js'
17
18
  import { dirname, join, basename } from 'node:path'
@@ -37,14 +38,13 @@ import { createConversationMenu } from './conversation-menu.js'
37
38
  import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
38
39
  import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
39
40
  import { discoverDefaults } from './client-defaults.js'
40
- import { initializeWorkspace } from './workspace.js'
41
+ import { runPromptSuffix } from './prompt-suffix.js'
41
42
  import { softwareStatus } from './software-status.js'
42
- import { PagerDutyStocksMonitor } from './pagerduty.js'
43
43
 
44
44
  export const createRelay = (config: Config, launch = startExecutorJob) => {
45
45
  const safeError = (error: unknown): string => {
46
46
  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, config.pagerDutyRoutingKey]) {
47
+ for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
48
48
  if (secret) message = message.replaceAll(secret, '[redacted]')
49
49
  }
50
50
  return message
@@ -70,19 +70,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
70
70
  let taskTimer: ReturnType<typeof setInterval> | undefined
71
71
  let drainTimer: ReturnType<typeof setInterval> | undefined
72
72
  const codexHome = join(config.controlDir, 'cli', 'codex')
73
- const conversationMenu = createConversationMenu(control, runs)
73
+ const conversationMenu = createConversationMenu(control)
74
74
  const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
75
75
  const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
76
76
  const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
77
- const pagerDuty = config.pagerDutyRoutingKey && config.pagerDutyStocksHealthUrl
78
- ? new PagerDutyStocksMonitor({
79
- routingKey: config.pagerDutyRoutingKey,
80
- healthUrl: config.pagerDutyStocksHealthUrl,
81
- pollMs: config.pagerDutyPollMs!,
82
- failureThreshold: config.pagerDutyFailureThreshold!,
83
- onError: (error) => console.error('PagerDuty Stocks health check failed', safeError(error)),
84
- })
85
- : undefined
86
77
 
87
78
  let activeTypingTimer: ReturnType<typeof setInterval> | null = null
88
79
  let activeBackend = false
@@ -112,6 +103,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
112
103
  controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
113
104
  aiControls: aiMenu,
114
105
  wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
106
+ createTelegramPairing: async (bindingId, owner) => {
107
+ const username = bot?.botInfo?.username
108
+ if (!username || !/^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(username))
109
+ throw new Error('Telegram connection is unavailable')
110
+ const pairing = await control.createApplicationTelegramPairing(bindingId, owner)
111
+ if (!pairing) return { connected: true } as const
112
+ return {
113
+ connected: false as const,
114
+ url: `https://t.me/${username}?start=${pairing.token}`,
115
+ expiresAt: pairing.expiresAt,
116
+ }
117
+ },
118
+ telegramAvailable: () => Boolean(bot?.botInfo?.username),
115
119
  cancel: id => withStartLock(async () => {
116
120
  const run = await runs.get(id)
117
121
  if (run?.status === 'queued') await runs.patch(id, {status:'cancelled',endedAt:new Date().toISOString()})
@@ -176,7 +180,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
176
180
  catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); await releaseExternal(run); return }
177
181
  }
178
182
  if (config.channelBackendUrl) {
179
- if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); await releaseExternal(run); return }
183
+ if (run.external || run.taskId || run.scheduled) { await runs.patch(run.id, { status: 'failed' }); await releaseExternal(run); return }
180
184
  activeBackend = true
181
185
  await runs.patch(run.id, { status: 'running', backendSubmitted: true })
182
186
  void dispatchChannel(config, run).then(async reply => {
@@ -224,14 +228,13 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
224
228
  const launchStarted = performance.now()
225
229
  const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
226
230
  if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
227
- const maintenanceWakeup = run.id.startsWith('r_update_')
228
- const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled || maintenanceWakeup)
231
+ const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
229
232
  const session = startsOwnSession
230
233
  ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
231
234
  : await control.executionSession(started.execution!)
232
235
  const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
233
236
  const { child, cleanup } = await launch(texts, {
234
- workspace: run.scheduled ? await taskWorkspace(config.workspace,run.id) : config.workspace,
237
+ workspace: run.scheduled ? await taskWorkspace(config.controlDir,run.id) : config.workspace,
235
238
  timeoutMs: config.executorTimeoutMs,
236
239
  repairEnabled: config.repairEnabled,
237
240
  runId: started.id,
@@ -240,12 +243,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
240
243
  cli: selected.cli,
241
244
  model: selected.model,
242
245
  effort: selected.effort,
246
+ provider: selected.provider,
243
247
  codexAutoCompactTokens: config.codexAutoCompactTokens,
244
248
  codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
245
249
  sessionId: session.nativeSessionId || session.sessionId,
246
250
  isResume: session.hasStarted,
247
- eventSource: run.external?.sourceId,
248
- onSession: run.external || run.taskId || maintenanceWakeup ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
251
+ promptSuffix: runPromptSuffix(started),
252
+ taskRun: Boolean(run.taskId),
253
+ nativeSession: Boolean(run.scheduled),
254
+ onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
249
255
  })
250
256
  const executionStarted = performance.now()
251
257
  // Attach before disk writes: a fast child can close while PID persistence
@@ -384,7 +390,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
384
390
  for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
385
391
  try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
386
392
  }
387
- if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
388
393
  }
389
394
  for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
390
395
  try {
@@ -448,10 +453,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
448
453
  if (message?.media_group_id) item.text = `[Telegram album: ${message.media_group_id}]\n${item.text}`
449
454
  if (message && (message.chat.type === 'group' || message.chat.type === 'supergroup'))
450
455
  item.text = `[Telegram sender ${message.from?.id}, name ${JSON.stringify(message.from?.first_name)}]\n${item.text}`
451
- if (message && !message.text && message.reply_to_message) {
452
- const quoted = message.reply_to_message
453
- item.text = `[Quoted message ${quoted.message_id}]: ${quoted.text || quoted.caption || '[media]'}\n\n${item.text}`
454
- }
455
456
  collected.push(item)
456
457
  }
457
458
  const scheduleIntake = () => {
@@ -564,7 +565,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
564
565
  ])
565
566
  console.info('run reaction sent', { run_id: item.runId, emoji })
566
567
  } else if (item.type === 'document' && item.documentPath) {
567
- const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.workspace,origin.id) : config.workspace, item.documentPath)
568
+ const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.controlDir,origin.id) : config.workspace, item.documentPath)
568
569
  await paceSend()
569
570
  await authorizeChannelDelivery()
570
571
  attemptedDelivery = true
@@ -622,11 +623,34 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
622
623
  })().finally(() => { outboxWork = undefined }))
623
624
  }
624
625
 
626
+ // Delivery socket: cross-process producers (engine children, plugin
627
+ // children, host executor, CLIs) enqueue and query the memory ledger
628
+ // through the shared handler in delivery-socket.ts. The pump below stays
629
+ // the single delivery executor; the socket only admits work into it.
630
+ let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
631
+ const handleDeliveryOp = createLedgerHandler(config.controlDir, {
632
+ wake: () => { void drainOutbox() },
633
+ status: () => ({
634
+ polling: Boolean(bot?.isRunning()),
635
+ applicationOnly: !telegramEnabled,
636
+ telegramConfigured: config.telegramBotToken !== '',
637
+ version: packageVersion,
638
+ }),
639
+ })
640
+
625
641
  const checkOwner = async (ctx: Context): Promise<boolean> => {
626
642
  if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
627
643
  const state = await control.status()
628
644
  if (!telegramOwner(state.owner)) {
629
645
  if (ctx.chat?.type !== 'private') return false
646
+ const applicationToken = ctx.message?.text?.trim().match(/^\/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]{43})$/)?.[1]
647
+ if (applicationToken) {
648
+ const linked = await applicationChannel.claimTelegramConnection(applicationToken, ctx.from.id, ctx.chat.id)
649
+ await ctx.reply(linked
650
+ ? 'Telegram is connected to your Ez agent. You can keep chatting here.'
651
+ : 'This Telegram connection link expired or is no longer valid. Create a new one in the connected application.', { link_preview_options: { is_disabled: true } })
652
+ return false
653
+ }
630
654
  const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
631
655
  if (result === 'requested')
632
656
  await ctx.reply('Owner approval is pending. Confirm this request through the local setup assistant.')
@@ -653,18 +677,29 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
653
677
  const commands = [...mainCommands, ...(launcher ? [{ command: launcher.command, description: launcher.label }] : [])]
654
678
  const controlCommand = (text?: string) => /^\/rename(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
655
679
  ? '/rename' : text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
680
+ const tasksCommand = (text?: string): number | undefined | null => {
681
+ const match = text?.trim().match(/^\/tasks(?:@[a-zA-Z0-9_]+)?(?:\s+([1-9]\d*))?$/)
682
+ return match ? (match[1] ? Number(match[1]) : undefined) : null
683
+ }
684
+ const isTasksCommand = (text?: string): boolean => /^\/tasks(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
656
685
  const statusKeyboard = () => new InlineKeyboard()
657
686
  .text('Stop active work', 'menu:stop').text('Cancel queue', 'menu:cancel').row()
658
- .text('Retry failed incoming message', 'menu:retry').row()
659
- .text('Scheduled tasks', 'menu:scheduled-tasks')
687
+ .text('Retry failed incoming message', 'menu:retry')
660
688
  const scheduledTasks = async () => {
661
689
  const owner = (await control.status()).owner
662
690
  if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
663
691
  // This intentionally uses the reader that does not create a schedules directory.
664
- return scheduledTasksText(await scheduler.listActiveReadOnly(await runs.list()), owner)
692
+ const active = await scheduler.listActiveReadOnly(await runs.list())
693
+ return { owner, active, text: scheduledTasksText(active, owner) }
665
694
  }
666
- const replyScheduledTasks = async (ctx: Context) => {
667
- for (const part of splitTelegramText(await scheduledTasks())) await ctx.reply(part)
695
+ const replyScheduledTasks = async (ctx: Context, number?: number) => {
696
+ const result = await scheduledTasks()
697
+ const text = typeof result === 'string' ? result : number === undefined ? result.text : (() => {
698
+ const owned = ownedScheduledTasks(result.active, result.owner)
699
+ const schedule = owned[number - 1]
700
+ return schedule ? scheduledTaskDetailText(schedule, number, owned.length) : `Scheduled task ${number} was not found. Use /tasks to list active tasks.`
701
+ })()
702
+ for (const part of splitTelegramText(text)) await ctx.reply(part)
668
703
  }
669
704
  const statusText = async () => {
670
705
  const running = await runs.running(false)
@@ -693,7 +728,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
693
728
  '🟢 Ez is online',
694
729
  '',
695
730
  'System',
696
- ...await softwareStatus(config.controlDir),
731
+ ...await softwareStatus(config.controlDir, config.isolation),
697
732
  `AI: ${selected.name} (${presetLabel(displayedSelected)})`,
698
733
  '',
699
734
  'Work',
@@ -770,7 +805,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
770
805
  }
771
806
  const message = ctx.message
772
807
  const command = controlCommand(message?.text)
773
- if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) return next()
808
+ if ((command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) || tasksCommand(message?.text) !== null || isTasksCommand(message?.text)) return next()
774
809
  const ordinary = message && (message.text || message.photo || message.document || message.voice)
775
810
  const approval = ctx.callbackQuery?.data?.startsWith('approval:')
776
811
  if (!ordinary && !approval) return next()
@@ -814,6 +849,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
814
849
  return
815
850
  }
816
851
 
852
+ const taskNumber = tasksCommand(ctx.message.text)
853
+ if (taskNumber !== null) {
854
+ await replyScheduledTasks(ctx, taskNumber)
855
+ return
856
+ }
857
+ if (isTasksCommand(ctx.message.text)) {
858
+ await ctx.reply('Usage: /tasks [number]. Use /tasks to list active tasks.')
859
+ return
860
+ }
861
+
817
862
  if (text === '/help') {
818
863
  await ctx.reply([...commands, ...aliases.filter((a) => !commands.some((c) => c.command === a.command))]
819
864
  .map((c) => `/${c.command} — ${c.description}`).join('\n'))
@@ -880,19 +925,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
880
925
  return
881
926
  }
882
927
 
883
- // Quoted reply context forwarding
884
- let promptText = ctx.message.text
885
- if (ctx.message.reply_to_message) {
886
- const quoted = ctx.message.reply_to_message
887
- const quotedText = ('text' in quoted && quoted.text) || ('caption' in quoted && quoted.caption) || ''
888
- const quotedSender = quoted.from?.is_bot ? 'Agent' : 'Owner'
889
- if (quotedText) {
890
- promptText = `[Quoted message from ${quotedSender} (ID: ${quoted.message_id})]: "${quotedText}"\n\n${promptText}`
891
- }
892
- }
893
-
894
928
  collectItem({
895
- text: promptText,
929
+ text: ctx.message.text,
896
930
  messageId: ctx.message.message_id,
897
931
  updateId: ctx.update.update_id,
898
932
  chatId: ctx.chat.id,
@@ -910,7 +944,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
910
944
  const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
911
945
  const buffer = await downloadTelegramFile(fileUrl)
912
946
  const fileName = sanitizeFileName(basename(fileInfo.file_path) || 'photo.jpg')
913
- const staged = await stageChatAttachment(config.workspace, fileName, buffer, ctx.message.caption ?? '')
947
+ const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
914
948
  collectItem({
915
949
  text: staged.text,
916
950
  attachment: staged.attachment,
@@ -937,7 +971,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
937
971
  const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
938
972
  const buffer = await downloadTelegramFile(fileUrl)
939
973
  const fileName = sanitizeFileName(doc.file_name || basename(fileInfo.file_path) || 'document.bin')
940
- const staged = await stageChatAttachment(config.workspace, fileName, buffer, ctx.message.caption ?? '')
974
+ const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
941
975
  collectItem({
942
976
  text: staged.text,
943
977
  attachment: staged.attachment,
@@ -1103,6 +1137,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1103
1137
  shuttingDown = true
1104
1138
  runtimeStarted = false
1105
1139
  pollingAbort.abort()
1140
+ await deliveryServer?.stop().catch(() => {})
1141
+ deliveryServer = null
1106
1142
  await applicationChannel.stop()
1107
1143
  wakePollRetry?.()
1108
1144
  if (intakeTimer) clearTimeout(intakeTimer)
@@ -1118,7 +1154,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1118
1154
  })
1119
1155
  await Promise.all(completions)
1120
1156
  await Promise.all([taskWork, outboxWork].map(work => work?.catch(() => {})))
1121
- pagerDuty?.stop()
1122
1157
  try {
1123
1158
  if (bot?.isRunning()) await bot.stop()
1124
1159
  } finally {
@@ -1129,10 +1164,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1129
1164
  const start = async () => {
1130
1165
  let failed = false
1131
1166
  try {
1132
- await initializeWorkspace(config.workspace)
1167
+ // Workspace seeding and managed-block replacement happen at install or
1168
+ // upgrade only; relay startup never writes the agent workspace.
1169
+ // The delivery socket is the cross-process ledger transport and the
1170
+ // single-relay guard (replacing relay.lock): a live sibling answers the
1171
+ // ping and this relay refuses to split-brain the agent.
1172
+ if (await deliverySocketAlive(deliverySocketPath(config.controlDir)).catch(() => false))
1173
+ throw new Error(`Another relay owns ${config.controlDir}; stop it before starting a second one`)
1174
+ const deliveryTcpPort = Number(process.env.EZ_DELIVERY_TCP_PORT ?? '')
1175
+ deliveryServer = await serveDeliverySocket(config.controlDir, handleDeliveryOp, {
1176
+ ...(Number.isSafeInteger(deliveryTcpPort) && deliveryTcpPort >= 1024 && deliveryTcpPort <= 65535 ? { tcpPort: deliveryTcpPort } : {}),
1177
+ })
1133
1178
  if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
1134
1179
  runtimeStarted = true
1135
- pagerDuty?.start()
1136
1180
  const owner = (await control.status()).owner
1137
1181
  if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
1138
1182
  await scheduler.recover(runs)
@@ -1209,7 +1253,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1209
1253
  }
1210
1254
  }
1211
1255
  }
1212
- return { bot: bot!, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
1256
+ return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
1213
1257
  }
1214
1258
 
1215
1259
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -11,7 +11,7 @@ const inside = (parent: string, child: string) => {
11
11
  }
12
12
 
13
13
  // Configuration only: no provider calls, owner approval, service start, or AI turn.
14
- export const configureInstallation = async (directory: string, cli: string, token?: string) => {
14
+ export const configureInstallation = async (directory: string, cli: string, token?: string, purposeFile?: string) => {
15
15
  let executor: string
16
16
  try { executor = resolveExecutor(cli).name }
17
17
  catch { throw new Error('Unsupported executor. See ezenciel-agents-setup status.') }
@@ -44,7 +44,7 @@ export const configureInstallation = async (directory: string, cli: string, toke
44
44
  if (!quote) throw new Error('Existing .env has a value requiring manual quoting; it was not changed.')
45
45
  return `${key}=${quote}${value}${quote}\n`
46
46
  }).join('')
47
- const created = await initializeWorkspace(workspace)
47
+ const created = await initializeWorkspace(workspace, purposeFile)
48
48
  const temporary = `${envFile}.${randomUUID()}.tmp`
49
49
  try {
50
50
  await writeFile(temporary, content, { mode: 0o600, flag: 'wx' })
@@ -4,6 +4,10 @@ import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createHash, randomUUID } from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
+ import { createServer } from 'node:net';
8
+ import { parseEnv } from 'node:util';
9
+ import { callDeliverySocket } from './delivery-socket-client.mjs';
10
+ import { ledgerOverlayName, ledgerOverlayYaml } from './ledger-endpoint.mjs';
7
11
 
8
12
  const root=fileURLToPath(new URL('../',import.meta.url));
9
13
  const read=async file=>JSON.parse(await fs.readFile(file,'utf8'));
@@ -49,50 +53,123 @@ export async function installationStatus(deployment) {
49
53
  const paired=Boolean(owner&&Number.isSafeInteger(owner.telegramUserId)&&owner.telegramUserId>0&&Number.isSafeInteger(owner.telegramChatId)&&(owner.kind==='group'?owner.telegramChatId<0:owner.kind===undefined&&owner.telegramChatId>0)&&Number.isFinite(Date.parse(owner.pairedAt)));
50
54
  const relay=await read(path.join(control,'heartbeat.json')).catch(absent),host=await read(path.join(control,'host-executor/heartbeat.json')).catch(absent);
51
55
  const fresh=(h,ms)=>Boolean(h&&Number.isFinite(h.at)&&h.at<=Date.now()+1000&&Date.now()-h.at<ms);
52
- const runtimeReady=Boolean(relay?.polling&&fresh(relay,20000)&&fresh(host,15000));
56
+ let isolated=false;
57
+ try { isolated=(await read(path.join(deployment,'agent.json'))).isolation==='isolated'; } catch { isolated=false; }
58
+ const runtimeReady=Boolean(relay?.polling&&fresh(relay,20000)&&(isolated||fresh(host,15000)));
59
+ // Delivery receipts live in relay memory and are re-authorized server-side;
60
+ // ask the running relay. A restart drops receipts by design.
53
61
  let reply=null;
54
- if(paired)for(const name of await fs.readdir(path.join(control,'outbox')).catch(e=>{if(e.code==='ENOENT')return [];throw e;})) {
55
- if(!name.endsWith('.sent.json'))continue;
56
- const item=await read(path.join(control,'outbox',name));
57
- if(!/^tg_\d+$/.test(item.runId||'')||item.chatId!==owner.telegramChatId||(item.type&&item.type!=='message')||!Array.isArray(item.receipt?.messageIds)||!item.receipt.messageIds.length||!item.receipt.messageIds.every(n=>Number.isSafeInteger(n)&&n>0))continue;
58
- const delivered=Date.parse(item.receipt.deliveredAt);if(!Number.isFinite(delivered)||delivered<Date.parse(owner.pairedAt)||delivered>Date.now())continue;
59
- const r=await read(path.join(control,'runs',item.runId+'.json')).catch(absent);
60
- if(r?.status==='completed'&&!r.external&&r.chatId===owner.telegramChatId&&Number.isSafeInteger(r.telegramUserId)&&r.telegramUserId>0&&(owner.kind==='group'||r.telegramUserId===owner.telegramUserId)&&(!reply||delivered>Date.parse(reply.deliveredAt)))reply={runId:item.runId,messageIds:item.receipt.messageIds,deliveredAt:item.receipt.deliveredAt};
61
- }
62
+ if(paired)reply=(await callDeliverySocket(path.join(control,'delivery.sock'),{op:'latestReply'},2000).catch(()=>null))?.reply??null;
62
63
  return {deployment,configured,runtimeReady,ownerPaired:paired,telegramReplyVerified:Boolean(reply),reply,
63
64
  stage:!configured?'not-configured':!runtimeReady?'runtime-offline':!paired?'awaiting-owner':!reply?'awaiting-telegram-reply':'ready-for-telegram-plugin-request',
64
- note:'Read-only: a saved receipt is historical delivery evidence, not a fresh live probe or proof of reboot persistence. Request plugins through the working Telegram conversation.'};
65
+ note:'Read-only: a live receipt from the running relay is delivery evidence; it is not retained across a relay restart. Request plugins through the working Telegram conversation.'};
66
+ }
67
+ // Existing host-capable deployments created before the loopback endpoint need
68
+ // one idempotent migration: a published loopback port in docker.env plus the
69
+ // generated overlay that maps it. New agents receive both at create time.
70
+ const envLine = (text, key, value) => {
71
+ if (/['\r\n\0]/.test(value)) throw Error('Unsafe deployment value');
72
+ const line = `${key}='${value}'`;
73
+ const pattern = new RegExp(`^${key}=.*$`, 'm');
74
+ return pattern.test(text) ? text.replace(pattern, () => line) : text.replace(/\n*$/, '\n') + line + '\n';
75
+ }
76
+
77
+ const freeLoopbackPort = async (start = 20000) => {
78
+ for (let port = start; port < start + 500 && port <= 65535; port++) {
79
+ const free = await new Promise(resolve => {
80
+ const probe = createServer();
81
+ probe.once('error', () => resolve(false));
82
+ probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true)));
83
+ });
84
+ if (free) return port;
85
+ }
86
+ throw Error('No free loopback port in the deployment range');
87
+ };
88
+
89
+ const ledgerPortStart = name => 20000 + createHash('sha256').update(name).digest().readUInt16BE(0) % 10000;
90
+
91
+ export async function migrateLedger({ deployment } = {}) {
92
+ if (!path.isAbsolute(deployment || '')) throw Error('Supply --deployment with the absolute deployment directory');
93
+ const directory = path.resolve(deployment), envFile = path.join(directory, 'docker.env');
94
+ const text = await fs.readFile(envFile, 'utf8').catch(error => { if (error.code === 'ENOENT') throw Error('Deployment docker.env not found'); throw error; });
95
+ const values = parseEnv(text);
96
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(values.COMPOSE_PROJECT_NAME || '')) throw Error('Invalid deployment COMPOSE_PROJECT_NAME');
97
+ if (values.EZ_ISOLATION === 'isolated' || values.EZ_EXECUTOR_TRANSPORT === 'local')
98
+ return { ok: true, deployment: directory, changed: false, note: 'Isolated deployment; no host ledger endpoint is needed.' };
99
+ if (!values.COMPOSE_FILE) throw Error('Deployment COMPOSE_FILE not found');
100
+ const overlay = path.join(directory, ledgerOverlayName);
101
+ const files = values.COMPOSE_FILE.split(path.delimiter).filter(Boolean);
102
+ const configured = values.EZ_DELIVERY_TCP_PORT !== undefined && files.includes(overlay) &&
103
+ await fs.stat(overlay).then(info => info.isFile(), () => false);
104
+ if (configured) return { ok: true, deployment: directory, changed: false, port: Number(values.EZ_DELIVERY_TCP_PORT) };
105
+ const port = values.EZ_DELIVERY_TCP_PORT !== undefined ? Number(values.EZ_DELIVERY_TCP_PORT) : await freeLoopbackPort(ledgerPortStart(values.COMPOSE_PROJECT_NAME));
106
+ if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw Error('Invalid EZ_DELIVERY_TCP_PORT');
107
+ const next = envLine(envLine(text, 'EZ_DELIVERY_TCP_PORT', String(port)), 'COMPOSE_FILE', [...new Set([...files, overlay])].join(path.delimiter));
108
+ // Publish the overlay before docker.env references it: a crash in between
109
+ // leaves an unreferenced file instead of a deployment Compose cannot start.
110
+ await fs.writeFile(overlay, ledgerOverlayYaml(), { mode: 0o600 });
111
+ const temporary = `${envFile}.${process.pid}.tmp`;
112
+ await fs.writeFile(temporary, next, { mode: 0o600 });
113
+ await fs.rename(temporary, envFile);
114
+ return { ok: true, deployment: directory, changed: true, port, overlay };
65
115
  }
116
+
66
117
  async function fingerprintOf(source,invoke) {
67
118
  const listing=JSON.parse(await invoke('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:source}));
68
119
  const hash=createHash('sha256');
69
120
  for(const f of listing[0].files){const file=path.resolve(source,f.path);if(!file.startsWith(source+path.sep))throw Error('Invalid package path');const data=await fs.readFile(file);hash.update(JSON.stringify([f.path,f.mode,data.length]));hash.update(data);}
70
121
  return hash.digest('hex');
71
122
  }
72
- export async function build({home=defaultHome(),source=root},invoke=run) {
123
+ // Source-checkout builds are RCs. The reviewed commit is the only SHA that may
124
+ // label an installable image, so a dirty tree can never become one and /status
125
+ // never falls back to the bare package version on a deployment.
126
+ export const rcLabel = /^\d+\.\d+\.\d+-beta\.\d+\.rc\.[1-9]\d*$/;
127
+ async function buildIdentity(source,label,invoke) {
128
+ const git=async args=>{try{return await invoke('git',['-C',source,...args]);}catch{return undefined;}};
129
+ // The package root itself must be the checkout root; a tarball unpacked
130
+ // inside an unrelated repository is not a source checkout.
131
+ const top=await git(['rev-parse','--show-toplevel']);
132
+ const atRoot=Boolean(top)&&await fs.realpath(top).then(value=>value===source,()=>false);
133
+ if(!atRoot) {
134
+ if(label!==undefined)throw Error('--label requires a git checkout so the RC pins the reviewed commit');
135
+ return {};
136
+ }
137
+ if(label===undefined)throw Error('A source checkout requires --label X.Y.Z-beta.N.rc.M; commit, push and open the PR first');
138
+ if(!rcLabel.test(label))throw Error('RC label must look like X.Y.Z-beta.N.rc.M');
139
+ if((await git(['status','--porcelain']))!=='')throw Error('Commit the reviewed source before an RC build; uncommitted work cannot be installed as an RC');
140
+ const sha=await git(['rev-parse','HEAD']);
141
+ if(!/^[a-f0-9]{40}$/.test(sha||''))throw Error('RC builds require the reviewed commit SHA');
142
+ return {label,sha};
143
+ }
144
+ export async function build({home=defaultHome(),source=root,label},invoke=run) {
73
145
  source=await fs.realpath(source);
74
- const fingerprint=await fingerprintOf(source,invoke),image='ezenciel-agents:install-'+fingerprint.slice(0,24);
75
- const dir=path.join(path.resolve(home),'builds',fingerprint);await fs.mkdir(dir,{recursive:true,mode:0o700});
146
+ const identity=await buildIdentity(source,label,invoke);
147
+ const fingerprint=await fingerprintOf(source,invoke);
148
+ const artifact=createHash('sha256').update(JSON.stringify([fingerprint,identity.label??'',identity.sha??''])).digest('hex');
149
+ const image='ezenciel-agents:install-'+artifact.slice(0,24);
150
+ const dir=path.join(path.resolve(home),'builds',artifact);await fs.mkdir(dir,{recursive:true,mode:0o700});
76
151
  const lock=path.join(dir,'lock'),receipt=path.join(dir,'status.json'),log=path.join(dir,'build.log');
77
152
  let handle;
78
153
  try{handle=await fs.open(lock,'wx',0o600);}catch(e){if(e.code!=='EEXIST')throw e;return {state:'busy-or-interrupted',image,log,note:'An existing build owns this artifact. Inspect its status/log and owning process; do not start another build or remove a live lock.'};}
79
154
  try {
80
- await handle.writeFile(JSON.stringify({pid:process.pid,source,image}));
155
+ await handle.writeFile(JSON.stringify({pid:process.pid,source,image,...identity}));
81
156
  const prior=await read(receipt).catch(absent);
82
157
  if(prior?.state==='completed')try{if(await invoke('docker',['image','inspect','--format','{{.Id}}',image])===prior.imageId)return {...prior,reused:true};}catch{/* Image was removed; rebuild under the same exclusive lock. */}
83
- await atomic(receipt,{state:'building',pid:process.pid,source,image,log});
84
- console.error(JSON.stringify({state:'building',image,log}));
158
+ await atomic(receipt,{state:'building',pid:process.pid,source,image,log,...identity});
159
+ console.error(JSON.stringify({state:'building',image,log,...identity}));
160
+ const buildArgs=identity.label?['--build-arg',`BUILD_TAG=${identity.label}`,'--build-arg',`BUILD_SHA=${identity.sha}`]:[];
161
+ const tags=[image,...(identity.label?[`ezenciel-agents:${identity.label}`]:[])];
85
162
  const output=await fs.open(log,'a',0o600);
86
- try{await invoke('docker',['build','--progress','plain','--target','runtime','-t',image,source],{log:output.fd,timeout:3600000});}finally{await output.close();}
163
+ try{await invoke('docker',['build','--progress','plain','--target','runtime',...buildArgs,...tags.flatMap(t=>['-t',t]),source],{log:output.fd,timeout:3600000});}finally{await output.close();}
87
164
  if(await fingerprintOf(source,invoke)!==fingerprint)throw Error('Package changed during build; keep the source stable and retry');
88
- const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log};await atomic(receipt,result);return result;
89
- }catch(e){await atomic(receipt,{state:'failed',source,image,log,error:e.message});throw e;}
165
+ const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log,...identity};await atomic(receipt,result);return result;
166
+ }catch(e){await atomic(receipt,{state:'failed',source,image,log,error:e.message,...identity});throw e;}
90
167
  finally{await handle.close();await fs.rm(lock);}
91
168
  }
92
169
  export async function main(args) {
93
170
  const [action,...rest]=args;const options={};
94
- if(!action||action==='--help'){console.log(JSON.stringify({commands:['preflight --executor <name-or-absolute-path> [--home PATH]','build [--home PATH]','status --deployment PATH'],note:'Host prerequisites and main-agent diagnostics only. Initialize an empty registry; verify Telegram before asking the installed agent to add plugins.'}));return;}
95
- for(let i=0;i<rest.length;i+=2){if(!['--home','--executor','--deployment'].includes(rest[i])||!rest[i+1]||Object.hasOwn(options,rest[i].slice(2)))throw Error('Invalid arguments');options[rest[i].slice(2)]=rest[i+1];}
96
- const allowed={preflight:['home','executor'],build:['home'],status:['deployment']};if(!allowed[action]||Object.keys(options).some(k=>!allowed[action].includes(k)))throw Error('Invalid action/options');
97
- const result=action==='preflight'?await preflight(options):action==='build'?await build(options):await installationStatus(options.deployment);console.log(JSON.stringify(result));if(result.ok===false)process.exitCode=1;
171
+ if(!action||action==='--help'){console.log(JSON.stringify({commands:['preflight --executor <name-or-absolute-path> [--home PATH]','build [--home PATH] [--label X.Y.Z-beta.N.rc.M]','migrate-ledger --deployment PATH','status --deployment PATH'],note:'Source checkouts build RCs only: commit and open the PR first, then pass the next --label. migrate-ledger adds the loopback host endpoint to an existing host-capable deployment. Host prerequisites and main-agent diagnostics only; initialize an empty registry and verify Telegram before asking the installed agent to add plugins.'}));return;}
172
+ for(let i=0;i<rest.length;i+=2){if(!['--home','--executor','--deployment','--label'].includes(rest[i])||!rest[i+1]||Object.hasOwn(options,rest[i].slice(2)))throw Error('Invalid arguments');options[rest[i].slice(2)]=rest[i+1];}
173
+ const allowed={preflight:['home','executor'],build:['home','label'],'migrate-ledger':['deployment'],status:['deployment']};if(!allowed[action]||Object.keys(options).some(k=>!allowed[action].includes(k)))throw Error('Invalid action/options');
174
+ const result=action==='preflight'?await preflight(options):action==='build'?await build(options):action==='migrate-ledger'?await migrateLedger(options):await installationStatus(options.deployment);console.log(JSON.stringify(result));if(result.ok===false)process.exitCode=1;
98
175
  }
@@ -0,0 +1,25 @@
1
+ export type IsolationClass = 'isolated' | 'host-capable'
2
+
3
+ export const isolationTransport = (isolation: IsolationClass): 'local' | 'host' =>
4
+ isolation === 'host-capable' ? 'host' : 'local'
5
+
6
+ export const parseIsolationClass = (value: string): IsolationClass => {
7
+ if (value !== 'isolated' && value !== 'host-capable') throw new Error('Isolation must be isolated or host-capable')
8
+ return value
9
+ }
10
+
11
+ // Isolated runs the native CLI in the relay. Host-capable reuses the installer UID.
12
+ // Unlabeled host transport stays host-capable so existing deployments are not flipped.
13
+ export const resolveIsolation = (env: NodeJS.ProcessEnv = process.env): IsolationClass => {
14
+ const labeled = env.EZ_ISOLATION?.trim()
15
+ if (labeled) parseIsolationClass(labeled)
16
+ const transport = env.EZ_EXECUTOR_TRANSPORT?.trim() || ''
17
+ if (env.EZ_CHANNEL_BACKEND_URL?.trim()) {
18
+ if (labeled === 'host-capable' || transport === 'host') throw new Error('Host-capable isolation requires the host native CLI, not a channel backend')
19
+ return labeled ? parseIsolationClass(labeled) : 'isolated'
20
+ }
21
+ const isolation = labeled ? parseIsolationClass(labeled) : transport === 'host' ? 'host-capable' : 'isolated'
22
+ const expected = isolationTransport(isolation)
23
+ if ((transport || expected) !== expected) throw new Error(`Isolation ${isolation} requires EZ_EXECUTOR_TRANSPORT=${expected}`)
24
+ return isolation
25
+ }
@@ -0,0 +1,2 @@
1
+ export declare const ledgerOverlayName: string
2
+ export declare const ledgerOverlayYaml: () => string
@@ -0,0 +1,17 @@
1
+ // One source for the host-capable ledger overlay: agent creation, the
2
+ // deployment migration and its tests must emit the same compose fragment.
3
+ export const ledgerOverlayName = 'ledger.compose.yaml'
4
+
5
+ export const ledgerOverlayYaml = () => [
6
+ '# Host-capable execution: on Docker Desktop/OrbStack the control-volume',
7
+ '# Unix socket is not connectable from the host, so the relay serves its',
8
+ '# memory ledger on this published loopback port and requests authenticate',
9
+ '# with the per-run token in the control directory.',
10
+ 'services:',
11
+ ' relay:',
12
+ ' environment:',
13
+ ' EZ_DELIVERY_TCP_PORT: ${EZ_DELIVERY_TCP_PORT:?}',
14
+ ' ports:',
15
+ ' - "127.0.0.1:${EZ_DELIVERY_TCP_PORT:?}:${EZ_DELIVERY_TCP_PORT:?}"',
16
+ '',
17
+ ].join('\n')
package/src/menu.ts CHANGED
@@ -13,17 +13,18 @@ export const mainCommands = [
13
13
  { command: 'rename', description: 'Rename current conversation' },
14
14
  { command: 'ai', description: 'Choose AI' },
15
15
  { command: 'status', description: 'Work status' },
16
+ { command: 'tasks', description: 'Scheduled tasks' },
16
17
  ]
17
18
 
18
19
  export const mainKeyboard = () => new InlineKeyboard()
19
20
  .text('New conversation', 'menu:new').text('Conversations', 'menu:chats').row()
20
- .text('Choose AI', 'menu:ai')
21
- .text('Work status', 'menu:status')
21
+ .text('Choose AI', 'menu:ai').row()
22
+ .text('Work status', 'menu:status').text('Scheduled tasks', 'menu:scheduled-tasks')
22
23
 
23
24
  const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
24
25
 
25
26
  const matchesModel = (preset: AiPreset, model: ModelChoice) =>
26
- model.cli === preset.cli && (preset.model === undefined || model.model === preset.model) &&
27
+ model.cli === preset.cli && model.provider === preset.provider && (preset.model === undefined || model.model === preset.model) &&
27
28
  (preset.effort === undefined || model.efforts.includes(preset.effort))
28
29
 
29
30
  // Short-lived opaque button IDs: no model names or executable arguments from callbacks.
@@ -33,13 +34,20 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
33
34
  const initial = chatPreset(cli)
34
35
  const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
35
36
  if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
37
+ // Local execution runs Codex with CODEX_HOME set to the agent-bound control
38
+ // directory, but the default catalog probed the process home instead. Bind it
39
+ // to the same agent-bound home so /ai lists the models Codex can actually run.
40
+ else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome)
36
41
  const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome }))
37
42
  const validate = async (preset: AiPreset) => {
38
43
  assertEffort(preset.effort, preset.model, preset.cli)
39
44
  if (preset.id === initial.id) return
40
45
  if (preset.id.startsWith('detected_')) {
41
- const detected = await discoverDefaults(workspace, { codexHome })
42
- if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
46
+ if (host) await validateSelection(preset, await catalog(), async name => (await catalog()).some(model => model.cli === name))
47
+ else {
48
+ const detected = await discoverDefaults(workspace, { codexHome })
49
+ if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
50
+ }
43
51
  } else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
44
52
  }
45
53
  const buttons = new Map<string, { expires: number; action: (ctx: Context) => Promise<void> }>()
@@ -56,7 +64,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
56
64
  const state = (await control.status()).ai
57
65
  if (!state) throw new Error('AI settings not initialized')
58
66
  const current = state.presets.find((p) => p.id === state.selectedId)!
59
- const fresh = current.cli !== preset.cli || Boolean(session && !session.cli)
67
+ const fresh = current.cli !== preset.cli || current.provider !== preset.provider || Boolean(session && !session.cli)
60
68
  if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
61
69
  return { preset, fresh }
62
70
  }
@@ -116,9 +124,9 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
116
124
  const state = (await control.status()).ai
117
125
  if (!state) throw new Error('AI settings not initialized')
118
126
  const candidate: AiPreset = { id: randomBytes(8).toString('hex'),
119
- name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, model: model.model, effort }
127
+ name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, model: model.model, effort }
120
128
  const stored = persistedPreset(candidate)
121
- const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.model === stored.model && preset.effort === stored.effort)
129
+ const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.provider === stored.provider && preset.model === stored.model && preset.effort === stored.effort)
122
130
  const preset = existing ?? candidate
123
131
  await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
124
132
  await control.savePreset(preset, guard)