@jc_stack/ez-agents 0.1.0-beta.26 → 0.1.0-beta.27
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.
- package/.env.example +1 -1
- package/AGENTS.md +15 -8
- package/CHANGELOG.md +12 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +5 -4
- package/docs/architecture/ai-selection.md +12 -15
- package/docs/docker-runtime.md +9 -0
- package/docs/host-service.md +5 -8
- package/docs/local-qa.md +1 -1
- package/docs/plugins.md +14 -1
- package/docs/repair.md +26 -25
- package/docs/responsive-channels.md +13 -55
- package/docs/scheduling.md +40 -36
- package/docs/setup.md +11 -21
- package/docs/standalone-cli.md +2 -2
- package/docs/upgrades.md +32 -17
- package/package.json +2 -3
- package/src/agent-guidance.ts +32 -3
- package/src/ai-cli.ts +5 -1
- package/src/ai.ts +6 -28
- package/src/codex-session.ts +4 -9
- package/src/config.ts +3 -3
- package/src/control-state.ts +18 -6
- package/src/desktop-bridge.ts +11 -43
- package/src/executor.ts +20 -55
- package/src/host-executor.ts +4 -8
- package/src/index.ts +48 -45
- package/src/menu.ts +51 -47
- package/src/message-send.ts +1 -1
- package/src/message.ts +1 -0
- package/src/model-policy.ts +5 -15
- package/src/plugins/manager.mjs +31 -6
- package/src/repair-policy.ts +0 -8
- package/src/reply-context.ts +3 -29
- package/src/schedule-cli.ts +24 -11
- package/src/scheduled-tasks.ts +20 -21
- package/src/scheduler.ts +38 -15
- package/src/task-executor.ts +4 -5
- package/src/task-workspace.ts +2 -11
- package/src/update-attention.ts +1 -1
- package/src/updates/binding.mjs +2 -6
- package/src/updates/supervisor.mjs +10 -4
- package/src/workspace.ts +3 -1
- package/templates/agent/AGENTS.md +13 -55
- package/templates/agent-guidance.md +27 -30
- package/templates/failure-review.md +6 -0
- package/templates/maintainer-purpose.md +12 -6
- package/test/agent-guidance.test.ts +29 -39
- package/test/ai-cli.test.ts +9 -0
- package/test/ai.test.ts +66 -22
- package/test/busy-reply-relay.test.ts +11 -7
- package/test/client-defaults.test.ts +1 -1
- package/test/codex-session.test.ts +15 -10
- package/test/config.test.ts +1 -1
- package/test/desktop-bridge.test.ts +17 -11
- package/test/engine-handoff.test.ts +73 -0
- package/test/event-sources.test.ts +5 -8
- package/test/executor.test.ts +12 -16
- package/test/failure.test.ts +64 -0
- package/test/host-executor.test.ts +30 -17
- package/test/install-config.test.ts +1 -1
- package/test/intake-relay.test.ts +47 -24
- package/test/model-policy.test.ts +23 -48
- package/test/plugin-manager.test.mjs +36 -10
- package/test/repair-policy.test.ts +8 -12
- package/test/runs.test.ts +13 -0
- package/test/schedule-cli.test.ts +34 -5
- package/test/scheduled-tasks.test.ts +79 -8
- package/test/scheduler.test.ts +30 -1
- package/test/task-native.test.ts +5 -2
- package/test/update-attention.test.ts +1 -2
- package/test/updates.test.mjs +5 -5
- package/test/workspace.test.ts +2 -3
- package/scripts/smoke-busy-reply.ts +0 -58
- package/src/reply-executor.ts +0 -55
- package/src/reply-mcp.ts +0 -23
- package/templates/agent/TOOLS.md +0 -105
- package/templates/chat-guidance.md +0 -23
- package/templates/standalone-tools.md +0 -20
- package/templates/updates.md +0 -45
- package/test/reply.test.ts +0 -159
package/src/host-executor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { installAgentGuidance } from './agent-guidance.js'
|
|
1
2
|
import { redactFailure } from './failure.js'
|
|
2
3
|
import { RunStore } from './runs.js'
|
|
3
4
|
import { Tasks } from './tasks.js'
|
|
@@ -23,7 +24,6 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
23
24
|
new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
|
|
24
25
|
throw new Error('Each agent requires a separate workspace and control directory')
|
|
25
26
|
const active = new Map<string, ChildProcess>()
|
|
26
|
-
const busy = new Set<string>()
|
|
27
27
|
const tasks = new Set<Promise<void>>()
|
|
28
28
|
const locks: string[] = []
|
|
29
29
|
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
@@ -45,6 +45,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
45
45
|
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
46
46
|
await writeFile(lock,JSON.stringify({pid:process.pid}),{mode:0o600,flag:'wx'})
|
|
47
47
|
locks.push(lock)
|
|
48
|
+
await installAgentGuidance(agent.workspace)
|
|
48
49
|
await writeFile(path.join(directory,'models.json'),JSON.stringify(await catalog(agent)),{mode:0o600})
|
|
49
50
|
// A host crash is terminal for a claimed job. Never replay an action.
|
|
50
51
|
for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
|
|
@@ -88,11 +89,8 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
88
89
|
continue
|
|
89
90
|
}
|
|
90
91
|
const sharedWorkspace=sharedWorkspaces.get(agent)
|
|
91
|
-
const lane=run?.replyOnly ? 'reply:'+agent.name : sharedWorkspace ? 'workspace:'+sharedWorkspace : run?.scheduled ? agent.name+':'+id : agent.name
|
|
92
|
-
if(busy.has(lane) || (run?.scheduled && [...busy].filter(k=>k.startsWith(agent.name+':')).length>=4)) continue
|
|
93
92
|
const base=path.join(directory,id)
|
|
94
93
|
await rename(base+'.request.json',base+'.running.json')
|
|
95
|
-
busy.add(lane)
|
|
96
94
|
const task=(async()=>{
|
|
97
95
|
let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
|
|
98
96
|
let cancellation: ReturnType<typeof setInterval> | undefined
|
|
@@ -115,7 +113,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
115
113
|
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
116
114
|
sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model:opts.model,effort:opts.effort,codexAutoCompactTokens:opts.codexAutoCompactTokens}
|
|
117
115
|
job=await launch(request.texts,options)
|
|
118
|
-
active.set(
|
|
116
|
+
active.set(base,job.child)
|
|
119
117
|
await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
|
|
120
118
|
if(signal.aborted)terminateJob(job.child)
|
|
121
119
|
job.child.stdout?.on('data',chunk=>emit({stream:'stdout',text:chunk.toString()}))
|
|
@@ -134,12 +132,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
134
132
|
await rm(base+'.running.json',{force:true})
|
|
135
133
|
await rm(base+'.process.json',{force:true})
|
|
136
134
|
await rm(base+'.cancel',{force:true})
|
|
137
|
-
active.delete(
|
|
138
|
-
busy.delete(lane)
|
|
135
|
+
active.delete(base)
|
|
139
136
|
}
|
|
140
137
|
})()
|
|
141
138
|
tasks.add(task); void task.finally(()=>tasks.delete(task))
|
|
142
|
-
// The lane is reserved before spawning; other task workspaces may start.
|
|
143
139
|
}
|
|
144
140
|
}
|
|
145
141
|
await new Promise(resolve=>setTimeout(resolve,250))
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-
|
|
|
14
14
|
import { dirname, join, basename } from 'node:path'
|
|
15
15
|
import { fileURLToPath } from 'node:url'
|
|
16
16
|
import path from 'node:path'
|
|
17
|
-
import { Bot, InlineKeyboard, InputFile, GrammyError, type Context } from 'grammy'
|
|
17
|
+
import { Bot, InlineKeyboard, InputFile, GrammyError, HttpError, type Context } from 'grammy'
|
|
18
18
|
import type { ChildProcess } from 'node:child_process'
|
|
19
19
|
import { isOwner, ownsRun } from './identity.js'
|
|
20
20
|
import type { Update } from 'grammy/types'
|
|
@@ -65,7 +65,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
65
65
|
let drainTimer: ReturnType<typeof setInterval> | undefined
|
|
66
66
|
const codexHome = join(config.controlDir, 'cli', 'codex')
|
|
67
67
|
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
|
|
68
|
-
const durableWorkerChoice = () => (
|
|
68
|
+
const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
|
|
69
69
|
const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
|
|
70
70
|
const pagerDuty = config.pagerDutyRoutingKey && config.pagerDutyStocksHealthUrl
|
|
71
71
|
? new PagerDutyStocksMonitor({
|
|
@@ -79,11 +79,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
79
79
|
|
|
80
80
|
let activeTypingTimer: ReturnType<typeof setInterval> | null = null
|
|
81
81
|
let activeBackend = false
|
|
82
|
-
let activeReply: ChildProcess | null = null
|
|
83
82
|
const ownerStopped = new WeakSet<ChildProcess>()
|
|
84
83
|
let activeChild: ChildProcess | null = null
|
|
85
84
|
let shuttingDown = false
|
|
86
85
|
let wakePollRetry: (() => void) | undefined
|
|
86
|
+
const pollingAbort = new AbortController()
|
|
87
87
|
let nextSendAt = 0
|
|
88
88
|
const paceSend = async () => {
|
|
89
89
|
const delay = nextSendAt - Date.now()
|
|
@@ -135,12 +135,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
135
135
|
// stat uses the effective UID; access uses the relay's isolated real UID.
|
|
136
136
|
if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
|
|
137
137
|
if ((await runs.get(run.id))?.status !== 'queued') return
|
|
138
|
-
|
|
139
|
-
if (!config.channelBackendUrl && busy && /^tg_[0-9]+$/.test(run.id) && !run.external && !run.taskId && run.execution?.preset.cli === 'codex') {
|
|
140
|
-
if (activeReply) return
|
|
141
|
-
run = await runs.patch(run.id, { replyOnly: true })
|
|
142
|
-
}
|
|
143
|
-
if (!run.scheduled && !run.replyOnly && await runs.running(false)) return
|
|
138
|
+
if (!run.scheduled && await runs.running(false)) return
|
|
144
139
|
const owner = (await control.status()).owner
|
|
145
140
|
if (!owner || !ownsRun(owner, run)) {
|
|
146
141
|
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
|
|
@@ -167,7 +162,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
167
162
|
return
|
|
168
163
|
}
|
|
169
164
|
if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
|
|
170
|
-
if (run.
|
|
165
|
+
if (run.scheduled ? background.size >= 4 : activeChild) return
|
|
171
166
|
let texts = run.texts
|
|
172
167
|
if (run.external) {
|
|
173
168
|
// Availability failures leave durable queued work for a later check.
|
|
@@ -194,11 +189,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
194
189
|
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
195
190
|
if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
196
191
|
const maintenanceWakeup = run.id.startsWith('r_update_')
|
|
197
|
-
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled ||
|
|
192
|
+
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled || maintenanceWakeup)
|
|
198
193
|
const session = startsOwnSession
|
|
199
194
|
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
200
195
|
: await control.executionSession(started.execution!)
|
|
201
|
-
const selected = run.taskId ?
|
|
196
|
+
const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
202
197
|
const { child, cleanup } = await launch(texts, {
|
|
203
198
|
workspace: run.scheduled ? await taskWorkspace(config.workspace,run.id) : config.workspace,
|
|
204
199
|
timeoutMs: config.executorTimeoutMs,
|
|
@@ -213,7 +208,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
213
208
|
sessionId: session.nativeSessionId || session.sessionId,
|
|
214
209
|
isResume: session.hasStarted,
|
|
215
210
|
eventSource: run.external?.sourceId,
|
|
216
|
-
onSession: run.external || run.taskId ||
|
|
211
|
+
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) },
|
|
217
212
|
})
|
|
218
213
|
const executionStarted = performance.now()
|
|
219
214
|
// Attach before disk writes: a fast child can close while PID persistence
|
|
@@ -231,8 +226,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
231
226
|
console.info('run timing', { run_id: run.id, phase: 'launch',
|
|
232
227
|
queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
|
|
233
228
|
startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
|
|
234
|
-
if (run.
|
|
235
|
-
else if (run.scheduled) background.set(run.id,child)
|
|
229
|
+
if (run.scheduled) background.set(run.id,child)
|
|
236
230
|
else activeChild = child
|
|
237
231
|
const finished = new Promise<number | null>((resolve) => {
|
|
238
232
|
if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
|
|
@@ -247,8 +241,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
247
241
|
isResume: session.hasStarted,
|
|
248
242
|
})
|
|
249
243
|
|
|
250
|
-
if (!run.scheduled &&
|
|
251
|
-
if (!run.external && !run.taskId && !run.scheduled
|
|
244
|
+
if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
|
|
245
|
+
if (!run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
|
|
252
246
|
if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId, 'typing').catch(() => {})
|
|
253
247
|
}, 4000)
|
|
254
248
|
|
|
@@ -259,15 +253,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
259
253
|
await withStartLock(async () => {
|
|
260
254
|
try {
|
|
261
255
|
await cleanup()
|
|
262
|
-
if (code === 0 && !run.external && !run.taskId && !run.scheduled
|
|
256
|
+
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
263
257
|
const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
|
|
264
258
|
await runs.patch(started.id, { status: cancelled ? 'cancelled' : code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString(), exitCode: code, ...(code !== 0 && !cancelled ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
265
259
|
} catch (error) {
|
|
266
260
|
await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
267
261
|
console.error('Session completion failed', safeError(error))
|
|
268
262
|
} finally {
|
|
269
|
-
if (run.
|
|
270
|
-
else if (run.scheduled) background.delete(run.id)
|
|
263
|
+
if (run.scheduled) background.delete(run.id)
|
|
271
264
|
else {
|
|
272
265
|
activeChild = null
|
|
273
266
|
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
@@ -283,7 +276,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
283
276
|
completions.add(completion)
|
|
284
277
|
void completion.finally(() => completions.delete(completion))
|
|
285
278
|
} catch (error) {
|
|
286
|
-
if (!run.scheduled &&
|
|
279
|
+
if (!run.scheduled && activeTypingTimer) {
|
|
287
280
|
clearInterval(activeTypingTimer)
|
|
288
281
|
activeTypingTimer = null
|
|
289
282
|
}
|
|
@@ -313,7 +306,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
313
306
|
for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
|
|
314
307
|
try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
|
|
315
308
|
}
|
|
316
|
-
await queueUpdateAttention(config.controlDir,owner,runs,durableWorkerChoice())
|
|
309
|
+
await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
|
|
317
310
|
}
|
|
318
311
|
for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
319
312
|
try {
|
|
@@ -330,7 +323,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
330
323
|
await runs.create({
|
|
331
324
|
taskId: task?.id,
|
|
332
325
|
id: eventRunId(source, events), chatId: owner.telegramChatId, telegramUserId: owner.telegramUserId,
|
|
333
|
-
texts: [], execution: durableWorkerChoice(),
|
|
326
|
+
texts: [], execution: await durableWorkerChoice(),
|
|
334
327
|
external: { sourceId: source.id, bindingId: source.bindingId, eventIds: events.map(e => e.id) },
|
|
335
328
|
})
|
|
336
329
|
}
|
|
@@ -550,6 +543,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
550
543
|
{ command: 'retry', description: 'Retry the latest failed incoming batch' },
|
|
551
544
|
{ command: 'new', description: 'New conversation; keep workspace files' },
|
|
552
545
|
]
|
|
546
|
+
// Keep the retired command from becoming an agent prompt while old clients catch up.
|
|
547
|
+
const retiredCommands = ['/settings']
|
|
553
548
|
const commands = mainCommands
|
|
554
549
|
const controlCommand = (text?: string) => text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
|
|
555
550
|
const statusKeyboard = () => new InlineKeyboard()
|
|
@@ -560,7 +555,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
560
555
|
const owner = (await control.status()).owner
|
|
561
556
|
if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
|
|
562
557
|
// This intentionally uses the reader that does not create a schedules directory.
|
|
563
|
-
return scheduledTasksText(await scheduler.
|
|
558
|
+
return scheduledTasksText(await scheduler.listActiveReadOnly(await runs.list()), owner)
|
|
564
559
|
}
|
|
565
560
|
const replyScheduledTasks = async (ctx: Context) => {
|
|
566
561
|
for (const part of splitTelegramText(await scheduledTasks())) await ctx.reply(part)
|
|
@@ -650,7 +645,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
650
645
|
if (replay.has(ctx.update)) {
|
|
651
646
|
collected.push({
|
|
652
647
|
updateId: ctx.update.update_id, chatId: owner.telegramChatId, fromId: owner.telegramUserId,
|
|
653
|
-
text:
|
|
648
|
+
text: JSON.stringify({event:'owner_message_in_unbound_group',chatId:ctx.chat.id,title:ctx.chat.title,messageId:message.message_id,text:message.text}),
|
|
654
649
|
})
|
|
655
650
|
} else if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial))) scheduleIntake()
|
|
656
651
|
return
|
|
@@ -661,7 +656,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
661
656
|
}
|
|
662
657
|
const message = ctx.message
|
|
663
658
|
const command = controlCommand(message?.text)
|
|
664
|
-
if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu').includes(command)) return next()
|
|
659
|
+
if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) return next()
|
|
665
660
|
const ordinary = message && (message.text || message.photo || message.document || message.voice)
|
|
666
661
|
const approval = ctx.callbackQuery?.data?.startsWith('approval:')
|
|
667
662
|
if (!ordinary && !approval) return next()
|
|
@@ -674,8 +669,17 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
674
669
|
|
|
675
670
|
const text = controlCommand(ctx.message.text)
|
|
676
671
|
|
|
677
|
-
if (
|
|
678
|
-
await
|
|
672
|
+
if (config.channelBackendUrl && ['/new', '/ai', '/settings'].includes(text ?? '')) {
|
|
673
|
+
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (text === '/ai') {
|
|
678
|
+
await aiMenu.list(ctx)
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
if (text === '/settings') {
|
|
682
|
+
await ctx.reply('Settings was removed. Use /ai to choose the client, model, and reasoning level.')
|
|
679
683
|
return
|
|
680
684
|
}
|
|
681
685
|
|
|
@@ -695,11 +699,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
695
699
|
return
|
|
696
700
|
}
|
|
697
701
|
|
|
698
|
-
if (config.channelBackendUrl && ['/new', '/ai', '/settings'].includes(text ?? '')) {
|
|
699
|
-
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
700
|
-
return
|
|
701
|
-
}
|
|
702
|
-
|
|
703
702
|
// Steering & session commands
|
|
704
703
|
if (text === '/stop' && config.channelBackendUrl) {
|
|
705
704
|
await ctx.reply('This channel uses an application backend. Stopping its active job is not supported here; check the application. /cancel removes only pending relay work.')
|
|
@@ -709,7 +708,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
709
708
|
const running = await runs.running(false)
|
|
710
709
|
if ((running && running.pid) || background.size) {
|
|
711
710
|
try {
|
|
712
|
-
if (activeReply) { ownerStopped.add(activeReply); terminateJob(activeReply) }
|
|
713
711
|
if (activeChild) { ownerStopped.add(activeChild); terminateJob(activeChild) }
|
|
714
712
|
for (const [id,child] of background) { await scheduler.cancel(id); ownerStopped.add(child); terminateJob(child) }
|
|
715
713
|
} catch {}
|
|
@@ -905,16 +903,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
905
903
|
})
|
|
906
904
|
console.info('Approval decision recorded', { actionId, decision: isApproved ? 'approved' : 'denied' })
|
|
907
905
|
}
|
|
908
|
-
} else if (config.channelBackendUrl && ['menu:new', 'menu:ai', 'menu:settings'].includes(data)) {
|
|
906
|
+
} else if (config.channelBackendUrl && (['menu:new', 'menu:ai', 'menu:settings'].includes(data) || data.startsWith('ai:'))) {
|
|
909
907
|
await ctx.answerCallbackQuery()
|
|
910
908
|
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
911
909
|
} else if (await aiMenu.handle(ctx)) {
|
|
912
910
|
return
|
|
913
911
|
} else if (data.startsWith('menu:')) {
|
|
914
912
|
const action = data.slice(5)
|
|
915
|
-
if (action === 'ai'
|
|
913
|
+
if (action === 'ai') {
|
|
916
914
|
await ctx.answerCallbackQuery()
|
|
917
|
-
await aiMenu.list(ctx
|
|
915
|
+
await aiMenu.list(ctx)
|
|
916
|
+
} else if (action === 'settings') {
|
|
917
|
+
await ctx.answerCallbackQuery({ text: 'Settings was removed; use Choose AI.' })
|
|
918
|
+
await aiMenu.list(ctx)
|
|
918
919
|
} else if (action === 'retry') {
|
|
919
920
|
await ctx.answerCallbackQuery()
|
|
920
921
|
const id = await inbox.retryLatest(ctx.from.id, ctx.chat!.id, (await control.status()).owner)
|
|
@@ -943,7 +944,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
943
944
|
} else if (action === 'stop') {
|
|
944
945
|
const running = await runs.running(false)
|
|
945
946
|
if ((running && running.pid) || background.size) {
|
|
946
|
-
if (activeReply) { ownerStopped.add(activeReply); terminateJob(activeReply) }
|
|
947
947
|
if (activeChild) { ownerStopped.add(activeChild); terminateJob(activeChild) }
|
|
948
948
|
for (const [id,child] of background) { await scheduler.cancel(id); ownerStopped.add(child); terminateJob(child) }
|
|
949
949
|
await ctx.answerCallbackQuery({ text: 'Run stopped' })
|
|
@@ -967,6 +967,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
967
967
|
let stopWork: Promise<void> | undefined
|
|
968
968
|
const stop = (): Promise<void> => stopWork ?? (stopWork = (async () => {
|
|
969
969
|
shuttingDown = true
|
|
970
|
+
pollingAbort.abort()
|
|
970
971
|
wakePollRetry?.()
|
|
971
972
|
if (intakeTimer) clearTimeout(intakeTimer)
|
|
972
973
|
if (sourceTimer) clearInterval(sourceTimer)
|
|
@@ -975,7 +976,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
975
976
|
await Promise.all([sourceWork, intakeWork].map(work => work?.catch(() => {})))
|
|
976
977
|
// Finish registering in-flight launches before taking the child snapshot.
|
|
977
978
|
await withStartLock(async () => {
|
|
978
|
-
if (activeReply) terminateJob(activeReply)
|
|
979
979
|
if (activeChild) terminateJob(activeChild)
|
|
980
980
|
for (const child of background.values()) terminateJob(child)
|
|
981
981
|
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
@@ -1026,13 +1026,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1026
1026
|
|
|
1027
1027
|
scheduleIntake()
|
|
1028
1028
|
// Telegram polling is a delivery surface, not the scheduler or executor.
|
|
1029
|
-
//
|
|
1029
|
+
// grammY owns in-poll reconnects. Escaped permanent faults wait for
|
|
1030
|
+
// intervention without restarting setup or terminating authorized work.
|
|
1030
1031
|
while (!shuttingDown) {
|
|
1031
1032
|
try {
|
|
1032
|
-
await bot.api.deleteWebhook({ drop_pending_updates: false })
|
|
1033
1033
|
await bot.api.setMyCommands(commands)
|
|
1034
1034
|
await bot.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
|
|
1035
|
-
|
|
1035
|
+
// 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])
|
|
1037
|
+
if (shuttingDown) break
|
|
1036
1038
|
await bot.start({
|
|
1037
1039
|
drop_pending_updates: false,
|
|
1038
1040
|
onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
|
|
@@ -1040,15 +1042,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1040
1042
|
if (!shuttingDown) throw new Error('Telegram polling stopped unexpectedly')
|
|
1041
1043
|
} catch (error) {
|
|
1042
1044
|
if (shuttingDown) break
|
|
1043
|
-
|
|
1045
|
+
const transient = error instanceof HttpError || (error instanceof GrammyError && (error.error_code === 429 || error.error_code >= 500))
|
|
1046
|
+
console.error(transient ? 'Telegram transport interrupted; retrying' : 'Telegram polling stopped; repair configuration and restart the relay. Existing work remains active', safeError(error))
|
|
1044
1047
|
await new Promise<void>((resolve) => {
|
|
1045
|
-
let timer: ReturnType<typeof setTimeout>
|
|
1048
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1046
1049
|
const wake = () => {
|
|
1047
1050
|
clearTimeout(timer)
|
|
1048
1051
|
if (wakePollRetry === wake) wakePollRetry = undefined
|
|
1049
1052
|
resolve()
|
|
1050
1053
|
}
|
|
1051
|
-
timer = setTimeout(wake, 5000)
|
|
1054
|
+
if (transient) timer = setTimeout(wake, 5000)
|
|
1052
1055
|
wakePollRetry = wake
|
|
1053
1056
|
})
|
|
1054
1057
|
}
|
package/src/menu.ts
CHANGED
|
@@ -4,23 +4,29 @@ import path from 'node:path'
|
|
|
4
4
|
import { randomBytes } from 'node:crypto'
|
|
5
5
|
import { InlineKeyboard, type Context } from 'grammy'
|
|
6
6
|
import { ControlStore } from './control-state.js'
|
|
7
|
-
import { chatPreset, presetLabel, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
7
|
+
import { chatPreset, installed, persistedPreset, presetLabel, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
8
8
|
import { discoverDefaults } from './client-defaults.js'
|
|
9
9
|
|
|
10
10
|
export const mainCommands = [
|
|
11
11
|
{ command: 'new', description: 'New conversation' },
|
|
12
12
|
{ command: 'ai', description: 'Choose AI' },
|
|
13
13
|
{ command: 'status', description: 'Work status' },
|
|
14
|
-
{ command: 'settings', description: 'Settings' },
|
|
15
14
|
]
|
|
16
15
|
|
|
17
16
|
export const mainKeyboard = () => new InlineKeyboard()
|
|
18
17
|
.text('New conversation', 'menu:new').text('Choose AI', 'menu:ai').row()
|
|
19
|
-
.text('Work status', 'menu:status')
|
|
18
|
+
.text('Work status', 'menu:status')
|
|
19
|
+
|
|
20
|
+
const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
|
|
21
|
+
|
|
22
|
+
const matchesModel = (preset: AiPreset, model: ModelChoice) =>
|
|
23
|
+
model.cli === preset.cli && (preset.model === undefined || model.model === preset.model) &&
|
|
24
|
+
(preset.effort === undefined || model.efforts.includes(preset.effort))
|
|
20
25
|
|
|
21
26
|
// Short-lived opaque button IDs: no model names or executable arguments from callbacks.
|
|
22
27
|
// These are operational settings, not a second conversational/agent loop.
|
|
23
|
-
export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string
|
|
28
|
+
export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string,
|
|
29
|
+
isInstalled = installed) => {
|
|
24
30
|
const initial = chatPreset(cli)
|
|
25
31
|
const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
|
|
26
32
|
if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
|
|
@@ -31,7 +37,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
31
37
|
if (preset.id.startsWith('detected_')) {
|
|
32
38
|
const detected = await discoverDefaults(workspace, { codexHome })
|
|
33
39
|
if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
|
|
34
|
-
} else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) :
|
|
40
|
+
} else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
35
41
|
}
|
|
36
42
|
const buttons = new Map<string, { expires: number; action: (ctx: Context) => Promise<void> }>()
|
|
37
43
|
const button = (keyboard: InlineKeyboard, label: string, action: (ctx: Context) => Promise<void>) => {
|
|
@@ -52,63 +58,61 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
52
58
|
? 'CLI changed: fresh conversation. Files kept; queued work unchanged.'
|
|
53
59
|
: 'Selected for this conversation. Queued work unchanged.'}`)
|
|
54
60
|
}
|
|
55
|
-
const list = async (ctx: Context
|
|
56
|
-
|
|
57
|
-
const models = await catalog()
|
|
58
|
-
if (models.length) return available(ctx, 0, models)
|
|
59
|
-
}
|
|
61
|
+
const list = async (ctx: Context) => {
|
|
62
|
+
const models = await catalog()
|
|
60
63
|
const state = await control.aiState(initial)
|
|
61
64
|
const keyboard = new InlineKeyboard()
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
if (models.length) {
|
|
66
|
+
const recent = (state.recentIds ?? [])
|
|
67
|
+
.map((id) => state.presets.find((preset) => preset.id === id))
|
|
68
|
+
.filter((preset): preset is AiPreset => Boolean(preset))
|
|
69
|
+
.filter((preset) => models.some((model) => matchesModel(preset, model)))
|
|
70
|
+
.slice(0, 3)
|
|
71
|
+
for (const preset of recent) button(keyboard,
|
|
72
|
+
`${preset.id === state.selectedId ? '✓ ' : ''}Recent · ${clientLabel(preset.cli)} · ${preset.name}`,
|
|
73
|
+
(next) => choose(next, preset))
|
|
74
|
+
for (const cli of [...new Set(models.map((model) => model.cli))].sort((a, b) => clientLabel(a).localeCompare(clientLabel(b))))
|
|
75
|
+
button(keyboard, clientLabel(cli), (next) => available(next, cli, 0, models))
|
|
76
|
+
} else {
|
|
77
|
+
const current = state.presets.find((preset) => preset.id === initial.id)
|
|
78
|
+
if (current) button(keyboard, `✓ ${current.name}`, (next) => choose(next, current))
|
|
79
|
+
}
|
|
73
80
|
button(keyboard, 'Refresh available AIs', async (next) => {
|
|
74
81
|
await refresh()
|
|
75
|
-
await list(next
|
|
82
|
+
await list(next)
|
|
76
83
|
})
|
|
77
|
-
await ctx.reply(
|
|
78
|
-
? '
|
|
84
|
+
await ctx.reply(models.length
|
|
85
|
+
? 'Choose AI\nUse a recent choice or select an installed client, then choose its model and reasoning level.'
|
|
79
86
|
: 'Choose AI\nNo client catalog available. Showing the current client setup only.', { reply_markup: keyboard })
|
|
80
87
|
}
|
|
81
|
-
const available = async (ctx: Context, page = 0, listed?: ModelChoice[]) => {
|
|
82
|
-
const models = listed ?? await catalog()
|
|
88
|
+
const available = async (ctx: Context, cli: string, page = 0, listed?: ModelChoice[]) => {
|
|
89
|
+
const models = (listed ?? await catalog()).filter((model) => model.cli === cli)
|
|
83
90
|
const keyboard = new InlineKeyboard()
|
|
84
91
|
for (const model of models.slice(page * 8, page * 8 + 8)) {
|
|
85
|
-
button(keyboard,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
button(keyboard, model.name, async (next) => {
|
|
93
|
+
const supportedEfforts = model.efforts.filter(effort => allowedEffort(effort, model.model, model.cli))
|
|
94
|
+
if (!supportedEfforts.length) return save(next, model)
|
|
95
|
+
const effortKeyboard = new InlineKeyboard()
|
|
96
|
+
for (const effort of supportedEfforts) button(effortKeyboard, effort, (last) => save(last, model, effort))
|
|
97
|
+
button(effortKeyboard, 'Back to models', (last) => available(last, cli, page, listed))
|
|
98
|
+
await next.reply(`${clientLabel(cli)} · ${model.name}\nChoose reasoning level`, { reply_markup: effortKeyboard })
|
|
90
99
|
})
|
|
91
100
|
}
|
|
92
|
-
if (page > 0) button(keyboard, 'Previous', (next) => available(next, page - 1))
|
|
93
|
-
if (models.length > (page + 1) * 8) button(keyboard, 'Next', (next) => available(next, page + 1))
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
: 'No client catalog available. Open the installed CLI once, then try again.', { reply_markup: keyboard })
|
|
101
|
+
if (page > 0) button(keyboard, 'Previous', (next) => available(next, cli, page - 1, listed))
|
|
102
|
+
if (models.length > (page + 1) * 8) button(keyboard, 'Next', (next) => available(next, cli, page + 1, listed))
|
|
103
|
+
button(keyboard, 'Back to clients', (next) => list(next))
|
|
104
|
+
await ctx.reply(`${clientLabel(cli)}\nChoose a model`, { reply_markup: keyboard })
|
|
97
105
|
}
|
|
98
106
|
const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
|
|
99
107
|
const state = await control.aiState(initial)
|
|
100
|
-
const
|
|
101
|
-
const preset: AiPreset = existing ?? { id: randomBytes(8).toString('hex'),
|
|
108
|
+
const candidate: AiPreset = { id: randomBytes(8).toString('hex'),
|
|
102
109
|
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, model: model.model, effort }
|
|
103
|
-
|
|
110
|
+
const stored = persistedPreset(candidate)
|
|
111
|
+
const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.model === stored.model && preset.effort === stored.effort)
|
|
112
|
+
const preset = existing ?? candidate
|
|
113
|
+
await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
104
114
|
await control.savePreset(preset)
|
|
105
|
-
|
|
106
|
-
button(keyboard, 'Use now', (next) => choose(next, preset))
|
|
107
|
-
button(keyboard, 'Make default', async (next) => {
|
|
108
|
-
await control.defaultPreset(preset.id)
|
|
109
|
-
await next.reply(`Default: ${preset.name}. Applies to new conversations only.`)
|
|
110
|
-
})
|
|
111
|
-
await ctx.reply(`Saved: ${preset.name}\n${presetLabel(preset)}`, { reply_markup: keyboard })
|
|
115
|
+
await choose(ctx, preset)
|
|
112
116
|
}
|
|
113
117
|
return {
|
|
114
118
|
initial,
|
|
@@ -119,7 +123,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
119
123
|
if (!data?.startsWith('ai:')) return false
|
|
120
124
|
const entry = buttons.get(data.slice(3))
|
|
121
125
|
await ctx.answerCallbackQuery().catch(() => {})
|
|
122
|
-
if (!entry || entry.expires < Date.now()) await ctx.reply('Menu expired. Open /ai
|
|
126
|
+
if (!entry || entry.expires < Date.now()) await ctx.reply('Menu expired. Open /ai again.')
|
|
123
127
|
else {
|
|
124
128
|
buttons.delete(data.slice(3))
|
|
125
129
|
try { await entry.action(ctx) }
|
package/src/message-send.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const parseMessageArgs = (argv: string[]): MessageCliArgs => {
|
|
|
20
20
|
if (args[i] === '--text-file' && args[i + 1]) {
|
|
21
21
|
textFile = args[++i]
|
|
22
22
|
} else if (args[i] === '--text' && args[i + 1]) {
|
|
23
|
-
text = args[++i]
|
|
23
|
+
text = args[++i].replace(/\\(\\|n)/g, (_, escape: string) => escape === 'n' ? '\n' : '\\')
|
|
24
24
|
} else if (args[i] === '--reply-to' && args[i + 1]) {
|
|
25
25
|
const parsed = parseInt(args[++i], 10)
|
|
26
26
|
if (!Number.isNaN(parsed)) replyTo = parsed
|
package/src/message.ts
CHANGED
|
@@ -8,6 +8,7 @@ if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
|
|
|
8
8
|
console.log(
|
|
9
9
|
'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
|
|
10
10
|
)
|
|
11
|
+
console.log('Text: --text decodes \\n as a newline and \\\\ as a literal backslash; --text-file preserves file content.')
|
|
11
12
|
process.exit(0)
|
|
12
13
|
}
|
|
13
14
|
|
package/src/model-policy.ts
CHANGED
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
export const
|
|
3
|
-
export const CODEX_DEFAULT_MODEL = 'gpt-5.6-luna'
|
|
4
|
-
export const DEFAULT_EFFORT = 'max'
|
|
5
|
-
export const allowedEffort = (effort?: string, model?: string, cli?: string) => effort === undefined ||
|
|
6
|
-
['none', 'minimal', 'low', 'medium', 'high'].includes(effort) ||
|
|
7
|
-
(['xhigh', 'max'].includes(effort) && model === 'gpt-5.6-luna' && ['codex', 'codex-gui'].includes(cli || ''))
|
|
1
|
+
// Validate option syntax; the installed engine owns supported models and effort levels.
|
|
2
|
+
export const allowedEffort = (effort?: string, _model?: string, _cli?: string) => effort === undefined || /^[a-z][a-z0-9_-]{0,31}$/.test(effort)
|
|
8
3
|
export function assertEffort(effort?: string, model?: string, cli?: string) {
|
|
9
|
-
if (!allowedEffort(effort,
|
|
4
|
+
if (!allowedEffort(effort,model,cli)) throw new Error('Invalid reasoning effort')
|
|
10
5
|
}
|
|
11
6
|
export function executionDefaults<T extends { model?: string; effort?: string }>(cli: string, options: T): T {
|
|
12
|
-
assertEffort(options.effort,
|
|
13
|
-
|
|
14
|
-
return { ...options,
|
|
15
|
-
...(['codex', 'codex-gui'].includes(cli) ? { model } : {}),
|
|
16
|
-
...(['codex', 'codex-gui'].includes(cli)
|
|
17
|
-
? { effort: options.effort || (model === 'gpt-5.6-luna' ? DEFAULT_EFFORT : 'high') } : {}),
|
|
18
|
-
}
|
|
7
|
+
assertEffort(options.effort,options.model,cli)
|
|
8
|
+
return options
|
|
19
9
|
}
|
|
20
10
|
|
|
21
11
|
export function executionOverrides<T extends { model?: string; effort?: string }>(
|