@jc_stack/ez-agents 0.1.0-beta.25 → 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 +19 -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/releasing.md +3 -2
- 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 +50 -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-relay.test.ts +25 -0
- 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.
|
|
@@ -193,10 +188,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
193
188
|
const launchStarted = performance.now()
|
|
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
|
-
const
|
|
191
|
+
const maintenanceWakeup = run.id.startsWith('r_update_')
|
|
192
|
+
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled || maintenanceWakeup)
|
|
193
|
+
const session = startsOwnSession
|
|
197
194
|
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
198
195
|
: await control.executionSession(started.execution!)
|
|
199
|
-
const selected = run.taskId ?
|
|
196
|
+
const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
200
197
|
const { child, cleanup } = await launch(texts, {
|
|
201
198
|
workspace: run.scheduled ? await taskWorkspace(config.workspace,run.id) : config.workspace,
|
|
202
199
|
timeoutMs: config.executorTimeoutMs,
|
|
@@ -211,7 +208,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
211
208
|
sessionId: session.nativeSessionId || session.sessionId,
|
|
212
209
|
isResume: session.hasStarted,
|
|
213
210
|
eventSource: run.external?.sourceId,
|
|
214
|
-
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) },
|
|
215
212
|
})
|
|
216
213
|
const executionStarted = performance.now()
|
|
217
214
|
// Attach before disk writes: a fast child can close while PID persistence
|
|
@@ -229,8 +226,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
229
226
|
console.info('run timing', { run_id: run.id, phase: 'launch',
|
|
230
227
|
queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
|
|
231
228
|
startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
|
|
232
|
-
if (run.
|
|
233
|
-
else if (run.scheduled) background.set(run.id,child)
|
|
229
|
+
if (run.scheduled) background.set(run.id,child)
|
|
234
230
|
else activeChild = child
|
|
235
231
|
const finished = new Promise<number | null>((resolve) => {
|
|
236
232
|
if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
|
|
@@ -245,8 +241,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
245
241
|
isResume: session.hasStarted,
|
|
246
242
|
})
|
|
247
243
|
|
|
248
|
-
if (!run.scheduled &&
|
|
249
|
-
if (!run.external && !run.taskId && !run.scheduled
|
|
244
|
+
if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
|
|
245
|
+
if (!run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
|
|
250
246
|
if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId, 'typing').catch(() => {})
|
|
251
247
|
}, 4000)
|
|
252
248
|
|
|
@@ -257,15 +253,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
257
253
|
await withStartLock(async () => {
|
|
258
254
|
try {
|
|
259
255
|
await cleanup()
|
|
260
|
-
if (code === 0 && !run.external && !run.taskId && !run.scheduled
|
|
256
|
+
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
261
257
|
const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
|
|
262
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}`}`)) } : {}) })
|
|
263
259
|
} catch (error) {
|
|
264
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() })
|
|
265
261
|
console.error('Session completion failed', safeError(error))
|
|
266
262
|
} finally {
|
|
267
|
-
if (run.
|
|
268
|
-
else if (run.scheduled) background.delete(run.id)
|
|
263
|
+
if (run.scheduled) background.delete(run.id)
|
|
269
264
|
else {
|
|
270
265
|
activeChild = null
|
|
271
266
|
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
@@ -281,7 +276,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
281
276
|
completions.add(completion)
|
|
282
277
|
void completion.finally(() => completions.delete(completion))
|
|
283
278
|
} catch (error) {
|
|
284
|
-
if (!run.scheduled &&
|
|
279
|
+
if (!run.scheduled && activeTypingTimer) {
|
|
285
280
|
clearInterval(activeTypingTimer)
|
|
286
281
|
activeTypingTimer = null
|
|
287
282
|
}
|
|
@@ -311,7 +306,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
311
306
|
for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
|
|
312
307
|
try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
|
|
313
308
|
}
|
|
314
|
-
await queueUpdateAttention(config.controlDir,owner,runs,durableWorkerChoice())
|
|
309
|
+
await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
|
|
315
310
|
}
|
|
316
311
|
for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
317
312
|
try {
|
|
@@ -328,7 +323,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
328
323
|
await runs.create({
|
|
329
324
|
taskId: task?.id,
|
|
330
325
|
id: eventRunId(source, events), chatId: owner.telegramChatId, telegramUserId: owner.telegramUserId,
|
|
331
|
-
texts: [], execution: durableWorkerChoice(),
|
|
326
|
+
texts: [], execution: await durableWorkerChoice(),
|
|
332
327
|
external: { sourceId: source.id, bindingId: source.bindingId, eventIds: events.map(e => e.id) },
|
|
333
328
|
})
|
|
334
329
|
}
|
|
@@ -548,6 +543,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
548
543
|
{ command: 'retry', description: 'Retry the latest failed incoming batch' },
|
|
549
544
|
{ command: 'new', description: 'New conversation; keep workspace files' },
|
|
550
545
|
]
|
|
546
|
+
// Keep the retired command from becoming an agent prompt while old clients catch up.
|
|
547
|
+
const retiredCommands = ['/settings']
|
|
551
548
|
const commands = mainCommands
|
|
552
549
|
const controlCommand = (text?: string) => text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
|
|
553
550
|
const statusKeyboard = () => new InlineKeyboard()
|
|
@@ -558,7 +555,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
558
555
|
const owner = (await control.status()).owner
|
|
559
556
|
if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
|
|
560
557
|
// This intentionally uses the reader that does not create a schedules directory.
|
|
561
|
-
return scheduledTasksText(await scheduler.
|
|
558
|
+
return scheduledTasksText(await scheduler.listActiveReadOnly(await runs.list()), owner)
|
|
562
559
|
}
|
|
563
560
|
const replyScheduledTasks = async (ctx: Context) => {
|
|
564
561
|
for (const part of splitTelegramText(await scheduledTasks())) await ctx.reply(part)
|
|
@@ -648,7 +645,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
648
645
|
if (replay.has(ctx.update)) {
|
|
649
646
|
collected.push({
|
|
650
647
|
updateId: ctx.update.update_id, chatId: owner.telegramChatId, fromId: owner.telegramUserId,
|
|
651
|
-
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}),
|
|
652
649
|
})
|
|
653
650
|
} else if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial))) scheduleIntake()
|
|
654
651
|
return
|
|
@@ -659,7 +656,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
659
656
|
}
|
|
660
657
|
const message = ctx.message
|
|
661
658
|
const command = controlCommand(message?.text)
|
|
662
|
-
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()
|
|
663
660
|
const ordinary = message && (message.text || message.photo || message.document || message.voice)
|
|
664
661
|
const approval = ctx.callbackQuery?.data?.startsWith('approval:')
|
|
665
662
|
if (!ordinary && !approval) return next()
|
|
@@ -672,8 +669,17 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
672
669
|
|
|
673
670
|
const text = controlCommand(ctx.message.text)
|
|
674
671
|
|
|
675
|
-
if (
|
|
676
|
-
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.')
|
|
677
683
|
return
|
|
678
684
|
}
|
|
679
685
|
|
|
@@ -693,11 +699,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
693
699
|
return
|
|
694
700
|
}
|
|
695
701
|
|
|
696
|
-
if (config.channelBackendUrl && ['/new', '/ai', '/settings'].includes(text ?? '')) {
|
|
697
|
-
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
698
|
-
return
|
|
699
|
-
}
|
|
700
|
-
|
|
701
702
|
// Steering & session commands
|
|
702
703
|
if (text === '/stop' && config.channelBackendUrl) {
|
|
703
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.')
|
|
@@ -707,7 +708,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
707
708
|
const running = await runs.running(false)
|
|
708
709
|
if ((running && running.pid) || background.size) {
|
|
709
710
|
try {
|
|
710
|
-
if (activeReply) { ownerStopped.add(activeReply); terminateJob(activeReply) }
|
|
711
711
|
if (activeChild) { ownerStopped.add(activeChild); terminateJob(activeChild) }
|
|
712
712
|
for (const [id,child] of background) { await scheduler.cancel(id); ownerStopped.add(child); terminateJob(child) }
|
|
713
713
|
} catch {}
|
|
@@ -903,16 +903,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
903
903
|
})
|
|
904
904
|
console.info('Approval decision recorded', { actionId, decision: isApproved ? 'approved' : 'denied' })
|
|
905
905
|
}
|
|
906
|
-
} 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:'))) {
|
|
907
907
|
await ctx.answerCallbackQuery()
|
|
908
908
|
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
909
909
|
} else if (await aiMenu.handle(ctx)) {
|
|
910
910
|
return
|
|
911
911
|
} else if (data.startsWith('menu:')) {
|
|
912
912
|
const action = data.slice(5)
|
|
913
|
-
if (action === 'ai'
|
|
913
|
+
if (action === 'ai') {
|
|
914
914
|
await ctx.answerCallbackQuery()
|
|
915
|
-
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)
|
|
916
919
|
} else if (action === 'retry') {
|
|
917
920
|
await ctx.answerCallbackQuery()
|
|
918
921
|
const id = await inbox.retryLatest(ctx.from.id, ctx.chat!.id, (await control.status()).owner)
|
|
@@ -941,7 +944,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
941
944
|
} else if (action === 'stop') {
|
|
942
945
|
const running = await runs.running(false)
|
|
943
946
|
if ((running && running.pid) || background.size) {
|
|
944
|
-
if (activeReply) { ownerStopped.add(activeReply); terminateJob(activeReply) }
|
|
945
947
|
if (activeChild) { ownerStopped.add(activeChild); terminateJob(activeChild) }
|
|
946
948
|
for (const [id,child] of background) { await scheduler.cancel(id); ownerStopped.add(child); terminateJob(child) }
|
|
947
949
|
await ctx.answerCallbackQuery({ text: 'Run stopped' })
|
|
@@ -965,6 +967,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
965
967
|
let stopWork: Promise<void> | undefined
|
|
966
968
|
const stop = (): Promise<void> => stopWork ?? (stopWork = (async () => {
|
|
967
969
|
shuttingDown = true
|
|
970
|
+
pollingAbort.abort()
|
|
968
971
|
wakePollRetry?.()
|
|
969
972
|
if (intakeTimer) clearTimeout(intakeTimer)
|
|
970
973
|
if (sourceTimer) clearInterval(sourceTimer)
|
|
@@ -973,7 +976,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
973
976
|
await Promise.all([sourceWork, intakeWork].map(work => work?.catch(() => {})))
|
|
974
977
|
// Finish registering in-flight launches before taking the child snapshot.
|
|
975
978
|
await withStartLock(async () => {
|
|
976
|
-
if (activeReply) terminateJob(activeReply)
|
|
977
979
|
if (activeChild) terminateJob(activeChild)
|
|
978
980
|
for (const child of background.values()) terminateJob(child)
|
|
979
981
|
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
@@ -1024,13 +1026,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1024
1026
|
|
|
1025
1027
|
scheduleIntake()
|
|
1026
1028
|
// Telegram polling is a delivery surface, not the scheduler or executor.
|
|
1027
|
-
//
|
|
1029
|
+
// grammY owns in-poll reconnects. Escaped permanent faults wait for
|
|
1030
|
+
// intervention without restarting setup or terminating authorized work.
|
|
1028
1031
|
while (!shuttingDown) {
|
|
1029
1032
|
try {
|
|
1030
|
-
await bot.api.deleteWebhook({ drop_pending_updates: false })
|
|
1031
1033
|
await bot.api.setMyCommands(commands)
|
|
1032
1034
|
await bot.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
|
|
1033
|
-
|
|
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
|
|
1034
1038
|
await bot.start({
|
|
1035
1039
|
drop_pending_updates: false,
|
|
1036
1040
|
onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
|
|
@@ -1038,15 +1042,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1038
1042
|
if (!shuttingDown) throw new Error('Telegram polling stopped unexpectedly')
|
|
1039
1043
|
} catch (error) {
|
|
1040
1044
|
if (shuttingDown) break
|
|
1041
|
-
|
|
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))
|
|
1042
1047
|
await new Promise<void>((resolve) => {
|
|
1043
|
-
let timer: ReturnType<typeof setTimeout>
|
|
1048
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1044
1049
|
const wake = () => {
|
|
1045
1050
|
clearTimeout(timer)
|
|
1046
1051
|
if (wakePollRetry === wake) wakePollRetry = undefined
|
|
1047
1052
|
resolve()
|
|
1048
1053
|
}
|
|
1049
|
-
timer = setTimeout(wake, 5000)
|
|
1054
|
+
if (transient) timer = setTimeout(wake, 5000)
|
|
1050
1055
|
wakePollRetry = wake
|
|
1051
1056
|
})
|
|
1052
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 }>(
|