@jc_stack/ez-agents 0.1.0-beta.13 → 0.1.0-beta.19

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