@jc_stack/ez-agents 0.1.0-beta.12

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 (147) hide show
  1. package/.dockerignore +24 -0
  2. package/.env.example +26 -0
  3. package/AGENTS.md +84 -0
  4. package/CHANGELOG.md +39 -0
  5. package/CONTRIBUTING.md +73 -0
  6. package/Dockerfile +16 -0
  7. package/LICENSE +21 -0
  8. package/README.md +134 -0
  9. package/SECURITY.md +26 -0
  10. package/THIRD_PARTY_NOTICES.md +14 -0
  11. package/bin/ezenciel-agents +2 -0
  12. package/bin/ezenciel-agents-ai +2 -0
  13. package/bin/ezenciel-agents-ai.mjs +5 -0
  14. package/bin/ezenciel-agents-approval +2 -0
  15. package/bin/ezenciel-agents-approval.mjs +16 -0
  16. package/bin/ezenciel-agents-create +12 -0
  17. package/bin/ezenciel-agents-docker +6 -0
  18. package/bin/ezenciel-agents-host +5 -0
  19. package/bin/ezenciel-agents-install +2 -0
  20. package/bin/ezenciel-agents-message +2 -0
  21. package/bin/ezenciel-agents-message.mjs +16 -0
  22. package/bin/ezenciel-agents-owner +2 -0
  23. package/bin/ezenciel-agents-owner.mjs +18 -0
  24. package/bin/ezenciel-agents-react +2 -0
  25. package/bin/ezenciel-agents-react.mjs +16 -0
  26. package/bin/ezenciel-agents-setup.mjs +18 -0
  27. package/bin/ezenciel-agents-source +2 -0
  28. package/bin/ezenciel-agents-source.mjs +16 -0
  29. package/bin/ezenciel-agents-tools.mjs +3 -0
  30. package/bin/ezenciel-agents.mjs +37 -0
  31. package/compose.whatsapp.yaml +12 -0
  32. package/compose.yaml +40 -0
  33. package/default-plugins.json +1 -0
  34. package/docker/entrypoint.sh +15 -0
  35. package/docker/healthcheck.mjs +8 -0
  36. package/docker/plugin-smoke.mjs +48 -0
  37. package/docker/pnpm-lock.yaml +415 -0
  38. package/docker/recovery.ts +11 -0
  39. package/docker/run.ts +52 -0
  40. package/docker/smoke.mjs +47 -0
  41. package/docker/status-smoke.mjs +30 -0
  42. package/docker/upgrade-smoke.mjs +58 -0
  43. package/docs/architecture/ai-selection.md +37 -0
  44. package/docs/architecture/authority-boundaries.md +14 -0
  45. package/docs/architecture/event-sources.md +34 -0
  46. package/docs/architecture/telegram-intake.md +29 -0
  47. package/docs/development-and-testing.md +18 -0
  48. package/docs/docker-runtime.md +118 -0
  49. package/docs/host-service.md +80 -0
  50. package/docs/plugin-contributions.md +34 -0
  51. package/docs/plugins.md +181 -0
  52. package/docs/releasing.md +71 -0
  53. package/docs/setup.md +234 -0
  54. package/docs/upgrades.md +193 -0
  55. package/package.json +106 -0
  56. package/scripts/assert-local-registry.mjs +22 -0
  57. package/scripts/release-check.mjs +14 -0
  58. package/scripts/smoke.ts +102 -0
  59. package/src/agent-install.ts +96 -0
  60. package/src/ai-cli.ts +22 -0
  61. package/src/ai.ts +88 -0
  62. package/src/approval-cli.ts +59 -0
  63. package/src/approval.ts +119 -0
  64. package/src/audio.ts +184 -0
  65. package/src/client-defaults.ts +101 -0
  66. package/src/config.ts +48 -0
  67. package/src/control-state.ts +350 -0
  68. package/src/desktop-bridge.ts +284 -0
  69. package/src/event-sources.ts +112 -0
  70. package/src/executor.ts +335 -0
  71. package/src/files.ts +75 -0
  72. package/src/format.ts +57 -0
  73. package/src/host-executor-client.ts +46 -0
  74. package/src/host-executor-protocol.ts +2 -0
  75. package/src/host-executor.ts +129 -0
  76. package/src/identity.ts +17 -0
  77. package/src/inbox.ts +171 -0
  78. package/src/index.ts +812 -0
  79. package/src/install-config.ts +56 -0
  80. package/src/install-tools.mjs +98 -0
  81. package/src/menu.ts +123 -0
  82. package/src/message-send.ts +57 -0
  83. package/src/message.ts +68 -0
  84. package/src/owner-args.ts +4 -0
  85. package/src/owner.ts +30 -0
  86. package/src/plugins/manager.mjs +272 -0
  87. package/src/react.ts +33 -0
  88. package/src/reaction.ts +32 -0
  89. package/src/read-request.ts +72 -0
  90. package/src/reply.ts +13 -0
  91. package/src/runs.ts +445 -0
  92. package/src/service.ts +28 -0
  93. package/src/setup.ts +180 -0
  94. package/src/software-status.ts +23 -0
  95. package/src/source-cli.ts +18 -0
  96. package/src/update-attention.ts +18 -0
  97. package/src/updates/artifact.mjs +83 -0
  98. package/src/updates/binding.mjs +27 -0
  99. package/src/updates/control.mjs +131 -0
  100. package/src/updates/launch.mjs +13 -0
  101. package/src/updates/runtime.mjs +140 -0
  102. package/src/updates/status.mjs +49 -0
  103. package/src/updates/supervisor.mjs +102 -0
  104. package/src/version.ts +4 -0
  105. package/src/workspace.ts +32 -0
  106. package/templates/agent/AGENTS.md +49 -0
  107. package/templates/agent/SOUL.md +11 -0
  108. package/templates/agent/TOOLS.md +46 -0
  109. package/templates/agent/USER.md +5 -0
  110. package/templates/updates.md +45 -0
  111. package/test/agent-install.test.ts +48 -0
  112. package/test/ai-cli.test.ts +28 -0
  113. package/test/ai.test.ts +105 -0
  114. package/test/approval.test.ts +40 -0
  115. package/test/audio.test.ts +77 -0
  116. package/test/client-defaults.test.ts +64 -0
  117. package/test/codex-context.test.ts +33 -0
  118. package/test/config.test.ts +32 -0
  119. package/test/control-state.test.ts +58 -0
  120. package/test/desktop-bridge.test.ts +159 -0
  121. package/test/docker-runtime.test.ts +23 -0
  122. package/test/event-sources.test.ts +113 -0
  123. package/test/executor.test.ts +135 -0
  124. package/test/files.test.ts +50 -0
  125. package/test/format.test.ts +41 -0
  126. package/test/host-executor.test.ts +149 -0
  127. package/test/inbox-burst.test.ts +66 -0
  128. package/test/inbox.test.ts +102 -0
  129. package/test/install-config.test.ts +75 -0
  130. package/test/install-tools.test.mjs +59 -0
  131. package/test/intake-relay.test.ts +337 -0
  132. package/test/owner-help.test.mjs +10 -0
  133. package/test/plugin-manager.test.mjs +157 -0
  134. package/test/publish-guard.test.ts +21 -0
  135. package/test/reaction.test.ts +122 -0
  136. package/test/read-request.test.ts +134 -0
  137. package/test/relay.test.ts +254 -0
  138. package/test/release-entrypoints.test.mjs +26 -0
  139. package/test/runs.test.ts +116 -0
  140. package/test/security.test.ts +85 -0
  141. package/test/setup.test.ts +68 -0
  142. package/test/software-status.test.ts +31 -0
  143. package/test/update-attention.test.ts +21 -0
  144. package/test/updates.test.mjs +282 -0
  145. package/test/upgrade-pause.test.ts +70 -0
  146. package/test/workspace.test.ts +80 -0
  147. package/tsconfig.json +19 -0
@@ -0,0 +1,129 @@
1
+ import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { isHostRunId } from './host-executor-protocol.js'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { startExecutorJob, terminateJob, resolveExecutor, type ExecutorOptions } from './executor.js'
6
+ import { readModels, validateSelection } from './ai.js'
7
+ import type { ChildProcess } from 'node:child_process'
8
+ import { packageVersion } from './version.js'
9
+ import { installedPluginVersions } from './software-status.js'
10
+
11
+ export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string }
12
+ export type HostInstallation = { cli: string; agents: HostBinding[] }
13
+
14
+ export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal) => {
15
+ resolveExecutor(installation.cli)
16
+ if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
17
+ new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
18
+ throw new Error('Each agent requires a separate workspace and control directory')
19
+ const active = new Map<string, ChildProcess>()
20
+ const busy = new Set<string>()
21
+ const tasks = new Set<Promise<void>>()
22
+ const locks: string[] = []
23
+ try {
24
+ for (const agent of installation.agents) {
25
+ if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
26
+ if (agent.toolsHome) {
27
+ if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
28
+ const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
29
+ if (config.schemaVersion!==1 || config.workspace!==await realpath(agent.workspace)) throw new Error('Plugin registry belongs to another workspace')
30
+ }
31
+ const directory = path.join(agent.controlDir,'host-executor')
32
+ await mkdir(directory,{recursive:true,mode:0o700})
33
+ const lock=path.join(directory,'worker.lock')
34
+ try { const prior=JSON.parse(await readFile(lock,'utf8')); try { process.kill(prior.pid,0); throw new Error('Host executor already running') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }; await rm(lock) }
35
+ catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
36
+ await writeFile(lock,JSON.stringify({pid:process.pid}),{mode:0o600,flag:'wx'})
37
+ locks.push(lock)
38
+ await writeFile(path.join(directory,'models.json'),JSON.stringify(await readModels()),{mode:0o600})
39
+ // A host crash is terminal for a claimed job. Never replay an action.
40
+ for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
41
+ const base=path.join(directory,file.slice(0,-13))
42
+ try {
43
+ const prior=JSON.parse(await readFile(base+'.process.json','utf8'))
44
+ try { process.kill(prior.pid,0); throw new Error('Previous host CLI is still running; stop it before recovery') }
45
+ catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
46
+ } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
47
+ await rm(base+'.process.json',{force:true})
48
+ await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
49
+ await rm(path.join(directory,file))
50
+ }
51
+ }
52
+ let catalogAt=Date.now()
53
+ while (!signal.aborted) {
54
+ const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
55
+ if(parent) { try { process.kill(parent,0) } catch { break } }
56
+ if(Date.now()-catalogAt>30000){
57
+ const models=JSON.stringify(await readModels())
58
+ for(const agent of installation.agents){
59
+ const file=path.join(agent.controlDir,'host-executor/models.json')
60
+ await writeFile(file+'.tmp',models,{mode:0o600});await rename(file+'.tmp',file)
61
+ }
62
+ catalogAt=Date.now()
63
+ }
64
+ for (const agent of installation.agents) {
65
+ const directory=path.join(agent.controlDir,'host-executor')
66
+ 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
+ await rename(path.join(directory,'heartbeat.tmp'),path.join(directory,'heartbeat.json'))
68
+ 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))
71
+ await rename(base+'.request.json',base+'.running.json')
72
+ busy.add(agent.name)
73
+ const task=(async()=>{
74
+ let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
75
+ let cancellation: ReturnType<typeof setInterval> | undefined
76
+ let writes=Promise.resolve()
77
+ const emit=(event:unknown)=>{writes=writes.then(()=>appendFile(base+'.events',JSON.stringify(event)+'\n',{mode:0o600}))}
78
+ try {
79
+ try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
80
+ const request=JSON.parse(await readFile(base+'.running.json','utf8'))
81
+ if (!Array.isArray(request.texts) || request.texts.some((text:unknown)=>typeof text!=='string')) throw new Error('Invalid job')
82
+ const opts=request.options as ExecutorOptions
83
+ const cli = opts.cli || installation.cli
84
+ 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)
91
+ await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
92
+ if(signal.aborted)terminateJob(job.child)
93
+ job.child.stdout?.on('data',chunk=>emit({stream:'stdout',text:chunk.toString()}))
94
+ job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
95
+ cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{})},250)
96
+ const code=await new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
97
+ emit({stream:'exit',code})
98
+ } catch { emit({stream:'stderr',text:'Host CLI execution failed\n'}); emit({stream:'exit',code:1}) }
99
+ finally {
100
+ if(cancellation)clearInterval(cancellation)
101
+ await job?.cleanup()
102
+ await writes
103
+ await rm(base+'.running.json',{force:true})
104
+ await rm(base+'.process.json',{force:true})
105
+ await rm(base+'.cancel',{force:true})
106
+ active.delete(agent.name)
107
+ busy.delete(agent.name)
108
+ }
109
+ })()
110
+ tasks.add(task); void task.finally(()=>tasks.delete(task))
111
+ // Do not claim a second job while asynchronous spawning is pending.
112
+ break
113
+ }
114
+ }
115
+ await new Promise(resolve=>setTimeout(resolve,250))
116
+ }
117
+ } finally {
118
+ for(const child of active.values())terminateJob(child)
119
+ await Promise.allSettled(tasks)
120
+ for(const lock of locks)await rm(lock,{force:true})
121
+ }
122
+ }
123
+
124
+ if (process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
125
+ const abort=new AbortController()
126
+ for(const signal of ['SIGTERM','SIGINT'] as const)process.once(signal,()=>abort.abort())
127
+ const config=JSON.parse(await readFile(process.argv[2],'utf8'))
128
+ await serveHostExecutor(config,abort.signal)
129
+ }
@@ -0,0 +1,17 @@
1
+ import type { Context } from 'grammy'
2
+ import type { Owner } from './control-state.js'
3
+
4
+ export const isOwner = (ctx: Pick<Context, 'from' | 'chat'>, owner: Owner | null): boolean =>
5
+ Boolean(
6
+ owner &&
7
+ ctx.from &&
8
+ !ctx.from.is_bot &&
9
+ ctx.chat?.type === 'private' &&
10
+ ctx.from.id === owner.telegramUserId &&
11
+ ctx.chat.id === owner.telegramChatId,
12
+ )
13
+
14
+ export const assertId = (id: string): string => {
15
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error('Invalid record identifier')
16
+ return id
17
+ }
package/src/inbox.ts ADDED
@@ -0,0 +1,171 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { Update } from 'grammy/types'
4
+ import { isExecutionChoice, type ExecutionChoice } from './ai.js'
5
+
6
+ export type IncomingItem = {
7
+ text: string
8
+ messageId?: number
9
+ updateId: number
10
+ chatId: number
11
+ fromId: number
12
+ }
13
+
14
+ type Entry = { update: Update; receivedAt: number; execution?: ExecutionChoice }
15
+ export type InboxBatch = { id: string; entries: Entry[]; status: 'pending' | 'failed' | 'cancelled' }
16
+ type State = { version: 1; seen: number[]; waiting: Entry[]; batches: InboxBatch[] }
17
+
18
+ // One relay writer. Serialize read/modify/rename, including commands arriving during STT.
19
+ export class InboxStore {
20
+ private lock: Promise<unknown> = Promise.resolve()
21
+ private readonly file: string
22
+ constructor(
23
+ private readonly directory: string,
24
+ private readonly now = Date.now,
25
+ ) {
26
+ this.file = join(directory, 'inbox.json')
27
+ }
28
+
29
+ private async read(): Promise<State> {
30
+ try {
31
+ const state = JSON.parse(await readFile(this.file, 'utf8')) as State
32
+ const entry = (e: Entry) =>
33
+ e && Number.isSafeInteger(e.update?.update_id) && Number.isFinite(e.receivedAt) &&
34
+ (e.execution === undefined || isExecutionChoice(e.execution))
35
+ if (
36
+ state.version !== 1 ||
37
+ !Array.isArray(state.seen) ||
38
+ !state.seen.every(Number.isSafeInteger) ||
39
+ !Array.isArray(state.waiting) ||
40
+ !state.waiting.every(entry) ||
41
+ !Array.isArray(state.batches) ||
42
+ !state.batches.every(
43
+ (b) =>
44
+ /^tg_\d+$/.test(b.id) &&
45
+ ['pending', 'failed', 'cancelled'].includes(b.status) &&
46
+ Array.isArray(b.entries) &&
47
+ b.entries.length > 0 &&
48
+ b.entries.every(entry),
49
+ )
50
+ )
51
+ throw new Error('Invalid inbox shape')
52
+ return state
53
+ } catch (error) {
54
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT')
55
+ return { version: 1, seen: [], waiting: [], batches: [] }
56
+ throw new Error('Inbox unreadable; restore the local journal before accepting messages')
57
+ }
58
+ }
59
+
60
+ private change<T>(work: (state: State) => T): Promise<T> {
61
+ const next = this.lock.then(async () => {
62
+ const state = await this.read()
63
+ const result = work(state)
64
+ await mkdir(this.directory, { recursive: true, mode: 0o700 })
65
+ const temporary = `${this.file}.${process.pid}.tmp`
66
+ await writeFile(temporary, JSON.stringify(state), { mode: 0o600 })
67
+ await rename(temporary, this.file)
68
+ return result
69
+ })
70
+ this.lock = next.catch(() => {})
71
+ return next
72
+ }
73
+
74
+ accept(update: Update, execution?: ExecutionChoice): Promise<boolean> {
75
+ if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) throw new Error('Invalid update ID')
76
+ return this.change((state) => {
77
+ if (state.seen.includes(update.update_id)) return false
78
+ state.seen.push(update.update_id)
79
+ state.waiting.push({ update, receivedAt: this.now(), execution })
80
+ return true
81
+ })
82
+ }
83
+
84
+ // Seal membership BEFORE normalization or run creation. Replay uses the same run ID.
85
+ next(force = false): Promise<InboxBatch | undefined> {
86
+ return this.change((state) => {
87
+ const pending = state.batches.find((b) => b.status === 'pending')
88
+ if (pending) return pending
89
+ if (!state.waiting.length) return
90
+ const first = state.waiting[0]
91
+ const last = state.waiting.at(-1)!
92
+ if (
93
+ !force &&
94
+ (state.waiting.length < 10 || last.update.message?.media_group_id) &&
95
+ this.now() - last.receivedAt < 2000 &&
96
+ this.now() - first.receivedAt < 30000
97
+ )
98
+ return
99
+ const boundary = state.waiting.findIndex((e) => JSON.stringify(e.execution) !== JSON.stringify(first.execution))
100
+ const entries = state.waiting.splice(0, boundary < 0 ? 10 : Math.min(10, boundary))
101
+ // Telegram albums contain at most ten items. Don't split one at the batch boundary.
102
+ const album = entries.at(-1)?.update.message?.media_group_id
103
+ while (album && state.waiting[0]?.update.message?.media_group_id === album &&
104
+ JSON.stringify(state.waiting[0].execution) === JSON.stringify(first.execution))
105
+ entries.push(state.waiting.shift()!)
106
+ const batch: InboxBatch = { id: `tg_${first.update.update_id}`, entries, status: 'pending' }
107
+ state.batches.push(batch)
108
+ return batch
109
+ })
110
+ }
111
+
112
+ finish(id: string, failed = false): Promise<void> {
113
+ return this.change((state) => {
114
+ const batch = state.batches.find((b) => b.id === id)
115
+ if (!batch || batch.status !== 'pending') return
116
+ if (failed) batch.status = 'failed'
117
+ else state.batches = state.batches.filter((b) => b.id !== id)
118
+ })
119
+ }
120
+
121
+ cancel(): Promise<number> {
122
+ return this.change((state) => {
123
+ let count = state.waiting.length
124
+ state.waiting = []
125
+ for (const batch of state.batches) {
126
+ if (batch.status !== 'pending') continue
127
+ count += batch.entries.length
128
+ batch.status = 'cancelled'
129
+ }
130
+ return count
131
+ })
132
+ }
133
+
134
+ retryLatest(userId: number, chatId: number): Promise<string | undefined> {
135
+ return this.change((state) => {
136
+ const batch = [...state.batches].reverse().find(
137
+ (b) =>
138
+ b.status === 'failed' &&
139
+ b.entries.every((e) => {
140
+ const message = e.update.message || e.update.callback_query?.message
141
+ const from = e.update.message?.from || e.update.callback_query?.from
142
+ return (
143
+ from?.id === userId &&
144
+ !from.is_bot &&
145
+ message?.chat.type === 'private' &&
146
+ message.chat.id === chatId
147
+ )
148
+ }),
149
+ )
150
+ if (!batch) return
151
+ batch.status = 'pending'
152
+ return batch.id
153
+ })
154
+ }
155
+
156
+ async pending(id: string): Promise<boolean> {
157
+ await this.lock
158
+ return (await this.read()).batches.some((b) => b.id === id && b.status === 'pending')
159
+ }
160
+
161
+ async status(): Promise<{ pending: number; failed: number }> {
162
+ await this.lock
163
+ const state = await this.read()
164
+ return {
165
+ pending:
166
+ state.waiting.length +
167
+ state.batches.filter((b) => b.status === 'pending').reduce((n, b) => n + b.entries.length, 0),
168
+ failed: state.batches.filter((b) => b.status === 'failed').length,
169
+ }
170
+ }
171
+ }