@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.
- package/.dockerignore +24 -0
- package/.env.example +26 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +73 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/SECURITY.md +26 -0
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/bin/ezenciel-agents +2 -0
- package/bin/ezenciel-agents-ai +2 -0
- package/bin/ezenciel-agents-ai.mjs +5 -0
- package/bin/ezenciel-agents-approval +2 -0
- package/bin/ezenciel-agents-approval.mjs +16 -0
- package/bin/ezenciel-agents-create +12 -0
- package/bin/ezenciel-agents-docker +6 -0
- package/bin/ezenciel-agents-host +5 -0
- package/bin/ezenciel-agents-install +2 -0
- package/bin/ezenciel-agents-message +2 -0
- package/bin/ezenciel-agents-message.mjs +16 -0
- package/bin/ezenciel-agents-owner +2 -0
- package/bin/ezenciel-agents-owner.mjs +18 -0
- package/bin/ezenciel-agents-react +2 -0
- package/bin/ezenciel-agents-react.mjs +16 -0
- package/bin/ezenciel-agents-setup.mjs +18 -0
- package/bin/ezenciel-agents-source +2 -0
- package/bin/ezenciel-agents-source.mjs +16 -0
- package/bin/ezenciel-agents-tools.mjs +3 -0
- package/bin/ezenciel-agents.mjs +37 -0
- package/compose.whatsapp.yaml +12 -0
- package/compose.yaml +40 -0
- package/default-plugins.json +1 -0
- package/docker/entrypoint.sh +15 -0
- package/docker/healthcheck.mjs +8 -0
- package/docker/plugin-smoke.mjs +48 -0
- package/docker/pnpm-lock.yaml +415 -0
- package/docker/recovery.ts +11 -0
- package/docker/run.ts +52 -0
- package/docker/smoke.mjs +47 -0
- package/docker/status-smoke.mjs +30 -0
- package/docker/upgrade-smoke.mjs +58 -0
- package/docs/architecture/ai-selection.md +37 -0
- package/docs/architecture/authority-boundaries.md +14 -0
- package/docs/architecture/event-sources.md +34 -0
- package/docs/architecture/telegram-intake.md +29 -0
- package/docs/development-and-testing.md +18 -0
- package/docs/docker-runtime.md +118 -0
- package/docs/host-service.md +80 -0
- package/docs/plugin-contributions.md +34 -0
- package/docs/plugins.md +181 -0
- package/docs/releasing.md +71 -0
- package/docs/setup.md +234 -0
- package/docs/upgrades.md +193 -0
- package/package.json +106 -0
- package/scripts/assert-local-registry.mjs +22 -0
- package/scripts/release-check.mjs +14 -0
- package/scripts/smoke.ts +102 -0
- package/src/agent-install.ts +96 -0
- package/src/ai-cli.ts +22 -0
- package/src/ai.ts +88 -0
- package/src/approval-cli.ts +59 -0
- package/src/approval.ts +119 -0
- package/src/audio.ts +184 -0
- package/src/client-defaults.ts +101 -0
- package/src/config.ts +48 -0
- package/src/control-state.ts +350 -0
- package/src/desktop-bridge.ts +284 -0
- package/src/event-sources.ts +112 -0
- package/src/executor.ts +335 -0
- package/src/files.ts +75 -0
- package/src/format.ts +57 -0
- package/src/host-executor-client.ts +46 -0
- package/src/host-executor-protocol.ts +2 -0
- package/src/host-executor.ts +129 -0
- package/src/identity.ts +17 -0
- package/src/inbox.ts +171 -0
- package/src/index.ts +812 -0
- package/src/install-config.ts +56 -0
- package/src/install-tools.mjs +98 -0
- package/src/menu.ts +123 -0
- package/src/message-send.ts +57 -0
- package/src/message.ts +68 -0
- package/src/owner-args.ts +4 -0
- package/src/owner.ts +30 -0
- package/src/plugins/manager.mjs +272 -0
- package/src/react.ts +33 -0
- package/src/reaction.ts +32 -0
- package/src/read-request.ts +72 -0
- package/src/reply.ts +13 -0
- package/src/runs.ts +445 -0
- package/src/service.ts +28 -0
- package/src/setup.ts +180 -0
- package/src/software-status.ts +23 -0
- package/src/source-cli.ts +18 -0
- package/src/update-attention.ts +18 -0
- package/src/updates/artifact.mjs +83 -0
- package/src/updates/binding.mjs +27 -0
- package/src/updates/control.mjs +131 -0
- package/src/updates/launch.mjs +13 -0
- package/src/updates/runtime.mjs +140 -0
- package/src/updates/status.mjs +49 -0
- package/src/updates/supervisor.mjs +102 -0
- package/src/version.ts +4 -0
- package/src/workspace.ts +32 -0
- package/templates/agent/AGENTS.md +49 -0
- package/templates/agent/SOUL.md +11 -0
- package/templates/agent/TOOLS.md +46 -0
- package/templates/agent/USER.md +5 -0
- package/templates/updates.md +45 -0
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +28 -0
- package/test/ai.test.ts +105 -0
- package/test/approval.test.ts +40 -0
- package/test/audio.test.ts +77 -0
- package/test/client-defaults.test.ts +64 -0
- package/test/codex-context.test.ts +33 -0
- package/test/config.test.ts +32 -0
- package/test/control-state.test.ts +58 -0
- package/test/desktop-bridge.test.ts +159 -0
- package/test/docker-runtime.test.ts +23 -0
- package/test/event-sources.test.ts +113 -0
- package/test/executor.test.ts +135 -0
- package/test/files.test.ts +50 -0
- package/test/format.test.ts +41 -0
- package/test/host-executor.test.ts +149 -0
- package/test/inbox-burst.test.ts +66 -0
- package/test/inbox.test.ts +102 -0
- package/test/install-config.test.ts +75 -0
- package/test/install-tools.test.mjs +59 -0
- package/test/intake-relay.test.ts +337 -0
- package/test/owner-help.test.mjs +10 -0
- package/test/plugin-manager.test.mjs +157 -0
- package/test/publish-guard.test.ts +21 -0
- package/test/reaction.test.ts +122 -0
- package/test/read-request.test.ts +134 -0
- package/test/relay.test.ts +254 -0
- package/test/release-entrypoints.test.mjs +26 -0
- package/test/runs.test.ts +116 -0
- package/test/security.test.ts +85 -0
- package/test/setup.test.ts +68 -0
- package/test/software-status.test.ts +31 -0
- package/test/update-attention.test.ts +21 -0
- package/test/updates.test.mjs +282 -0
- package/test/upgrade-pause.test.ts +70 -0
- package/test/workspace.test.ts +80 -0
- 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 })
|