@jc_stack/ez-agents 0.1.0-beta.12

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 (147) hide show
  1. package/.dockerignore +24 -0
  2. package/.env.example +26 -0
  3. package/AGENTS.md +84 -0
  4. package/CHANGELOG.md +39 -0
  5. package/CONTRIBUTING.md +73 -0
  6. package/Dockerfile +16 -0
  7. package/LICENSE +21 -0
  8. package/README.md +134 -0
  9. package/SECURITY.md +26 -0
  10. package/THIRD_PARTY_NOTICES.md +14 -0
  11. package/bin/ezenciel-agents +2 -0
  12. package/bin/ezenciel-agents-ai +2 -0
  13. package/bin/ezenciel-agents-ai.mjs +5 -0
  14. package/bin/ezenciel-agents-approval +2 -0
  15. package/bin/ezenciel-agents-approval.mjs +16 -0
  16. package/bin/ezenciel-agents-create +12 -0
  17. package/bin/ezenciel-agents-docker +6 -0
  18. package/bin/ezenciel-agents-host +5 -0
  19. package/bin/ezenciel-agents-install +2 -0
  20. package/bin/ezenciel-agents-message +2 -0
  21. package/bin/ezenciel-agents-message.mjs +16 -0
  22. package/bin/ezenciel-agents-owner +2 -0
  23. package/bin/ezenciel-agents-owner.mjs +18 -0
  24. package/bin/ezenciel-agents-react +2 -0
  25. package/bin/ezenciel-agents-react.mjs +16 -0
  26. package/bin/ezenciel-agents-setup.mjs +18 -0
  27. package/bin/ezenciel-agents-source +2 -0
  28. package/bin/ezenciel-agents-source.mjs +16 -0
  29. package/bin/ezenciel-agents-tools.mjs +3 -0
  30. package/bin/ezenciel-agents.mjs +37 -0
  31. package/compose.whatsapp.yaml +12 -0
  32. package/compose.yaml +40 -0
  33. package/default-plugins.json +1 -0
  34. package/docker/entrypoint.sh +15 -0
  35. package/docker/healthcheck.mjs +8 -0
  36. package/docker/plugin-smoke.mjs +48 -0
  37. package/docker/pnpm-lock.yaml +415 -0
  38. package/docker/recovery.ts +11 -0
  39. package/docker/run.ts +52 -0
  40. package/docker/smoke.mjs +47 -0
  41. package/docker/status-smoke.mjs +30 -0
  42. package/docker/upgrade-smoke.mjs +58 -0
  43. package/docs/architecture/ai-selection.md +37 -0
  44. package/docs/architecture/authority-boundaries.md +14 -0
  45. package/docs/architecture/event-sources.md +34 -0
  46. package/docs/architecture/telegram-intake.md +29 -0
  47. package/docs/development-and-testing.md +18 -0
  48. package/docs/docker-runtime.md +118 -0
  49. package/docs/host-service.md +80 -0
  50. package/docs/plugin-contributions.md +34 -0
  51. package/docs/plugins.md +181 -0
  52. package/docs/releasing.md +71 -0
  53. package/docs/setup.md +234 -0
  54. package/docs/upgrades.md +193 -0
  55. package/package.json +106 -0
  56. package/scripts/assert-local-registry.mjs +22 -0
  57. package/scripts/release-check.mjs +14 -0
  58. package/scripts/smoke.ts +102 -0
  59. package/src/agent-install.ts +96 -0
  60. package/src/ai-cli.ts +22 -0
  61. package/src/ai.ts +88 -0
  62. package/src/approval-cli.ts +59 -0
  63. package/src/approval.ts +119 -0
  64. package/src/audio.ts +184 -0
  65. package/src/client-defaults.ts +101 -0
  66. package/src/config.ts +48 -0
  67. package/src/control-state.ts +350 -0
  68. package/src/desktop-bridge.ts +284 -0
  69. package/src/event-sources.ts +112 -0
  70. package/src/executor.ts +335 -0
  71. package/src/files.ts +75 -0
  72. package/src/format.ts +57 -0
  73. package/src/host-executor-client.ts +46 -0
  74. package/src/host-executor-protocol.ts +2 -0
  75. package/src/host-executor.ts +129 -0
  76. package/src/identity.ts +17 -0
  77. package/src/inbox.ts +171 -0
  78. package/src/index.ts +812 -0
  79. package/src/install-config.ts +56 -0
  80. package/src/install-tools.mjs +98 -0
  81. package/src/menu.ts +123 -0
  82. package/src/message-send.ts +57 -0
  83. package/src/message.ts +68 -0
  84. package/src/owner-args.ts +4 -0
  85. package/src/owner.ts +30 -0
  86. package/src/plugins/manager.mjs +272 -0
  87. package/src/react.ts +33 -0
  88. package/src/reaction.ts +32 -0
  89. package/src/read-request.ts +72 -0
  90. package/src/reply.ts +13 -0
  91. package/src/runs.ts +445 -0
  92. package/src/service.ts +28 -0
  93. package/src/setup.ts +180 -0
  94. package/src/software-status.ts +23 -0
  95. package/src/source-cli.ts +18 -0
  96. package/src/update-attention.ts +18 -0
  97. package/src/updates/artifact.mjs +83 -0
  98. package/src/updates/binding.mjs +27 -0
  99. package/src/updates/control.mjs +131 -0
  100. package/src/updates/launch.mjs +13 -0
  101. package/src/updates/runtime.mjs +140 -0
  102. package/src/updates/status.mjs +49 -0
  103. package/src/updates/supervisor.mjs +102 -0
  104. package/src/version.ts +4 -0
  105. package/src/workspace.ts +32 -0
  106. package/templates/agent/AGENTS.md +49 -0
  107. package/templates/agent/SOUL.md +11 -0
  108. package/templates/agent/TOOLS.md +46 -0
  109. package/templates/agent/USER.md +5 -0
  110. package/templates/updates.md +45 -0
  111. package/test/agent-install.test.ts +48 -0
  112. package/test/ai-cli.test.ts +28 -0
  113. package/test/ai.test.ts +105 -0
  114. package/test/approval.test.ts +40 -0
  115. package/test/audio.test.ts +77 -0
  116. package/test/client-defaults.test.ts +64 -0
  117. package/test/codex-context.test.ts +33 -0
  118. package/test/config.test.ts +32 -0
  119. package/test/control-state.test.ts +58 -0
  120. package/test/desktop-bridge.test.ts +159 -0
  121. package/test/docker-runtime.test.ts +23 -0
  122. package/test/event-sources.test.ts +113 -0
  123. package/test/executor.test.ts +135 -0
  124. package/test/files.test.ts +50 -0
  125. package/test/format.test.ts +41 -0
  126. package/test/host-executor.test.ts +149 -0
  127. package/test/inbox-burst.test.ts +66 -0
  128. package/test/inbox.test.ts +102 -0
  129. package/test/install-config.test.ts +75 -0
  130. package/test/install-tools.test.mjs +59 -0
  131. package/test/intake-relay.test.ts +337 -0
  132. package/test/owner-help.test.mjs +10 -0
  133. package/test/plugin-manager.test.mjs +157 -0
  134. package/test/publish-guard.test.ts +21 -0
  135. package/test/reaction.test.ts +122 -0
  136. package/test/read-request.test.ts +134 -0
  137. package/test/relay.test.ts +254 -0
  138. package/test/release-entrypoints.test.mjs +26 -0
  139. package/test/runs.test.ts +116 -0
  140. package/test/security.test.ts +85 -0
  141. package/test/setup.test.ts +68 -0
  142. package/test/software-status.test.ts +31 -0
  143. package/test/update-attention.test.ts +21 -0
  144. package/test/updates.test.mjs +282 -0
  145. package/test/upgrade-pause.test.ts +70 -0
  146. package/test/workspace.test.ts +80 -0
  147. package/tsconfig.json +19 -0
package/src/index.ts ADDED
@@ -0,0 +1,812 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { stat } from 'node:fs/promises'
3
+ import { queueUpdateAttention } from './update-attention.js'
4
+ import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
5
+ import { dirname, join, basename } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import path from 'node:path'
8
+ import { Bot, InlineKeyboard, InputFile, GrammyError, type Context } from 'grammy'
9
+ import type { ChildProcess } from 'node:child_process'
10
+ import { isOwner } from './identity.js'
11
+ import type { Update } from 'grammy/types'
12
+ import { InboxStore, type IncomingItem } from './inbox.js'
13
+ import { loadConfig, type Config } from './config.js'
14
+ import { ControlStore } from './control-state.js'
15
+ import { ApprovalStore } from './approval.js'
16
+ import { startExecutorJob, terminateJob } from './executor.js'
17
+ import { RunStore, type RunRecord } from './runs.js'
18
+ import { splitTelegramText } from './reply.js'
19
+ import { markdownToTelegramHtml, escapeHtml } from './format.js'
20
+ import { sanitizeFileName, stageIncomingFile, workspaceFile } from './files.js'
21
+ import { transcribeAudio, synthesizeSpeech } from './audio.js'
22
+ import { normalizeReactionEmoji } from './reaction.js'
23
+ import { downloadTelegramFile } from './read-request.js'
24
+ import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
25
+ import { presetLabel } from './ai.js'
26
+ import { initializeWorkspace } from './workspace.js'
27
+ import { softwareStatus } from './software-status.js'
28
+
29
+ export const createRelay = (config: Config, launch = startExecutorJob) => {
30
+ const safeError = (error: unknown): string => {
31
+ let message = error instanceof Error ? error.message : 'Unknown error'
32
+ for (const secret of [config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
33
+ if (secret) message = message.replaceAll(secret, '[redacted]')
34
+ }
35
+ return message
36
+ }
37
+ const bot = new Bot(config.telegramBotToken)
38
+ const control = new ControlStore(config.controlDir, config.pairingTtlMs)
39
+ const approvals = new ApprovalStore(config.controlDir)
40
+ const runs = new RunStore(config.controlDir)
41
+ const inbox = new InboxStore(config.controlDir)
42
+ const sources = new EventSources(config.controlDir)
43
+ const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace)
44
+ const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
45
+
46
+ let activeTypingTimer: ReturnType<typeof setInterval> | null = null
47
+ let activeChild: ChildProcess | null = null
48
+ let shuttingDown = false
49
+ let nextSendAt = 0
50
+ const paceSend = async () => {
51
+ const delay = nextSendAt - Date.now()
52
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
53
+ nextSendAt = Date.now() + 1000
54
+ }
55
+ let startLock: Promise<void> = Promise.resolve()
56
+ const withStartLock = (work: () => Promise<void>): Promise<void> => {
57
+ const next = startLock.then(work, work)
58
+ startLock = next.then(
59
+ () => undefined,
60
+ () => undefined,
61
+ )
62
+ return next
63
+ }
64
+
65
+ const sendChat = async (chatId: number, text: string, replyToMessageId?: number): Promise<number[]> => {
66
+ const ids: number[] = []
67
+ const parts = splitTelegramText(text)
68
+ for (let i = 0; i < parts.length; i++) {
69
+ await paceSend()
70
+ const part = parts[i]
71
+ const replyParams =
72
+ i === 0 && replyToMessageId ? { reply_parameters: { message_id: replyToMessageId } } : {}
73
+ try {
74
+ const html = markdownToTelegramHtml(part)
75
+ const sent = await bot.api.sendMessage(chatId, html, { parse_mode: 'HTML', ...replyParams })
76
+ ids.push(sent.message_id)
77
+ } catch (error) {
78
+ if (
79
+ !(error instanceof GrammyError) ||
80
+ error.error_code !== 400 ||
81
+ !error.description.includes('parse entities')
82
+ )
83
+ throw error
84
+ // Fallback to plain text if HTML parsing fails
85
+ const sent = await bot.api.sendMessage(chatId, part, { ...replyParams })
86
+ ids.push(sent.message_id)
87
+ }
88
+ }
89
+ return ids
90
+ }
91
+
92
+ const startJob = async (run: RunRecord): Promise<void> => {
93
+ await withStartLock(async () => {
94
+ if (shuttingDown || activeChild) return
95
+ // stat uses the effective UID; access uses the relay's isolated real UID.
96
+ if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
97
+ if ((await runs.get(run.id))?.status !== 'queued') return
98
+ if (await runs.running()) return
99
+ const owner = (await control.status()).owner
100
+ if (!owner || owner.telegramUserId !== run.telegramUserId || owner.telegramChatId !== run.chatId) {
101
+ await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
102
+ return
103
+ }
104
+ let texts = run.texts
105
+ if (run.external) {
106
+ // Availability failures leave durable queued work for a later check.
107
+ let events: SourceEvent[]
108
+ try { events = await sources.check(run.external, owner); unavailableSources.delete(run.external.sourceId) } catch { unavailableSources.add(run.external.sourceId); return }
109
+ if (!events.length) {
110
+ await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() })
111
+ return
112
+ }
113
+ texts = events.map(event => JSON.stringify(event))
114
+ }
115
+ try {
116
+ const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
117
+ if (!started.execution) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
118
+ const session = run.external
119
+ ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
120
+ : await control.executionSession(started.execution)
121
+ const selected = started.execution.preset
122
+ const { child, cleanup } = await launch(texts, {
123
+ workspace: config.workspace,
124
+ timeoutMs: config.executorTimeoutMs,
125
+ runId: started.id,
126
+ controlDir: config.controlDir,
127
+ binDir,
128
+ cli: selected.cli,
129
+ model: selected.model,
130
+ effort: selected.effort,
131
+ sessionId: session.nativeSessionId || session.sessionId,
132
+ isResume: session.hasStarted,
133
+ eventSource: run.external?.sourceId,
134
+ onSession: run.external ? undefined : (id) => control.saveNativeSession(session.sessionId, id),
135
+ })
136
+ activeChild = child
137
+ const finished = new Promise<number | null>((resolve) => {
138
+ if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
139
+ else child.once('close', resolve)
140
+ })
141
+ await runs.patch(started.id, { pid: child.pid })
142
+ console.info('run started', {
143
+ run_id: started.id,
144
+ pid: child.pid,
145
+ cli: selected.cli,
146
+ session: session.sessionId,
147
+ isResume: session.hasStarted,
148
+ })
149
+
150
+ if (activeTypingTimer) clearInterval(activeTypingTimer)
151
+ if (!run.external) activeTypingTimer = setInterval(() => {
152
+ void bot.api.sendChatAction(run.chatId, 'typing').catch(() => {})
153
+ }, 4000)
154
+
155
+ child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
156
+ if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
157
+ })
158
+ void finished.then((code) => {
159
+ void (async () => {
160
+ await withStartLock(async () => {
161
+ try {
162
+ await cleanup()
163
+ if (code === 0 && !run.external) await control.markSessionStarted(session.sessionId)
164
+ await runs.patch(started.id, { status: code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString() })
165
+ } catch (error) {
166
+ await runs.patch(started.id, { status: 'failed', endedAt: new Date().toISOString() })
167
+ console.error('Session completion failed', safeError(error))
168
+ } finally {
169
+ activeChild = null
170
+ if (activeTypingTimer) clearInterval(activeTypingTimer)
171
+ activeTypingTimer = null
172
+ }
173
+ })
174
+ console.info('run ended', { run_id: started.id, code })
175
+ const next = await runs.nextQueued()
176
+ if (next && !shuttingDown) await startJob(next)
177
+ })().catch((error) => console.error('Run completion failed', error.message))
178
+ })
179
+ } catch (error) {
180
+ if (activeTypingTimer) {
181
+ clearInterval(activeTypingTimer)
182
+ activeTypingTimer = null
183
+ }
184
+ await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
185
+ console.error('run start failed', run.id, safeError(error))
186
+ await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
187
+ setImmediate(() => {
188
+ void runs
189
+ .nextQueued()
190
+ .then((next) => next && startJob(next))
191
+ .catch(console.error)
192
+ })
193
+ }
194
+ })
195
+ }
196
+
197
+ const unavailableSources = new Set<string>()
198
+ let sourceWork: Promise<void> | undefined
199
+ const drainSources = (): Promise<void> => {
200
+ if (sourceWork) return sourceWork
201
+ sourceWork = (async () => {
202
+ if (shuttingDown) return
203
+ const owner = (await control.status()).owner
204
+ if (!owner) return
205
+ await queueUpdateAttention(config.controlDir,owner,runs,await control.captureChoice(aiMenu.initial))
206
+ for (const source of await sources.available(owner)) {
207
+ try {
208
+ const batch = await sources.batch(source)
209
+ unavailableSources.delete(source.id)
210
+ if (!batchReady(batch.events)) continue
211
+ await withStartLock(async () => {
212
+ if (shuttingDown) return
213
+ await sources.remember(source, batch)
214
+ const groups = new Map<string, SourceEvent[]>()
215
+ for (const event of batch.events) groups.set(event.conversationId, [...(groups.get(event.conversationId) || []), event])
216
+ for (const events of groups.values()) await runs.create({
217
+ id: eventRunId(source, events), chatId: owner.telegramChatId, telegramUserId: owner.telegramUserId,
218
+ texts: [], execution: await control.captureChoice(aiMenu.initial),
219
+ external: { sourceId: source.id, bindingId: source.bindingId, eventIds: events.map(e => e.id) },
220
+ })
221
+ // Acknowledgement follows durable run creation; replay uses the saved batch.
222
+ await sources.advance(source, batch.cursor)
223
+ })
224
+ } catch { unavailableSources.add(source.id) }
225
+ }
226
+ for (const run of (await runs.list()).filter(r => r.status === 'queued')) {
227
+ if (activeChild || shuttingDown) break
228
+ await startJob(run)
229
+ }
230
+ })().finally(() => { sourceWork = undefined })
231
+ return sourceWork
232
+ }
233
+ let sourceTimer: ReturnType<typeof setInterval> | undefined
234
+
235
+ const replay = new WeakSet<Update>()
236
+ let collected: IncomingItem[] = []
237
+ let normalizing: Update | undefined
238
+ let intakeTimer: ReturnType<typeof setTimeout> | undefined
239
+ let intakeWork: Promise<void> | undefined
240
+ const collectItem = (item: IncomingItem) => {
241
+ const message = normalizing?.message
242
+ if (message?.media_group_id) item.text = `[Telegram album: ${message.media_group_id}]\n${item.text}`
243
+ if (message && !message.text && message.reply_to_message) {
244
+ const quoted = message.reply_to_message
245
+ item.text = `[Quoted message ${quoted.message_id}]: ${quoted.text || quoted.caption || '[media]'}\n\n${item.text}`
246
+ }
247
+ collected.push(item)
248
+ }
249
+ const scheduleIntake = () => {
250
+ if (shuttingDown || intakeTimer) return
251
+ intakeTimer = setTimeout(() => {
252
+ intakeTimer = undefined
253
+ void drainInbox().catch((error) => console.error('Inbox drain failed', safeError(error)))
254
+ }, 250)
255
+ }
256
+ const drainInbox = (force = false): Promise<void> => {
257
+ if (intakeWork) return intakeWork
258
+ intakeWork = (async () => {
259
+ if (shuttingDown) return
260
+ const batch = await inbox.next(force)
261
+ if (!batch) return
262
+ try {
263
+ let run = await runs.get(batch.id)
264
+ if (!run) {
265
+ collected = []
266
+ for (const entry of batch.entries) {
267
+ if (shuttingDown || !(await inbox.pending(batch.id))) return
268
+ normalizing = entry.update
269
+ replay.add(entry.update)
270
+ try {
271
+ await bot.handleUpdate(entry.update)
272
+ } finally {
273
+ replay.delete(entry.update)
274
+ normalizing = undefined
275
+ }
276
+ }
277
+ await withStartLock(async () => {
278
+ if (shuttingDown || !(await inbox.pending(batch.id))) return
279
+ const first = collected[0]
280
+ if (first)
281
+ run = await runs.create({
282
+ id: batch.id,
283
+ chatId: first.chatId,
284
+ telegramUserId: first.fromId,
285
+ messageId: first.messageId,
286
+ texts: collected.map((item) => item.text),
287
+ execution: batch.entries[0].execution,
288
+ })
289
+ await inbox.finish(batch.id)
290
+ })
291
+ } else await inbox.finish(batch.id)
292
+ if (run) await startJob(run)
293
+ } catch (error) {
294
+ await inbox.finish(batch.id, true)
295
+ console.error('Inbox batch failed', batch.id, safeError(error))
296
+ }
297
+ })().finally(() => {
298
+ intakeWork = undefined
299
+ if (!shuttingDown)
300
+ void inbox
301
+ .status()
302
+ .then((state) => {
303
+ if (state.pending) scheduleIntake()
304
+ })
305
+ .catch((error) => console.error('Inbox status failed', safeError(error)))
306
+ })
307
+ return intakeWork
308
+ }
309
+
310
+ let isDraining = false
311
+ const drainOutbox = async (onlyRunId?: string): Promise<void> => {
312
+ if (isDraining) return
313
+ isDraining = true
314
+ try {
315
+ for (const item of await runs.pendingOutbox()) {
316
+ if (onlyRunId && item.runId !== onlyRunId) continue
317
+ const claimed = await runs.claimOutbox(item.id)
318
+ if (!claimed) continue
319
+ let attemptedDelivery = false
320
+ try {
321
+ const replyParams = item.replyToMessageId
322
+ ? { reply_parameters: { message_id: item.replyToMessageId } }
323
+ : {}
324
+ const owner = (await control.status()).owner
325
+ const origin = await runs.get(item.runId)
326
+ if (
327
+ !origin ||
328
+ !owner ||
329
+ origin.telegramUserId !== owner.telegramUserId ||
330
+ origin.chatId !== owner.telegramChatId ||
331
+ item.chatId !== origin.chatId
332
+ )
333
+ throw new Error('Outbox ownership mismatch')
334
+ const receiptIds: number[] = []
335
+
336
+ if (item.type === 'reaction' && item.emoji && item.messageId) {
337
+ const emoji = normalizeReactionEmoji(item.emoji)
338
+ if (!emoji) throw new Error('Unsupported reaction')
339
+ attemptedDelivery = true
340
+ await bot.api.setMessageReaction(item.chatId, item.messageId, [
341
+ { type: 'emoji', emoji: emoji as any },
342
+ ])
343
+ console.info('run reaction sent', { run_id: item.runId, emoji })
344
+ } else if (item.type === 'document' && item.documentPath) {
345
+ const docPath = await workspaceFile(config.workspace, item.documentPath)
346
+ await paceSend()
347
+ attemptedDelivery = true
348
+ const sent = await bot.api.sendDocument(item.chatId, new InputFile(docPath), {
349
+ caption: item.text,
350
+ ...replyParams,
351
+ })
352
+ console.info('run document sent', { run_id: item.runId, path: docPath })
353
+ receiptIds.push(sent.message_id)
354
+ } else if (item.type === 'voice' && item.voiceText) {
355
+ await bot.api.sendChatAction(item.chatId, 'record_voice')
356
+ const { buffer } = await synthesizeSpeech(item.voiceText, {
357
+ geminiApiKey: config.geminiApiKey,
358
+ openaiApiKey: config.openaiApiKey,
359
+ })
360
+ await paceSend()
361
+ attemptedDelivery = true
362
+ const sent = await bot.api.sendVoice(item.chatId, new InputFile(buffer, 'voice.ogg'), replyParams)
363
+ receiptIds.push(sent.message_id)
364
+ console.info('run voice sent', { run_id: item.runId })
365
+ } else if (item.type === 'approval' && item.approvalPrompt && item.approvalActionId) {
366
+ const keyboard = new InlineKeyboard()
367
+ .text('Approve ✅', `approval:${item.approvalActionId}:approve`)
368
+ .text('Deny ❌', `approval:${item.approvalActionId}:deny`)
369
+ const html = `⚠️ <b>Approval Required</b>\n\n${markdownToTelegramHtml(item.approvalPrompt)}`
370
+ await paceSend()
371
+ attemptedDelivery = true
372
+ const sent = await bot.api.sendMessage(item.chatId, html, {
373
+ parse_mode: 'HTML',
374
+ reply_markup: keyboard,
375
+ ...replyParams,
376
+ })
377
+ console.info('run approval requested', { run_id: item.runId, action_id: item.approvalActionId })
378
+ receiptIds.push(sent.message_id)
379
+ } else if (item.text) {
380
+ attemptedDelivery = true
381
+ const ids = await sendChat(item.chatId, item.text, item.replyToMessageId)
382
+ receiptIds.push(...ids)
383
+ console.info('run message sent', { run_id: item.runId, outbox_id: item.id, message_ids: ids })
384
+ } else throw new Error('Outbox item has no supported payload')
385
+ await runs.markOutboxSent(item.id, receiptIds)
386
+ } catch (error) {
387
+ console.error('outbox item processing failed', item.id, safeError(error))
388
+ await runs.failOutbox(
389
+ item.id,
390
+ safeError(error),
391
+ attemptedDelivery && !(error instanceof GrammyError),
392
+ )
393
+ }
394
+ }
395
+ } finally {
396
+ isDraining = false
397
+ }
398
+ }
399
+
400
+ const checkOwner = async (ctx: Context): Promise<boolean> => {
401
+ if (!ctx.from || ctx.from.is_bot || ctx.chat?.type !== 'private') return false
402
+ const state = await control.status()
403
+ if (!state.owner) {
404
+ const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
405
+ if (result === 'requested')
406
+ await ctx.reply('Owner approval is pending. Confirm this request through the local setup assistant.')
407
+ if (result === 'pending') await ctx.reply('Owner approval is still pending.')
408
+ if (result === 'capacity')
409
+ await ctx.reply('Owner setup is unavailable. Ask the local administrator to review pending requests.')
410
+ if (result === 'owner-exists') await ctx.reply('This agent already has an owner.')
411
+ return false
412
+ }
413
+ return isOwner(ctx, state.owner)
414
+ }
415
+
416
+ const aliases = [
417
+ { command: 'help', description: 'Show available controls' },
418
+ { command: 'status', description: 'Work, queue and delivery health' },
419
+ { command: 'stop', description: 'Stop active work; keep queued messages' },
420
+ { command: 'cancel', description: 'Cancel pending messages; keep active work' },
421
+ { command: 'retry', description: 'Retry the latest failed incoming batch' },
422
+ { command: 'new', description: 'New conversation; keep workspace files' },
423
+ ]
424
+ const commands = mainCommands
425
+ const controlCommand = (text?: string) => text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
426
+ const statusText = async () => {
427
+ const running = await runs.running()
428
+ const all = await runs.list()
429
+ const incoming = await inbox.status()
430
+ const delivery = await runs.deliveryStatus()
431
+ const session = await control.getActiveSession()
432
+ const ai = await control.aiState(aiMenu.initial)
433
+ const selected = ai.presets.find((p) => p.id === ai.selectedId)!
434
+ return [
435
+ ...await softwareStatus(config.controlDir),
436
+ `AI: ${selected.name} (${presetLabel(selected)})`,
437
+ `Default: ${ai.presets.find((p) => p.id === ai.defaultId)!.name}`,
438
+ `Session: ${session?.sessionId.slice(0, 8) || 'none'}`,
439
+ `Work: ${running ? `running ${running.id}` : 'idle'}`,
440
+ `Queue: ${all.filter((r) => r.status === 'queued').length} runs; ${incoming.pending} incoming messages`,
441
+ `Failed: ${all.filter((r) => r.status === 'failed').length} runs; ${incoming.failed} incoming batches`,
442
+ `Delivery: ${delivery.failed} failed; ${delivery.unknown} unknown/in-flight (inspect before retrying)`,
443
+ ...(unavailableSources.size ? [`Unavailable event sources: ${[...unavailableSources].join(', ')}`] : []),
444
+ '/stop stops active work only. /cancel clears pending work only.',
445
+ ...(config.executorCli === 'grok'
446
+ ? [
447
+ 'Known limitation: interrupted Grok sessions may stall on resume. /new explicitly resets the conversation.',
448
+ ]
449
+ : []),
450
+ ].join('\n')
451
+ }
452
+ const cancelPending = async () => {
453
+ let count = 0
454
+ await withStartLock(async () => {
455
+ count += await inbox.cancel()
456
+ for (const run of await runs.list()) {
457
+ if (run.status !== 'queued') continue
458
+ await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() })
459
+ count++
460
+ }
461
+ })
462
+ return `Cancelled ${count} pending messages/runs. Active work was not stopped.`
463
+ }
464
+
465
+ // Returning from the polling handler acknowledges intake, not execution. Only
466
+ // return after the authorized update has reached the atomic local journal.
467
+ bot.use(async (ctx, next) => {
468
+ if (replay.has(ctx.update)) {
469
+ if (isOwner(ctx, (await control.status()).owner)) return next()
470
+ return
471
+ }
472
+ const message = ctx.message
473
+ const command = controlCommand(message?.text)
474
+ if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu').includes(command)) return next()
475
+ const ordinary = message && (message.text || message.photo || message.document || message.voice)
476
+ const approval = ctx.callbackQuery?.data?.startsWith('approval:')
477
+ if (!ordinary && !approval) return next()
478
+ if (!(await checkOwner(ctx))) return
479
+ if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial))) scheduleIntake()
480
+ })
481
+
482
+ bot.on('message:text', async (ctx) => {
483
+ if (!(await checkOwner(ctx))) return
484
+
485
+ const text = controlCommand(ctx.message.text)
486
+
487
+ if (text === '/ai' || text === '/settings') {
488
+ await aiMenu.list(ctx, text === '/settings')
489
+ return
490
+ }
491
+
492
+ if (text === '/help') {
493
+ await ctx.reply([...commands, ...aliases.filter((a) => !commands.some((c) => c.command === a.command))]
494
+ .map((c) => `/${c.command} — ${c.description}`).join('\n'))
495
+ return
496
+ }
497
+ if (text === '/retry') {
498
+ const id = await inbox.retryLatest(ctx.from.id, ctx.chat.id)
499
+ if (id) scheduleIntake()
500
+ await ctx.reply(id ? `Incoming batch ${id} queued for retry.` : 'No failed incoming batch to retry.')
501
+ return
502
+ }
503
+ if (text === '/cancel') {
504
+ await ctx.reply(await cancelPending())
505
+ return
506
+ }
507
+
508
+ // Steering & session commands
509
+ if (text === '/stop') {
510
+ const running = await runs.running()
511
+ if (running && running.pid) {
512
+ try {
513
+ if (activeChild) terminateJob(activeChild)
514
+ } catch {}
515
+ if (activeTypingTimer) {
516
+ clearInterval(activeTypingTimer)
517
+ activeTypingTimer = null
518
+ }
519
+ await ctx.reply(
520
+ '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.',
521
+ )
522
+ } else {
523
+ await ctx.reply('No active run in progress.')
524
+ }
525
+ return
526
+ }
527
+
528
+ if (text === '/new') {
529
+ await control.aiState(aiMenu.initial)
530
+ const next = await control.resetSession()
531
+ await ctx.reply(
532
+ `🔄 Started fresh conversation session (<code>${next.sessionId.slice(0, 8)}</code>). Agent files and memory preserved.`,
533
+ { parse_mode: 'HTML' },
534
+ )
535
+ return
536
+ }
537
+
538
+ if (text === '/status') {
539
+ await ctx.reply(await statusText(), { reply_markup: new InlineKeyboard()
540
+ .text('Stop active work', 'menu:stop').text('Cancel queue', 'menu:cancel').row()
541
+ .text('Retry failed incoming message', 'menu:retry') })
542
+ return
543
+ }
544
+
545
+ if (text === '/menu') {
546
+ const menuKeyboard = mainKeyboard()
547
+ await ctx.reply('⚡ <b>Ezenciel Agent Menu</b>\nSelect an action below:', {
548
+ parse_mode: 'HTML',
549
+ reply_markup: menuKeyboard,
550
+ })
551
+ return
552
+ }
553
+
554
+ // Quoted reply context forwarding
555
+ let promptText = ctx.message.text
556
+ if (ctx.message.reply_to_message) {
557
+ const quoted = ctx.message.reply_to_message
558
+ const quotedText = ('text' in quoted && quoted.text) || ('caption' in quoted && quoted.caption) || ''
559
+ const quotedSender = quoted.from?.is_bot ? 'Agent' : 'Owner'
560
+ if (quotedText) {
561
+ promptText = `[Quoted message from ${quotedSender} (ID: ${quoted.message_id})]: "${quotedText}"\n\n${promptText}`
562
+ }
563
+ }
564
+
565
+ collectItem({
566
+ text: promptText,
567
+ messageId: ctx.message.message_id,
568
+ updateId: ctx.update.update_id,
569
+ chatId: ctx.chat.id,
570
+ fromId: ctx.from.id,
571
+ })
572
+ })
573
+
574
+ bot.on('message:photo', async (ctx) => {
575
+ if (!(await checkOwner(ctx))) return
576
+ try {
577
+ const photos = ctx.message.photo
578
+ const photo = photos[photos.length - 1] // highest resolution
579
+ const fileInfo = await ctx.api.getFile(photo.file_id)
580
+ if (!fileInfo.file_path) throw new Error('Telegram attachment path unavailable')
581
+ const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
582
+ const buffer = await downloadTelegramFile(fileUrl)
583
+ const fileName = sanitizeFileName(basename(fileInfo.file_path) || 'photo.jpg')
584
+ const staged = await stageIncomingFile(config.workspace, fileName, buffer)
585
+ const caption = ctx.message.caption?.trim() || ''
586
+ const prompt = `[Attached image staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
587
+ collectItem({
588
+ text: prompt,
589
+ messageId: ctx.message.message_id,
590
+ updateId: ctx.update.update_id,
591
+ chatId: ctx.chat.id,
592
+ fromId: ctx.from.id,
593
+ })
594
+ } catch (err) {
595
+ console.error('Failed to process incoming photo:', safeError(err))
596
+ await ctx
597
+ .reply('⚠️ Photo could not be processed. Check the local relay log; /retry retries the saved batch.')
598
+ .catch(() => {})
599
+ throw err
600
+ }
601
+ })
602
+
603
+ bot.on('message:document', async (ctx) => {
604
+ if (!(await checkOwner(ctx))) return
605
+ try {
606
+ const doc = ctx.message.document
607
+ const fileInfo = await ctx.api.getFile(doc.file_id)
608
+ if (!fileInfo.file_path) throw new Error('Telegram attachment path unavailable')
609
+ const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
610
+ const buffer = await downloadTelegramFile(fileUrl)
611
+ const fileName = sanitizeFileName(doc.file_name || basename(fileInfo.file_path) || 'document.bin')
612
+ const staged = await stageIncomingFile(config.workspace, fileName, buffer)
613
+ const caption = ctx.message.caption?.trim() || ''
614
+ const prompt = `[Attached document staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
615
+ collectItem({
616
+ text: prompt,
617
+ messageId: ctx.message.message_id,
618
+ updateId: ctx.update.update_id,
619
+ chatId: ctx.chat.id,
620
+ fromId: ctx.from.id,
621
+ })
622
+ } catch (err) {
623
+ console.error('Failed to process incoming document:', safeError(err))
624
+ await ctx
625
+ .reply(
626
+ '⚠️ Document could not be processed. Check the local relay log; /retry retries the saved batch.',
627
+ )
628
+ .catch(() => {})
629
+ throw err
630
+ }
631
+ })
632
+
633
+ bot.on('message:voice', async (ctx) => {
634
+ if (!(await checkOwner(ctx))) return
635
+ try {
636
+ await bot.api.sendChatAction(ctx.chat.id, 'typing').catch(() => {})
637
+ const voice = ctx.message.voice
638
+ const fileInfo = await ctx.api.getFile(voice.file_id)
639
+ if (!fileInfo.file_path) throw new Error('Telegram attachment path unavailable')
640
+ const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
641
+ const buffer = await downloadTelegramFile(fileUrl)
642
+ const transcript = await transcribeAudio(buffer, voice.mime_type || 'audio/ogg', {
643
+ geminiApiKey: config.geminiApiKey,
644
+ openaiApiKey: config.openaiApiKey,
645
+ })
646
+ const prompt = `[Inbound Voice Note (duration: ${voice.duration}s)]:\n"${transcript}"`
647
+ collectItem({
648
+ text: prompt,
649
+ messageId: ctx.message.message_id,
650
+ updateId: ctx.update.update_id,
651
+ chatId: ctx.chat.id,
652
+ fromId: ctx.from.id,
653
+ })
654
+ } catch (err) {
655
+ console.error('Failed to transcribe incoming voice note:', safeError(err))
656
+ await ctx
657
+ .reply(
658
+ '⚠️ Voice note could not be transcribed. The log identifies the failing request. Use /retry to retry the saved batch.',
659
+ )
660
+ .catch(() => {})
661
+ throw err
662
+ }
663
+ })
664
+
665
+ bot.on('callback_query:data', async (ctx) => {
666
+ if (!isOwner(ctx, (await control.status()).owner)) {
667
+ await ctx.answerCallbackQuery()
668
+ return
669
+ }
670
+ const data = ctx.callbackQuery.data
671
+ if (data.startsWith('approval:')) {
672
+ const [, actionId, decision] = data.split(':')
673
+ if (actionId && (decision === 'approve' || decision === 'deny')) {
674
+ const request = await approvals.getDecision(actionId)
675
+ const run = request?.runId ? await runs.get(request.runId) : null
676
+ if (!run || run.chatId !== ctx.chat?.id || run.telegramUserId !== ctx.from.id) {
677
+ await ctx.answerCallbackQuery({ text: 'Approval unavailable' })
678
+ return
679
+ }
680
+ const isApproved = decision === 'approve'
681
+ try {
682
+ await approvals.recordDecision(
683
+ actionId,
684
+ isApproved ? 'approved' : 'denied',
685
+ ctx.from.id,
686
+ ctx.update.update_id,
687
+ )
688
+ } catch {
689
+ await ctx.answerCallbackQuery({ text: 'Approval expired or already decided' })
690
+ return
691
+ }
692
+ await ctx.answerCallbackQuery({ text: isApproved ? 'Approved ✅' : 'Denied ❌' }).catch(() => {})
693
+ const original = ctx.callbackQuery.message?.text || ''
694
+ const updated = `${escapeHtml(original)}\n\n<b>Decision:</b> ${isApproved ? 'Approved ✅' : 'Denied ❌'}`
695
+ await ctx.editMessageText(updated, { parse_mode: 'HTML' }).catch(() => {})
696
+ collectItem({
697
+ text: JSON.stringify({ event: 'approval_decision', actionId, decision, prompt: request?.prompt }),
698
+ messageId: ctx.callbackQuery.message?.message_id,
699
+ updateId: ctx.update.update_id,
700
+ chatId: run.chatId,
701
+ fromId: ctx.from.id,
702
+ })
703
+ console.info('Approval decision recorded', { actionId, decision: isApproved ? 'approved' : 'denied' })
704
+ }
705
+ } else if (await aiMenu.handle(ctx)) {
706
+ return
707
+ } else if (data.startsWith('menu:')) {
708
+ const action = data.slice(5)
709
+ if (action === 'ai' || action === 'settings') {
710
+ await ctx.answerCallbackQuery()
711
+ await aiMenu.list(ctx, action === 'settings')
712
+ } else if (action === 'retry') {
713
+ await ctx.answerCallbackQuery()
714
+ const id = await inbox.retryLatest(ctx.from.id, ctx.chat!.id)
715
+ if (id) scheduleIntake()
716
+ await ctx.reply(id ? `Incoming batch ${id} queued for retry.` : 'No failed incoming batch to retry.')
717
+ } else if (action === 'new') {
718
+ await control.aiState(aiMenu.initial)
719
+ const next = await control.resetSession()
720
+ await ctx.answerCallbackQuery({ text: 'New session started' })
721
+ await ctx.reply(
722
+ `🔄 Started fresh conversation session (<code>${next.sessionId.slice(0, 8)}</code>).`,
723
+ { parse_mode: 'HTML' },
724
+ )
725
+ } else if (action === 'status') {
726
+ await ctx.answerCallbackQuery()
727
+ await ctx.reply(await statusText())
728
+ } else if (action === 'cancel') {
729
+ await ctx.answerCallbackQuery()
730
+ await ctx.reply(await cancelPending())
731
+ } else if (action === 'stop') {
732
+ const running = await runs.running()
733
+ if (running && running.pid) {
734
+ if (activeChild) terminateJob(activeChild)
735
+ await ctx.answerCallbackQuery({ text: 'Run stopped' })
736
+ await ctx.reply(
737
+ '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.',
738
+ )
739
+ } else {
740
+ await ctx.answerCallbackQuery({ text: 'No run active' })
741
+ await ctx.reply('No active run in progress.')
742
+ }
743
+ }
744
+ }
745
+ })
746
+
747
+ bot.catch((error) => {
748
+ console.error('Telegram update failure', safeError(error.error))
749
+ // Do not let polling acknowledge an update whose journal write failed.
750
+ throw error
751
+ })
752
+
753
+ const stop = async () => {
754
+ shuttingDown = true
755
+ if (intakeTimer) clearTimeout(intakeTimer)
756
+ if (sourceTimer) clearInterval(sourceTimer)
757
+ if (activeChild) terminateJob(activeChild)
758
+ if (activeTypingTimer) clearInterval(activeTypingTimer)
759
+ if (sourceWork) await sourceWork.catch(() => {})
760
+ if (bot.isRunning()) await bot.stop()
761
+ }
762
+
763
+ const start = async () => {
764
+ await initializeWorkspace(config.workspace)
765
+ sourceTimer = setInterval(() => {
766
+ void drainSources().catch(error => console.error('Event-source drain failed', safeError(error)))
767
+ }, 1000)
768
+ const drainTimer = setInterval(() => {
769
+ void drainOutbox().catch((error) => console.error('Outbox drain failed', error.message))
770
+ }, 250)
771
+ drainTimer.unref()
772
+ try {
773
+ console.log(`ezenciel-agents listening with workspace ${config.workspace}`)
774
+ console.log(`Authority control state: ${config.controlDir}`)
775
+ console.log(`CLI executor: ${config.executorCli}`)
776
+ await aiMenu.refresh()
777
+
778
+ // Reconcile stale runs and start any queued run on boot
779
+ const currentRunning = await runs.running()
780
+ if (!currentRunning) {
781
+ const pendingRun = await runs.nextQueued()
782
+ if (pendingRun) {
783
+ console.info('Processing queued run on startup:', pendingRun.id)
784
+ void startJob(pendingRun)
785
+ }
786
+ }
787
+
788
+ await bot.api.deleteWebhook({ drop_pending_updates: false })
789
+ await bot.api.setMyCommands(commands)
790
+ await bot.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
791
+ await bot.init()
792
+ scheduleIntake()
793
+ await bot.start({
794
+ drop_pending_updates: false,
795
+ onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
796
+ })
797
+ } finally {
798
+ clearInterval(drainTimer)
799
+ if (sourceTimer) clearInterval(sourceTimer)
800
+ }
801
+ }
802
+ return { bot, start, stop, drainOutbox, drainInbox, drainSources }
803
+ }
804
+
805
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
806
+ const relay = createRelay(loadConfig())
807
+ for (const signal of ['SIGINT', 'SIGTERM'] as const)
808
+ process.once(signal, () => {
809
+ void relay.stop()
810
+ })
811
+ await relay.start()
812
+ }