@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.
Files changed (132) hide show
  1. package/.dockerignore +4 -0
  2. package/.env.example +16 -1
  3. package/AGENTS.md +16 -4
  4. package/CHANGELOG.md +71 -0
  5. package/CONTRIBUTING.md +37 -4
  6. package/README.md +114 -9
  7. package/SECURITY.md +7 -1
  8. package/bin/ezenciel-agents-schedule +2 -0
  9. package/bin/ezenciel-agents-schedule.mjs +16 -0
  10. package/bin/ezenciel-agents-task +2 -0
  11. package/bin/ezenciel-agents-task.mjs +16 -0
  12. package/compose.yaml +10 -2
  13. package/docker/recovery.ts +2 -2
  14. package/docker/run.ts +2 -2
  15. package/docs/architecture/ai-selection.md +8 -0
  16. package/docs/architecture/authority-boundaries.md +137 -12
  17. package/docs/architecture/event-sources.md +12 -7
  18. package/docs/architecture/telegram-intake.md +1 -1
  19. package/docs/channel-backend.md +36 -0
  20. package/docs/docker-runtime.md +35 -0
  21. package/docs/host-service.md +19 -0
  22. package/docs/local-qa.md +45 -0
  23. package/docs/pagerduty.md +42 -0
  24. package/docs/plugin-catalog.md +71 -0
  25. package/docs/plugin-contributions.md +12 -0
  26. package/docs/plugins.md +61 -1
  27. package/docs/releasing.md +20 -9
  28. package/docs/repair.md +41 -0
  29. package/docs/scheduling.md +153 -0
  30. package/docs/selective-monitoring.md +114 -0
  31. package/docs/setup.md +46 -0
  32. package/docs/standalone-cli.md +62 -0
  33. package/docs/trusted-publishing.md +140 -0
  34. package/docs/upgrades.md +24 -4
  35. package/package.json +12 -4
  36. package/scripts/generate-publish-caller.mjs +60 -0
  37. package/scripts/smoke-busy-reply.ts +58 -0
  38. package/scripts/smoke-scheduler.ts +90 -0
  39. package/scripts/stage-qa.mjs +42 -0
  40. package/scripts/trusted-beta.mjs +289 -0
  41. package/src/agent-guidance.ts +5 -0
  42. package/src/ai-cli.ts +2 -1
  43. package/src/ai.ts +15 -5
  44. package/src/channel-backend.ts +46 -0
  45. package/src/client-defaults.ts +29 -13
  46. package/src/codex-session.ts +98 -0
  47. package/src/config.ts +35 -2
  48. package/src/control-state.ts +24 -7
  49. package/src/desktop-bridge.ts +37 -12
  50. package/src/event-sources.ts +2 -1
  51. package/src/execution-authority.ts +25 -0
  52. package/src/executor.ts +97 -21
  53. package/src/failure.ts +32 -0
  54. package/src/host-executor.ts +48 -19
  55. package/src/identity.ts +8 -3
  56. package/src/inbox.ts +11 -3
  57. package/src/index.ts +315 -91
  58. package/src/install-tools.mjs +2 -2
  59. package/src/menu.ts +6 -4
  60. package/src/model-policy.ts +15 -0
  61. package/src/owner.ts +3 -3
  62. package/src/pagerduty.ts +109 -0
  63. package/src/plugins/exposure.mjs +13 -0
  64. package/src/plugins/manager.mjs +74 -20
  65. package/src/plugins/shared.mjs +76 -0
  66. package/src/process-tree.ts +33 -0
  67. package/src/repair-policy.ts +13 -0
  68. package/src/reply-context.ts +67 -0
  69. package/src/reply-executor.ts +54 -0
  70. package/src/reply-mcp.ts +23 -0
  71. package/src/runs.ts +63 -19
  72. package/src/schedule-cli.ts +98 -0
  73. package/src/schedule-time.ts +85 -0
  74. package/src/scheduler.ts +130 -0
  75. package/src/setup.ts +2 -1
  76. package/src/software-status.ts +5 -5
  77. package/src/source-cli.ts +1 -1
  78. package/src/task-cli.ts +16 -0
  79. package/src/task-executor.ts +65 -0
  80. package/src/task-mcp.ts +36 -0
  81. package/src/task-rpc.ts +45 -0
  82. package/src/task-workspace.ts +22 -0
  83. package/src/tasks.ts +210 -0
  84. package/src/telegram-source.ts +94 -0
  85. package/src/updates/artifact.mjs +16 -0
  86. package/src/updates/binding.mjs +4 -1
  87. package/src/updates/control.mjs +4 -4
  88. package/src/updates/runtime.mjs +3 -1
  89. package/src/updates/status.mjs +7 -1
  90. package/templates/agent/AGENTS.md +10 -2
  91. package/templates/agent/TOOLS.md +60 -1
  92. package/templates/agent-guidance.md +13 -0
  93. package/templates/failure-review.md +9 -0
  94. package/templates/maintainer-purpose.md +15 -0
  95. package/templates/standalone-tools.md +20 -0
  96. package/templates/updates.md +2 -2
  97. package/test/agent-guidance.test.ts +110 -0
  98. package/test/ai-cli.test.ts +7 -6
  99. package/test/ai.test.ts +41 -0
  100. package/test/busy-reply-relay.test.ts +41 -0
  101. package/test/channel-backend.test.ts +100 -0
  102. package/test/client-defaults.test.ts +37 -5
  103. package/test/codex-context.test.ts +39 -1
  104. package/test/codex-session.test.ts +51 -0
  105. package/test/config.test.ts +31 -2
  106. package/test/desktop-bridge.test.ts +19 -0
  107. package/test/event-sources.test.ts +47 -11
  108. package/test/execution-authority.test.ts +42 -0
  109. package/test/executor.test.ts +53 -2
  110. package/test/failure.test.ts +250 -0
  111. package/test/group-owner.test.ts +36 -0
  112. package/test/helpers/owner-run.ts +13 -0
  113. package/test/host-executor.test.ts +47 -10
  114. package/test/intake-relay.test.ts +141 -4
  115. package/test/local-qa.test.mjs +38 -0
  116. package/test/model-policy.test.ts +61 -0
  117. package/test/pagerduty.test.ts +104 -0
  118. package/test/plugin-manager.test.mjs +73 -3
  119. package/test/relay.test.ts +2 -2
  120. package/test/repair-policy.test.ts +23 -0
  121. package/test/reply.test.ts +131 -0
  122. package/test/schedule-cli.test.ts +55 -0
  123. package/test/scheduler-host.test.ts +55 -0
  124. package/test/scheduler-relay.test.ts +67 -0
  125. package/test/scheduler.test.ts +104 -0
  126. package/test/shared-services.test.mjs +98 -0
  127. package/test/software-status.test.ts +5 -5
  128. package/test/task-native.test.ts +87 -0
  129. package/test/tasks.test.ts +187 -0
  130. package/test/telegram-source.test.ts +75 -0
  131. package/test/trusted-beta.test.mjs +224 -0
  132. package/test/updates.test.mjs +35 -3
@@ -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 readModels()),{mode:0o600})
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 ((!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) || busy.has(agent.name)) continue
70
- const base=path.join(directory,file.slice(0,-13))
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(agent.name)
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 readModels())
86
- const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,cli,
87
- runId:path.basename(base),timeoutMs:Math.min(Math.max(Number(opts.timeoutMs)||300000,1000),1800000),
88
- sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model:opts.model,effort:opts.effort}
89
- job=await startExecutorJob(request.texts,options)
90
- active.set(agent.name,job.child)
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(agent.name)
107
- busy.delete(agent.name)
136
+ active.delete(lane)
137
+ busy.delete(lane)
108
138
  }
109
139
  })()
110
140
  tasks.add(task); void task.finally(()=>tasks.delete(task))
111
- // Do not claim a second job while asynchronous spawning is pending.
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
- ctx.chat?.type === 'private' &&
10
- ctx.from.id === owner.telegramUserId &&
11
- ctx.chat.id === owner.telegramChatId,
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 boundary = state.waiting.findIndex((e) => JSON.stringify(e.execution) !== JSON.stringify(first.execution))
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