@jc_stack/ez-agents 0.1.0-beta.12 → 0.1.0-beta.18
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/.dockerignore +4 -0
- package/.env.example +16 -1
- package/AGENTS.md +16 -4
- package/CHANGELOG.md +71 -0
- package/CONTRIBUTING.md +37 -4
- package/README.md +114 -9
- package/SECURITY.md +7 -1
- package/bin/ezenciel-agents-schedule +2 -0
- package/bin/ezenciel-agents-schedule.mjs +16 -0
- package/bin/ezenciel-agents-task +2 -0
- package/bin/ezenciel-agents-task.mjs +16 -0
- package/compose.yaml +10 -2
- package/docker/recovery.ts +2 -2
- package/docker/run.ts +2 -2
- package/docs/architecture/ai-selection.md +8 -0
- package/docs/architecture/authority-boundaries.md +137 -12
- package/docs/architecture/event-sources.md +12 -7
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/channel-backend.md +36 -0
- package/docs/docker-runtime.md +35 -0
- package/docs/host-service.md +19 -0
- package/docs/local-qa.md +45 -0
- package/docs/pagerduty.md +42 -0
- package/docs/plugin-catalog.md +71 -0
- package/docs/plugin-contributions.md +12 -0
- package/docs/plugins.md +61 -1
- package/docs/releasing.md +20 -9
- package/docs/repair.md +41 -0
- package/docs/scheduling.md +153 -0
- package/docs/selective-monitoring.md +114 -0
- package/docs/setup.md +46 -0
- package/docs/standalone-cli.md +62 -0
- package/docs/trusted-publishing.md +140 -0
- package/docs/upgrades.md +24 -4
- package/package.json +12 -4
- package/scripts/generate-publish-caller.mjs +60 -0
- package/scripts/smoke-busy-reply.ts +58 -0
- package/scripts/smoke-scheduler.ts +90 -0
- package/scripts/stage-qa.mjs +42 -0
- package/scripts/trusted-beta.mjs +289 -0
- package/src/agent-guidance.ts +5 -0
- package/src/ai-cli.ts +2 -1
- package/src/ai.ts +15 -5
- package/src/channel-backend.ts +46 -0
- package/src/client-defaults.ts +29 -13
- package/src/codex-session.ts +98 -0
- package/src/config.ts +35 -2
- package/src/control-state.ts +24 -7
- package/src/desktop-bridge.ts +37 -12
- package/src/event-sources.ts +2 -1
- package/src/execution-authority.ts +25 -0
- package/src/executor.ts +97 -21
- package/src/failure.ts +32 -0
- package/src/host-executor.ts +48 -19
- package/src/identity.ts +8 -3
- package/src/inbox.ts +11 -3
- package/src/index.ts +315 -91
- package/src/install-tools.mjs +2 -2
- package/src/menu.ts +6 -4
- package/src/model-policy.ts +15 -0
- package/src/owner.ts +3 -3
- package/src/pagerduty.ts +109 -0
- package/src/plugins/exposure.mjs +13 -0
- package/src/plugins/manager.mjs +74 -20
- package/src/plugins/shared.mjs +76 -0
- package/src/process-tree.ts +33 -0
- package/src/repair-policy.ts +13 -0
- package/src/reply-context.ts +67 -0
- package/src/reply-executor.ts +54 -0
- package/src/reply-mcp.ts +23 -0
- package/src/runs.ts +63 -19
- package/src/schedule-cli.ts +98 -0
- package/src/schedule-time.ts +85 -0
- package/src/scheduler.ts +130 -0
- package/src/setup.ts +2 -1
- package/src/software-status.ts +5 -5
- package/src/source-cli.ts +1 -1
- package/src/task-cli.ts +16 -0
- package/src/task-executor.ts +65 -0
- package/src/task-mcp.ts +36 -0
- package/src/task-rpc.ts +45 -0
- package/src/task-workspace.ts +22 -0
- package/src/tasks.ts +210 -0
- package/src/telegram-source.ts +94 -0
- package/src/updates/artifact.mjs +16 -0
- package/src/updates/binding.mjs +4 -1
- package/src/updates/control.mjs +4 -4
- package/src/updates/runtime.mjs +3 -1
- package/src/updates/status.mjs +7 -1
- package/templates/agent/AGENTS.md +10 -2
- package/templates/agent/TOOLS.md +60 -1
- package/templates/agent-guidance.md +13 -0
- package/templates/failure-review.md +9 -0
- package/templates/maintainer-purpose.md +15 -0
- package/templates/standalone-tools.md +20 -0
- package/templates/updates.md +2 -2
- package/test/agent-guidance.test.ts +110 -0
- package/test/ai-cli.test.ts +7 -6
- package/test/ai.test.ts +41 -0
- package/test/busy-reply-relay.test.ts +41 -0
- package/test/channel-backend.test.ts +100 -0
- package/test/client-defaults.test.ts +37 -5
- package/test/codex-context.test.ts +39 -1
- package/test/codex-session.test.ts +51 -0
- package/test/config.test.ts +31 -2
- package/test/desktop-bridge.test.ts +19 -0
- package/test/event-sources.test.ts +47 -11
- package/test/execution-authority.test.ts +42 -0
- package/test/executor.test.ts +53 -2
- package/test/failure.test.ts +250 -0
- package/test/group-owner.test.ts +36 -0
- package/test/helpers/owner-run.ts +13 -0
- package/test/host-executor.test.ts +47 -10
- package/test/intake-relay.test.ts +141 -4
- package/test/local-qa.test.mjs +38 -0
- package/test/model-policy.test.ts +61 -0
- package/test/pagerduty.test.ts +104 -0
- package/test/plugin-manager.test.mjs +73 -3
- package/test/relay.test.ts +2 -2
- package/test/repair-policy.test.ts +23 -0
- package/test/reply.test.ts +131 -0
- package/test/schedule-cli.test.ts +55 -0
- package/test/scheduler-host.test.ts +55 -0
- package/test/scheduler-relay.test.ts +67 -0
- package/test/scheduler.test.ts +104 -0
- package/test/shared-services.test.mjs +98 -0
- package/test/software-status.test.ts +5 -5
- package/test/task-native.test.ts +87 -0
- package/test/tasks.test.ts +187 -0
- package/test/telegram-source.test.ts +75 -0
- package/test/trusted-beta.test.mjs +224 -0
- package/test/updates.test.mjs +35 -3
package/src/host-executor.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { redactFailure } from './failure.js'
|
|
2
|
+
import { RunStore } from './runs.js'
|
|
3
|
+
import { Tasks } from './tasks.js'
|
|
4
|
+
import { requireOwnerExecution } from './execution-authority.js'
|
|
1
5
|
import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
|
|
2
6
|
import path from 'node:path'
|
|
3
7
|
import { isHostRunId } from './host-executor-protocol.js'
|
|
@@ -5,13 +9,14 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
9
|
import { startExecutorJob, terminateJob, resolveExecutor, type ExecutorOptions } from './executor.js'
|
|
6
10
|
import { readModels, validateSelection } from './ai.js'
|
|
7
11
|
import type { ChildProcess } from 'node:child_process'
|
|
12
|
+
import { taskWorkspace } from './task-workspace.js'
|
|
8
13
|
import { packageVersion } from './version.js'
|
|
9
14
|
import { installedPluginVersions } from './software-status.js'
|
|
10
15
|
|
|
11
|
-
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string }
|
|
16
|
+
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string }
|
|
12
17
|
export type HostInstallation = { cli: string; agents: HostBinding[] }
|
|
13
18
|
|
|
14
|
-
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal) => {
|
|
19
|
+
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob) => {
|
|
15
20
|
resolveExecutor(installation.cli)
|
|
16
21
|
if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
|
|
17
22
|
new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
|
|
@@ -20,9 +25,13 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
20
25
|
const busy = new Set<string>()
|
|
21
26
|
const tasks = new Set<Promise<void>>()
|
|
22
27
|
const locks: string[] = []
|
|
28
|
+
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
29
|
+
const catalog = (agent: HostBinding) => readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
|
|
23
30
|
try {
|
|
24
31
|
for (const agent of installation.agents) {
|
|
25
32
|
if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
|
|
33
|
+
if (agent.sharedWorkspace && !path.isAbsolute(agent.sharedWorkspace)) throw new Error('Shared workspace requires an absolute path')
|
|
34
|
+
if (agent.sharedWorkspace) sharedWorkspaces.set(agent, await realpath(agent.sharedWorkspace))
|
|
26
35
|
if (agent.toolsHome) {
|
|
27
36
|
if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
|
|
28
37
|
const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
|
|
@@ -35,7 +44,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
35
44
|
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
36
45
|
await writeFile(lock,JSON.stringify({pid:process.pid}),{mode:0o600,flag:'wx'})
|
|
37
46
|
locks.push(lock)
|
|
38
|
-
await writeFile(path.join(directory,'models.json'),JSON.stringify(await
|
|
47
|
+
await writeFile(path.join(directory,'models.json'),JSON.stringify(await catalog(agent)),{mode:0o600})
|
|
39
48
|
// A host crash is terminal for a claimed job. Never replay an action.
|
|
40
49
|
for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
|
|
41
50
|
const base=path.join(directory,file.slice(0,-13))
|
|
@@ -54,8 +63,8 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
54
63
|
const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
|
|
55
64
|
if(parent) { try { process.kill(parent,0) } catch { break } }
|
|
56
65
|
if(Date.now()-catalogAt>30000){
|
|
57
|
-
const models=JSON.stringify(await readModels())
|
|
58
66
|
for(const agent of installation.agents){
|
|
67
|
+
const models=JSON.stringify(await catalog(agent))
|
|
59
68
|
const file=path.join(agent.controlDir,'host-executor/models.json')
|
|
60
69
|
await writeFile(file+'.tmp',models,{mode:0o600});await rename(file+'.tmp',file)
|
|
61
70
|
}
|
|
@@ -66,10 +75,23 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
66
75
|
await writeFile(path.join(directory,'heartbeat.tmp'),JSON.stringify({at:Date.now(),pid:process.pid,version:packageVersion,plugins:await installedPluginVersions(agent.toolsHome)}),{mode:0o600})
|
|
67
76
|
await rename(path.join(directory,'heartbeat.tmp'),path.join(directory,'heartbeat.json'))
|
|
68
77
|
for (const file of await readdir(directory)) {
|
|
69
|
-
if (
|
|
70
|
-
const
|
|
78
|
+
if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
|
|
79
|
+
const id=file.slice(0,-13)
|
|
80
|
+
let run
|
|
81
|
+
try {
|
|
82
|
+
run = await new RunStore(agent.controlDir).get(id)
|
|
83
|
+
if(id.startsWith('r_schedule_') && !run?.scheduled) throw new Error('Missing scheduled run')
|
|
84
|
+
} catch {
|
|
85
|
+
await appendFile(path.join(directory,id+'.events'),JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
|
|
86
|
+
await rm(path.join(directory,file))
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
const sharedWorkspace=sharedWorkspaces.get(agent)
|
|
90
|
+
const lane=run?.replyOnly ? 'reply:'+agent.name : sharedWorkspace ? 'workspace:'+sharedWorkspace : run?.scheduled ? agent.name+':'+id : agent.name
|
|
91
|
+
if(busy.has(lane) || (run?.scheduled && [...busy].filter(k=>k.startsWith(agent.name+':')).length>=4)) continue
|
|
92
|
+
const base=path.join(directory,id)
|
|
71
93
|
await rename(base+'.request.json',base+'.running.json')
|
|
72
|
-
busy.add(
|
|
94
|
+
busy.add(lane)
|
|
73
95
|
const task=(async()=>{
|
|
74
96
|
let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
|
|
75
97
|
let cancellation: ReturnType<typeof setInterval> | undefined
|
|
@@ -79,37 +101,44 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
79
101
|
try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
80
102
|
const request=JSON.parse(await readFile(base+'.running.json','utf8'))
|
|
81
103
|
if (!Array.isArray(request.texts) || request.texts.some((text:unknown)=>typeof text!=='string')) throw new Error('Invalid job')
|
|
104
|
+
const run = await new RunStore(agent.controlDir).get(path.basename(base))
|
|
105
|
+
if (run?.taskId) {
|
|
106
|
+
if (run.status !== 'running') throw new Error('No active task run')
|
|
107
|
+
await new Tasks(agent.controlDir).authorize(run, false)
|
|
108
|
+
} else await requireOwnerExecution(agent.controlDir, path.basename(base))
|
|
82
109
|
const opts=request.options as ExecutorOptions
|
|
83
110
|
const cli = opts.cli || installation.cli
|
|
84
111
|
resolveExecutor(cli)
|
|
85
|
-
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,model:opts.model,effort:opts.effort},await
|
|
86
|
-
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,cli,
|
|
87
|
-
runId:path.basename(base),timeoutMs:
|
|
88
|
-
sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model:opts.model,effort:opts.effort}
|
|
89
|
-
job=await
|
|
90
|
-
active.set(
|
|
112
|
+
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,model:opts.model,effort:opts.effort},await catalog(agent))
|
|
113
|
+
const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.workspace,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,cli,
|
|
114
|
+
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
115
|
+
sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model:opts.model,effort:opts.effort,codexAutoCompactTokens:opts.codexAutoCompactTokens}
|
|
116
|
+
job=await launch(request.texts,options)
|
|
117
|
+
active.set(lane,job.child)
|
|
91
118
|
await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
|
|
92
119
|
if(signal.aborted)terminateJob(job.child)
|
|
93
120
|
job.child.stdout?.on('data',chunk=>emit({stream:'stdout',text:chunk.toString()}))
|
|
94
121
|
job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
|
|
95
122
|
cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{})},250)
|
|
96
123
|
const code=await new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
|
|
124
|
+
if (cancellation) clearInterval(cancellation)
|
|
125
|
+
await job.cleanup()
|
|
126
|
+
job = undefined
|
|
97
127
|
emit({stream:'exit',code})
|
|
98
|
-
} catch { emit({stream:'stderr',text:'Host CLI execution failed\n'}); emit({stream:'exit',code:1}) }
|
|
128
|
+
} catch (error) { emit({stream:'stderr',text:'Host CLI execution failed: '+redactFailure(error instanceof Error ? error.message : 'Unknown error')+'\n'}); emit({stream:'exit',code:1}) }
|
|
99
129
|
finally {
|
|
100
130
|
if(cancellation)clearInterval(cancellation)
|
|
101
|
-
await job?.cleanup()
|
|
131
|
+
await job?.cleanup().catch(() => {})
|
|
102
132
|
await writes
|
|
103
133
|
await rm(base+'.running.json',{force:true})
|
|
104
134
|
await rm(base+'.process.json',{force:true})
|
|
105
135
|
await rm(base+'.cancel',{force:true})
|
|
106
|
-
active.delete(
|
|
107
|
-
busy.delete(
|
|
136
|
+
active.delete(lane)
|
|
137
|
+
busy.delete(lane)
|
|
108
138
|
}
|
|
109
139
|
})()
|
|
110
140
|
tasks.add(task); void task.finally(()=>tasks.delete(task))
|
|
111
|
-
//
|
|
112
|
-
break
|
|
141
|
+
// The lane is reserved before spawning; other task workspaces may start.
|
|
113
142
|
}
|
|
114
143
|
}
|
|
115
144
|
await new Promise(resolve=>setTimeout(resolve,250))
|
package/src/identity.ts
CHANGED
|
@@ -6,11 +6,16 @@ export const isOwner = (ctx: Pick<Context, 'from' | 'chat'>, owner: Owner | null
|
|
|
6
6
|
owner &&
|
|
7
7
|
ctx.from &&
|
|
8
8
|
!ctx.from.is_bot &&
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
(owner.kind === 'group'
|
|
10
|
+
? ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
|
|
11
|
+
: ctx.chat?.type === 'private' && ctx.from.id === owner.telegramUserId) &&
|
|
12
|
+
ctx.chat?.id === owner.telegramChatId,
|
|
12
13
|
)
|
|
13
14
|
|
|
15
|
+
export const ownsRun = (owner: Owner | null, run: {telegramUserId: number; chatId: number}): boolean =>
|
|
16
|
+
Boolean(owner && Number.isSafeInteger(run.telegramUserId) && run.telegramUserId > 0 &&
|
|
17
|
+
run.chatId === owner.telegramChatId && (owner.kind === 'group' || run.telegramUserId === owner.telegramUserId))
|
|
18
|
+
|
|
14
19
|
export const assertId = (id: string): string => {
|
|
15
20
|
if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error('Invalid record identifier')
|
|
16
21
|
return id
|
package/src/inbox.ts
CHANGED
|
@@ -2,9 +2,15 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { Update } from 'grammy/types'
|
|
4
4
|
import { isExecutionChoice, type ExecutionChoice } from './ai.js'
|
|
5
|
+
import { isOwner } from './identity.js'
|
|
6
|
+
import type { Owner } from './control-state.js'
|
|
5
7
|
|
|
6
8
|
export type IncomingItem = {
|
|
7
9
|
text: string
|
|
10
|
+
attachment?: { path: string; type: string }
|
|
11
|
+
sentAt?: number
|
|
12
|
+
caption?: string
|
|
13
|
+
albumId?: string
|
|
8
14
|
messageId?: number
|
|
9
15
|
updateId: number
|
|
10
16
|
chatId: number
|
|
@@ -96,11 +102,13 @@ export class InboxStore {
|
|
|
96
102
|
this.now() - first.receivedAt < 30000
|
|
97
103
|
)
|
|
98
104
|
return
|
|
99
|
-
const
|
|
105
|
+
const chatId = (update: Update) => update.message?.chat.id ?? update.callback_query?.message?.chat.id
|
|
106
|
+
const boundary = state.waiting.findIndex((e) => JSON.stringify(e.execution) !== JSON.stringify(first.execution) || chatId(e.update) !== chatId(first.update))
|
|
100
107
|
const entries = state.waiting.splice(0, boundary < 0 ? 10 : Math.min(10, boundary))
|
|
101
108
|
// Telegram albums contain at most ten items. Don't split one at the batch boundary.
|
|
102
109
|
const album = entries.at(-1)?.update.message?.media_group_id
|
|
103
110
|
while (album && state.waiting[0]?.update.message?.media_group_id === album &&
|
|
111
|
+
chatId(state.waiting[0].update) === chatId(first.update) &&
|
|
104
112
|
JSON.stringify(state.waiting[0].execution) === JSON.stringify(first.execution))
|
|
105
113
|
entries.push(state.waiting.shift()!)
|
|
106
114
|
const batch: InboxBatch = { id: `tg_${first.update.update_id}`, entries, status: 'pending' }
|
|
@@ -131,7 +139,7 @@ export class InboxStore {
|
|
|
131
139
|
})
|
|
132
140
|
}
|
|
133
141
|
|
|
134
|
-
retryLatest(userId: number, chatId: number): Promise<string | undefined> {
|
|
142
|
+
retryLatest(userId: number, chatId: number, owner?: Owner | null): Promise<string | undefined> {
|
|
135
143
|
return this.change((state) => {
|
|
136
144
|
const batch = [...state.batches].reverse().find(
|
|
137
145
|
(b) =>
|
|
@@ -140,7 +148,7 @@ export class InboxStore {
|
|
|
140
148
|
const message = e.update.message || e.update.callback_query?.message
|
|
141
149
|
const from = e.update.message?.from || e.update.callback_query?.from
|
|
142
150
|
return (
|
|
143
|
-
from?.id === userId &&
|
|
151
|
+
owner?.kind === 'group' ? !e.update.message?.sender_chat && isOwner({from, chat: message?.chat}, owner) : from?.id === userId &&
|
|
144
152
|
!from.is_bot &&
|
|
145
153
|
message?.chat.type === 'private' &&
|
|
146
154
|
message.chat.id === chatId
|