@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37

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 (142) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +96 -0
  5. package/Dockerfile +20 -11
  6. package/README.md +10 -13
  7. package/THIRD_PARTY_NOTICES.md +7 -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 +41 -4
  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 +28 -6
  17. package/docs/application-channel.md +32 -10
  18. package/docs/architecture/ai-selection.md +10 -2
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +98 -11
  22. package/docs/local-qa.md +2 -2
  23. package/docs/plugin-guide.md +202 -0
  24. package/docs/plugins.md +48 -2
  25. package/docs/releasing.md +13 -0
  26. package/docs/responsive-channels.md +1 -1
  27. package/docs/scheduling.md +4 -4
  28. package/docs/setup.md +11 -5
  29. package/docs/upgrades.md +46 -23
  30. package/package.json +5 -5
  31. package/scripts/smoke-scheduler.ts +10 -3
  32. package/scripts/smoke.ts +14 -11
  33. package/src/agent-guidance.ts +6 -9
  34. package/src/agent-install.ts +28 -7
  35. package/src/ai-cli.ts +1 -1
  36. package/src/ai.ts +131 -5
  37. package/src/application-channel.ts +78 -7
  38. package/src/application-cli.ts +2 -2
  39. package/src/approval-cli.ts +14 -7
  40. package/src/approval.ts +27 -37
  41. package/src/channel-backend.ts +2 -3
  42. package/src/config.ts +12 -27
  43. package/src/control-state.ts +175 -8
  44. package/src/conversation-menu.ts +6 -17
  45. package/src/delivery-socket-client.d.mts +4 -0
  46. package/src/delivery-socket-client.mjs +104 -0
  47. package/src/delivery-socket.ts +354 -0
  48. package/src/execution-authority.ts +21 -5
  49. package/src/executor.ts +81 -24
  50. package/src/files.ts +38 -11
  51. package/src/host-executor.ts +33 -17
  52. package/src/inbox.ts +15 -49
  53. package/src/index.ts +98 -54
  54. package/src/install-tools.mjs +97 -22
  55. package/src/ledger-endpoint.d.mts +2 -0
  56. package/src/ledger-endpoint.mjs +17 -0
  57. package/src/menu.ts +8 -3
  58. package/src/message-history.ts +13 -18
  59. package/src/message.ts +35 -18
  60. package/src/plugin-broker.mjs +396 -0
  61. package/src/plugins/connection.mjs +2 -2
  62. package/src/plugins/manager.mjs +131 -23
  63. package/src/plugins/native-tasks.mjs +2 -2
  64. package/src/prompt-suffix.ts +12 -0
  65. package/src/react.ts +6 -4
  66. package/src/runs.ts +156 -243
  67. package/src/schedule-cli.ts +29 -10
  68. package/src/scheduled-tasks.ts +53 -10
  69. package/src/scheduler.ts +20 -7
  70. package/src/service.ts +3 -3
  71. package/src/setup.ts +1 -0
  72. package/src/software-status.ts +36 -4
  73. package/src/task-executor.ts +2 -2
  74. package/src/task-workspace.ts +4 -3
  75. package/src/tasks.ts +1 -1
  76. package/src/telegram-provisioning-cli.ts +22 -0
  77. package/src/telegram-provisioning.ts +214 -0
  78. package/src/updates/control.mjs +77 -7
  79. package/src/updates/runtime.mjs +28 -8
  80. package/src/updates/status.mjs +25 -3
  81. package/src/updates/supervisor.mjs +15 -16
  82. package/src/version.ts +20 -0
  83. package/src/workspace.ts +3 -9
  84. package/templates/failure-review.md +1 -1
  85. package/templates/maintainer-purpose.md +1 -1
  86. package/test/agent-guidance.test.ts +14 -80
  87. package/test/agent-install.test.ts +14 -0
  88. package/test/ai.test.ts +220 -1
  89. package/test/application-channel.test.ts +61 -13
  90. package/test/application-only.test.ts +14 -13
  91. package/test/channel-backend.test.ts +3 -7
  92. package/test/channel-delivery.test.ts +4 -1
  93. package/test/channel-owner.test.ts +6 -3
  94. package/test/codex-context.test.ts +2 -2
  95. package/test/config.test.ts +38 -43
  96. package/test/control-state.test.ts +83 -4
  97. package/test/conversation-menu.test.ts +2 -4
  98. package/test/delivery-transport.test.ts +72 -0
  99. package/test/docker-runtime.test.ts +27 -14
  100. package/test/engine-handoff.test.ts +7 -6
  101. package/test/event-sources.test.ts +2 -2
  102. package/test/execution-authority.test.ts +9 -9
  103. package/test/executor.test.ts +120 -12
  104. package/test/failure.test.ts +12 -5
  105. package/test/files.test.ts +2 -2
  106. package/test/helpers/ledger.ts +18 -0
  107. package/test/host-executor.test.ts +87 -3
  108. package/test/inbox.test.ts +8 -10
  109. package/test/install-config.test.ts +1 -0
  110. package/test/install-tools.test.mjs +74 -7
  111. package/test/intake-relay.test.ts +58 -11
  112. package/test/message-history.test.ts +39 -47
  113. package/test/native-tasks.test.ts +1 -1
  114. package/test/plugin-broker.test.mjs +172 -0
  115. package/test/plugin-manager.test.mjs +136 -2
  116. package/test/reaction.test.ts +4 -0
  117. package/test/relay.test.ts +5 -7
  118. package/test/repair-policy.test.ts +1 -4
  119. package/test/schedule-cli.test.ts +14 -9
  120. package/test/scheduled-tasks.test.ts +29 -6
  121. package/test/scheduler-host.test.ts +5 -5
  122. package/test/scheduler-relay.test.ts +15 -9
  123. package/test/security.test.ts +6 -3
  124. package/test/software-status.test.ts +21 -3
  125. package/test/telegram-provisioning.test.ts +160 -0
  126. package/test/updates.test.mjs +93 -13
  127. package/test/workspace.test.ts +4 -1
  128. package/bin/ezenciel-agents-watch.mjs +0 -8
  129. package/compose.workforce-watch.yaml +0 -33
  130. package/docker/recovery.ts +0 -11
  131. package/docs/pagerduty.md +0 -42
  132. package/docs/workforce-watch.md +0 -101
  133. package/src/pagerduty.ts +0 -109
  134. package/src/reply-context.ts +0 -43
  135. package/src/update-attention.ts +0 -18
  136. package/src/workforce-watch-cli.ts +0 -14
  137. package/src/workforce-watch.ts +0 -155
  138. package/templates/agent/AGENTS.md +0 -5
  139. package/templates/agent-guidance.md +0 -17
  140. package/test/pagerduty.test.ts +0 -104
  141. package/test/update-attention.test.ts +0 -20
  142. 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'
@@ -25,7 +26,7 @@ import { InboxStore, type IncomingItem } from './inbox.js'
25
26
  import { loadConfig, type Config } from './config.js'
26
27
  import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
27
28
  import { ApprovalStore } from './approval.js'
28
- import { startExecutorJob, terminateJob } from './executor.js'
29
+ import { startExecutorJob, terminateJob, opencodeDataHome } from './executor.js'
29
30
  import { RunStore, type RunRecord } from './runs.js'
30
31
  import { splitTelegramText } from './reply.js'
31
32
  import { markdownToTelegramHtml, escapeHtml } from './format.js'
@@ -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,11 @@ 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)
74
- const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
73
+ const conversationMenu = createConversationMenu(control)
74
+ const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome,
75
+ undefined, opencodeDataHome(config.controlDir))
75
76
  const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
76
77
  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
78
 
87
79
  let activeTypingTimer: ReturnType<typeof setInterval> | null = null
88
80
  let activeBackend = false
@@ -112,6 +104,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
112
104
  controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
113
105
  aiControls: aiMenu,
114
106
  wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
107
+ createTelegramPairing: async (bindingId, owner) => {
108
+ const username = bot?.botInfo?.username
109
+ if (!username || !/^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(username))
110
+ throw new Error('Telegram connection is unavailable')
111
+ const pairing = await control.createApplicationTelegramPairing(bindingId, owner)
112
+ if (!pairing) return { connected: true } as const
113
+ return {
114
+ connected: false as const,
115
+ url: `https://t.me/${username}?start=${pairing.token}`,
116
+ expiresAt: pairing.expiresAt,
117
+ }
118
+ },
119
+ telegramAvailable: () => Boolean(bot?.botInfo?.username),
115
120
  cancel: id => withStartLock(async () => {
116
121
  const run = await runs.get(id)
117
122
  if (run?.status === 'queued') await runs.patch(id, {status:'cancelled',endedAt:new Date().toISOString()})
@@ -176,7 +181,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
176
181
  catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); await releaseExternal(run); return }
177
182
  }
178
183
  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 }
184
+ if (run.external || run.taskId || run.scheduled) { await runs.patch(run.id, { status: 'failed' }); await releaseExternal(run); return }
180
185
  activeBackend = true
181
186
  await runs.patch(run.id, { status: 'running', backendSubmitted: true })
182
187
  void dispatchChannel(config, run).then(async reply => {
@@ -224,14 +229,13 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
224
229
  const launchStarted = performance.now()
225
230
  const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
226
231
  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)
232
+ const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
229
233
  const session = startsOwnSession
230
234
  ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
231
235
  : await control.executionSession(started.execution!)
232
236
  const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
233
237
  const { child, cleanup } = await launch(texts, {
234
- workspace: run.scheduled ? await taskWorkspace(config.workspace,run.id) : config.workspace,
238
+ workspace: run.scheduled ? await taskWorkspace(config.controlDir,run.id) : config.workspace,
235
239
  timeoutMs: config.executorTimeoutMs,
236
240
  repairEnabled: config.repairEnabled,
237
241
  runId: started.id,
@@ -245,8 +249,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
245
249
  codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
246
250
  sessionId: session.nativeSessionId || session.sessionId,
247
251
  isResume: session.hasStarted,
248
- eventSource: run.external?.sourceId,
249
- 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) },
252
+ promptSuffix: runPromptSuffix(started),
253
+ taskRun: Boolean(run.taskId),
254
+ nativeSession: Boolean(run.scheduled),
255
+ onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
250
256
  })
251
257
  const executionStarted = performance.now()
252
258
  // Attach before disk writes: a fast child can close while PID persistence
@@ -385,7 +391,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
385
391
  for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
386
392
  try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
387
393
  }
388
- if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
389
394
  }
390
395
  for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
391
396
  try {
@@ -449,10 +454,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
449
454
  if (message?.media_group_id) item.text = `[Telegram album: ${message.media_group_id}]\n${item.text}`
450
455
  if (message && (message.chat.type === 'group' || message.chat.type === 'supergroup'))
451
456
  item.text = `[Telegram sender ${message.from?.id}, name ${JSON.stringify(message.from?.first_name)}]\n${item.text}`
452
- if (message && !message.text && message.reply_to_message) {
453
- const quoted = message.reply_to_message
454
- item.text = `[Quoted message ${quoted.message_id}]: ${quoted.text || quoted.caption || '[media]'}\n\n${item.text}`
455
- }
456
457
  collected.push(item)
457
458
  }
458
459
  const scheduleIntake = () => {
@@ -565,7 +566,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
565
566
  ])
566
567
  console.info('run reaction sent', { run_id: item.runId, emoji })
567
568
  } else if (item.type === 'document' && item.documentPath) {
568
- const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.workspace,origin.id) : config.workspace, item.documentPath)
569
+ const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.controlDir,origin.id) : config.workspace, item.documentPath)
569
570
  await paceSend()
570
571
  await authorizeChannelDelivery()
571
572
  attemptedDelivery = true
@@ -623,11 +624,34 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
623
624
  })().finally(() => { outboxWork = undefined }))
624
625
  }
625
626
 
627
+ // Delivery socket: cross-process producers (engine children, plugin
628
+ // children, host executor, CLIs) enqueue and query the memory ledger
629
+ // through the shared handler in delivery-socket.ts. The pump below stays
630
+ // the single delivery executor; the socket only admits work into it.
631
+ let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
632
+ const handleDeliveryOp = createLedgerHandler(config.controlDir, {
633
+ wake: () => { void drainOutbox() },
634
+ status: () => ({
635
+ polling: Boolean(bot?.isRunning()),
636
+ applicationOnly: !telegramEnabled,
637
+ telegramConfigured: config.telegramBotToken !== '',
638
+ version: packageVersion,
639
+ }),
640
+ })
641
+
626
642
  const checkOwner = async (ctx: Context): Promise<boolean> => {
627
643
  if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
628
644
  const state = await control.status()
629
645
  if (!telegramOwner(state.owner)) {
630
646
  if (ctx.chat?.type !== 'private') return false
647
+ const applicationToken = ctx.message?.text?.trim().match(/^\/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]{43})$/)?.[1]
648
+ if (applicationToken) {
649
+ const linked = await applicationChannel.claimTelegramConnection(applicationToken, ctx.from.id, ctx.chat.id)
650
+ await ctx.reply(linked
651
+ ? 'Telegram is connected to your Ez agent. You can keep chatting here.'
652
+ : 'This Telegram connection link expired or is no longer valid. Create a new one in the connected application.', { link_preview_options: { is_disabled: true } })
653
+ return false
654
+ }
631
655
  const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
632
656
  if (result === 'requested')
633
657
  await ctx.reply('Owner approval is pending. Confirm this request through the local setup assistant.')
@@ -654,18 +678,29 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
654
678
  const commands = [...mainCommands, ...(launcher ? [{ command: launcher.command, description: launcher.label }] : [])]
655
679
  const controlCommand = (text?: string) => /^\/rename(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
656
680
  ? '/rename' : text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
681
+ const tasksCommand = (text?: string): number | undefined | null => {
682
+ const match = text?.trim().match(/^\/tasks(?:@[a-zA-Z0-9_]+)?(?:\s+([1-9]\d*))?$/)
683
+ return match ? (match[1] ? Number(match[1]) : undefined) : null
684
+ }
685
+ const isTasksCommand = (text?: string): boolean => /^\/tasks(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
657
686
  const statusKeyboard = () => new InlineKeyboard()
658
687
  .text('Stop active work', 'menu:stop').text('Cancel queue', 'menu:cancel').row()
659
- .text('Retry failed incoming message', 'menu:retry').row()
660
- .text('Scheduled tasks', 'menu:scheduled-tasks')
688
+ .text('Retry failed incoming message', 'menu:retry')
661
689
  const scheduledTasks = async () => {
662
690
  const owner = (await control.status()).owner
663
691
  if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
664
692
  // This intentionally uses the reader that does not create a schedules directory.
665
- return scheduledTasksText(await scheduler.listActiveReadOnly(await runs.list()), owner)
693
+ const active = await scheduler.listActiveReadOnly(await runs.list())
694
+ return { owner, active, text: scheduledTasksText(active, owner) }
666
695
  }
667
- const replyScheduledTasks = async (ctx: Context) => {
668
- for (const part of splitTelegramText(await scheduledTasks())) await ctx.reply(part)
696
+ const replyScheduledTasks = async (ctx: Context, number?: number) => {
697
+ const result = await scheduledTasks()
698
+ const text = typeof result === 'string' ? result : number === undefined ? result.text : (() => {
699
+ const owned = ownedScheduledTasks(result.active, result.owner)
700
+ const schedule = owned[number - 1]
701
+ return schedule ? scheduledTaskDetailText(schedule, number, owned.length) : `Scheduled task ${number} was not found. Use /tasks to list active tasks.`
702
+ })()
703
+ for (const part of splitTelegramText(text)) await ctx.reply(part)
669
704
  }
670
705
  const statusText = async () => {
671
706
  const running = await runs.running(false)
@@ -694,7 +729,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
694
729
  '🟢 Ez is online',
695
730
  '',
696
731
  'System',
697
- ...await softwareStatus(config.controlDir),
732
+ ...await softwareStatus(config.controlDir, config.isolation),
698
733
  `AI: ${selected.name} (${presetLabel(displayedSelected)})`,
699
734
  '',
700
735
  'Work',
@@ -771,7 +806,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
771
806
  }
772
807
  const message = ctx.message
773
808
  const command = controlCommand(message?.text)
774
- if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) return next()
809
+ if ((command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) || tasksCommand(message?.text) !== null || isTasksCommand(message?.text)) return next()
775
810
  const ordinary = message && (message.text || message.photo || message.document || message.voice)
776
811
  const approval = ctx.callbackQuery?.data?.startsWith('approval:')
777
812
  if (!ordinary && !approval) return next()
@@ -815,6 +850,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
815
850
  return
816
851
  }
817
852
 
853
+ const taskNumber = tasksCommand(ctx.message.text)
854
+ if (taskNumber !== null) {
855
+ await replyScheduledTasks(ctx, taskNumber)
856
+ return
857
+ }
858
+ if (isTasksCommand(ctx.message.text)) {
859
+ await ctx.reply('Usage: /tasks [number]. Use /tasks to list active tasks.')
860
+ return
861
+ }
862
+
818
863
  if (text === '/help') {
819
864
  await ctx.reply([...commands, ...aliases.filter((a) => !commands.some((c) => c.command === a.command))]
820
865
  .map((c) => `/${c.command} — ${c.description}`).join('\n'))
@@ -881,19 +926,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
881
926
  return
882
927
  }
883
928
 
884
- // Quoted reply context forwarding
885
- let promptText = ctx.message.text
886
- if (ctx.message.reply_to_message) {
887
- const quoted = ctx.message.reply_to_message
888
- const quotedText = ('text' in quoted && quoted.text) || ('caption' in quoted && quoted.caption) || ''
889
- const quotedSender = quoted.from?.is_bot ? 'Agent' : 'Owner'
890
- if (quotedText) {
891
- promptText = `[Quoted message from ${quotedSender} (ID: ${quoted.message_id})]: "${quotedText}"\n\n${promptText}`
892
- }
893
- }
894
-
895
929
  collectItem({
896
- text: promptText,
930
+ text: ctx.message.text,
897
931
  messageId: ctx.message.message_id,
898
932
  updateId: ctx.update.update_id,
899
933
  chatId: ctx.chat.id,
@@ -911,7 +945,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
911
945
  const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
912
946
  const buffer = await downloadTelegramFile(fileUrl)
913
947
  const fileName = sanitizeFileName(basename(fileInfo.file_path) || 'photo.jpg')
914
- const staged = await stageChatAttachment(config.workspace, fileName, buffer, ctx.message.caption ?? '')
948
+ const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
915
949
  collectItem({
916
950
  text: staged.text,
917
951
  attachment: staged.attachment,
@@ -938,7 +972,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
938
972
  const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
939
973
  const buffer = await downloadTelegramFile(fileUrl)
940
974
  const fileName = sanitizeFileName(doc.file_name || basename(fileInfo.file_path) || 'document.bin')
941
- const staged = await stageChatAttachment(config.workspace, fileName, buffer, ctx.message.caption ?? '')
975
+ const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
942
976
  collectItem({
943
977
  text: staged.text,
944
978
  attachment: staged.attachment,
@@ -1104,6 +1138,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1104
1138
  shuttingDown = true
1105
1139
  runtimeStarted = false
1106
1140
  pollingAbort.abort()
1141
+ await deliveryServer?.stop().catch(() => {})
1142
+ deliveryServer = null
1107
1143
  await applicationChannel.stop()
1108
1144
  wakePollRetry?.()
1109
1145
  if (intakeTimer) clearTimeout(intakeTimer)
@@ -1119,7 +1155,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1119
1155
  })
1120
1156
  await Promise.all(completions)
1121
1157
  await Promise.all([taskWork, outboxWork].map(work => work?.catch(() => {})))
1122
- pagerDuty?.stop()
1123
1158
  try {
1124
1159
  if (bot?.isRunning()) await bot.stop()
1125
1160
  } finally {
@@ -1130,10 +1165,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1130
1165
  const start = async () => {
1131
1166
  let failed = false
1132
1167
  try {
1133
- await initializeWorkspace(config.workspace)
1168
+ // Workspace seeding and managed-block replacement happen at install or
1169
+ // upgrade only; relay startup never writes the agent workspace.
1170
+ // The delivery socket is the cross-process ledger transport and the
1171
+ // single-relay guard (replacing relay.lock): a live sibling answers the
1172
+ // ping and this relay refuses to split-brain the agent.
1173
+ if (await deliverySocketAlive(deliverySocketPath(config.controlDir)).catch(() => false))
1174
+ throw new Error(`Another relay owns ${config.controlDir}; stop it before starting a second one`)
1175
+ const deliveryTcpPort = Number(process.env.EZ_DELIVERY_TCP_PORT ?? '')
1176
+ deliveryServer = await serveDeliverySocket(config.controlDir, handleDeliveryOp, {
1177
+ ...(Number.isSafeInteger(deliveryTcpPort) && deliveryTcpPort >= 1024 && deliveryTcpPort <= 65535 ? { tcpPort: deliveryTcpPort } : {}),
1178
+ })
1134
1179
  if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
1135
1180
  runtimeStarted = true
1136
- pagerDuty?.start()
1137
1181
  const owner = (await control.status()).owner
1138
1182
  if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
1139
1183
  await scheduler.recover(runs)
@@ -1210,7 +1254,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1210
1254
  }
1211
1255
  }
1212
1256
  }
1213
- return { bot: bot!, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
1257
+ return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
1214
1258
  }
1215
1259
 
1216
1260
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -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'));
@@ -52,49 +56,120 @@ export async function installationStatus(deployment) {
52
56
  let isolated=false;
53
57
  try { isolated=(await read(path.join(deployment,'agent.json'))).isolation==='isolated'; } catch { isolated=false; }
54
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.
55
61
  let reply=null;
56
- if(paired)for(const name of await fs.readdir(path.join(control,'outbox')).catch(e=>{if(e.code==='ENOENT')return [];throw e;})) {
57
- if(!name.endsWith('.sent.json'))continue;
58
- const item=await read(path.join(control,'outbox',name));
59
- 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;
60
- const delivered=Date.parse(item.receipt.deliveredAt);if(!Number.isFinite(delivered)||delivered<Date.parse(owner.pairedAt)||delivered>Date.now())continue;
61
- const r=await read(path.join(control,'runs',item.runId+'.json')).catch(absent);
62
- 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};
63
- }
62
+ if(paired)reply=(await callDeliverySocket(path.join(control,'delivery.sock'),{op:'latestReply'},2000).catch(()=>null))?.reply??null;
64
63
  return {deployment,configured,runtimeReady,ownerPaired:paired,telegramReplyVerified:Boolean(reply),reply,
65
64
  stage:!configured?'not-configured':!runtimeReady?'runtime-offline':!paired?'awaiting-owner':!reply?'awaiting-telegram-reply':'ready-for-telegram-plugin-request',
66
- 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 };
67
115
  }
116
+
68
117
  async function fingerprintOf(source,invoke) {
69
118
  const listing=JSON.parse(await invoke('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:source}));
70
119
  const hash=createHash('sha256');
71
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);}
72
121
  return hash.digest('hex');
73
122
  }
74
- 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) {
75
145
  source=await fs.realpath(source);
76
- const fingerprint=await fingerprintOf(source,invoke),image='ezenciel-agents:install-'+fingerprint.slice(0,24);
77
- 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});
78
151
  const lock=path.join(dir,'lock'),receipt=path.join(dir,'status.json'),log=path.join(dir,'build.log');
79
152
  let handle;
80
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.'};}
81
154
  try {
82
- await handle.writeFile(JSON.stringify({pid:process.pid,source,image}));
155
+ await handle.writeFile(JSON.stringify({pid:process.pid,source,image,...identity}));
83
156
  const prior=await read(receipt).catch(absent);
84
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. */}
85
- await atomic(receipt,{state:'building',pid:process.pid,source,image,log});
86
- 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}`]:[])];
87
162
  const output=await fs.open(log,'a',0o600);
88
- 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();}
89
164
  if(await fingerprintOf(source,invoke)!==fingerprint)throw Error('Package changed during build; keep the source stable and retry');
90
- const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log};await atomic(receipt,result);return result;
91
- }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;}
92
167
  finally{await handle.close();await fs.rm(lock);}
93
168
  }
94
169
  export async function main(args) {
95
170
  const [action,...rest]=args;const options={};
96
- 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;}
97
- 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];}
98
- 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');
99
- 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;
100
175
  }
@@ -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,12 +13,13 @@ 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
 
@@ -29,10 +30,14 @@ const matchesModel = (preset: AiPreset, model: ModelChoice) =>
29
30
  // Short-lived opaque button IDs: no model names or executable arguments from callbacks.
30
31
  // These are operational settings, not a second conversational/agent loop.
31
32
  export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string,
32
- isInstalled = installed) => {
33
+ isInstalled = installed, opencodeDataHome?: string) => {
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, undefined, opencodeDataHome, undefined, process.env.EZ_CONTROL_DIR)
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)
@@ -1,11 +1,12 @@
1
- import { readFile, readdir } from 'node:fs/promises'
2
- import { join, basename } from 'node:path'
1
+ import { basename } from 'node:path'
3
2
  import { requireOwnerExecution } from './execution-authority.js'
4
3
  import { ControlStore } from './control-state.js'
5
4
  import { ownsRun } from './identity.js'
6
- import { RunStore } from './runs.js'
5
+ import { RunStore, sentOutbox } from './runs.js'
7
6
 
8
- // Read existing delivery receipts; native sessions still own conversation history.
7
+ // Read existing delivery receipts from the relay memory ledger; native
8
+ // sessions still own conversation history. Explicit tool path only (message
9
+ // history CLI / delivery socket history op), never ambient prompt enrichment.
9
10
  export async function deliveredMessages(controlDir: string, runId: string, options: { limit?: number; messageId?: number } = {}) {
10
11
  const limit = options.limit ?? 8
11
12
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error('Limit must be 1..50')
@@ -20,26 +21,20 @@ export async function deliveredMessages(controlDir: string, runId: string, optio
20
21
  ownsRun(owner, run) && !run.external && !run.taskId && !run.application &&
21
22
  Date.parse(run.createdAt) >= pairedAt && (!run.scheduled || run.scheduled.pairedAt === owner!.pairedAt)
22
23
  ).map(run => [run.id, run]))
23
- const directory = join(controlDir, 'outbox')
24
- const files = await readdir(directory).catch((error: NodeJS.ErrnoException) => {
25
- if (error.code === 'ENOENT') return []
26
- throw error
27
- })
28
24
  const messages = []
29
- for (const file of files.filter(file => file.endsWith('.sent.json'))) {
30
- // Corrupt or incomplete receipts are not delivery evidence.
31
- let item
32
- try { item = JSON.parse(await readFile(join(directory, file), 'utf8')) } catch { continue }
25
+ for (const item of sentOutbox(controlDir)) {
26
+ // Incomplete receipts are not delivery evidence.
33
27
  if (!item || typeof item !== 'object') continue
34
- const run = runs.get(item.runId)
35
- const deliveredAt = Date.parse(item.receipt?.deliveredAt)
36
- const ids = item.receipt?.messageIds
28
+ const run = item.runId === undefined ? undefined : runs.get(item.runId)
29
+ const receipt = (item.receipt ?? {}) as { deliveredAt?: unknown; messageIds?: unknown }
30
+ const deliveredAt = Date.parse(receipt.deliveredAt as string)
31
+ const ids = receipt.messageIds
37
32
  if (!run || item.chatId !== caller.chatId || !Number.isFinite(deliveredAt) || deliveredAt < pairedAt ||
38
33
  !Array.isArray(ids) || !ids.length || !ids.every(id => Number.isSafeInteger(id) && id > 0)) continue
39
- if (options.messageId !== undefined && !ids.includes(options.messageId)) continue
34
+ if (options.messageId !== undefined && !(ids as number[]).includes(options.messageId)) continue
40
35
  messages.push({
41
36
  runId: run.id, sessionId: run.execution?.sessionId, nativeSessionId: run.nativeSessionId,
42
- scheduleId: run.scheduled?.id, messageIds: ids as number[], deliveredAt: item.receipt.deliveredAt as string,
37
+ scheduleId: run.scheduled?.id, messageIds: ids as number[], deliveredAt: receipt.deliveredAt as string,
43
38
  type: item.type || 'message', text: typeof item.text === 'string' ? item.text
44
39
  : typeof item.approvalPrompt === 'string' ? item.approvalPrompt : undefined,
45
40
  voiceText: typeof item.voiceText === 'string' ? item.voiceText : undefined,