@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37

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 (142) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +96 -0
  5. package/Dockerfile +20 -11
  6. package/README.md +10 -13
  7. package/THIRD_PARTY_NOTICES.md +7 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +41 -4
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +28 -6
  17. package/docs/application-channel.md +32 -10
  18. package/docs/architecture/ai-selection.md +10 -2
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +98 -11
  22. package/docs/local-qa.md +2 -2
  23. package/docs/plugin-guide.md +202 -0
  24. package/docs/plugins.md +48 -2
  25. package/docs/releasing.md +13 -0
  26. package/docs/responsive-channels.md +1 -1
  27. package/docs/scheduling.md +4 -4
  28. package/docs/setup.md +11 -5
  29. package/docs/upgrades.md +46 -23
  30. package/package.json +5 -5
  31. package/scripts/smoke-scheduler.ts +10 -3
  32. package/scripts/smoke.ts +14 -11
  33. package/src/agent-guidance.ts +6 -9
  34. package/src/agent-install.ts +28 -7
  35. package/src/ai-cli.ts +1 -1
  36. package/src/ai.ts +131 -5
  37. package/src/application-channel.ts +78 -7
  38. package/src/application-cli.ts +2 -2
  39. package/src/approval-cli.ts +14 -7
  40. package/src/approval.ts +27 -37
  41. package/src/channel-backend.ts +2 -3
  42. package/src/config.ts +12 -27
  43. package/src/control-state.ts +175 -8
  44. package/src/conversation-menu.ts +6 -17
  45. package/src/delivery-socket-client.d.mts +4 -0
  46. package/src/delivery-socket-client.mjs +104 -0
  47. package/src/delivery-socket.ts +354 -0
  48. package/src/execution-authority.ts +21 -5
  49. package/src/executor.ts +81 -24
  50. package/src/files.ts +38 -11
  51. package/src/host-executor.ts +33 -17
  52. package/src/inbox.ts +15 -49
  53. package/src/index.ts +98 -54
  54. package/src/install-tools.mjs +97 -22
  55. package/src/ledger-endpoint.d.mts +2 -0
  56. package/src/ledger-endpoint.mjs +17 -0
  57. package/src/menu.ts +8 -3
  58. package/src/message-history.ts +13 -18
  59. package/src/message.ts +35 -18
  60. package/src/plugin-broker.mjs +396 -0
  61. package/src/plugins/connection.mjs +2 -2
  62. package/src/plugins/manager.mjs +131 -23
  63. package/src/plugins/native-tasks.mjs +2 -2
  64. package/src/prompt-suffix.ts +12 -0
  65. package/src/react.ts +6 -4
  66. package/src/runs.ts +156 -243
  67. package/src/schedule-cli.ts +29 -10
  68. package/src/scheduled-tasks.ts +53 -10
  69. package/src/scheduler.ts +20 -7
  70. package/src/service.ts +3 -3
  71. package/src/setup.ts +1 -0
  72. package/src/software-status.ts +36 -4
  73. package/src/task-executor.ts +2 -2
  74. package/src/task-workspace.ts +4 -3
  75. package/src/tasks.ts +1 -1
  76. package/src/telegram-provisioning-cli.ts +22 -0
  77. package/src/telegram-provisioning.ts +214 -0
  78. package/src/updates/control.mjs +77 -7
  79. package/src/updates/runtime.mjs +28 -8
  80. package/src/updates/status.mjs +25 -3
  81. package/src/updates/supervisor.mjs +15 -16
  82. package/src/version.ts +20 -0
  83. package/src/workspace.ts +3 -9
  84. package/templates/failure-review.md +1 -1
  85. package/templates/maintainer-purpose.md +1 -1
  86. package/test/agent-guidance.test.ts +14 -80
  87. package/test/agent-install.test.ts +14 -0
  88. package/test/ai.test.ts +220 -1
  89. package/test/application-channel.test.ts +61 -13
  90. package/test/application-only.test.ts +14 -13
  91. package/test/channel-backend.test.ts +3 -7
  92. package/test/channel-delivery.test.ts +4 -1
  93. package/test/channel-owner.test.ts +6 -3
  94. package/test/codex-context.test.ts +2 -2
  95. package/test/config.test.ts +38 -43
  96. package/test/control-state.test.ts +83 -4
  97. package/test/conversation-menu.test.ts +2 -4
  98. package/test/delivery-transport.test.ts +72 -0
  99. package/test/docker-runtime.test.ts +27 -14
  100. package/test/engine-handoff.test.ts +7 -6
  101. package/test/event-sources.test.ts +2 -2
  102. package/test/execution-authority.test.ts +9 -9
  103. package/test/executor.test.ts +120 -12
  104. package/test/failure.test.ts +12 -5
  105. package/test/files.test.ts +2 -2
  106. package/test/helpers/ledger.ts +18 -0
  107. package/test/host-executor.test.ts +87 -3
  108. package/test/inbox.test.ts +8 -10
  109. package/test/install-config.test.ts +1 -0
  110. package/test/install-tools.test.mjs +74 -7
  111. package/test/intake-relay.test.ts +58 -11
  112. package/test/message-history.test.ts +39 -47
  113. package/test/native-tasks.test.ts +1 -1
  114. package/test/plugin-broker.test.mjs +172 -0
  115. package/test/plugin-manager.test.mjs +136 -2
  116. package/test/reaction.test.ts +4 -0
  117. package/test/relay.test.ts +5 -7
  118. package/test/repair-policy.test.ts +1 -4
  119. package/test/schedule-cli.test.ts +14 -9
  120. package/test/scheduled-tasks.test.ts +29 -6
  121. package/test/scheduler-host.test.ts +5 -5
  122. package/test/scheduler-relay.test.ts +15 -9
  123. package/test/security.test.ts +6 -3
  124. package/test/software-status.test.ts +21 -3
  125. package/test/telegram-provisioning.test.ts +160 -0
  126. package/test/updates.test.mjs +93 -13
  127. package/test/workspace.test.ts +4 -1
  128. package/bin/ezenciel-agents-watch.mjs +0 -8
  129. package/compose.workforce-watch.yaml +0 -33
  130. package/docker/recovery.ts +0 -11
  131. package/docs/pagerduty.md +0 -42
  132. package/docs/workforce-watch.md +0 -101
  133. package/src/pagerduty.ts +0 -109
  134. package/src/reply-context.ts +0 -43
  135. package/src/update-attention.ts +0 -18
  136. package/src/workforce-watch-cli.ts +0 -14
  137. package/src/workforce-watch.ts +0 -155
  138. package/templates/agent/AGENTS.md +0 -5
  139. package/templates/agent-guidance.md +0 -17
  140. package/test/pagerduty.test.ts +0 -104
  141. package/test/update-attention.test.ts +0 -20
  142. package/test/workforce-watch.test.ts +0 -180
package/src/ai.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  import { assertEffort, allowedEffort } from './model-policy.js'
2
2
  import { access, readFile } from 'node:fs/promises'
3
3
  import { constants } from 'node:fs'
4
+ import { execFile } from 'node:child_process'
5
+ import { promisify } from 'node:util'
4
6
  import { homedir } from 'node:os'
5
7
  import { join, delimiter } from 'node:path'
6
- import { executorKey, resolveExecutor } from './executor.js'
8
+ import { executorEnvironment, executorKey, resolveExecutor } from './executor.js'
7
9
  import { desktopCodexPath } from './desktop-bridge.js'
8
10
 
9
11
  export type AiPreset = { id: string; name: string; cli: string; provider?: string; model?: string; effort?: string }
10
12
  export type ExecutionChoice = { sessionId: string; preset: AiPreset }
11
13
  export type ModelChoice = { cli: string; provider?: string; model?: string; name: string; efforts: string[] }
12
14
  const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
15
+ const knownClis = ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'agy', 'pi']
16
+ const nativeEffort = (cli: string, effort: string) =>
17
+ cli === 'pi' ? ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort) : true
13
18
  export const isPreset = (p: unknown): p is AiPreset => {
14
19
  if (!p || typeof p !== 'object') return false
15
20
  const v = p as AiPreset
16
21
  return safe(v.id) && typeof v.name === 'string' && v.name.length > 0 && v.name.length <= 80 &&
17
- ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'agy'].includes(v.cli) &&
22
+ knownClis.includes(v.cli) &&
18
23
  (v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
19
24
  }
20
25
  export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
@@ -45,7 +50,81 @@ export const installed = async (cli: string): Promise<boolean> => {
45
50
 
46
51
  // Read only metadata from native client catalogs. Never import prompts, credentials,
47
52
  // provider configuration, or model instructions into relay context.
48
- export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex')): Promise<ModelChoice[]> => {
53
+ // The optional runner injects `opencode models` output in tests; production
54
+ // spawns the installed CLI with the whitelisted executor environment.
55
+ export const readOpencodeModels = async (run?: (args: string[]) => Promise<string>, dataHome?: string): Promise<ModelChoice[]> => {
56
+ const exec = run ?? (async (args: string[]) =>
57
+ (await promisify(execFile)('opencode', args, { env: { ...executorEnvironment(), ...(dataHome ? { XDG_DATA_HOME: dataHome } : {}) }, timeout: 8000, maxBuffer: 4 * 1024 * 1024 })).stdout)
58
+ const record = (value: unknown): Record<string, unknown> =>
59
+ value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
60
+ try {
61
+ const stdout = await exec(['models', '--verbose'])
62
+ // Verbose output alternates a `provider/model` identifier line with its
63
+ // pretty-printed JSON metadata (name, variants). Split on identifier lines
64
+ // so variant keys can be projected as selectable efforts.
65
+ const segments = stdout.split(/^([A-Za-z0-9_.\-]+\/[A-Za-z0-9_./:~\-]+)$/m)
66
+ const entries: ModelChoice[] = []
67
+ for (let index = 1; index + 1 < segments.length; index += 2) {
68
+ const id = segments[index].trim()
69
+ if (!safe(id)) continue
70
+ let meta: Record<string, unknown>
71
+ try { meta = record(JSON.parse(segments[index + 1])) } catch { continue }
72
+ const friendly = typeof meta.name === 'string' ? meta.name.trim() : ''
73
+ const efforts = Object.keys(record(meta.variants))
74
+ .filter((effort) => safe(effort) && allowedEffort(effort, id, 'opencode'))
75
+ entries.push({ cli: 'opencode', model: id,
76
+ name: (friendly ? `${friendly} · ${id}` : id).slice(0, 80), efforts })
77
+ }
78
+ if (entries.length) return entries
79
+ } catch { /* fall through to the plain list, then the client default */ }
80
+ try {
81
+ const stdout = await exec(['models'])
82
+ const entries: ModelChoice[] = []
83
+ for (const line of stdout.split(/\r?\n/)) {
84
+ const id = line.trim()
85
+ if (!id || !safe(id)) continue
86
+ entries.push({ cli: 'opencode', model: id, name: id.slice(0, 80), efforts: [] })
87
+ }
88
+ if (entries.length) return entries
89
+ } catch { /* unavailable metadata stays client default */ }
90
+ return []
91
+ }
92
+
93
+ // User-curated entries live in the agent's own setup (control/ai-models.json),
94
+ // never in the repo. They pin models the native catalogs may not currently
95
+ // serve (e.g. paid tiers) and merge ahead of discovered entries, so selection
96
+ // validation accepts them.
97
+ export const readCuratedModels = async (controlDir?: string): Promise<ModelChoice[]> => {
98
+ if (!controlDir) return []
99
+ const curated: ModelChoice[] = []
100
+ const seen = new Set<string>()
101
+ try {
102
+ const raw = JSON.parse(await readFile(join(controlDir, 'ai-models.json'), 'utf8'))
103
+ if (!Array.isArray(raw)) return []
104
+ for (const candidate of raw) {
105
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue
106
+ const entry = candidate as Record<string, unknown>
107
+ if (typeof entry.cli !== 'string' || !knownClis.includes(entry.cli)) continue
108
+ if (entry.provider !== undefined && !safe(entry.provider)) continue
109
+ if (entry.model !== undefined && !safe(entry.model)) continue
110
+ const key = `${entry.cli}‖${entry.provider ?? ''}‖${entry.model ?? ''}`
111
+ if (seen.has(key)) continue
112
+ seen.add(key)
113
+ curated.push({
114
+ cli: entry.cli,
115
+ ...(entry.provider !== undefined ? { provider: entry.provider as string } : {}),
116
+ ...(entry.model !== undefined ? { model: entry.model as string } : {}),
117
+ name: (typeof entry.name === 'string' && entry.name ? entry.name : String(entry.model ?? entry.cli)).slice(0, 80),
118
+ efforts: (Array.isArray(entry.efforts) ? entry.efforts : [])
119
+ .filter((effort): effort is string => safe(effort))
120
+ .filter((effort) => allowedEffort(effort, typeof entry.model === 'string' ? entry.model : undefined, entry.cli as string) && nativeEffort(entry.cli as string, effort)),
121
+ })
122
+ }
123
+ } catch { return [] }
124
+ return curated
125
+ }
126
+
127
+ export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex'), opencodeRunner?: (args: string[]) => Promise<string>, opencodeDataHome?: string, opencodeAllowlist?: string[], curationDir?: string): Promise<ModelChoice[]> => {
49
128
  const models: ModelChoice[] = []
50
129
  const record = (value: unknown): Record<string, unknown> =>
51
130
  value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
@@ -79,9 +158,56 @@ export const readModels = async (home = homedir(), available = installed, codexH
79
158
  models.push(...(desktop.length ? desktop : [{ cli: 'codex-gui', name: 'codex-gui · desktop', efforts: [] }]))
80
159
  }
81
160
  // Other adapters expose the authenticated client's default, not a guessed catalog.
82
- for (const cli of ['claude', 'opencode', 'agy'])
161
+ for (const cli of ['claude', 'agy'])
83
162
  if (await available(cli)) models.push({ cli, name: `${cli} · client default`, efforts: [] })
84
- return models
163
+ const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
164
+ if (await available('opencode')) models.push(...await opencodeCatalogModels(opencodeRunner, opencodeDataHome, allow))
165
+ // Pi has separate provider configuration. OpenCode's catalog cannot
166
+ // establish which models Pi can execute.
167
+ if (!allow && await available('pi')) models.push({ cli: 'pi', name: 'Pi · client default', efforts: [] })
168
+ const curated = await readCuratedModels(curationDir)
169
+ if (!curated.length) return models
170
+ const scoped: ModelChoice[] = []
171
+ for (const entry of curated) {
172
+ if (!(await available(entry.cli))) continue
173
+ if (['opencode', 'pi'].includes(entry.cli) && allow &&
174
+ (entry.model === undefined || !allow.includes(entry.model.split('/')[0]))) continue
175
+ scoped.push(entry)
176
+ }
177
+ const key = (m: { cli: string; provider?: string; model?: string }) => `${m.cli}‖${m.provider ?? ''}‖${m.model ?? ''}`
178
+ const curatedKeys = new Set(scoped.map(key))
179
+ return [...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))]
180
+ }
181
+ export const opencodeCatalogModels = async (
182
+ opencodeRunner?: (args: string[]) => Promise<string>,
183
+ opencodeDataHome?: string,
184
+ opencodeAllowlist?: string[],
185
+ ): Promise<ModelChoice[]> => {
186
+ const discovered = await readOpencodeModels(opencodeRunner, opencodeDataHome)
187
+ const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
188
+ const scoped = allow ? discovered.filter((m) => m.model && allow.includes(m.model.split('/')[0])) : discovered
189
+ if (scoped.length) return scoped
190
+ // A set allowlist that matches nothing offers no choice rather than falling
191
+ // back outside the allowed providers.
192
+ if (allow) return []
193
+ return [{ cli: 'opencode', name: 'opencode · client default', efforts: [] as string[] }]
194
+ }
195
+
196
+ // Optional deployment-scoped restriction of the OpenCode catalog to named
197
+ // providers (e.g. EZ_OPENCODE_PROVIDERS=opencode-go). Unset means unfiltered.
198
+ // Reads process env directly like OPENCODE_MODEL; malformed values fail fast
199
+ // so /ai reports the misconfiguration instead of a silently wrong list.
200
+ const providerId = (p: unknown): p is string => typeof p === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(p)
201
+ export const validateOpencodeProviders = (value: unknown): string[] | undefined => {
202
+ if (value === undefined) return undefined
203
+ if (!Array.isArray(value) || !value.length || value.length > 16 || new Set(value).size !== value.length || value.some((p) => !providerId(p)))
204
+ throw new Error('opencode provider allowlist must be one to sixteen unique provider IDs')
205
+ return value as string[]
206
+ }
207
+ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env): string[] | undefined => {
208
+ const raw = env.EZ_OPENCODE_PROVIDERS?.trim()
209
+ if (!raw) return undefined
210
+ return validateOpencodeProviders([...new Set(raw.split(',').map((p) => p.trim()).filter(Boolean))])
85
211
  }
86
212
 
87
213
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
@@ -2,7 +2,7 @@ import { createServer, type Server, type IncomingMessage, type ServerResponse }
2
2
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
3
3
  import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
- import { ControlStore, sessionTitle, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
5
+ import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
6
6
  import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
7
7
  import { ownsRun } from './identity.js'
8
8
  import { applicationId, validApplicationOrigin } from './application-origin.js'
@@ -74,6 +74,8 @@ export class ApplicationChannel {
74
74
  controlDir: string; workspace?: string; initial: AiPreset
75
75
  wake: () => void
76
76
  cancel: (id: string) => Promise<void>
77
+ createTelegramPairing?: (bindingId: string, owner: Owner) => Promise<{ connected: true } | { connected: false; url: string; expiresAt: string }>
78
+ telegramAvailable?: () => boolean
77
79
  aiControls?: {
78
80
  catalog: () => Promise<ModelChoice[]>
79
81
  select: (preset: AiPreset, expectedSession: string | null, guard?: ControlGuard) => Promise<unknown>
@@ -88,6 +90,29 @@ export class ApplicationChannel {
88
90
  if (!binding || (shared && !binding.shareTelegram) || !sameOwner(binding.owner, state.owner)) throw new Error('Application authority does not permit controls')
89
91
  return binding.owner
90
92
  }
93
+ private async telegramConnection(bindingId: string) {
94
+ await this.sharedBinding(bindingId)
95
+ const owner = (await new ControlStore(this.options.controlDir, 900000).status()).owner
96
+ const connected = Boolean(telegramOwner(owner))
97
+ return this.options.telegramAvailable
98
+ ? { connected, ready: this.options.telegramAvailable() }
99
+ : { connected }
100
+ }
101
+ private async createTelegramConnection(bindingId: string) {
102
+ const owner = await this.sharedBinding(bindingId)
103
+ const current = (await new ControlStore(this.options.controlDir, 900000).status()).owner
104
+ if (telegramOwner(current)) return { connected: true } as const
105
+ if (!this.options.createTelegramPairing) throw new Error('Telegram connection is unavailable')
106
+ return this.options.createTelegramPairing(bindingId, owner)
107
+ }
108
+ async claimTelegramConnection(token: string, telegramUserId: number, telegramChatId: number): Promise<boolean> {
109
+ const control = new ControlStore(this.options.controlDir, 900000)
110
+ const owner = await control.claimApplicationTelegramPairing(token, telegramUserId, telegramChatId, async (bindingId, expectedOwner) => {
111
+ const binding = (await this.bindings.list()).find(item => item.bindingId === bindingId)
112
+ return Boolean(binding?.shareTelegram && sameOwner(binding.owner, expectedOwner))
113
+ })
114
+ return Boolean(owner)
115
+ }
91
116
  async controls(bindingId: string) {
92
117
  await this.sharedBinding(bindingId)
93
118
  if (!this.options.aiControls) throw new Error('Application controls unavailable')
@@ -233,8 +258,8 @@ export class ApplicationChannel {
233
258
  }
234
259
  const control = new ControlStore(this.options.controlDir, 900000)
235
260
  if (!sameOwner(binding.owner, (await control.status()).owner)) throw new Error('Application authority revoked')
236
- if (bytes && !this.options.workspace) throw new Error('Invalid application attachment: workspace unavailable')
237
- const staged = bytes ? await stageChatAttachment(this.options.workspace!, value.attachment!.name as string, bytes, value.text) : undefined
261
+ if (bytes && !this.options.controlDir) throw new Error('Invalid application attachment: control unavailable')
262
+ const staged = bytes ? await stageChatAttachment(this.options.controlDir, value.attachment!.name as string, bytes, value.text) : undefined
238
263
  let run: RunRecord
239
264
  try {
240
265
  const execution = value.followTelegram ? await control.captureChoice(this.options.initial) : await control.captureApplicationChoice(this.options.initial, applicationScope(bindingId, value.scope), binding.shareTelegram === true && value.activateTelegram === true, requestedPreset, value.expectedNativeSessionId as string | undefined)
@@ -270,13 +295,33 @@ export class ApplicationChannel {
270
295
  if (request.method === 'GET' && path === '/v1/registration') {
271
296
  send(200, {ownerId: ownerId(binding.owner), bindingId: binding.bindingId, channel: binding.id}); return
272
297
  }
298
+ if (path === '/v1/telegram' && request.method === 'GET') {
299
+ if ([...url.searchParams.keys()].length) throw new Error('Invalid application Telegram request')
300
+ if (!binding.shareTelegram) { send(403, { error: 'Application authority does not permit Telegram connection' }); return }
301
+ send(200, await this.telegramConnection(binding.bindingId)); return
302
+ }
303
+ if (path === '/v1/telegram/link' && request.method === 'POST') {
304
+ const chunks: Buffer[] = []; let size = 0
305
+ for await (const chunk of request) { size += chunk.length; if (size > 1024) throw new Error('Invalid application Telegram request'); chunks.push(chunk) }
306
+ if (Buffer.concat(chunks).toString('utf8').trim()) throw new Error('Invalid application Telegram request')
307
+ if (!binding.shareTelegram) { send(403, { error: 'Application authority does not permit Telegram connection' }); return }
308
+ send(200, await this.createTelegramConnection(binding.bindingId)); return
309
+ }
273
310
  if (request.method === 'GET' && path === '/v1/runs') {
274
- const all = (await this.runs.list()).filter(run => (run.application ?? run.delivery)?.bindingId === binding.bindingId)
311
+ const owned = await this.runs.list()
312
+ const all = owned.filter(run => (run.application ?? run.delivery)?.bindingId === binding.bindingId)
313
+ const currentOwner = binding.shareTelegram ? telegramOwner((await new ControlStore(this.options.controlDir, 900000).status()).owner) : null
314
+ const telegram = currentOwner
315
+ ? owned.filter(run => run.application === undefined && run.delivery === undefined && run.taskId === undefined && run.external === undefined && run.scheduled === undefined && run.telegramUserId === currentOwner.telegramUserId && run.ownerId === ownerId(currentOwner) && run.ownerEpoch === ownerEpoch(currentOwner) && (run.telegramApplication === undefined || run.telegramApplication.bindingId === binding.bindingId) && !all.some(item => item.id === run.id))
316
+ : []
317
+ const visible = [...all, ...telegram]
275
318
  const before = url.searchParams.get('before')
276
- const end = before ? all.findIndex(run => run.id === before) : all.length
319
+ const end = before ? visible.findIndex(run => run.id === before) : visible.length
277
320
  if (end < 0) throw new Error('Invalid application inbox cursor')
278
- const records = all.slice(Math.max(0,end-100),end)
279
- send(200, {runs: await Promise.all(records.map(run => this.snapshot(binding.bindingId, run.id))), nextCursor:end>100?records[0].id:null}); return
321
+ const records = visible.slice(Math.max(0,end-100),end)
322
+ send(200, {runs: await Promise.all(records.map(run => (run.application ?? run.delivery)
323
+ ? this.snapshot(binding.bindingId, run.id)
324
+ : Promise.resolve({id: run.id, scope: 'telegram', status: run.status, texts: run.texts, telegramUserId: run.telegramUserId, createdAt: run.createdAt}))), nextCursor:end>100?records[0].id:null}); return
280
325
  }
281
326
  if (path==='/v1/scope-control' && ['GET','POST'].includes(request.method ?? '')) {
282
327
  const scope = url.searchParams.get('scope')
@@ -314,6 +359,32 @@ export class ApplicationChannel {
314
359
  if(!await new Tasks(this.options.controlDir).decide(approvalMatch[1]))throw new Error('Unknown application approval')
315
360
  send(200,{id:approvalMatch[1],decision:input.decision});return
316
361
  }
362
+ const attachMatch = path.match(/^\/v1\/runs\/([A-Za-z0-9_-]{1,160})\/application$/)
363
+ if (attachMatch && request.method === 'POST') {
364
+ if (!binding.shareTelegram) { send(403, { error: 'Application authority does not permit Telegram admission' }); return }
365
+ const chunks: Buffer[] = []; let size = 0
366
+ for await (const chunk of request) { size += chunk.length; if (size > 65536) throw new Error('Invalid application admission size'); chunks.push(chunk) }
367
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { context?: unknown; scope?: unknown }
368
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => key !== 'context' && key !== 'scope')) throw new Error('Invalid application admission')
369
+ const scope = input.scope === undefined ? 'telegram' : input.scope
370
+ if (typeof scope !== 'string') throw new Error('Invalid application admission')
371
+ const target = await this.runs.get(attachMatch[1])
372
+ if (target?.telegramApplication?.bindingId === binding.bindingId) {
373
+ const same = target.telegramApplication.scope === scope &&
374
+ JSON.stringify(target.telegramApplication.context ?? null) === JSON.stringify(input.context ?? null)
375
+ if (same) { send(200, { id: target.id, scope: target.telegramApplication.scope, status: target.status }); return }
376
+ throw new Error('Application admission conflicts with prior context')
377
+ }
378
+ if (!target || target.application || target.telegramApplication || target.taskId || target.external || target.scheduled || target.telegramUserId === undefined) throw new Error('Unknown application run')
379
+ const state = await new ControlStore(this.options.controlDir, 900000).status()
380
+ const linked = telegramOwner(state.owner)
381
+ if (!linked || target.telegramUserId !== linked.telegramUserId || !sameOwner(binding.owner, state.owner)) throw new Error('Application authority does not permit Telegram admission')
382
+ if (target.status !== 'queued' && target.status !== 'running') throw new Error('Application admission conflicts with finished run')
383
+ const application = { bindingId: binding.bindingId, scope, requestId: target.id, context: input.context as Record<string, unknown> }
384
+ if (!validApplicationOrigin(application)) throw new Error('Invalid application admission')
385
+ const admitted = await this.runs.attachTelegramApplication(target.id, application)
386
+ send(200, { id: admitted.id, scope, status: admitted.status }); return
387
+ }
317
388
  const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
318
389
  if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
319
390
  await this.snapshot(binding.bindingId, match[1])
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from 'node:util'
2
2
  import { readFile } from 'node:fs/promises'
3
- import { loadControlConfig } from './config.js'
3
+ import { loadControlConfig, telegramToken } from './config.js'
4
4
  import { ControlStore, sameOwner, ownerId } from './control-state.js'
5
5
  import { ApplicationBindings, applicationScope, validateApplicationRegistration } from './application-channel.js'
6
6
  import { initialPreset } from './ai.js'
@@ -17,7 +17,7 @@ async function main() {
17
17
  const config = loadControlConfig(), control = new ControlStore(config.controlDir, config.pairingTtlMs)
18
18
  if (values.owner && values['owner-id']) throw new Error('Choose one owner registration form')
19
19
  if (values['owner-id'] && (!values.id || !values['token-file'] || values.revoke || values.list || values['import-scope'])) throw new Error('--owner-id requires channel registration')
20
- if (values.owner && (process.env.EZ_TELEGRAM_ENABLED !== 'false' || !values.id || !values['token-file'] || values.revoke || values.list || values['import-scope'])) throw new Error('--owner bootstrap requires explicit application-only registration')
20
+ if (values.owner && (telegramToken(process.env) || !values.id || !values['token-file'] || values.revoke || values.list || values['import-scope'])) throw new Error('--owner bootstrap requires an agent without a Telegram bot')
21
21
  const registrationToken = values['token-file'] ? (await readFile(values['token-file'], 'utf8')).trim() : null
22
22
  if (values.owner || values['owner-id']) validateApplicationRegistration(values.id!, registrationToken)
23
23
  const owner = values['owner-id'] ? await control.registerOwner(values['owner-id']) : values.owner ? await control.bootstrapApplicationOwner(Number(values.owner)) : (await control.status()).owner
@@ -1,6 +1,5 @@
1
1
  import { loadControlConfig } from './config.js'
2
- import { ApprovalStore } from './approval.js'
3
- import { RunStore } from './runs.js'
2
+ import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
4
3
 
5
4
  const args = process.argv.slice(2).filter((arg) => arg !== '--')
6
5
  if (args.includes('--help') || args.includes('-h')) {
@@ -27,10 +26,12 @@ for (let i = 0; i < args.length; i++) {
27
26
  }
28
27
 
29
28
  const config = loadControlConfig()
30
- const approvalStore = new ApprovalStore(config.controlDir)
29
+ const socketPath = socketPathFor(config.controlDir)
31
30
 
32
31
  if (checkId) {
33
- const record = await approvalStore.getDecision(checkId)
32
+ const record = await callDeliverySocket(socketPath, { op: 'approvalCheck', payload: { actionId: checkId } }) as {
33
+ actionId: string; decision: string; decidedAt?: string; decidedBy?: number
34
+ }
34
35
  console.log(JSON.stringify({
35
36
  ok: true,
36
37
  actionId: checkId,
@@ -53,7 +54,13 @@ if (!runId) {
53
54
  process.exit(1)
54
55
  }
55
56
 
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 })
57
+ // The relay records the approval request and delivers its card; the decision
58
+ // arrives later through Telegram and is read back with --check.
59
+ const item = await callDeliverySocket(socketPath, {
60
+ op: 'enqueue',
61
+ payload: {
62
+ kind: 'approval', runId, approvalPrompt: prompt, approvalActionId: actionId,
63
+ ...(replyTo !== undefined ? { replyToMessageId: replyTo } : {}),
64
+ },
65
+ }) as { outbox_id: string; id: string }
59
66
  console.log(JSON.stringify({ ok: true, run: runId, actionId, outbox_id: item.id, status: 'requested' }))
package/src/approval.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
- import path from 'node:path'
3
1
  import { assertId } from './identity.js'
4
2
  import { ownerEpoch, ownerId, type Owner } from './control-state.js'
5
3
 
@@ -34,28 +32,32 @@ const isApprovalRecord = (value: unknown): value is ApprovalRecord => {
34
32
  )
35
33
  }
36
34
 
37
- export class ApprovalStore {
38
- private readonly approvalsDir: string
35
+ // Stateless pipe: approvals live in relay memory. Engine children request
36
+ // decisions through the delivery socket (folded into the approval send);
37
+ // callbacks and task arbitration record them in-relay. A restart drops
38
+ // pending approvals by design.
39
+ const approvalsByControl = new Map<string, Map<string, ApprovalRecord>>()
39
40
 
40
- constructor(controlDir: string) {
41
- this.approvalsDir = path.join(controlDir, 'approvals')
42
- }
41
+ const approvalsFor = (controlDir: string): Map<string, ApprovalRecord> => {
42
+ let map = approvalsByControl.get(controlDir)
43
+ if (!map) { map = new Map(); approvalsByControl.set(controlDir, map) }
44
+ return map
45
+ }
43
46
 
44
- private async ensure(): Promise<void> {
45
- await mkdir(this.approvalsDir, { recursive: true, mode: 0o700 })
46
- }
47
+ export class ApprovalStore {
48
+ constructor(private readonly controlDir: string) {}
47
49
 
48
- private filePath(actionId: string): string {
50
+ private key(actionId: string): string {
49
51
  const safe = assertId(actionId)
50
52
  if (safe.length > 40) throw new Error('Approval identifier must be at most 40 characters')
51
- return path.join(this.approvalsDir, `${safe}.json`)
53
+ return safe
52
54
  }
53
55
 
54
56
  async requestApproval(actionId: string, prompt: string, runId?: string): Promise<ApprovalRecord> {
55
57
  if (!prompt.trim()) throw new Error('Approval prompt is empty')
56
58
  if (runId) assertId(runId)
57
- await this.ensure()
58
- const existing = await this.getDecision(actionId)
59
+ const store = approvalsFor(this.controlDir)
60
+ const existing = store.get(this.key(actionId)) ?? null
59
61
  if (existing) {
60
62
  if (existing.runId !== runId || existing.prompt !== prompt)
61
63
  throw new Error('Approval ID already belongs to another request')
@@ -70,10 +72,7 @@ export class ApprovalStore {
70
72
  decision: 'pending',
71
73
  createdAt: new Date().toISOString(),
72
74
  }
73
- const file = this.filePath(actionId)
74
- const temporary = `${file}.${process.pid}.tmp`
75
- await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
76
- await rename(temporary, file)
75
+ store.set(this.key(actionId), record)
77
76
  return record
78
77
  }
79
78
 
@@ -83,8 +82,8 @@ export class ApprovalStore {
83
82
  decidedBy: number,
84
83
  decisionUpdateId?: number,
85
84
  ): Promise<ApprovalRecord> {
86
- await this.ensure()
87
- const existing = await this.getDecision(actionId)
85
+ const store = approvalsFor(this.controlDir)
86
+ const existing = store.get(this.key(actionId)) ?? null
88
87
  if (!existing) throw new Error('Unknown approval request')
89
88
  if (
90
89
  decisionUpdateId !== undefined &&
@@ -102,34 +101,25 @@ export class ApprovalStore {
102
101
  decidedBy,
103
102
  decisionUpdateId,
104
103
  }
105
- const file = this.filePath(actionId)
106
- const temporary = `${file}.${process.pid}.tmp`
107
- await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
108
- await rename(temporary, file)
104
+ store.set(this.key(actionId), record)
109
105
  return record
110
106
  }
111
107
 
112
108
  async recordOwnerDecision(actionId: string, decision: 'approved' | 'denied', owner: Owner): Promise<ApprovalRecord> {
113
- await this.ensure()
114
- const existing = await this.getDecision(actionId)
109
+ const store = approvalsFor(this.controlDir)
110
+ const existing = store.get(this.key(actionId)) ?? null
115
111
  if (!existing) throw new Error('Unknown approval request')
116
112
  if (existing.decision !== 'pending') throw new Error('Approval already decided')
117
113
  if (Date.now() - Date.parse(existing.createdAt) > 900_000) throw new Error('Approval expired')
118
114
  const record: ApprovalRecord = {...existing,version:2,decision,decidedAt:new Date().toISOString(),decidedOwnerId:ownerId(owner),decidedOwnerEpoch:ownerEpoch(owner)}
119
- const file=this.filePath(actionId),temporary=`${file}.${process.pid}.tmp`
120
- await writeFile(temporary,`${JSON.stringify(record,null,2)}\n`,{mode:0o600});await rename(temporary,file)
115
+ store.set(this.key(actionId), record)
121
116
  return record
122
117
  }
123
118
 
124
119
  async getDecision(actionId: string): Promise<ApprovalRecord | null> {
125
- try {
126
- const parsed: unknown = JSON.parse(await readFile(this.filePath(actionId), 'utf8'))
127
- if (!isApprovalRecord(parsed) || parsed.actionId !== actionId)
128
- throw new Error('Invalid approval record shape')
129
- return parsed
130
- } catch (error: unknown) {
131
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
132
- throw error
133
- }
120
+ const record = approvalsFor(this.controlDir).get(this.key(actionId)) ?? null
121
+ if (record && (!isApprovalRecord(record) || record.actionId !== actionId))
122
+ throw new Error('Invalid approval record shape')
123
+ return record
134
124
  }
135
125
  }
@@ -1,7 +1,6 @@
1
- import { readFile } from 'node:fs/promises'
2
1
  import type { Config } from './config.js'
3
2
  import type { RunRecord } from './runs.js'
4
- import { workspaceFile } from './files.js'
3
+ import { readStagedAttachment } from './files.js'
5
4
 
6
5
  // Application-owned jobs; no CLI state, provider credentials or business routing here.
7
6
  export async function dispatchChannel(config: Config, run: RunRecord): Promise<string | null> {
@@ -14,7 +13,7 @@ export async function dispatchChannel(config: Config, run: RunRecord): Promise<s
14
13
  for (const item of run.items ?? []) {
15
14
  let attachment
16
15
  if (item.attachment) {
17
- const bytes = await readFile(await workspaceFile(config.workspace, item.attachment.path))
16
+ const bytes = await readStagedAttachment([config.controlDir, config.workspace], item.attachment.path)
18
17
  if (bytes.length > 12 * 1024 * 1024) throw new Error('Channel attachment exceeds 12 MB')
19
18
  attachment = { type: item.attachment.type, data: bytes.toString('base64') }
20
19
  }
package/src/config.ts CHANGED
@@ -26,10 +26,6 @@ export type Config = ControlConfig & {
26
26
  applicationHost?: string
27
27
  geminiApiKey?: string
28
28
  openaiApiKey?: string
29
- pagerDutyRoutingKey?: string
30
- pagerDutyStocksHealthUrl?: string
31
- pagerDutyPollMs?: number
32
- pagerDutyFailureThreshold?: number
33
29
  }
34
30
 
35
31
  const positiveInteger = (value: string | undefined, name: string, fallback?: number): number => {
@@ -49,12 +45,20 @@ export const loadControlConfig = (env: NodeJS.ProcessEnv = process.env): Control
49
45
  }
50
46
  }
51
47
 
48
+ export const telegramToken = (env: NodeJS.ProcessEnv = process.env): string => {
49
+ const token = env.TELEGRAM_BOT_TOKEN?.trim() || ''
50
+ if (token && !/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) throw new Error('TELEGRAM_BOT_TOKEN is malformed; refusing to start Telegram transport')
51
+ return token
52
+ }
53
+
52
54
  export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
53
- if (env.EZ_TELEGRAM_ENABLED !== undefined && !['true', 'false'].includes(env.EZ_TELEGRAM_ENABLED)) throw new Error('EZ_TELEGRAM_ENABLED must be true or false')
54
- const telegramEnabled = env.EZ_TELEGRAM_ENABLED !== 'false'
55
- const telegramBotToken = telegramEnabled ? env.TELEGRAM_BOT_TOKEN?.trim() || '' : ''
55
+ const legacy = env.EZ_TELEGRAM_ENABLED
56
+ if (legacy !== undefined && legacy !== 'true' && legacy !== 'false') throw new Error('EZ_TELEGRAM_ENABLED must be true or false')
57
+ const telegramBotToken = telegramToken(env)
58
+ const telegramEnabled = Boolean(telegramBotToken)
59
+ if (legacy !== undefined && (legacy === 'false') === telegramEnabled)
60
+ throw new Error('EZ_TELEGRAM_ENABLED was removed; it contradicts the inferred Telegram transport (a token means enabled). Unset EZ_TELEGRAM_ENABLED, and remove any stale TELEGRAM_BOT_TOKEN to stay application-only.')
56
61
  if (!telegramEnabled && !env.EZ_APPLICATION_PORT) throw new Error('Application-only execution requires EZ_APPLICATION_PORT')
57
- if (telegramEnabled && !telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
58
62
 
59
63
  if (env.EZ_CHANNEL_BACKEND_URL && !env.EZ_CHANNEL_BACKEND_TOKEN?.trim()) throw new Error('EZ_CHANNEL_BACKEND_TOKEN is required')
60
64
  if (env.EZ_APPLICATION_PORT && env.EZ_CHANNEL_BACKEND_URL) throw new Error('Application input requires the native Ez executor, not a channel backend')
@@ -63,17 +67,6 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
63
67
  const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
64
68
  if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
65
69
  if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || transport !== 'local')) throw new Error('External Codex sandbox requires native local execution')
66
- const pagerDutyRoutingKey = env.PAGERDUTY_ROUTING_KEY?.trim()
67
- const pagerDutyStocksHealthUrl = env.EZ_PAGERDUTY_STOCKS_HEALTH_URL?.trim()
68
- if (pagerDutyStocksHealthUrl && !pagerDutyRoutingKey)
69
- throw new Error('PAGERDUTY_ROUTING_KEY is required when EZ_PAGERDUTY_STOCKS_HEALTH_URL is set')
70
- if (pagerDutyStocksHealthUrl) {
71
- let url: URL
72
- try { url = new URL(pagerDutyStocksHealthUrl) }
73
- catch { throw new Error('EZ_PAGERDUTY_STOCKS_HEALTH_URL must be an absolute HTTP(S) URL') }
74
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash)
75
- throw new Error('EZ_PAGERDUTY_STOCKS_HEALTH_URL must be an absolute HTTP(S) URL without credentials or a fragment')
76
- }
77
70
  return {
78
71
  ...loadControlConfig(env),
79
72
  telegramEnabled,
@@ -92,13 +85,5 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
92
85
  channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
93
86
  geminiApiKey: env.GEMINI_API_KEY?.trim(),
94
87
  openaiApiKey: env.OPENAI_API_KEY?.trim(),
95
- pagerDutyRoutingKey,
96
- pagerDutyStocksHealthUrl,
97
- pagerDutyPollMs: pagerDutyRoutingKey && pagerDutyStocksHealthUrl
98
- ? positiveInteger(env.EZ_PAGERDUTY_POLL_SECONDS, 'EZ_PAGERDUTY_POLL_SECONDS', 30) * 1_000
99
- : undefined,
100
- pagerDutyFailureThreshold: pagerDutyRoutingKey && pagerDutyStocksHealthUrl
101
- ? positiveInteger(env.EZ_PAGERDUTY_FAILURE_THRESHOLD, 'EZ_PAGERDUTY_FAILURE_THRESHOLD', 3)
102
- : undefined,
103
88
  }
104
89
  }