@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
package/src/runs.ts ADDED
@@ -0,0 +1,445 @@
1
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { randomBytes } from 'node:crypto'
4
+ import { validOrigin, type ExternalOrigin } from './event-sources.js'
5
+ import { normalizeReactionEmoji } from './reaction.js'
6
+ import { assertId } from './identity.js'
7
+ import { isExecutionChoice, type ExecutionChoice } from './ai.js'
8
+
9
+ export type RunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
10
+
11
+ export type RunRecord = {
12
+ version: 1
13
+ id: string
14
+ chatId: number
15
+ telegramUserId: number
16
+ messageId?: number
17
+ texts: string[]
18
+ status: RunStatus
19
+ createdAt: string
20
+ startedAt?: string
21
+ endedAt?: string
22
+ pid?: number
23
+ execution?: ExecutionChoice
24
+ external?: ExternalOrigin
25
+ }
26
+
27
+ export type OutboxItemType = 'message' | 'reaction' | 'document' | 'voice' | 'approval'
28
+
29
+ export type OutboxItem = {
30
+ id: string
31
+ runId: string
32
+ chatId: number
33
+ type?: OutboxItemType
34
+ text?: string
35
+ emoji?: string
36
+ messageId?: number
37
+ replyToMessageId?: number
38
+ documentPath?: string
39
+ voiceText?: string
40
+ approvalPrompt?: string
41
+ approvalActionId?: string
42
+ createdAt: string
43
+ }
44
+
45
+ const isRun = (value: unknown): value is RunRecord => {
46
+ if (!value || typeof value !== 'object') return false
47
+ const candidate = value as Partial<RunRecord>
48
+ return (
49
+ candidate.version === 1 &&
50
+ typeof candidate.id === 'string' &&
51
+ /^[a-zA-Z0-9_-]+$/.test(candidate.id) &&
52
+ Number.isSafeInteger(candidate.chatId) &&
53
+ Number.isSafeInteger(candidate.telegramUserId) &&
54
+ Array.isArray(candidate.texts) &&
55
+ candidate.texts.every((text) => typeof text === 'string') &&
56
+ ['queued', 'running', 'completed', 'failed', 'cancelled'].includes(candidate.status ?? '') &&
57
+ typeof candidate.createdAt === 'string' &&
58
+ Number.isFinite(Date.parse(candidate.createdAt)) &&
59
+ (candidate.pid === undefined || (Number.isSafeInteger(candidate.pid) && candidate.pid > 0)) &&
60
+ (candidate.external === undefined || validOrigin(candidate.external)) &&
61
+ (candidate.execution === undefined || isExecutionChoice(candidate.execution))
62
+ )
63
+ }
64
+
65
+ export const newRunId = (): string => `r_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
66
+
67
+ export const isPidAlive = (pid: number): boolean => {
68
+ try {
69
+ process.kill(pid, 0)
70
+ return true
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
76
+ export class RunStore {
77
+ private readonly runsDir: string
78
+ private readonly outboxDir: string
79
+
80
+ constructor(controlDir: string) {
81
+ this.runsDir = path.join(controlDir, 'runs')
82
+ this.outboxDir = path.join(controlDir, 'outbox')
83
+ }
84
+
85
+ private async ensure(): Promise<void> {
86
+ await mkdir(this.runsDir, { recursive: true, mode: 0o700 })
87
+ await mkdir(this.outboxDir, { recursive: true, mode: 0o700 })
88
+ }
89
+
90
+ private runPath(id: string): string {
91
+ return path.join(this.runsDir, `${assertId(id)}.json`)
92
+ }
93
+
94
+ private async writeRun(run: RunRecord): Promise<void> {
95
+ await this.ensure()
96
+ const temporary = `${this.runPath(run.id)}.${process.pid}.tmp`
97
+ await writeFile(temporary, `${JSON.stringify(run, null, 2)}\n`, { mode: 0o600 })
98
+ await rename(temporary, this.runPath(run.id))
99
+ }
100
+
101
+ async create(input: {
102
+ id?: string
103
+ chatId: number
104
+ telegramUserId: number
105
+ texts: string[]
106
+ messageId?: number
107
+ execution?: ExecutionChoice
108
+ external?: ExternalOrigin
109
+ }): Promise<RunRecord> {
110
+ if (input.id) {
111
+ const existing = await this.get(input.id)
112
+ if (existing) {
113
+ if (existing.chatId !== input.chatId || existing.telegramUserId !== input.telegramUserId)
114
+ throw new Error('Run ownership mismatch')
115
+ return existing
116
+ }
117
+ }
118
+ const run: RunRecord = {
119
+ version: 1,
120
+ id: input.id ?? newRunId(),
121
+ chatId: input.chatId,
122
+ telegramUserId: input.telegramUserId,
123
+ messageId: input.messageId,
124
+ texts: input.texts,
125
+ execution: input.execution,
126
+ external: input.external,
127
+ status: 'queued',
128
+ createdAt: new Date().toISOString(),
129
+ }
130
+ await this.writeRun(run)
131
+ return run
132
+ }
133
+
134
+ async get(id: string): Promise<RunRecord | null> {
135
+ try {
136
+ const parsed: unknown = JSON.parse(await readFile(this.runPath(id), 'utf8'))
137
+ if (!isRun(parsed) || parsed.id !== id) throw new Error('Run record has an unsupported shape')
138
+ return parsed
139
+ } catch (error: unknown) {
140
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
141
+ throw error
142
+ }
143
+ }
144
+
145
+ async patch(
146
+ id: string,
147
+ change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid'>>,
148
+ ): Promise<RunRecord> {
149
+ const run = await this.get(id)
150
+ if (!run) throw new Error(`Unknown run ${id}`)
151
+ const next = { ...run, ...change }
152
+ await this.writeRun(next)
153
+ return next
154
+ }
155
+
156
+ async list(): Promise<RunRecord[]> {
157
+ await this.ensure()
158
+ const names = await readdir(this.runsDir)
159
+ const runs: RunRecord[] = []
160
+ for (const name of names) {
161
+ if (!name.endsWith('.json') || name.includes('.tmp')) continue
162
+ try {
163
+ const run = await this.get(name.slice(0, -5))
164
+ if (run) runs.push(run)
165
+ } catch {
166
+ console.error('Unreadable run record', name)
167
+ }
168
+ }
169
+ return runs.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
170
+ }
171
+
172
+ async running(): Promise<RunRecord | undefined> {
173
+ const runs = await this.list()
174
+ for (const run of runs) {
175
+ if (run.status === 'running') {
176
+ if (run.pid && !isPidAlive(run.pid)) {
177
+ await this.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
178
+ continue
179
+ }
180
+ return run
181
+ }
182
+ }
183
+ return undefined
184
+ }
185
+
186
+ async nextQueued(): Promise<RunRecord | undefined> {
187
+ return (await this.list()).find((run) => run.status === 'queued')
188
+ }
189
+
190
+ async deliveryStatus(): Promise<{ failed: number; unknown: number }> {
191
+ await this.ensure()
192
+ const names = await readdir(this.outboxDir)
193
+ const result = { failed: 0, unknown: names.filter((name) => name.endsWith('.sending.json')).length }
194
+ for (const name of names.filter((name) => name.endsWith('.failed.json'))) {
195
+ try {
196
+ const item = JSON.parse(await readFile(path.join(this.outboxDir, name), 'utf8'))
197
+ if (item.deliveryUnknown) result.unknown++
198
+ else result.failed++
199
+ } catch {
200
+ result.unknown++
201
+ }
202
+ }
203
+ return result
204
+ }
205
+
206
+ private async writeOutboxItem(item: OutboxItem): Promise<OutboxItem> {
207
+ await this.ensure()
208
+ const file = path.join(this.outboxDir, `${item.id}.json`)
209
+ const temporary = `${file}.${process.pid}.tmp`
210
+ await writeFile(temporary, `${JSON.stringify(item, null, 2)}\n`, { mode: 0o600 })
211
+ await rename(temporary, file)
212
+ return item
213
+ }
214
+
215
+ async enqueueMessage(
216
+ runId: string,
217
+ text: string,
218
+ options?: { replyToMessageId?: number },
219
+ ): Promise<OutboxItem> {
220
+ const run = await this.get(runId)
221
+ if (!run) throw new Error(`Unknown run ${runId}`)
222
+ if (run.status !== 'running' && run.status !== 'queued') throw new Error(`Run ${runId} cannot send`)
223
+ const item: OutboxItem = {
224
+ id: `${runId}_${Date.now().toString(36)}_${randomBytes(2).toString('hex')}`,
225
+ runId,
226
+ chatId: run.chatId,
227
+ type: 'message',
228
+ text,
229
+ replyToMessageId: options?.replyToMessageId,
230
+ createdAt: new Date().toISOString(),
231
+ }
232
+ return this.writeOutboxItem(item)
233
+ }
234
+
235
+ async enqueueDocument(
236
+ runId: string,
237
+ documentPath: string,
238
+ options?: { caption?: string; replyToMessageId?: number },
239
+ ): Promise<OutboxItem> {
240
+ const run = await this.get(runId)
241
+ if (!run) throw new Error(`Unknown run ${runId}`)
242
+ if (run.status !== 'running' && run.status !== 'queued') throw new Error(`Run ${runId} cannot send`)
243
+ const item: OutboxItem = {
244
+ id: `${runId}_${Date.now().toString(36)}_${randomBytes(2).toString('hex')}`,
245
+ runId,
246
+ chatId: run.chatId,
247
+ type: 'document',
248
+ documentPath,
249
+ text: options?.caption,
250
+ replyToMessageId: options?.replyToMessageId,
251
+ createdAt: new Date().toISOString(),
252
+ }
253
+ return this.writeOutboxItem(item)
254
+ }
255
+
256
+ async enqueueVoice(
257
+ runId: string,
258
+ voiceText: string,
259
+ options?: { replyToMessageId?: number },
260
+ ): Promise<OutboxItem> {
261
+ const run = await this.get(runId)
262
+ if (!run) throw new Error(`Unknown run ${runId}`)
263
+ if (run.status !== 'running' && run.status !== 'queued') throw new Error(`Run ${runId} cannot send`)
264
+ const item: OutboxItem = {
265
+ id: `${runId}_${Date.now().toString(36)}_${randomBytes(2).toString('hex')}`,
266
+ runId,
267
+ chatId: run.chatId,
268
+ type: 'voice',
269
+ voiceText,
270
+ replyToMessageId: options?.replyToMessageId,
271
+ createdAt: new Date().toISOString(),
272
+ }
273
+ return this.writeOutboxItem(item)
274
+ }
275
+
276
+ async enqueueApproval(
277
+ runId: string,
278
+ prompt: string,
279
+ actionId: string,
280
+ options?: { replyToMessageId?: number },
281
+ ): Promise<OutboxItem> {
282
+ const run = await this.get(runId)
283
+ if (!run) throw new Error(`Unknown run ${runId}`)
284
+ if (run.status !== 'running' && run.status !== 'queued')
285
+ throw new Error(`Run ${runId} cannot request approval`)
286
+ const item: OutboxItem = {
287
+ id: `${runId}_${Date.now().toString(36)}_${randomBytes(2).toString('hex')}`,
288
+ runId,
289
+ chatId: run.chatId,
290
+ type: 'approval',
291
+ approvalPrompt: prompt,
292
+ approvalActionId: actionId,
293
+ replyToMessageId: options?.replyToMessageId,
294
+ createdAt: new Date().toISOString(),
295
+ }
296
+ return this.writeOutboxItem(item)
297
+ }
298
+
299
+ async enqueueReaction(runId: string, rawEmoji: string): Promise<OutboxItem> {
300
+ const run = await this.get(runId)
301
+ if (!run) throw new Error(`Unknown run ${runId}`)
302
+ if (run.status !== 'running' && run.status !== 'queued') throw new Error(`Run ${runId} cannot react`)
303
+ if (!run.messageId) throw new Error(`Run ${runId} has no messageId to react to`)
304
+ const emoji = normalizeReactionEmoji(rawEmoji)
305
+ if (!emoji) {
306
+ throw new Error(`Invalid Telegram reaction emoji "${rawEmoji}"`)
307
+ }
308
+ const item: OutboxItem = {
309
+ id: `${runId}_${Date.now().toString(36)}_${randomBytes(2).toString('hex')}`,
310
+ runId,
311
+ chatId: run.chatId,
312
+ type: 'reaction',
313
+ emoji,
314
+ messageId: run.messageId,
315
+ createdAt: new Date().toISOString(),
316
+ }
317
+ return this.writeOutboxItem(item)
318
+ }
319
+
320
+ async pendingOutbox(): Promise<OutboxItem[]> {
321
+ await this.ensure()
322
+ const names = (await readdir(this.outboxDir)).filter(
323
+ (name) =>
324
+ name.endsWith('.json') &&
325
+ !name.endsWith('.sent.json') &&
326
+ !name.endsWith('.failed.json') &&
327
+ !name.endsWith('.sending.json') &&
328
+ !name.includes('.tmp'),
329
+ )
330
+ const items: OutboxItem[] = []
331
+ for (const name of names) {
332
+ try {
333
+ const parsed: unknown = JSON.parse(await readFile(path.join(this.outboxDir, name), 'utf8'))
334
+ if (
335
+ parsed &&
336
+ typeof parsed === 'object' &&
337
+ ('text' in parsed ||
338
+ 'emoji' in parsed ||
339
+ 'documentPath' in parsed ||
340
+ 'voiceText' in parsed ||
341
+ 'approvalPrompt' in parsed ||
342
+ 'type' in parsed)
343
+ ) {
344
+ items.push(parsed as OutboxItem)
345
+ }
346
+ } catch (error: unknown) {
347
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
348
+ console.error('Unreadable outbox record', name)
349
+ }
350
+ }
351
+ return items.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
352
+ }
353
+
354
+ async claimOutbox(id: string): Promise<boolean> {
355
+ assertId(id)
356
+ const from = path.join(this.outboxDir, `${id}.json`)
357
+ const to = path.join(this.outboxDir, `${id}.sending.json`)
358
+ try {
359
+ await rename(from, to)
360
+ return true
361
+ } catch (error: unknown) {
362
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
363
+ throw error
364
+ }
365
+ }
366
+
367
+ async waitForDelivery(id: string, timeoutMs = 120_000): Promise<unknown> {
368
+ assertId(id)
369
+ const deadline = Date.now() + timeoutMs
370
+ while (Date.now() < deadline) {
371
+ for (const status of ['sent', 'failed'] as const) {
372
+ let item
373
+ try {
374
+ item = JSON.parse(await readFile(path.join(this.outboxDir, `${id}.${status}.json`), 'utf8'))
375
+ } catch (error) {
376
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
377
+ throw error
378
+ }
379
+ if (status === 'failed' && item.deliveryUnknown)
380
+ throw new Error(`Delivery outcome unknown; inspect before retrying: ${item.deliveryError || id}`)
381
+ if (status === 'failed')
382
+ throw new Error(item.deliveryError || 'Delivery failed; inspect the local outbox')
383
+ return item.receipt ?? { delivered: true }
384
+ }
385
+ await new Promise((resolve) => setTimeout(resolve, 100))
386
+ }
387
+ throw new Error(`Delivery outcome unknown for ${id}; inspect the outbox before retrying`)
388
+ }
389
+
390
+ async failOutbox(id: string, reason = 'Delivery failed', deliveryUnknown = false): Promise<void> {
391
+ assertId(id)
392
+ const source = path.join(this.outboxDir, `${id}.sending.json`)
393
+ const item = JSON.parse(await readFile(source, 'utf8'))
394
+ const temporary = `${source}.${process.pid}.tmp`
395
+ await writeFile(temporary, JSON.stringify({ ...item, deliveryError: reason, deliveryUnknown }), {
396
+ mode: 0o600,
397
+ })
398
+ await rename(temporary, source)
399
+ await rename(
400
+ path.join(this.outboxDir, `${id}.sending.json`),
401
+ path.join(this.outboxDir, `${id}.failed.json`),
402
+ )
403
+ }
404
+
405
+ async unclaimOutbox(id: string): Promise<void> {
406
+ assertId(id)
407
+ const from = path.join(this.outboxDir, `${id}.sending.json`)
408
+ const to = path.join(this.outboxDir, `${id}.json`)
409
+ try {
410
+ await rename(from, to)
411
+ } catch (error: unknown) {
412
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
413
+ }
414
+ }
415
+
416
+ async markOutboxSent(id: string, messageIds: number[] = []): Promise<void> {
417
+ assertId(id)
418
+ const sendingFile = path.join(this.outboxDir, `${id}.sending.json`)
419
+ const normalFile = path.join(this.outboxDir, `${id}.json`)
420
+ const target = path.join(this.outboxDir, `${id}.sent.json`)
421
+ if (messageIds.length) {
422
+ const record = JSON.parse(await readFile(sendingFile, 'utf8'))
423
+ const temporary = `${sendingFile}.${process.pid}.tmp`
424
+ await writeFile(
425
+ temporary,
426
+ JSON.stringify({ ...record, receipt: { messageIds, deliveredAt: new Date().toISOString() } }),
427
+ { mode: 0o600 },
428
+ )
429
+ await rename(temporary, sendingFile)
430
+ }
431
+ try {
432
+ await rename(sendingFile, target)
433
+ } catch (err: unknown) {
434
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
435
+ try {
436
+ await rename(normalFile, target)
437
+ } catch (e: unknown) {
438
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e
439
+ }
440
+ } else {
441
+ throw err
442
+ }
443
+ }
444
+ }
445
+ }
package/src/service.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { lstat, readFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { parseEnv } from 'node:util'
7
+
8
+ const run = promisify(execFile)
9
+ const packageRoot = fileURLToPath(new URL('../', import.meta.url))
10
+
11
+ export const serviceDefinition = (directory: string) => {
12
+ const root = path.resolve(directory)
13
+ if (/[\r\n\0]/.test(root)) throw new Error('Service paths must be single-line values.')
14
+ return { command: 'docker', args: ['compose', '--project-directory', packageRoot,
15
+ '--env-file', path.join(root, 'docker.env'), 'up', '-d', '--wait', 'relay'] }
16
+ }
17
+
18
+ // Docker owns lifecycle. No host-service fallback or provider onboarding here.
19
+ export const installService = async (directory: string) => {
20
+ const definition = serviceDefinition(directory)
21
+ const file = path.join(path.resolve(directory), 'docker.env')
22
+ if (!(await lstat(file)).isFile()) throw new Error('docker.env must be a regular file')
23
+ const values = parseEnv(await readFile(file, 'utf8'))
24
+ if (!values.COMPOSE_PROJECT_NAME || !values.EZ_RELAY_ENV_FILE)
25
+ throw new Error('docker.env must bind COMPOSE_PROJECT_NAME and EZ_RELAY_ENV_FILE; see docs/docker-runtime.md')
26
+ await run(definition.command, definition.args)
27
+ return { service: 'relay', runtime: 'docker', project: values.COMPOSE_PROJECT_NAME, active: true }
28
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { access, constants, readFile, writeFile } from 'node:fs/promises'
2
+ import { fileURLToPath } from 'node:url'
3
+ import path from 'node:path'
4
+ import { parseEnv } from 'node:util'
5
+ import { initializeWorkspace } from './workspace.js'
6
+ import { configureInstallation } from './install-config.js'
7
+ import { installService } from './service.js'
8
+ import { discoverDefaults } from './client-defaults.js'
9
+ import { initialPreset } from './ai.js'
10
+ import { ControlStore } from './control-state.js'
11
+ import { loadControlConfig } from './config.js'
12
+ import { EXECUTOR_REGISTRY, resolveExecutor, executorKey } from './executor.js'
13
+ import { desktopCodexPath } from './desktop-bridge.js'
14
+
15
+ export const findExecutableInPath = async (
16
+ command: string,
17
+ envPath: string = process.env.PATH ?? '',
18
+ ): Promise<string | null> => {
19
+ const dirs = envPath.split(path.delimiter).filter(Boolean)
20
+ for (const dir of dirs) {
21
+ const fullPath = path.join(dir, command)
22
+ try {
23
+ await access(fullPath, constants.X_OK)
24
+ return fullPath
25
+ } catch {
26
+ // continue searching
27
+ }
28
+ }
29
+ return null
30
+ }
31
+
32
+ export const readActiveExecutor = async (envFilePath: string): Promise<string> => {
33
+ try {
34
+ const content = await readFile(envFilePath, 'utf8')
35
+ const active = parseEnv(content).EZ_EXECUTOR_CLI?.trim()
36
+ if (active) return active
37
+ } catch {
38
+ // env file doesn't exist yet
39
+ }
40
+ return process.env.EZ_EXECUTOR_CLI?.trim() || 'agy'
41
+ }
42
+
43
+ export const setExecutorInEnv = async (envFilePath: string, cliName: string): Promise<void> => {
44
+ const adapter = resolveExecutor(cliName)
45
+ let content = ''
46
+ try {
47
+ content = await readFile(envFilePath, 'utf8')
48
+ } catch {
49
+ content = ''
50
+ }
51
+
52
+ const newLine = `EZ_EXECUTOR_CLI=${adapter.name}`
53
+ if (/^EZ_EXECUTOR_CLI=.*$/m.test(content)) {
54
+ content = content.replace(/^EZ_EXECUTOR_CLI=.*$/m, newLine)
55
+ } else {
56
+ content = content ? `${content.trimEnd()}\n${newLine}\n` : `${newLine}\n`
57
+ }
58
+ await writeFile(envFilePath, content, 'utf8')
59
+ }
60
+
61
+ export type ExecutorStatus = {
62
+ id: string
63
+ name: string
64
+ command: string
65
+ description: string
66
+ installedPath: string | null
67
+ isActive: boolean
68
+ }
69
+
70
+ export const getExecutorsStatus = async (
71
+ envFilePath: string = path.resolve(process.cwd(), '.env'),
72
+ ): Promise<{ active: string; items: ExecutorStatus[] }> => {
73
+ const active = await readActiveExecutor(envFilePath)
74
+ const resolvedActive = executorKey(active)
75
+
76
+ const items: ExecutorStatus[] = []
77
+ for (const [id, adapter] of Object.entries(EXECUTOR_REGISTRY)) {
78
+ const installedPath = id === 'codex-gui' ? await desktopCodexPath() : await findExecutableInPath(adapter.command)
79
+ items.push({
80
+ id,
81
+ name: adapter.name,
82
+ command: adapter.command,
83
+ description: adapter.description,
84
+ installedPath,
85
+ isActive: id === resolvedActive,
86
+ })
87
+ }
88
+ return { active, items }
89
+ }
90
+
91
+ const printStatus = (status: { active: string; items: ExecutorStatus[] }): void => {
92
+ console.log('\nSupported CLI Executors:\n')
93
+ for (const item of status.items) {
94
+ const activeBadge = item.isActive ? ' [ACTIVE]' : ''
95
+ const statusText = item.installedPath ? `āœ“ installed (${item.installedPath})` : 'āœ— not found in PATH'
96
+ console.log(` * ${item.id.padEnd(14)} (${item.command})${activeBadge}`)
97
+ console.log(` ${item.description}`)
98
+ console.log(` Status: ${statusText}\n`)
99
+ }
100
+ console.log(`Active executor: ${status.active}`)
101
+ console.log('Initialize the persistent mind: ezenciel-agents-setup init')
102
+ console.log('\nTo switch executor:')
103
+ console.log(' pnpm run setup <executor-name>')
104
+ console.log(' Examples: pnpm run setup claude')
105
+ console.log(' pnpm run setup agy')
106
+ console.log(' pnpm run setup grok')
107
+ console.log(' pnpm run setup opencode')
108
+ console.log(' pnpm run setup codex-gui\n')
109
+ }
110
+
111
+ export const runCli = async (): Promise<void> => {
112
+ const args = process.argv.slice(2).filter((arg) => arg !== '--')
113
+ const envFilePath = path.resolve(process.cwd(), '.env')
114
+
115
+ if (args[0] === '--help' || args[0] === '-h') {
116
+ console.log('Usage: ezenciel-agents-setup configure <executor> [--token-stdin] | service | init | status | <executor>\nconfigure writes private .env paths and seeds missing mind files; it does not start or pair the bot.\n--token-stdin accepts the BotFather token without putting it in command arguments or output.\nservice starts only the Docker Compose relay bound by docker.env in the current directory. It does not install the host-executor service; follow docs/host-service.md. It does not pair the owner.\nFollow docs/setup.md through a real Telegram reply; package installation alone is incomplete.')
117
+ return
118
+ }
119
+
120
+ if (args[0] === 'service') {
121
+ if (args.length !== 1) throw new Error('Usage: ezenciel-agents-setup service')
122
+ console.log(JSON.stringify(await installService(process.cwd())))
123
+ return
124
+ }
125
+
126
+ if (args[0] === 'configure') {
127
+ if (!args[1] || args.length > 3 || (args[2] && args[2] !== '--token-stdin'))
128
+ throw new Error('Usage: ezenciel-agents-setup configure <executor> [--token-stdin]')
129
+ let token: string | undefined
130
+ if (args[2]) {
131
+ if (process.stdin.isTTY) throw new Error('Supply the token through stdin, not a command argument.')
132
+ let input = ''
133
+ for await (const chunk of process.stdin) {
134
+ input += chunk.toString()
135
+ if (input.length > 512) throw new Error('Token input is too long.')
136
+ }
137
+ token = input.trim()
138
+ }
139
+ console.log(JSON.stringify(await configureInstallation(process.cwd(), args[1], token)))
140
+ return
141
+ }
142
+
143
+ if (args[0] === 'init') {
144
+ if (args.length !== 1) throw new Error('Usage: ezenciel-agents-setup init (uses EZ_AGENT_WORKSPACE or ./agent)')
145
+ const workspace = path.resolve(process.env.EZ_AGENT_WORKSPACE?.trim() || './agent')
146
+ const created = await initializeWorkspace(workspace)
147
+ const config = loadControlConfig()
148
+ await new ControlStore(config.controlDir, config.pairingTtlMs).syncClientPresets(
149
+ initialPreset(await readActiveExecutor(envFilePath)), await discoverDefaults(workspace))
150
+ console.log(JSON.stringify({ workspace, created }))
151
+ return
152
+ }
153
+
154
+ if (args.length === 0 || args[0] === 'status') {
155
+ const status = await getExecutorsStatus(envFilePath)
156
+ printStatus(status)
157
+ return
158
+ }
159
+
160
+ const target = args[0]
161
+ try {
162
+ const adapter = resolveExecutor(target)
163
+ await setExecutorInEnv(envFilePath, adapter.name)
164
+ const installed = await findExecutableInPath(adapter.command)
165
+ console.log(`\nāœ“ Active CLI executor switched to: ${adapter.name} (${adapter.command})`)
166
+ if (installed) {
167
+ console.log(` Binary verified at: ${installed}`)
168
+ } else {
169
+ console.warn(` Warning: Binary "${adapter.command}" not found in PATH. Install it before starting the relay.`)
170
+ }
171
+ console.log(` Updated: ${envFilePath}\n`)
172
+ } catch (error) {
173
+ console.error(`\nError: ${(error as Error).message}\n`)
174
+ process.exit(1)
175
+ }
176
+ }
177
+
178
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
179
+ void runCli().catch(error => { console.error((error as Error).message); process.exitCode = 1 })
180
+ }
@@ -0,0 +1,23 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { packageVersion } from './version.js'
4
+
5
+ export const installedPluginVersions = async (toolsHome?: string) => {
6
+ if (!toolsHome) return null
7
+ try {
8
+ const registry = JSON.parse(await readFile(path.join(toolsHome, 'registry.json'), 'utf8')) as {plugins: Record<string, {manifest: {id: string; version: string}}>}
9
+ return Object.values(registry.plugins).map(record => ({ id: record.manifest.id, version: record.manifest.version }))
10
+ } catch { return null }
11
+ }
12
+
13
+ export const softwareStatus = async (controlDir: string): Promise<string[]> => {
14
+ const lines = [`Ez relay: ${packageVersion} (running)`]
15
+ try {
16
+ const h = JSON.parse(await readFile(path.join(controlDir, 'host-executor/heartbeat.json'), 'utf8'))
17
+ if (!Number.isFinite(h.at) || h.at > Date.now() + 5000 || Date.now() - h.at >= 15000) throw Error('Stale host')
18
+ lines.push(`Host transport: ${typeof h.version === 'string' ? h.version : 'version unknown'} (running)`)
19
+ if (!Array.isArray(h.plugins)) lines.push('Plugins (installed): unknown')
20
+ else lines.push(`Plugins (installed): ${h.plugins.length ? h.plugins.map((p: {id: string; version: string}) => `${p.id} ${p.version}`).join(', ') : 'none'}`)
21
+ } catch { lines.push('Host transport: unavailable', 'Plugins (installed): unknown') }
22
+ return lines
23
+ }
@@ -0,0 +1,18 @@
1
+ import { parseArgs } from 'node:util'
2
+ import { loadControlConfig } from './config.js'
3
+ import { ControlStore } from './control-state.js'
4
+ import { EventSources } from './event-sources.js'
5
+
6
+ async function main() {
7
+ const { values } = parseArgs({ options: { name: { type: 'string' }, socket: { type: 'string' }, remove: { type: 'boolean' }, list: { type: 'boolean' }, help: { type: 'boolean' } } })
8
+ if (values.help) { console.log('ezenciel-agents-source --list | --name NAME --socket /absolute/service.sock | --name NAME --remove'); return }
9
+ const config = loadControlConfig()
10
+ const sources = new EventSources(config.controlDir)
11
+ if (values.list) { console.log(JSON.stringify(await sources.list())); return }
12
+ if (!values.name || (!!values.socket === !!values.remove)) throw new Error('Supply --name and either --socket or --remove')
13
+ const owner = (await new ControlStore(config.controlDir, config.pairingTtlMs).status()).owner
14
+ if (!owner) throw new Error('Pair an owner before registering event sources')
15
+ const source = await sources.register(values.name, values.remove ? null : values.socket!, owner)
16
+ console.log(JSON.stringify({ ok: true, source: source ?? null }))
17
+ }
18
+ main().catch(error => { console.error(error.message); process.exitCode = 1 })