@jc_stack/ez-agents 0.1.0-beta.27 → 0.1.0-beta.29

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 (75) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +25 -1
  3. package/CHANGELOG.md +29 -0
  4. package/CONTRIBUTING.md +28 -0
  5. package/Dockerfile +1 -0
  6. package/README.md +79 -8
  7. package/bin/ezenciel-agents-application +2 -0
  8. package/bin/ezenciel-agents-application.mjs +16 -0
  9. package/compose.yaml +8 -0
  10. package/docker/entrypoint.sh +20 -2
  11. package/docker/healthcheck.mjs +1 -1
  12. package/docker/run.ts +3 -3
  13. package/docker/smoke.mjs +41 -2
  14. package/docs/application-channel.md +366 -0
  15. package/docs/docker-runtime.md +20 -0
  16. package/docs/managed-applications.md +68 -0
  17. package/docs/plugin-catalog.md +1 -0
  18. package/docs/plugin-connection.md +76 -0
  19. package/docs/plugins.md +40 -4
  20. package/docs/upgrades.md +11 -1
  21. package/package.json +7 -2
  22. package/src/application-channel.ts +308 -0
  23. package/src/application-cli.ts +41 -0
  24. package/src/application-client.mjs +87 -0
  25. package/src/application-origin.ts +15 -0
  26. package/src/codex-session.ts +5 -3
  27. package/src/config.ts +20 -2
  28. package/src/control-state.ts +256 -15
  29. package/src/conversation-menu.ts +89 -0
  30. package/src/delivery-context.d.mts +5 -0
  31. package/src/delivery-context.mjs +25 -0
  32. package/src/event-sources.ts +2 -2
  33. package/src/execution-authority.ts +2 -0
  34. package/src/executor.ts +10 -4
  35. package/src/host-executor.ts +7 -1
  36. package/src/identity.ts +11 -3
  37. package/src/index.ts +149 -54
  38. package/src/menu.ts +26 -9
  39. package/src/message-history.ts +52 -0
  40. package/src/message.ts +48 -7
  41. package/src/owner.ts +7 -1
  42. package/src/plugins/connection-artifacts.mjs +31 -0
  43. package/src/plugins/connection.mjs +124 -0
  44. package/src/plugins/manager.mjs +63 -18
  45. package/src/plugins/native-tasks.d.mts +4 -0
  46. package/src/plugins/native-tasks.mjs +66 -0
  47. package/src/plugins/workspace-lease.d.mts +3 -0
  48. package/src/plugins/workspace-lease.mjs +44 -0
  49. package/src/runs.ts +67 -9
  50. package/src/schedule-cli.ts +11 -6
  51. package/src/scheduler.ts +17 -7
  52. package/src/updates/control.mjs +4 -0
  53. package/src/web-launcher.ts +19 -0
  54. package/templates/agent-guidance.md +58 -2
  55. package/templates/deployments.md +24 -0
  56. package/test/application-channel.test.ts +283 -0
  57. package/test/application-client.test.mjs +84 -0
  58. package/test/application-controls.test.ts +224 -0
  59. package/test/application-only.test.ts +100 -0
  60. package/test/channel-delivery.test.ts +63 -0
  61. package/test/channel-owner.test.ts +161 -0
  62. package/test/codex-session.test.ts +8 -5
  63. package/test/config.test.ts +15 -0
  64. package/test/connection-artifacts.test.mjs +32 -0
  65. package/test/conversation-menu.test.ts +67 -0
  66. package/test/conversations.test.ts +84 -0
  67. package/test/executor.test.ts +56 -0
  68. package/test/host-executor.test.ts +28 -0
  69. package/test/intake-relay.test.ts +126 -5
  70. package/test/message-history.test.ts +127 -0
  71. package/test/native-tasks.test.ts +36 -0
  72. package/test/plugin-connection.test.mjs +124 -0
  73. package/test/plugin-manager.test.mjs +34 -0
  74. package/test/runtime-identity.test.mjs +18 -0
  75. package/test/updates.test.mjs +39 -0
package/src/identity.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Context } from 'grammy'
2
2
  import type { Owner } from './control-state.js'
3
+ import { ownerId, ownerEpoch } from './control-state.js'
3
4
 
4
5
  export const isOwner = (ctx: Pick<Context, 'from' | 'chat'>, owner: Owner | null): boolean =>
5
6
  Boolean(
@@ -12,9 +13,16 @@ export const isOwner = (ctx: Pick<Context, 'from' | 'chat'>, owner: Owner | null
12
13
  ctx.chat?.id === owner.telegramChatId,
13
14
  )
14
15
 
15
- export const ownsRun = (owner: Owner | null, run: {telegramUserId: number; chatId: number}): boolean =>
16
- Boolean(owner && Number.isSafeInteger(run.telegramUserId) && run.telegramUserId > 0 &&
17
- run.chatId === owner.telegramChatId && (owner.kind === 'group' || run.telegramUserId === owner.telegramUserId))
16
+ export const ownsRun = (owner: Owner | null, run: {application?: unknown; ownerId?: string; ownerEpoch?: string; telegramEpoch?: string; telegramUserId?: number; chatId?: number}): boolean =>
17
+ // Legacy app runs used the original Telegram owner IDs as bookkeeping. Their
18
+ // validated application binding remains the authority, not the current TG link.
19
+ Boolean(owner && (run.application && run.ownerId === undefined
20
+ ? ownerId(owner) === `telegram:${run.telegramUserId}:${run.chatId}`
21
+ : (run.chatId === undefined ||
22
+ (run.chatId === owner.telegramChatId && (run.telegramEpoch ?? owner.pairedAt) === (owner.telegramLinkedAt ?? owner.pairedAt))) && (run.ownerId !== undefined
23
+ ? run.ownerId === ownerId(owner) && run.ownerEpoch === ownerEpoch(owner)
24
+ : Number.isSafeInteger(run.telegramUserId) && run.telegramUserId! > 0 &&
25
+ run.chatId === owner.telegramChatId && (owner.kind === 'group' || run.telegramUserId === owner.telegramUserId))))
18
26
 
19
27
  export const assertId = (id: string): string => {
20
28
  if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error('Invalid record identifier')
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { ApplicationChannel } from './application-channel.js'
1
2
  import { failureEvidence } from './failure.js'
2
3
  import { TelegramSource } from './telegram-source.js'
3
4
  import { Tasks } from './tasks.js'
4
5
  import { taskRequests } from './task-rpc.js'
5
6
  import { executionBlockReason } from './execution-authority.js'
7
+ import {authorizeDeliveryContext} from './delivery-context.mjs'
6
8
  import { dispatchChannel } from './channel-backend.js'
7
9
  import { randomUUID } from 'node:crypto'
8
10
  import { stat } from 'node:fs/promises'
@@ -20,7 +22,7 @@ import { isOwner, ownsRun } from './identity.js'
20
22
  import type { Update } from 'grammy/types'
21
23
  import { InboxStore, type IncomingItem } from './inbox.js'
22
24
  import { loadConfig, type Config } from './config.js'
23
- import { ControlStore } from './control-state.js'
25
+ import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
24
26
  import { ApprovalStore } from './approval.js'
25
27
  import { startExecutorJob, terminateJob } from './executor.js'
26
28
  import { RunStore, type RunRecord } from './runs.js'
@@ -30,6 +32,7 @@ import { sanitizeFileName, stageIncomingFile, workspaceFile } from './files.js'
30
32
  import { transcribeAudio, synthesizeSpeech } from './audio.js'
31
33
  import { normalizeReactionEmoji } from './reaction.js'
32
34
  import { downloadTelegramFile } from './read-request.js'
35
+ import { createConversationMenu } from './conversation-menu.js'
33
36
  import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
34
37
  import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
35
38
  import { discoverDefaults } from './client-defaults.js'
@@ -45,7 +48,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
45
48
  }
46
49
  return message
47
50
  }
48
- const bot = new Bot(config.telegramBotToken)
51
+ const telegramEnabled = config.telegramEnabled !== false
52
+ if (!telegramEnabled && (!config.applicationPort || config.channelBackendUrl)) throw new Error('Application-only execution requires the native application listener')
53
+ const bot = telegramEnabled ? new Bot(config.telegramBotToken) : null
49
54
  const control = new ControlStore(config.controlDir, config.pairingTtlMs)
50
55
  const approvals = new ApprovalStore(config.controlDir)
51
56
  const runs = new RunStore(config.controlDir)
@@ -64,6 +69,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
64
69
  let taskTimer: ReturnType<typeof setInterval> | undefined
65
70
  let drainTimer: ReturnType<typeof setInterval> | undefined
66
71
  const codexHome = join(config.controlDir, 'cli', 'codex')
72
+ const conversationMenu = createConversationMenu(control, runs)
67
73
  const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
68
74
  const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
69
75
  const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
@@ -81,6 +87,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
81
87
  let activeBackend = false
82
88
  const ownerStopped = new WeakSet<ChildProcess>()
83
89
  let activeChild: ChildProcess | null = null
90
+ let runtimeStarted = false
84
91
  let shuttingDown = false
85
92
  let wakePollRetry: (() => void) | undefined
86
93
  const pollingAbort = new AbortController()
@@ -100,17 +107,36 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
100
107
  return next
101
108
  }
102
109
 
103
- const sendChat = async (chatId: number, text: string, replyToMessageId?: number): Promise<number[]> => {
110
+ const applicationChannel = new ApplicationChannel({
111
+ controlDir: config.controlDir, initial: aiMenu.initial,
112
+ aiControls: aiMenu,
113
+ wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
114
+ cancel: id => withStartLock(async () => {
115
+ const run = await runs.get(id)
116
+ if (run?.status === 'queued') await runs.patch(id, {status:'cancelled',endedAt:new Date().toISOString()})
117
+ else if (run?.status === 'running') {
118
+ const child = background.get(id)
119
+ if (child) { await scheduler.cancel(id); terminateJob(child); return }
120
+ if (!activeChild || (await runs.running(false))?.id !== id) throw new Error('Application cancellation unavailable')
121
+ ownerStopped.add(activeChild)
122
+ terminateJob(activeChild)
123
+ }
124
+ }),
125
+ })
126
+
127
+ const sendChat = async (chatId: number, text: string, replyToMessageId?: number, authorize?:()=>Promise<void>): Promise<number[]> => {
128
+ if (!bot) throw new Error('Telegram delivery is disabled')
104
129
  const ids: number[] = []
105
130
  const parts = splitTelegramText(text)
106
131
  for (let i = 0; i < parts.length; i++) {
107
132
  await paceSend()
133
+ await authorize?.()
108
134
  const part = parts[i]
109
135
  const replyParams =
110
136
  i === 0 && replyToMessageId ? { reply_parameters: { message_id: replyToMessageId } } : {}
111
137
  try {
112
138
  const html = markdownToTelegramHtml(part)
113
- const sent = await bot.api.sendMessage(chatId, html, { parse_mode: 'HTML', ...replyParams })
139
+ const sent = await bot!.api.sendMessage(chatId, html, { parse_mode: 'HTML', ...replyParams })
114
140
  ids.push(sent.message_id)
115
141
  } catch (error) {
116
142
  if (
@@ -120,14 +146,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
120
146
  )
121
147
  throw error
122
148
  // Fallback to plain text if HTML parsing fails
123
- const sent = await bot.api.sendMessage(chatId, part, { ...replyParams })
149
+ const sent = await bot!.api.sendMessage(chatId, part, { ...replyParams })
124
150
  ids.push(sent.message_id)
125
151
  }
126
152
  }
127
153
  return ids
128
154
  }
129
155
 
130
- const telegramSource = new TelegramSource(config.controlDir, config.telegramBotToken.split(':')[0], sendChat)
156
+ const telegramSource = telegramEnabled ? new TelegramSource(config.controlDir, config.telegramBotToken.split(':')[0], sendChat) : null
131
157
 
132
158
  const startJob = async (run: RunRecord): Promise<void> => {
133
159
  await withStartLock(async () => {
@@ -135,12 +161,17 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
135
161
  // stat uses the effective UID; access uses the relay's isolated real UID.
136
162
  if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
137
163
  if ((await runs.get(run.id))?.status !== 'queued') return
164
+ if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled) return
138
165
  if (!run.scheduled && await runs.running(false)) return
139
166
  const owner = (await control.status()).owner
140
167
  if (!owner || !ownsRun(owner, run)) {
141
168
  await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
142
169
  return
143
170
  }
171
+ if (run.application || run.delivery) {
172
+ try { await applicationChannel.bindings.authorize(run) }
173
+ catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); return }
174
+ }
144
175
  if (config.channelBackendUrl) {
145
176
  if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); return }
146
177
  activeBackend = true
@@ -205,6 +236,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
205
236
  model: selected.model,
206
237
  effort: selected.effort,
207
238
  codexAutoCompactTokens: config.codexAutoCompactTokens,
239
+ codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
208
240
  sessionId: session.nativeSessionId || session.sessionId,
209
241
  isResume: session.hasStarted,
210
242
  eventSource: run.external?.sourceId,
@@ -242,8 +274,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
242
274
  })
243
275
 
244
276
  if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
245
- if (!run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
246
- if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId, 'typing').catch(() => {})
277
+ if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
278
+ if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
247
279
  }, 4000)
248
280
 
249
281
  const completion = finished.then((code) => {
@@ -282,7 +314,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
282
314
  }
283
315
  await runs.patch(run.id, { status: 'failed', failureReason: 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
284
316
  console.error('run start failed', run.id, safeError(error))
285
- await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
317
+ if (bot && !run.application && !run.delivery && run.chatId !== undefined) await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
286
318
  setImmediate(() => {
287
319
  void runs
288
320
  .nextQueued(false)
@@ -299,16 +331,32 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
299
331
  if (sourceWork) return sourceWork
300
332
  sourceWork = (async () => {
301
333
  if (shuttingDown) return
334
+ const foreground = await runs.running(false)
302
335
  const owner = (await control.status()).owner
336
+ if (foreground && activeChild) {
337
+ try {
338
+ if (!ownsRun(owner, foreground)) throw new Error('Owner channel revoked')
339
+ if (foreground.application) await applicationChannel.bindings.authorize(foreground)
340
+ }
341
+ catch { ownerStopped.add(activeChild); terminateJob(activeChild) }
342
+ }
343
+ for (const [id, child] of background) {
344
+ const run = await runs.get(id)
345
+ try {
346
+ if (!run || !ownsRun(owner, run)) throw new Error('Owner revoked')
347
+ if (run.delivery) await applicationChannel.bindings.authorize(run)
348
+ } catch { ownerStopped.add(child); terminateJob(child); continue }
349
+ if (await scheduler.cancelled(id)) terminateJob(child)
350
+ }
303
351
  if (!owner) return
304
352
  if (!config.channelBackendUrl) {
305
353
  await drainTaskRequests()
306
354
  for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
307
355
  try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
308
356
  }
309
- await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
357
+ if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
310
358
  }
311
- for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
359
+ for (const source of !telegramOwner(owner) || config.channelBackendUrl ? [] : await sources.available(owner)) {
312
360
  try {
313
361
  const batch = await sources.batch(source)
314
362
  unavailableSources.delete(source.id)
@@ -334,9 +382,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
334
382
  }
335
383
  await runs.running(true)
336
384
  if (!config.channelBackendUrl) await scheduler.tick(owner,runs)
337
- for (const [id,child] of background) {
338
- if (await scheduler.cancelled(id)) terminateJob(child)
339
- }
340
385
  for (const run of (await runs.list()).filter(r => r.status === 'queued')) {
341
386
  if (shuttingDown) break
342
387
  await startJob(run)
@@ -373,6 +418,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
373
418
  }, 250)
374
419
  }
375
420
  const drainInbox = (force = false): Promise<void> => {
421
+ if (!telegramEnabled) return Promise.resolve()
376
422
  if (intakeWork) return intakeWork
377
423
  intakeWork = (async () => {
378
424
  if (shuttingDown) return
@@ -387,7 +433,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
387
433
  normalizing = entry.update
388
434
  replay.add(entry.update)
389
435
  try {
390
- await bot.handleUpdate(entry.update)
436
+ await bot!.handleUpdate(entry.update)
391
437
  } finally {
392
438
  replay.delete(entry.update)
393
439
  normalizing = undefined
@@ -396,9 +442,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
396
442
  await withStartLock(async () => {
397
443
  if (shuttingDown || !(await inbox.pending(batch.id))) return
398
444
  const first = collected[0]
399
- if (first)
445
+ const owner = telegramOwner((await control.status()).owner)
446
+ if (first && owner)
400
447
  run = await runs.create({
401
448
  id: batch.id,
449
+ ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner),
450
+ telegramEpoch: owner.telegramLinkedAt ?? owner.pairedAt,
402
451
  chatId: first.chatId,
403
452
  telegramUserId: first.fromId,
404
453
  messageId: first.messageId,
@@ -442,8 +491,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
442
491
  ? { reply_parameters: { message_id: item.replyToMessageId } }
443
492
  : {}
444
493
  const owner = (await control.status()).owner
445
- const origin = await runs.get(item.runId)
446
- if (
494
+ const authorizeChannelDelivery=async()=>{if(item.deliveryContext)authorizeDeliveryContext(item.deliveryContext,(await control.status()).owner)}
495
+ const origin = item.runId ? await runs.get(item.runId) : null
496
+ if (item.deliveryContext) {
497
+ const context=authorizeDeliveryContext(item.deliveryContext,owner)
498
+ if(item.runId||item.chatId!==context.owner.telegramChatId||!['message','document','voice'].includes(item.type??''))throw new Error('Outbox delivery context mismatch')
499
+ } else if (
447
500
  !origin ||
448
501
  !owner ||
449
502
  !ownsRun(owner, origin) ||
@@ -451,35 +504,42 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
451
504
  item.chatId !== origin.chatId
452
505
  )
453
506
  throw new Error('Outbox ownership mismatch')
507
+ if (origin?.application || origin?.delivery) {
508
+ await applicationChannel.deliver(origin, item)
509
+ continue
510
+ }
511
+ if (!telegramEnabled || item.chatId === undefined) throw new Error('Telegram delivery is disabled')
454
512
  const receiptIds: number[] = []
455
513
 
456
514
  if (item.type === 'reaction' && item.emoji && item.messageId) {
457
515
  const emoji = normalizeReactionEmoji(item.emoji)
458
516
  if (!emoji) throw new Error('Unsupported reaction')
459
517
  attemptedDelivery = true
460
- await bot.api.setMessageReaction(item.chatId, item.messageId, [
518
+ await bot!.api.setMessageReaction(item.chatId, item.messageId, [
461
519
  { type: 'emoji', emoji: emoji as any },
462
520
  ])
463
521
  console.info('run reaction sent', { run_id: item.runId, emoji })
464
522
  } else if (item.type === 'document' && item.documentPath) {
465
- const docPath = await workspaceFile(origin.scheduled ? await taskWorkspace(config.workspace,origin.id) : config.workspace, item.documentPath)
523
+ const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.workspace,origin.id) : config.workspace, item.documentPath)
466
524
  await paceSend()
525
+ await authorizeChannelDelivery()
467
526
  attemptedDelivery = true
468
- const sent = await bot.api.sendDocument(item.chatId, new InputFile(docPath), {
527
+ const sent = await bot!.api.sendDocument(item.chatId, new InputFile(docPath), {
469
528
  caption: item.text,
470
529
  ...replyParams,
471
530
  })
472
531
  console.info('run document sent', { run_id: item.runId, path: docPath })
473
532
  receiptIds.push(sent.message_id)
474
533
  } else if (item.type === 'voice' && item.voiceText) {
475
- await bot.api.sendChatAction(item.chatId, 'record_voice')
534
+ await bot!.api.sendChatAction(item.chatId, 'record_voice')
476
535
  const { buffer } = await synthesizeSpeech(item.voiceText, {
477
536
  geminiApiKey: config.geminiApiKey,
478
537
  openaiApiKey: config.openaiApiKey,
479
538
  })
480
539
  await paceSend()
540
+ await authorizeChannelDelivery()
481
541
  attemptedDelivery = true
482
- const sent = await bot.api.sendVoice(item.chatId, new InputFile(buffer, 'voice.ogg'), replyParams)
542
+ const sent = await bot!.api.sendVoice(item.chatId, new InputFile(buffer, 'voice.ogg'), replyParams)
483
543
  receiptIds.push(sent.message_id)
484
544
  console.info('run voice sent', { run_id: item.runId })
485
545
  } else if (item.type === 'approval' && item.approvalPrompt && item.approvalActionId) {
@@ -489,7 +549,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
489
549
  const html = `⚠️ <b>Approval Required</b>\n\n${markdownToTelegramHtml(item.approvalPrompt)}`
490
550
  await paceSend()
491
551
  attemptedDelivery = true
492
- const sent = await bot.api.sendMessage(item.chatId, html, {
552
+ const sent = await bot!.api.sendMessage(item.chatId, html, {
493
553
  parse_mode: 'HTML',
494
554
  reply_markup: keyboard,
495
555
  ...replyParams,
@@ -498,14 +558,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
498
558
  receiptIds.push(sent.message_id)
499
559
  } else if (item.text) {
500
560
  attemptedDelivery = true
501
- const ids = await sendChat(item.chatId, item.text, item.replyToMessageId)
561
+ const ids = await sendChat(item.chatId, item.text, item.replyToMessageId,authorizeChannelDelivery)
502
562
  receiptIds.push(...ids)
503
563
  console.info('run message sent', { run_id: item.runId, outbox_id: item.id, message_ids: ids })
504
564
  } else throw new Error('Outbox item has no supported payload')
505
565
  await runs.markOutboxSent(item.id, receiptIds)
506
566
  console.info('run timing', { run_id: item.runId, outbox_id: item.id, phase: 'delivery',
507
567
  delivery_processing_ms: Math.round(performance.now() - deliveryStarted),
508
- run_to_delivery_ms: Math.max(0, Date.now() - Date.parse(origin.createdAt)) })
568
+ run_to_delivery_ms: Math.max(0, Date.now() - Date.parse(origin?.createdAt??item.createdAt)) })
509
569
  } catch (error) {
510
570
  console.error('outbox item processing failed', item.id, safeError(error))
511
571
  await runs.failOutbox(
@@ -521,7 +581,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
521
581
  const checkOwner = async (ctx: Context): Promise<boolean> => {
522
582
  if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
523
583
  const state = await control.status()
524
- if (!state.owner) {
584
+ if (!telegramOwner(state.owner)) {
525
585
  if (ctx.chat?.type !== 'private') return false
526
586
  const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
527
587
  if (result === 'requested')
@@ -545,8 +605,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
545
605
  ]
546
606
  // Keep the retired command from becoming an agent prompt while old clients catch up.
547
607
  const retiredCommands = ['/settings']
548
- const commands = mainCommands
549
- const controlCommand = (text?: string) => text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
608
+ const launcher = config.webLauncher
609
+ const commands = [...mainCommands, ...(launcher ? [{ command: launcher.command, description: launcher.label }] : [])]
610
+ const controlCommand = (text?: string) => /^\/rename(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
611
+ ? '/rename' : text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
550
612
  const statusKeyboard = () => new InlineKeyboard()
551
613
  .text('Stop active work', 'menu:stop').text('Cancel queue', 'menu:cancel').row()
552
614
  .text('Retry failed incoming message', 'menu:retry').row()
@@ -619,11 +681,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
619
681
 
620
682
  // Returning from the polling handler acknowledges intake, not execution. Only
621
683
  // return after the authorized update has reached the atomic local journal.
684
+ if (bot) {
622
685
  bot.use(async (ctx, next) => {
623
686
  if (ctx.callbackQuery && (await control.status()).owner?.kind === 'group') {
624
687
  if (!isOwner(ctx, (await control.status()).owner)) return
625
688
  try {
626
- const member = await bot.api.getChatMember(ctx.chat!.id, ctx.from!.id)
689
+ const member = await bot!.api.getChatMember(ctx.chat!.id, ctx.from!.id)
627
690
  if (!['creator', 'administrator', 'member'].includes(member.status) &&
628
691
  !(member.status === 'restricted' && member.is_member)) return
629
692
  } catch { throw new Error('Group membership verification unavailable; retry the update') }
@@ -631,15 +694,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
631
694
  if ((ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') &&
632
695
  !isOwner(ctx, (await control.status()).owner)) {
633
696
  if (config.channelBackendUrl) return
634
- const owner = (await control.status()).owner
697
+ const owner = telegramOwner((await control.status()).owner)
635
698
  const message = ctx.message
636
699
  if (!owner && message && !message.sender_chat && ctx.from && !ctx.from.is_bot) {
637
700
  await control.requestPairing(ctx.from.id, ctx.chat.id, ctx.chat.title)
638
701
  return
639
702
  }
640
703
  if (!owner || !message?.text || message.sender_chat || !ctx.from || ctx.from.is_bot) return
641
- await telegramSource.start(owner)
642
- if (await telegramSource.capture(ctx.update.update_id, message as import('grammy/types').Message.TextMessage, ctx.from)) return
704
+ await telegramSource!.start(owner)
705
+ if (await telegramSource!.capture(ctx.update.update_id, message as import('grammy/types').Message.TextMessage, ctx.from)) return
643
706
  if (owner.kind === 'group') return
644
707
  if (ctx.from.id !== owner.telegramUserId) return
645
708
  if (replay.has(ctx.update)) {
@@ -647,7 +710,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
647
710
  updateId: ctx.update.update_id, chatId: owner.telegramChatId, fromId: owner.telegramUserId,
648
711
  text: JSON.stringify({event:'owner_message_in_unbound_group',chatId:ctx.chat.id,title:ctx.chat.title,messageId:message.message_id,text:message.text}),
649
712
  })
650
- } else if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial))) scheduleIntake()
713
+ } else if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial, message?.text || message?.caption))) scheduleIntake()
651
714
  return
652
715
  }
653
716
  if (replay.has(ctx.update)) {
@@ -661,19 +724,36 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
661
724
  const approval = ctx.callbackQuery?.data?.startsWith('approval:')
662
725
  if (!ordinary && !approval) return next()
663
726
  if (!(await checkOwner(ctx))) return
664
- if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial))) scheduleIntake()
727
+ if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial, message?.text || message?.caption))) scheduleIntake()
665
728
  })
666
729
 
667
- bot.on('message:text', async (ctx) => {
730
+ bot!.on('message:text', async (ctx) => {
668
731
  if (!(await checkOwner(ctx))) return
669
732
 
670
733
  const text = controlCommand(ctx.message.text)
671
734
 
672
- if (config.channelBackendUrl && ['/new', '/ai', '/settings'].includes(text ?? '')) {
735
+ if (config.channelBackendUrl && ['/new', '/chats', '/rename', '/ai', '/settings'].includes(text ?? '')) {
673
736
  await ctx.reply('Conversation and model settings are managed in the connected application.')
674
737
  return
675
738
  }
676
739
 
740
+ if (launcher && text === `/${launcher.command}`) {
741
+ if (ctx.chat.type !== 'private' || (await control.status()).owner?.kind === 'group') return
742
+ await ctx.reply(launcher.label, { reply_markup: new InlineKeyboard().webApp(launcher.label, launcher.url) })
743
+ return
744
+ }
745
+
746
+ if (text === '/chats') {
747
+ await conversationMenu.list(ctx)
748
+ return
749
+ }
750
+ if (text === '/rename') {
751
+ try {
752
+ await control.renameSession(ctx.message.text.trim().replace(/^\/rename(?:@[a-zA-Z0-9_]+)?(?:\s+|$)/, ''))
753
+ await ctx.reply('Conversation renamed.')
754
+ } catch (error) { await ctx.reply(error instanceof Error ? error.message : 'Rename failed.') }
755
+ return
756
+ }
677
757
  if (text === '/ai') {
678
758
  await aiMenu.list(ctx)
679
759
  return
@@ -741,6 +821,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
741
821
 
742
822
  if (text === '/menu') {
743
823
  const menuKeyboard = mainKeyboard()
824
+ if (launcher && ctx.chat.type === 'private' && (await control.status()).owner?.kind !== 'group') menuKeyboard.row().webApp(launcher.label, launcher.url)
744
825
  await ctx.reply('⚡ <b>Ezenciel Agent Menu</b>\nSelect an action below:', {
745
826
  parse_mode: 'HTML',
746
827
  reply_markup: menuKeyboard,
@@ -768,7 +849,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
768
849
  })
769
850
  })
770
851
 
771
- bot.on('message:photo', async (ctx) => {
852
+ bot!.on('message:photo', async (ctx) => {
772
853
  if (!(await checkOwner(ctx))) return
773
854
  try {
774
855
  const photos = ctx.message.photo
@@ -798,7 +879,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
798
879
  }
799
880
  })
800
881
 
801
- bot.on('message:document', async (ctx) => {
882
+ bot!.on('message:document', async (ctx) => {
802
883
  if (!(await checkOwner(ctx))) return
803
884
  try {
804
885
  const doc = ctx.message.document
@@ -829,10 +910,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
829
910
  }
830
911
  })
831
912
 
832
- bot.on('message:voice', async (ctx) => {
913
+ bot!.on('message:voice', async (ctx) => {
833
914
  if (!(await checkOwner(ctx))) return
834
915
  try {
835
- await bot.api.sendChatAction(ctx.chat.id, 'typing').catch(() => {})
916
+ await bot!.api.sendChatAction(ctx.chat.id, 'typing').catch(() => {})
836
917
  const voice = ctx.message.voice
837
918
  const fileInfo = await ctx.api.getFile(voice.file_id)
838
919
  if (!fileInfo.file_path) throw new Error('Telegram attachment path unavailable')
@@ -861,7 +942,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
861
942
  }
862
943
  })
863
944
 
864
- bot.on('callback_query:data', async (ctx) => {
945
+ bot!.on('callback_query:data', async (ctx) => {
865
946
  if (!isOwner(ctx, (await control.status()).owner)) {
866
947
  await ctx.answerCallbackQuery()
867
948
  return
@@ -898,19 +979,24 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
898
979
  text: JSON.stringify({ event: 'approval_decision', actionId, decision, prompt: request?.prompt }),
899
980
  messageId: ctx.callbackQuery.message?.message_id,
900
981
  updateId: ctx.update.update_id,
901
- chatId: run.chatId,
982
+ chatId: run.chatId!,
902
983
  fromId: ctx.from.id,
903
984
  })
904
985
  console.info('Approval decision recorded', { actionId, decision: isApproved ? 'approved' : 'denied' })
905
986
  }
906
- } else if (config.channelBackendUrl && (['menu:new', 'menu:ai', 'menu:settings'].includes(data) || data.startsWith('ai:'))) {
987
+ } else if (config.channelBackendUrl && (['menu:new', 'menu:chats', 'menu:ai', 'menu:settings'].includes(data) || data.startsWith('ai:') || data.startsWith('chat:'))) {
907
988
  await ctx.answerCallbackQuery()
908
989
  await ctx.reply('Conversation and model settings are managed in the connected application.')
990
+ } else if (await conversationMenu.handle(ctx)) {
991
+ return
909
992
  } else if (await aiMenu.handle(ctx)) {
910
993
  return
911
994
  } else if (data.startsWith('menu:')) {
912
995
  const action = data.slice(5)
913
- if (action === 'ai') {
996
+ if (action === 'chats') {
997
+ await ctx.answerCallbackQuery()
998
+ await conversationMenu.list(ctx)
999
+ } else if (action === 'ai') {
914
1000
  await ctx.answerCallbackQuery()
915
1001
  await aiMenu.list(ctx)
916
1002
  } else if (action === 'settings') {
@@ -958,16 +1044,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
958
1044
  }
959
1045
  })
960
1046
 
961
- bot.catch((error) => {
1047
+ bot!.catch((error) => {
962
1048
  console.error('Telegram update failure', safeError(error.error))
963
1049
  // Do not let polling acknowledge an update whose journal write failed.
964
1050
  throw error
965
1051
  })
1052
+ }
966
1053
 
967
1054
  let stopWork: Promise<void> | undefined
968
1055
  const stop = (): Promise<void> => stopWork ?? (stopWork = (async () => {
969
1056
  shuttingDown = true
1057
+ runtimeStarted = false
970
1058
  pollingAbort.abort()
1059
+ await applicationChannel.stop()
971
1060
  wakePollRetry?.()
972
1061
  if (intakeTimer) clearTimeout(intakeTimer)
973
1062
  if (sourceTimer) clearInterval(sourceTimer)
@@ -984,9 +1073,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
984
1073
  await Promise.all([taskWork, outboxWork].map(work => work?.catch(() => {})))
985
1074
  pagerDuty?.stop()
986
1075
  try {
987
- if (bot.isRunning()) await bot.stop()
1076
+ if (bot?.isRunning()) await bot.stop()
988
1077
  } finally {
989
- await telegramSource.stop()
1078
+ await telegramSource?.stop()
990
1079
  }
991
1080
  })())
992
1081
 
@@ -994,9 +1083,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
994
1083
  let failed = false
995
1084
  try {
996
1085
  await initializeWorkspace(config.workspace)
1086
+ if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
1087
+ runtimeStarted = true
997
1088
  pagerDuty?.start()
998
1089
  const owner = (await control.status()).owner
999
- if (owner && !config.channelBackendUrl) await telegramSource.start(owner)
1090
+ if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
1000
1091
  await scheduler.recover(runs)
1001
1092
  sourceTimer = setInterval(() => {
1002
1093
  void drainSources().catch(error => console.error('Event-source drain failed', safeError(error)))
@@ -1024,18 +1115,22 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1024
1115
  }
1025
1116
  }
1026
1117
 
1118
+ if (!telegramEnabled) {
1119
+ await new Promise<void>(resolve => { wakePollRetry = resolve; if (shuttingDown) resolve() })
1120
+ return
1121
+ }
1027
1122
  scheduleIntake()
1028
1123
  // Telegram polling is a delivery surface, not the scheduler or executor.
1029
1124
  // grammY owns in-poll reconnects. Escaped permanent faults wait for
1030
1125
  // intervention without restarting setup or terminating authorized work.
1031
1126
  while (!shuttingDown) {
1032
1127
  try {
1033
- await bot.api.setMyCommands(commands)
1034
- await bot.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
1128
+ await bot!.api.setMyCommands(commands)
1129
+ await bot!.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
1035
1130
  // grammY types its Node signal with the older abort-controller shim.
1036
- await bot.init(pollingAbort.signal as unknown as Parameters<typeof bot.init>[0])
1131
+ await bot!.init(pollingAbort.signal as unknown as Parameters<typeof Bot.prototype.init>[0])
1037
1132
  if (shuttingDown) break
1038
- await bot.start({
1133
+ await bot!.start({
1039
1134
  drop_pending_updates: false,
1040
1135
  onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
1041
1136
  })
@@ -1067,7 +1162,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1067
1162
  }
1068
1163
  }
1069
1164
  }
1070
- return { bot, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests }
1165
+ return { bot: bot!, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
1071
1166
  }
1072
1167
 
1073
1168
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {