@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,59 @@
1
+ import { loadControlConfig } from './config.js'
2
+ import { ApprovalStore } from './approval.js'
3
+ import { RunStore } from './runs.js'
4
+
5
+ const args = process.argv.slice(2).filter((arg) => arg !== '--')
6
+ if (args.includes('--help') || args.includes('-h')) {
7
+ console.log('Usage: ezenciel-agents-approval --prompt "..." --action-id "..." [--reply-to <id>]')
8
+ console.log(' or: ezenciel-agents-approval --check <action-id>')
9
+ process.exit(0)
10
+ }
11
+
12
+ let prompt: string | undefined
13
+ let actionId: string | undefined
14
+ let checkId: string | undefined
15
+ let replyTo: number | undefined
16
+
17
+ for (let i = 0; i < args.length; i++) {
18
+ if (args[i] === '--prompt' && args[i + 1]) {
19
+ prompt = args[++i]
20
+ } else if (args[i] === '--action-id' && args[i + 1]) {
21
+ actionId = args[++i]
22
+ } else if (args[i] === '--check' && args[i + 1]) {
23
+ checkId = args[++i]
24
+ } else if (args[i] === '--reply-to' && args[i + 1]) {
25
+ replyTo = parseInt(args[++i], 10)
26
+ }
27
+ }
28
+
29
+ const config = loadControlConfig()
30
+ const approvalStore = new ApprovalStore(config.controlDir)
31
+
32
+ if (checkId) {
33
+ const record = await approvalStore.getDecision(checkId)
34
+ console.log(JSON.stringify({
35
+ ok: true,
36
+ actionId: checkId,
37
+ decision: record?.decision || 'pending',
38
+ decidedAt: record?.decidedAt,
39
+ decidedBy: record?.decidedBy,
40
+ }))
41
+ process.exit(0)
42
+ }
43
+
44
+ if (!prompt || !actionId) {
45
+ console.error('Usage: ezenciel-agents-approval --prompt "..." --action-id "..." [--reply-to <id>]')
46
+ console.error(' or: ezenciel-agents-approval --check <action-id>')
47
+ process.exit(1)
48
+ }
49
+
50
+ const runId = process.env.EZ_RUN_ID?.trim()
51
+ if (!runId) {
52
+ console.error('EZ_RUN_ID is required to request approval')
53
+ process.exit(1)
54
+ }
55
+
56
+ const runStore = new RunStore(config.controlDir)
57
+ await approvalStore.requestApproval(actionId, prompt, runId)
58
+ const item = await runStore.enqueueApproval(runId, prompt, actionId, { replyToMessageId: replyTo })
59
+ console.log(JSON.stringify({ ok: true, run: runId, actionId, outbox_id: item.id, status: 'requested' }))
@@ -0,0 +1,119 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { assertId } from './identity.js'
4
+
5
+ export type ApprovalDecision = 'pending' | 'approved' | 'denied'
6
+
7
+ export type ApprovalRecord = {
8
+ version: 1
9
+ actionId: string
10
+ runId?: string
11
+ prompt: string
12
+ decision: ApprovalDecision
13
+ createdAt: string
14
+ decidedAt?: string
15
+ decidedBy?: number
16
+ decisionUpdateId?: number
17
+ }
18
+
19
+ const isApprovalRecord = (value: unknown): value is ApprovalRecord => {
20
+ if (!value || typeof value !== 'object') return false
21
+ const candidate = value as Partial<ApprovalRecord>
22
+ return (
23
+ candidate.version === 1 &&
24
+ typeof candidate.actionId === 'string' &&
25
+ /^[a-zA-Z0-9_-]{1,40}$/.test(candidate.actionId) &&
26
+ typeof candidate.prompt === 'string' &&
27
+ ['pending', 'approved', 'denied'].includes(candidate.decision ?? '') &&
28
+ typeof candidate.createdAt === 'string' &&
29
+ Number.isFinite(Date.parse(candidate.createdAt))
30
+ )
31
+ }
32
+
33
+ export class ApprovalStore {
34
+ private readonly approvalsDir: string
35
+
36
+ constructor(controlDir: string) {
37
+ this.approvalsDir = path.join(controlDir, 'approvals')
38
+ }
39
+
40
+ private async ensure(): Promise<void> {
41
+ await mkdir(this.approvalsDir, { recursive: true, mode: 0o700 })
42
+ }
43
+
44
+ private filePath(actionId: string): string {
45
+ const safe = assertId(actionId)
46
+ if (safe.length > 40) throw new Error('Approval identifier must be at most 40 characters')
47
+ return path.join(this.approvalsDir, `${safe}.json`)
48
+ }
49
+
50
+ async requestApproval(actionId: string, prompt: string, runId?: string): Promise<ApprovalRecord> {
51
+ if (!prompt.trim()) throw new Error('Approval prompt is empty')
52
+ if (runId) assertId(runId)
53
+ await this.ensure()
54
+ const existing = await this.getDecision(actionId)
55
+ if (existing) {
56
+ if (existing.runId !== runId || existing.prompt !== prompt)
57
+ throw new Error('Approval ID already belongs to another request')
58
+ return existing
59
+ }
60
+
61
+ const record: ApprovalRecord = {
62
+ version: 1,
63
+ actionId,
64
+ runId,
65
+ prompt,
66
+ decision: 'pending',
67
+ createdAt: new Date().toISOString(),
68
+ }
69
+ const file = this.filePath(actionId)
70
+ const temporary = `${file}.${process.pid}.tmp`
71
+ await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
72
+ await rename(temporary, file)
73
+ return record
74
+ }
75
+
76
+ async recordDecision(
77
+ actionId: string,
78
+ decision: 'approved' | 'denied',
79
+ decidedBy: number,
80
+ decisionUpdateId?: number,
81
+ ): Promise<ApprovalRecord> {
82
+ await this.ensure()
83
+ const existing = await this.getDecision(actionId)
84
+ if (!existing) throw new Error('Unknown approval request')
85
+ if (
86
+ decisionUpdateId !== undefined &&
87
+ existing.decisionUpdateId === decisionUpdateId &&
88
+ existing.decidedBy === decidedBy &&
89
+ existing.decision === decision
90
+ )
91
+ return existing
92
+ if (existing.decision !== 'pending') throw new Error('Approval already decided')
93
+ if (Date.now() - Date.parse(existing.createdAt) > 900_000) throw new Error('Approval expired')
94
+ const record: ApprovalRecord = {
95
+ ...existing,
96
+ decision,
97
+ decidedAt: new Date().toISOString(),
98
+ decidedBy,
99
+ decisionUpdateId,
100
+ }
101
+ const file = this.filePath(actionId)
102
+ const temporary = `${file}.${process.pid}.tmp`
103
+ await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
104
+ await rename(temporary, file)
105
+ return record
106
+ }
107
+
108
+ async getDecision(actionId: string): Promise<ApprovalRecord | null> {
109
+ try {
110
+ const parsed: unknown = JSON.parse(await readFile(this.filePath(actionId), 'utf8'))
111
+ if (!isApprovalRecord(parsed) || parsed.actionId !== actionId)
112
+ throw new Error('Invalid approval record shape')
113
+ return parsed
114
+ } catch (error: unknown) {
115
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
116
+ throw error
117
+ }
118
+ }
119
+ }
package/src/audio.ts ADDED
@@ -0,0 +1,184 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { readRequest } from './read-request.js'
3
+
4
+ export type AudioOptions = {
5
+ geminiApiKey?: string
6
+ openaiApiKey?: string
7
+ voice?: string
8
+ }
9
+
10
+ export const pcmToWav = (pcm: Buffer, sampleRate = 24000, channels = 1): Buffer => {
11
+ const header = Buffer.alloc(44)
12
+ const byteRate = sampleRate * channels * 2
13
+ const blockAlign = channels * 2
14
+ header.write('RIFF', 0)
15
+ header.writeUInt32LE(36 + pcm.length, 4)
16
+ header.write('WAVE', 8)
17
+ header.write('fmt ', 12)
18
+ header.writeUInt32LE(16, 16)
19
+ header.writeUInt16LE(1, 20)
20
+ header.writeUInt16LE(channels, 22)
21
+ header.writeUInt32LE(sampleRate, 24)
22
+ header.writeUInt32LE(byteRate, 28)
23
+ header.writeUInt16LE(blockAlign, 32)
24
+ header.writeUInt16LE(16, 34)
25
+ header.write('data', 36)
26
+ header.writeUInt32LE(pcm.length, 40)
27
+ return Buffer.concat([header, pcm])
28
+ }
29
+
30
+ export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Promise<Buffer> => {
31
+ return new Promise((resolve, reject) => {
32
+ const child = spawn(
33
+ 'ffmpeg',
34
+ [
35
+ '-f',
36
+ 's16le',
37
+ '-ar',
38
+ String(sampleRate),
39
+ '-ac',
40
+ String(channels),
41
+ '-i',
42
+ '-',
43
+ '-c:a',
44
+ 'libopus',
45
+ '-b:a',
46
+ '32k',
47
+ '-f',
48
+ 'ogg',
49
+ '-',
50
+ ],
51
+ { stdio: ['pipe', 'pipe', 'pipe'] },
52
+ )
53
+
54
+ const output: Buffer[] = []
55
+ let error = ''
56
+
57
+ child.stdout.on('data', (chunk) => output.push(chunk))
58
+ child.stderr.on('data', (chunk) => {
59
+ error = (error + chunk.toString()).slice(-2048)
60
+ })
61
+ child.stdin.on('error', reject)
62
+
63
+ child.on('error', reject)
64
+
65
+ child.on('close', (code) => {
66
+ if (code === 0 && output.length > 0) {
67
+ resolve(Buffer.concat(output))
68
+ } else {
69
+ reject(new Error(`Voice encoding failed (${code}): ${error}`))
70
+ }
71
+ })
72
+
73
+ child.stdin.end(pcm)
74
+ })
75
+ }
76
+
77
+ export const transcribeAudio = async (
78
+ audioBytes: Buffer,
79
+ mimeType: string,
80
+ options: AudioOptions = {},
81
+ ): Promise<string> => {
82
+ const geminiKey = (options.geminiApiKey ?? process.env.GEMINI_API_KEY)?.trim()
83
+ if (geminiKey) {
84
+ const url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.6-flash:generateContent'
85
+ const payload = await readRequest(
86
+ 'Gemini transcription',
87
+ url,
88
+ {
89
+ method: 'POST',
90
+ headers: { 'content-type': 'application/json', 'x-goog-api-key': geminiKey },
91
+ signal: AbortSignal.timeout(60_000),
92
+ body: JSON.stringify({
93
+ contents: [
94
+ {
95
+ parts: [
96
+ {
97
+ text: 'Transcribe this audio faithfully. Return only the transcript, preserving its language.',
98
+ },
99
+ { inlineData: { mimeType: mimeType || 'audio/ogg', data: audioBytes.toString('base64') } },
100
+ ],
101
+ },
102
+ ],
103
+ }),
104
+ },
105
+ (response) => response.json(),
106
+ )
107
+ const transcript = payload?.candidates?.[0]?.content?.parts?.[0]?.text?.trim()
108
+ if (!transcript) throw new Error('Gemini returned an empty transcript')
109
+ return transcript
110
+ }
111
+
112
+ const openaiKey = options.openaiApiKey?.trim() || process.env.OPENAI_API_KEY?.trim()
113
+ if (openaiKey) {
114
+ const form = new FormData()
115
+ form.set('model', 'whisper-1')
116
+ form.set('response_format', 'text')
117
+ form.set('file', new Blob([new Uint8Array(audioBytes)], { type: mimeType || 'audio/ogg' }), 'audio.ogg')
118
+
119
+ const transcript = await readRequest(
120
+ 'OpenAI transcription',
121
+ 'https://api.openai.com/v1/audio/transcriptions',
122
+ {
123
+ method: 'POST',
124
+ headers: { authorization: `Bearer ${openaiKey}` },
125
+ body: form,
126
+ },
127
+ (response) => response.text(),
128
+ )
129
+ if (!transcript.trim()) throw new Error('OpenAI returned an empty transcript')
130
+ return transcript.trim()
131
+ }
132
+
133
+ throw new Error('No audio transcription API key configured (set GEMINI_API_KEY or OPENAI_API_KEY)')
134
+ }
135
+
136
+ export const synthesizeSpeech = async (
137
+ text: string,
138
+ options: AudioOptions = {},
139
+ ): Promise<{ buffer: Buffer; mimeType: string }> => {
140
+ const trimmed = text.trim()
141
+ if (!trimmed) throw new Error('Cannot synthesize speech for empty text')
142
+
143
+ const geminiKey = (options.geminiApiKey ?? process.env.GEMINI_API_KEY)?.trim()
144
+ if (geminiKey) {
145
+ const url =
146
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent'
147
+ const voiceName = options.voice || 'Kore'
148
+ const response = await fetch(url, {
149
+ method: 'POST',
150
+ headers: { 'content-type': 'application/json', 'x-goog-api-key': geminiKey },
151
+ signal: AbortSignal.timeout(60_000),
152
+ body: JSON.stringify({
153
+ contents: [{ parts: [{ text: trimmed }] }],
154
+ generationConfig: {
155
+ responseModalities: ['AUDIO'],
156
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName } } },
157
+ },
158
+ }),
159
+ })
160
+
161
+ if (!response.ok) {
162
+ const err = await response.text().catch(() => '')
163
+ throw new Error(`Gemini speech synthesis error ${response.status}: ${err}`)
164
+ }
165
+
166
+ const payload = (await response.json()) as any
167
+ const candidate = payload?.candidates?.[0]
168
+ const inline = candidate?.content?.parts?.find(
169
+ (part: { inlineData?: { data?: string; mimeType?: string } }) =>
170
+ part.inlineData?.mimeType?.startsWith('audio/') && part.inlineData?.data,
171
+ )?.inlineData
172
+ if (!inline?.data)
173
+ throw new Error(`Gemini returned no audio data (${candidate?.finishReason || 'no candidate'})`)
174
+
175
+ const rawPcm = Buffer.from(inline.data, 'base64')
176
+ const encoded = await encodeOggOpus(rawPcm, 24000, 1)
177
+ return {
178
+ buffer: encoded,
179
+ mimeType: 'audio/ogg',
180
+ }
181
+ }
182
+
183
+ throw new Error('No speech synthesis API key configured (set GEMINI_API_KEY)')
184
+ }
@@ -0,0 +1,101 @@
1
+ import { execFile, spawn } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { createInterface } from 'node:readline'
4
+ import { readFile } from 'node:fs/promises'
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { createHash } from 'node:crypto'
8
+ import { installed, type AiPreset } from './ai.js'
9
+ import { executorEnvironment } from './executor.js'
10
+
11
+ const record = (v: unknown): Record<string, unknown> =>
12
+ v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
13
+ const value = (v: unknown): string | undefined =>
14
+ typeof v === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(v) ? v : undefined
15
+ const command = async (cli: string, args: string[], cwd: string): Promise<string> =>
16
+ (await promisify(execFile)(cli, args, { cwd, env: executorEnvironment(), timeout: 8000, maxBuffer: 2 * 1024 * 1024 })).stdout
17
+
18
+ // Native config/read resolves Codex's layers; do not reimplement TOML or start a turn.
19
+ export const codexDefaults = (cwd: string): Promise<Record<string, unknown>> => new Promise((resolve) => {
20
+ const child = spawn('codex', ['app-server'], { cwd, env: executorEnvironment(), stdio: ['pipe', 'pipe', 'ignore'] })
21
+ const lines = createInterface({ input: child.stdout })
22
+ let done = false
23
+ const finish = (config: Record<string, unknown> = {}) => {
24
+ if (done) return
25
+ done = true
26
+ clearTimeout(timer); lines.close(); child.stdin.end(); child.kill(); resolve(config)
27
+ }
28
+ const timer = setTimeout(() => finish(), 8000)
29
+ const send = (message: unknown) => child.stdin.write(`${JSON.stringify(message)}\n`)
30
+ child.on('error', () => finish())
31
+ child.stdin.on('error', () => finish())
32
+ child.on('close', () => finish())
33
+ lines.on('line', (line) => {
34
+ try {
35
+ const message = JSON.parse(line)
36
+ if (message.id === 0 && message.result) {
37
+ send({ method: 'initialized', params: {} })
38
+ send({ id: 1, method: 'config/read', params: { includeLayers: false, cwd } })
39
+ } else if (message.id === 1) {
40
+ const config = record(message.result?.config)
41
+ const managed = record(record(config.models).new_thread)
42
+ finish({ model: managed.model ?? config.model,
43
+ effort: managed.model_reasoning_effort ?? config.model_reasoning_effort })
44
+ }
45
+ } catch { finish() }
46
+ })
47
+ send({ id: 0, method: 'initialize', params: { clientInfo: { name: 'ez_defaults', version: '1' } } })
48
+ })
49
+
50
+ // Only the two simple, documented Grok [models] strings are read. Unsupported
51
+ // syntax stays unspecified so the native client resolves it, rather than guessing.
52
+ export const grokSettings = (text: string): { model?: string; effort?: string } => {
53
+ const section = (text.split(/^\[models\][ \t]*\r?$/m)[1] || '').split(/^\[/m)[0]
54
+ const get = (key: string) => value(section.match(new RegExp(`^\\s*${key}\\s*=\\s*["']([^"'\\r\\n]+)["']\\s*(?:#.*)?$`, 'm'))?.[1])
55
+ return { model: get('default'), effort: get('default_reasoning_effort') }
56
+ }
57
+
58
+ export const discoverDefaults = async (cwd: string, options: {
59
+ home?: string; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
60
+ } = {}): Promise<AiPreset[]> => {
61
+ const home = options.home ?? homedir()
62
+ const available = options.available ?? installed
63
+ const run = options.run ?? command
64
+ const text = async (file: string) => { try { return await readFile(file, 'utf8') } catch { return '' } }
65
+ const json = async (file: string) => { try { return record(JSON.parse(await text(file))) } catch { return {} } }
66
+ const clients = ['grok', 'codex', 'claude', 'opencode']
67
+ const results = await Promise.all(clients.map(async (cli): Promise<AiPreset | undefined> => {
68
+ if (!(await available(cli))) return
69
+ let model: string | undefined, effort: string | undefined
70
+ try {
71
+ if (cli === 'grok') {
72
+ const settings = grokSettings(await text(join(home, '.grok/config.toml')))
73
+ model = settings.model ?? value((await run(cli, ['models'], cwd)).match(/^Default model:\s*(\S+)/m)?.[1])
74
+ effort = settings.effort
75
+ } else if (cli === 'codex') {
76
+ const config = await (options.codex ?? codexDefaults)(cwd)
77
+ model = value(config.model); effort = value(config.effort)
78
+ } else if (cli === 'claude') {
79
+ // Match Claude's documented user -> project -> local settings precedence.
80
+ const settings = Object.assign({}, await json(join(home, '.claude/settings.json')),
81
+ await json(join(cwd, '.claude/settings.json')), await json(join(cwd, '.claude/settings.local.json')))
82
+ model = value(settings.model); effort = value(settings.effortLevel)
83
+ } else if (cli === 'opencode') {
84
+ const config = record(JSON.parse(await run(cli, ['debug', 'config'], cwd)))
85
+ model = value(config.model)
86
+ }
87
+ } catch { /* Unavailable metadata: expose client default, never invent values. */ }
88
+ const id = 'detected_' + createHash('sha256').update(JSON.stringify([cli, model, effort])).digest('hex').slice(0, 12)
89
+ return { id, cli, model, effort,
90
+ name: `${cli} · ${model || 'client default'}${effort ? ` · ${effort}` : ''}`.slice(0, 80) }
91
+ }))
92
+ const discovered = results.filter((p): p is AiPreset => Boolean(p))
93
+ if (await available('codex-gui')) {
94
+ const source = discovered.find((preset) => preset.cli === 'codex')
95
+ const model = source?.model, effort = source?.effort
96
+ const id = 'detected_' + createHash('sha256').update(JSON.stringify(['codex-gui', model, effort])).digest('hex').slice(0, 12)
97
+ discovered.push({ id, cli: 'codex-gui', model, effort,
98
+ name: `codex-gui · ${model || 'desktop'}${effort ? ` · ${effort}` : ''}`.slice(0, 80) })
99
+ }
100
+ return discovered
101
+ }
package/src/config.ts ADDED
@@ -0,0 +1,48 @@
1
+ import path from 'node:path'
2
+ import { homedir } from 'node:os'
3
+
4
+ export type ControlConfig = {
5
+ controlDir: string
6
+ pairingTtlMs: number
7
+ }
8
+
9
+ export type Config = ControlConfig & {
10
+ telegramBotToken: string
11
+ workspace: string
12
+ executorTimeoutMs: number
13
+ executorCli: string
14
+ geminiApiKey?: string
15
+ openaiApiKey?: string
16
+ }
17
+
18
+ const positiveInteger = (value: string | undefined, name: string, fallback: number): number => {
19
+ if (!value) return fallback
20
+ const parsed = Number(value)
21
+ if (!Number.isInteger(parsed) || parsed <= 0) {
22
+ throw new Error(`${name} must be a positive integer`)
23
+ }
24
+ return parsed
25
+ }
26
+
27
+ export const loadControlConfig = (env: NodeJS.ProcessEnv = process.env): ControlConfig => {
28
+ const stateHome = env.XDG_STATE_HOME?.trim() || path.join(homedir(), '.local', 'state')
29
+ return {
30
+ controlDir: path.resolve(env.EZ_CONTROL_DIR?.trim() || path.join(stateHome, 'ezenciel-agents')),
31
+ pairingTtlMs: positiveInteger(env.EZ_PAIRING_TTL_SECONDS, 'EZ_PAIRING_TTL_SECONDS', 900) * 1_000,
32
+ }
33
+ }
34
+
35
+ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
36
+ const telegramBotToken = env.TELEGRAM_BOT_TOKEN?.trim()
37
+ if (!telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
38
+
39
+ return {
40
+ ...loadControlConfig(env),
41
+ telegramBotToken,
42
+ workspace: path.resolve(env.EZ_AGENT_WORKSPACE?.trim() || './agent'),
43
+ executorTimeoutMs: positiveInteger(env.EZ_EXECUTOR_TIMEOUT_SECONDS, 'EZ_EXECUTOR_TIMEOUT_SECONDS', 300) * 1_000,
44
+ executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
45
+ geminiApiKey: env.GEMINI_API_KEY?.trim(),
46
+ openaiApiKey: env.OPENAI_API_KEY?.trim(),
47
+ }
48
+ }