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

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 (137) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +67 -0
  5. package/Dockerfile +7 -9
  6. package/README.md +10 -13
  7. package/bin/ez +97 -0
  8. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  9. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  10. package/bin/ezenciel-agents.mjs +1 -1
  11. package/compose.yaml +40 -4
  12. package/docker/entrypoint.sh +1 -1
  13. package/docker/healthcheck.mjs +12 -4
  14. package/docker/run.ts +5 -6
  15. package/docker/smoke.mjs +28 -6
  16. package/docs/application-channel.md +32 -10
  17. package/docs/architecture/authority-boundaries.md +2 -2
  18. package/docs/channel-backend.md +4 -3
  19. package/docs/docker-runtime.md +79 -11
  20. package/docs/local-qa.md +2 -2
  21. package/docs/plugin-guide.md +202 -0
  22. package/docs/plugins.md +48 -2
  23. package/docs/releasing.md +13 -0
  24. package/docs/responsive-channels.md +1 -1
  25. package/docs/scheduling.md +4 -4
  26. package/docs/setup.md +11 -5
  27. package/docs/upgrades.md +46 -23
  28. package/package.json +5 -5
  29. package/scripts/smoke-scheduler.ts +10 -3
  30. package/scripts/smoke.ts +14 -11
  31. package/src/agent-guidance.ts +6 -9
  32. package/src/agent-install.ts +28 -7
  33. package/src/application-channel.ts +78 -7
  34. package/src/application-cli.ts +2 -2
  35. package/src/approval-cli.ts +14 -7
  36. package/src/approval.ts +27 -37
  37. package/src/channel-backend.ts +2 -3
  38. package/src/config.ts +12 -27
  39. package/src/control-state.ts +175 -8
  40. package/src/conversation-menu.ts +6 -17
  41. package/src/delivery-socket-client.d.mts +4 -0
  42. package/src/delivery-socket-client.mjs +104 -0
  43. package/src/delivery-socket.ts +354 -0
  44. package/src/execution-authority.ts +21 -5
  45. package/src/executor.ts +20 -23
  46. package/src/files.ts +38 -11
  47. package/src/host-executor.ts +28 -14
  48. package/src/inbox.ts +15 -49
  49. package/src/index.ts +95 -52
  50. package/src/install-tools.mjs +97 -22
  51. package/src/ledger-endpoint.d.mts +2 -0
  52. package/src/ledger-endpoint.mjs +17 -0
  53. package/src/menu.ts +7 -2
  54. package/src/message-history.ts +13 -18
  55. package/src/message.ts +35 -18
  56. package/src/plugin-broker.mjs +396 -0
  57. package/src/plugins/connection.mjs +2 -2
  58. package/src/plugins/manager.mjs +132 -23
  59. package/src/plugins/native-tasks.mjs +2 -2
  60. package/src/prompt-suffix.ts +12 -0
  61. package/src/react.ts +6 -4
  62. package/src/runs.ts +156 -243
  63. package/src/schedule-cli.ts +29 -10
  64. package/src/scheduled-tasks.ts +53 -10
  65. package/src/scheduler.ts +20 -7
  66. package/src/service.ts +3 -3
  67. package/src/software-status.ts +36 -4
  68. package/src/task-executor.ts +2 -2
  69. package/src/task-workspace.ts +4 -3
  70. package/src/tasks.ts +1 -1
  71. package/src/telegram-provisioning-cli.ts +22 -0
  72. package/src/telegram-provisioning.ts +214 -0
  73. package/src/updates/control.mjs +77 -7
  74. package/src/updates/runtime.mjs +28 -8
  75. package/src/updates/status.mjs +25 -3
  76. package/src/updates/supervisor.mjs +15 -16
  77. package/src/version.ts +20 -0
  78. package/src/workspace.ts +3 -9
  79. package/templates/failure-review.md +1 -1
  80. package/templates/maintainer-purpose.md +1 -1
  81. package/test/agent-guidance.test.ts +14 -80
  82. package/test/agent-install.test.ts +14 -0
  83. package/test/ai.test.ts +36 -0
  84. package/test/application-channel.test.ts +61 -13
  85. package/test/application-only.test.ts +14 -13
  86. package/test/channel-backend.test.ts +3 -7
  87. package/test/channel-delivery.test.ts +4 -1
  88. package/test/channel-owner.test.ts +6 -3
  89. package/test/codex-context.test.ts +2 -2
  90. package/test/config.test.ts +38 -43
  91. package/test/control-state.test.ts +83 -4
  92. package/test/conversation-menu.test.ts +2 -4
  93. package/test/delivery-transport.test.ts +72 -0
  94. package/test/docker-runtime.test.ts +27 -14
  95. package/test/engine-handoff.test.ts +7 -6
  96. package/test/event-sources.test.ts +2 -2
  97. package/test/execution-authority.test.ts +9 -9
  98. package/test/executor.test.ts +10 -11
  99. package/test/failure.test.ts +12 -5
  100. package/test/files.test.ts +2 -2
  101. package/test/helpers/ledger.ts +18 -0
  102. package/test/host-executor.test.ts +74 -0
  103. package/test/inbox.test.ts +8 -10
  104. package/test/install-config.test.ts +1 -0
  105. package/test/install-tools.test.mjs +74 -7
  106. package/test/intake-relay.test.ts +58 -11
  107. package/test/message-history.test.ts +39 -47
  108. package/test/native-tasks.test.ts +1 -1
  109. package/test/plugin-broker.test.mjs +172 -0
  110. package/test/plugin-manager.test.mjs +119 -2
  111. package/test/reaction.test.ts +4 -0
  112. package/test/relay.test.ts +5 -7
  113. package/test/repair-policy.test.ts +1 -4
  114. package/test/schedule-cli.test.ts +14 -9
  115. package/test/scheduled-tasks.test.ts +29 -6
  116. package/test/scheduler-host.test.ts +5 -5
  117. package/test/scheduler-relay.test.ts +15 -9
  118. package/test/security.test.ts +6 -3
  119. package/test/software-status.test.ts +21 -3
  120. package/test/telegram-provisioning.test.ts +160 -0
  121. package/test/updates.test.mjs +93 -13
  122. package/test/workspace.test.ts +4 -1
  123. package/bin/ezenciel-agents-watch.mjs +0 -8
  124. package/compose.workforce-watch.yaml +0 -33
  125. package/docker/recovery.ts +0 -11
  126. package/docs/pagerduty.md +0 -42
  127. package/docs/workforce-watch.md +0 -101
  128. package/src/pagerduty.ts +0 -109
  129. package/src/reply-context.ts +0 -43
  130. package/src/update-attention.ts +0 -18
  131. package/src/workforce-watch-cli.ts +0 -14
  132. package/src/workforce-watch.ts +0 -155
  133. package/templates/agent/AGENTS.md +0 -5
  134. package/templates/agent-guidance.md +0 -17
  135. package/test/pagerduty.test.ts +0 -104
  136. package/test/update-attention.test.ts +0 -20
  137. package/test/workforce-watch.test.ts +0 -180
package/docs/upgrades.md CHANGED
@@ -16,9 +16,9 @@ The agent owns release review, policy decisions and communication. The host
16
16
  supervisor owns interruption-safe replacement. There is one host service per
17
17
  agent, not a second agent or an additional updater daemon. Its replaceable child
18
18
  runs the normal CLI transport. It checks npm every six hours while running and
19
- queues an owner-bound maintenance turn only when an automatic channel changes.
20
- No owner means no maintenance executor. Normal user work and maintenance share
21
- one serial queue. Checks use the installed scoped npm identity; failures are
19
+ records automatic-channel changes; discovery never wakes the agent or injects
20
+ wrapper state into a session. Updates stay owner-invoked through `ez updates`.
21
+ Checks use the installed scoped npm identity; failures are
22
22
  visible in `updates check` and private `tools/updates/available.json`. Plugins marked
23
23
  `private: true` in their package metadata are reported as local-source updates
24
24
  only, without querying public npm; this does not mean they are up to date.
@@ -144,7 +144,7 @@ rollback is a recovery operation for the previous installation only.
144
144
  queuing it.** It must not wait for completion in that same turn. The supervisor
145
145
  pauses new run admission, drains already-started work, then consumes the job.
146
146
  One job at a time; an unresolved recovery blocks new upgrades. The agent processes
147
- multiple requested packages over successive maintenance turns.
147
+ multiple requested packages over successive update requests.
148
148
 
149
149
  The exact archive is reverified and re-extracted before execution; editing a
150
150
  prepared tree cannot substitute code. Main dependencies install with the frozen
@@ -164,24 +164,43 @@ running. Failed activation restores the previous code/configuration and checks i
164
164
  If recovery itself fails, inspect and fix the reported infrastructure problem,
165
165
  then use `ez updates recover <job-id>` and finish the turn. This retries only the
166
166
  saved code/configuration recovery, not provider operations.
167
- Completion or failure wakes the agent to inspect the receipt and report naturally.
167
+ An update never wakes the agent or injects wrapper state into its session; the
168
+ owner or agent inspects receipts with `ez updates` when asked.
168
169
 
169
170
  Backups contain credentials and must stay private. Main backups cover the
170
171
  canonical mind/control and deployment files. Plugins back up existing named
171
172
  volumes while their services are stopped, using the previous image's `tar` and
172
173
  read-only volume mounts; archive output is written by the host with mode 0600.
173
- No backup deletes live data or revokes credentials. Retain old package roots and
174
- images until QA and any recovery window are complete; there is no automatic GC.
174
+ No backup deletes live data or revokes credentials. After a successful update, the
175
+ supervisor retains the newest completed backup per target and removes backup
176
+ directories from older terminal receipts (`completed`, `failed` or
177
+ `rolled-back`). Queued, applying and recovery-required jobs are never pruned. Old
178
+ package roots and images remain available until they are no longer needed for the
179
+ active or immediate rollback path; backup cleanup does not remove them. A cleanup
180
+ failure is recorded on the completed receipt and does not turn a successful
181
+ update into a rollback. The supervisor repeats this reconciliation at startup so
182
+ a release restart also cleans backups left by a crash window or by the release
183
+ that installed the policy.
175
184
 
176
185
  The updater accepts only matching state-schema and protocol contracts and an
177
- unchanged deployment layout. Plugins may add command routes to existing services;
178
- existing routes and all services, privileges, volumes and other deployment
179
- configuration must remain unchanged. Incompatible changes fail before replacement,
180
- even for an explicit candidate, and need a separately reviewed migration rather
181
- than an override flag. Release authors must truthfully declare schema compatibility.
182
- Code rollback **does not rewind private
183
- state**, provider cursors or operation receipts; uncertain actions are never
184
- replayed. Docker health is not proof of live provider identity or delivery.
186
+ unchanged effective deployment layout. The reviewed beta.34 runtime migration
187
+ allows the optional empty `EZ_ISOLATION` passthrough and a changed purpose-file
188
+ fallback when the deployment already binds `EZ_AGENT_PURPOSE_FILE`; the job
189
+ receipt records that migration. Plugins may add command routes to existing
190
+ services; existing routes and all services, privileges, volumes and other
191
+ deployment configuration must remain unchanged. Incompatible changes fail
192
+ before replacement, even for an explicit candidate, and need a separately
193
+ reviewed migration rather than an override flag. Release authors must truthfully
194
+ declare schema compatibility. Code rollback **does not rewind private state**,
195
+ provider cursors or operation receipts; uncertain actions are never replayed.
196
+ Docker health is not proof of live provider identity or delivery.
197
+
198
+ The updater also records the exact Library beta14 command migration: it removes
199
+ the retired `library-document` route and changes `library-query` from
200
+ `query-context` to `search`, while requiring every other manifest and deployment
201
+ field to remain unchanged. A completed main upgrade exits the active supervisor
202
+ after publishing its receipt; the retained bootstrap is then restarted by the
203
+ host service and loads the new package root.
185
204
 
186
205
  ## Contributor acceptance
187
206
 
@@ -214,12 +233,16 @@ policy selects non-deprecated stable versions, even when latest is a prerelease.
214
233
  No eligible version is reported as available:null with newer:false. Older installed
215
234
  updaters need an exact-version core update to adopt this discovery behavior.
216
235
 
217
- ## Shared agent guidance
236
+ ## Operating-agent instructions
218
237
 
219
- Setup and runtime startup install `templates/agent-guidance.md` into a marked
220
- section of the workspace's native `AGENTS.md` (and existing `AGENTS.override.md`).
221
- Upgrade refreshes only that section; personal content outside it is preserved
222
- byte-for-byte. New scheduled task workspaces receive the same shared guidance.
238
+ Setup and the main upgrade step strip any obsolete `<!-- ez shared guidance -->`
239
+ handbook from workspace `AGENTS.md` (and existing `AGENTS.override.md`).
240
+ Personal text outside that block is preserved byte-for-byte. Do not reinsert
241
+ coding, KISS, contribution or repair loops into operating minds.
242
+
243
+ The package-owned locator is the managed `<!-- ez tools -->` block: the bound
244
+ `ez` launcher, `ez --help`, and `ez tools list --details`. Inventory is
245
+ generated on read. Plugin registry init and update binding refresh that locator.
223
246
  Malformed markers and symlinks fail visibly rather than overwriting personal work.
224
247
 
225
248
  Codex, agy, Grok and OpenCode discover workspace instructions natively. Claude
@@ -236,9 +259,9 @@ preserved. Restricted contact tasks retain their separate
236
259
  bounded instruction scopes. For delivered busy replies absent from the native
237
260
  conversation, the owner engine can call `ezenciel-agents-schedule context`.
238
261
 
239
- Keep general defaults in the shipped shared file and identity/preferences in
240
- workspace personal files. Instructions cannot grant permissions; explicit owner
241
- requests take precedence within existing execution authority.
262
+ Keep identity and purpose in the agent's own `AGENTS.md`. Instructions cannot
263
+ grant permissions; explicit owner requests take precedence within existing
264
+ execution authority.
242
265
 
243
266
  Update discovery runs independently of active host work. A discovery failure is
244
267
  logged locally and retried at the next regular six-hour check; it does not stop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.34",
3
+ "version": "0.1.0-beta.36",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
@@ -21,8 +21,9 @@
21
21
  "ezenciel-agents-host": "bin/ezenciel-agents-host",
22
22
  "ezenciel-agents-tools": "bin/ezenciel-agents-tools.mjs",
23
23
  "ezenciel-agents-ai": "bin/ezenciel-agents-ai.mjs",
24
- "ezenciel-agents-watch": "bin/ezenciel-agents-watch.mjs",
25
- "ezenciel-agents-application": "bin/ezenciel-agents-application.mjs"
24
+ "ezenciel-agents-application": "bin/ezenciel-agents-application.mjs",
25
+ "ezenciel-agents-provision-telegram": "bin/ezenciel-agents-provision-telegram.mjs",
26
+ "ezenciel-agents-plugin-broker": "bin/ezenciel-agents-plugin-broker.mjs"
26
27
  },
27
28
  "files": [
28
29
  "default-plugins.json",
@@ -37,7 +38,6 @@
37
38
  "CONTRIBUTING.md",
38
39
  "Dockerfile",
39
40
  "compose.yaml",
40
- "compose.workforce-watch.yaml",
41
41
  "docker",
42
42
  "compose.whatsapp.yaml",
43
43
  "scripts/smoke.ts",
@@ -61,7 +61,7 @@
61
61
  "scripts": {
62
62
  "setup": "tsx --env-file-if-exists=.env src/setup.ts",
63
63
  "dev": "EZ_DEVELOPMENT=1 tsx watch --clear-screen=false --exclude 'agent/**' --exclude 'test/**' --env-file=.env src/index.ts",
64
- "start": "./bin/ezenciel-agents-docker up -d --wait relay",
64
+ "start": "./bin/ezenciel-agents-docker up -d --wait",
65
65
  "owner": "tsx --env-file=.env src/owner.ts",
66
66
  "message": "tsx src/message.ts",
67
67
  "react": "tsx src/react.ts",
@@ -7,6 +7,8 @@ import { createRelay } from '../src/index.js'
7
7
  import { ControlStore } from '../src/control-state.js'
8
8
  import { Scheduler } from '../src/scheduler.js'
9
9
  import { RunStore } from '../src/runs.js'
10
+ import { serveDeliverySocket, createLedgerHandler } from '../src/delivery-socket.js'
11
+ import { packageVersion } from '../src/version.js'
10
12
  import { initialPreset } from '../src/ai.js'
11
13
  import { initializeWorkspace } from '../src/workspace.js'
12
14
  import type { Update } from 'grammy/types'
@@ -22,6 +24,11 @@ await control.requestPairing(101,101);await control.approveOwner(101)
22
24
  const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset(cli))
23
25
  const replies:{at:number;text:string}[]=[]
24
26
  const relay=createRelay({workspace,controlDir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:cli,telegramBotToken:'fixture'})
27
+ // Engine children reach this process's memory ledger through the socket.
28
+ const ledger=await serveDeliverySocket(controlDir,createLedgerHandler(controlDir,{
29
+ wake:()=>{void relay.drainOutbox()},
30
+ status:()=>({polling:false,applicationOnly:!relay.telegramEnabled,telegramConfigured:true,version:packageVersion}),
31
+ }))
25
32
  relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as typeof relay.bot.botInfo
26
33
  relay.bot.api.config.use(async(_previous,method,payload)=>{
27
34
  if(method==='sendMessage') {const reply={at:Date.now(),text:(payload as {text:string}).text};replies.push(reply);console.log(JSON.stringify({reply}))}
@@ -59,11 +66,11 @@ try{
59
66
  await relay.drainOutbox()
60
67
  const status=(await runs.get(background.id))?.status
61
68
  if(status!=='completed' || replies.filter(r=>r.text.includes('BACKGROUND_DONE')).length!==1)throw new Error('Missing completed run or exactly one delivered result')
62
- const finished=await readFile(join(workspace,'work/tasks',background.id,'finished.txt'),'utf8')
69
+ const finished=await readFile(join(controlDir,'work/tasks',background.id,'finished.txt'),'utf8')
63
70
  if(finished.trim()!=='DONE')throw new Error('Missing finished.txt artifact')
64
71
  if(nativeGoal){
65
72
  for(const [file,expected] of [['phase1.txt','ONE'],['phase2.txt','TWO']])
66
- if((await readFile(join(workspace,'work/tasks',background.id,file),'utf8')).trim()!==expected)throw new Error(`Missing native goal artifact: ${file}`)
73
+ if((await readFile(join(controlDir,'work/tasks',background.id,file),'utf8')).trim()!==expected)throw new Error(`Missing native goal artifact: ${file}`)
67
74
  }
68
75
  const record=(await runs.get(background.id))!
69
76
  if(Date.parse(record.endedAt!)-Date.parse(record.startedAt!) < duration*1000)throw new Error('Worker completed before requested duration')
@@ -88,4 +95,4 @@ try{
88
95
  const evidence={duration,cli,nativeGoal,goalEvidence,status,replies,run:await runs.get(background.id)}
89
96
  await writeFile(join(root,'evidence.json'),JSON.stringify(evidence,null,2),{mode:0o600})
90
97
  console.log(JSON.stringify({passed:true,evidence:join(root,'evidence.json')}))
91
- }finally{clearInterval(tick);while(draining)await new Promise(r=>setTimeout(r,50));await relay.stop()}
98
+ }finally{clearInterval(tick);while(draining)await new Promise(r=>setTimeout(r,50));await relay.stop();await ledger.stop()}
package/scripts/smoke.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { dirname, join } from 'node:path'
2
- import { readdir, readFile } from 'node:fs/promises'
3
2
  import { fileURLToPath } from 'node:url'
4
3
  import { randomUUID } from 'node:crypto'
5
4
  import { loadConfig } from '../src/config.js'
6
5
  import { ControlStore } from '../src/control-state.js'
7
6
  import { startExecutorJob, terminateJob } from '../src/executor.js'
8
- import { RunStore } from '../src/runs.js'
7
+ import { RunStore, sentOutbox } from '../src/runs.js'
9
8
  import { createRelay } from '../src/index.js'
9
+ import { serveDeliverySocket, createLedgerHandler } from '../src/delivery-socket.js'
10
+ import { packageVersion } from '../src/version.js'
10
11
 
11
12
  // Outbound integration test, not proof of Telegram intake or UI interactions.
12
13
  // Run while the polling relay is stopped; the owner must already be paired.
@@ -39,6 +40,11 @@ async function main() {
39
40
  texts: [prompt],
40
41
  })
41
42
  await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
43
+ // The engine child reaches the smoke-process memory ledger through the socket.
44
+ const ledger = await serveDeliverySocket(config.controlDir, createLedgerHandler(config.controlDir, {
45
+ wake: () => { void relay.drainOutbox(run.id) },
46
+ status: () => ({ polling: false, applicationOnly: !relay.telegramEnabled, telegramConfigured: config.telegramBotToken !== '', version: packageVersion }),
47
+ }))
42
48
  const job = await startExecutorJob(run.texts, {
43
49
  workspace: config.workspace,
44
50
  timeoutMs: config.executorTimeoutMs,
@@ -72,18 +78,14 @@ async function main() {
72
78
  status: code === 0 ? 'completed' : 'failed',
73
79
  endedAt: new Date().toISOString(),
74
80
  })
75
- const names = (await readdir(join(config.controlDir, 'outbox'))).filter((name) =>
76
- name.startsWith(run.id + '_'),
77
- )
78
- const receipts = []
79
- for (const name of names.filter((name) => name.endsWith('.sent.json'))) {
80
- const item = JSON.parse(await readFile(join(config.controlDir, 'outbox', name), 'utf8'))
81
- if (item.receipt) receipts.push(item.receipt)
82
- }
81
+ const receipts = sentOutbox(config.controlDir)
82
+ .filter((item) => item.runId === run.id && item.receipt)
83
+ .map((item) => item.receipt)
84
+ const delivery = await runs.deliveryStatus()
83
85
  console.log(
84
86
  JSON.stringify({ runId: run.id, executor: config.executorCli, model: choice?.model, effort: choice?.effort, exitCode: code, receipts }, null, 2),
85
87
  )
86
- if (code !== 0 || !receipts.length || names.some((name) => !name.endsWith('.sent.json'))) {
88
+ if (code !== 0 || !receipts.length || (await runs.pendingOutbox()).length || delivery.failed || delivery.unknown) {
87
89
  throw new Error('Smoke failed: executor failure, missing receipt, or undelivered outbox item')
88
90
  }
89
91
  console.log('Outbound smoke passed. Telegram intake and button/media UI still require a live chat test.')
@@ -93,6 +95,7 @@ async function main() {
93
95
  process.removeListener('SIGTERM', stop)
94
96
  await job.cleanup()
95
97
  await relay.stop()
98
+ await ledger.stop()
96
99
  }
97
100
  }
98
101
 
@@ -1,17 +1,12 @@
1
1
  import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import path from 'node:path'
4
- import { readFileSync } from 'node:fs'
5
-
6
- // Resolve against the installed package, never the agent's editable workspace.
7
- export const agentGuidance = (): string =>
8
- readFileSync(new URL('../templates/agent-guidance.md', import.meta.url), 'utf8').trim()
9
4
 
10
5
  const start = '<!-- ez shared guidance: begin -->'
11
6
  const end = '<!-- ez shared guidance: end -->'
12
7
 
13
- // Native instruction installation, refreshed at setup/runtime upgrade, not per turn.
14
- // Personal instructions outside this one managed block remain byte-for-byte intact.
8
+ // Drop the obsolete package-owned handbook. Discovery lives in the managed
9
+ // ez tools locator. Personal text outside the markers stays byte-for-byte.
15
10
  export async function installAgentGuidance(workspace: string): Promise<void> {
16
11
  for (const name of ['AGENTS.md', 'AGENTS.override.md']) {
17
12
  const file = path.join(workspace, name)
@@ -23,11 +18,13 @@ export async function installAgentGuidance(workspace: string): Promise<void> {
23
18
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
24
19
  throw error
25
20
  }
26
- const block = `${start}\n${agentGuidance()}\n${end}\n\n`
27
21
  const from = original.indexOf(start), to = original.indexOf(end)
28
22
  if ((from < 0) !== (to < 0) || (from >= 0 && (to < from || original.indexOf(start, from + start.length) >= 0 || original.indexOf(end, to + end.length) >= 0)))
29
23
  throw new Error(`Malformed shared guidance block: ${file}`)
30
- const updated = from < 0 ? block + original : original.slice(0, from) + block.trimEnd() + original.slice(to + end.length)
24
+ if (from < 0) continue
25
+ let rest = original.slice(to + end.length)
26
+ if (from === 0 && rest.startsWith('\n')) rest = rest.slice(1)
27
+ const updated = original.slice(0, from) + rest
31
28
  if (updated === original) continue
32
29
  const temporary = `${file}.${randomUUID()}.tmp`
33
30
  try {
@@ -1,8 +1,11 @@
1
- import { mkdir, readFile, writeFile, rm, readdir } from 'node:fs/promises'
2
- import { isAbsolute, join, resolve, dirname } from 'node:path'
1
+ import { chmod, mkdir, readFile, writeFile, rm, readdir } from 'node:fs/promises'
2
+ import { createHash } from 'node:crypto'
3
+ import { isAbsolute, join, resolve, dirname, delimiter } from 'node:path'
3
4
  import { parseArgs } from 'node:util'
4
5
  import { executorKey } from './executor.js'
5
6
  import { isolationTransport, parseIsolationClass, type IsolationClass } from './isolation.js'
7
+ import { ledgerOverlayName, ledgerOverlayYaml } from './ledger-endpoint.mjs'
8
+ import { initializeWorkspace } from './workspace.js'
6
9
  import { fileURLToPath } from 'node:url'
7
10
  import { validateCodexProvider, type CodexProviderBinding } from './executor.js'
8
11
 
@@ -32,7 +35,7 @@ export const installationCli = async (root: string, installerCli?: string): Prom
32
35
  }
33
36
 
34
37
  export const createAgent = async (options: {
35
- root: string; hostRoot: string; composeFile: string; name: string; purpose: string; token: string; cli?: string; image?: string; isolation?: string; codexProvider?: CodexProviderBinding
38
+ root: string; hostRoot: string; composeFile: string; name: string; purpose: string; token: string; cli?: string; image?: string; isolation?: string; codexProvider?: CodexProviderBinding; ledgerPort?: number
36
39
  }) => {
37
40
  const { root, hostRoot, composeFile, name, purpose, token } = options
38
41
  const requestedIsolation = options.isolation?.trim()
@@ -49,6 +52,11 @@ export const createAgent = async (options: {
49
52
  const isolation: IsolationClass = requestedIsolation ? parseIsolationClass(requestedIsolation) : cli === 'codex' ? 'isolated' : 'host-capable'
50
53
  if (isolation === 'isolated' && cli !== 'codex') throw new Error(`Isolated execution is unavailable for ${cli}; use host-capable isolation`)
51
54
  const transport = isolationTransport(isolation)
55
+ const ledgerPort = isolation === 'host-capable'
56
+ ? options.ledgerPort ?? 20000 + (createHash('sha256').update(name).digest().readUInt16BE(0) % 10000)
57
+ : undefined
58
+ if (isolation === 'host-capable' && (!Number.isSafeInteger(ledgerPort) || ledgerPort! < 1024 || ledgerPort! > 65535))
59
+ throw new Error('Host-capable agents require a free loopback ledger port for host CLI access')
52
60
  const codexProvider = options.codexProvider ? validateCodexProvider(options.codexProvider) : undefined
53
61
  if (codexProvider && cli !== 'codex') throw new Error('A Codex provider requires the Codex installation CLI')
54
62
  const directory = join(root, name), deploymentDir = join(hostRoot, name)
@@ -56,28 +64,41 @@ export const createAgent = async (options: {
56
64
  await mkdir(directory, {mode:0o700})
57
65
  try {
58
66
  const project = `ez-agent-${name}`
67
+ const ledgerOverlay = join(deploymentDir, ledgerOverlayName)
59
68
  const values = {
60
69
  COMPOSE_PROJECT_NAME: project,
61
- COMPOSE_FILE: composeFile,
70
+ COMPOSE_FILE: [composeFile, ...(ledgerPort ? [ledgerOverlay] : [])].join(delimiter),
62
71
  EZ_RELAY_IMAGE: image,
63
72
  EZ_RELAY_ENV_FILE: join(deploymentDir, 'relay.env'),
64
73
  EZ_AGENT_PURPOSE_FILE: join(deploymentDir, 'purpose.md'),
65
74
  EZ_EXECUTOR_CLI: cli,
66
75
  EZ_ISOLATION: isolation,
67
76
  EZ_EXECUTOR_TRANSPORT: transport,
77
+ ...(ledgerPort ? { EZ_DELIVERY_TCP_PORT: String(ledgerPort) } : {}),
68
78
  ...(isolation === 'isolated' && cli === 'codex' ? { EZ_CODEX_SANDBOX: 'external' } : {}),
69
79
  EZ_AGENT_WORKSPACE: join(deploymentDir, 'mind'),
70
80
  EZ_CONTROL_DIR: join(deploymentDir, 'control'),
81
+ EZ_TOOLS_HOME: join(deploymentDir, 'tools'),
82
+ EZ_PLUGIN_BROKER_SOCKET: join(deploymentDir, 'control', 'plugin-broker.sock'),
83
+ EZ_PLUGIN_BROKER_HOST_CONFIG: join(deploymentDir, 'host-executor.json'),
84
+ ...(isolation === 'isolated' ? { COMPOSE_PROFILES: 'isolated' } : {}),
71
85
  EZ_WHATSAPP_IPC_VOLUME: `${project}-whatsapp-ipc`,
72
86
  EZ_WHATSAPP_CLIENT_VOLUME: `${project}-whatsapp-client`,
73
87
  }
74
88
  await writeFile(join(directory, 'docker.env'), Object.entries(values).map(([k,v]) => `${k}='${v}'\n`).join(''), {mode:0o600, flag:'wx'})
89
+ if (ledgerPort) await writeFile(join(directory, ledgerOverlayName), ledgerOverlayYaml(), {mode:0o600, flag:'wx'})
75
90
  await writeFile(join(directory, 'relay.env'), `TELEGRAM_BOT_TOKEN=${token}\n`, {mode:0o600, flag:'wx'})
76
91
  await writeFile(join(directory, 'purpose.md'), purpose.trim()+'\n', {mode:0o644, flag:'wx'})
77
92
  await mkdir(join(directory,'mind'),{mode:0o700})
93
+ // Install-time seed: the relay never writes the workspace at startup.
94
+ // Readable beyond the installer UID so a mismatched runtime UID still
95
+ // loads its own purpose; the 0700 deployment directory keeps it private.
96
+ const seeded = await initializeWorkspace(join(directory,'mind'), join(directory,'purpose.md'))
97
+ if (seeded.includes('AGENTS.md')) await chmod(join(directory,'mind','AGENTS.md'), 0o644)
78
98
  await mkdir(join(directory,'control'),{mode:0o700})
99
+ await mkdir(join(directory,'tools'),{mode:0o700})
79
100
  const agent = {name, project, deploymentDir, purpose:purpose.trim(), executor:cli, isolation}
80
- const host = {cli,isolation,agents:[{name,workspace:join(deploymentDir,'mind'),controlDir:join(deploymentDir,'control'),binDir:join(dirname(composeFile),'bin'),...(codexProvider?{codexProviders:[codexProvider]}:{})}]}
101
+ const host = {cli,isolation,agents:[{name,workspace:join(deploymentDir,'mind'),controlDir:join(deploymentDir,'control'),binDir:join(dirname(composeFile),'bin'),...(isolation === 'isolated' ? {toolsHome:join(deploymentDir,'tools')} : {}),...(codexProvider?{codexProviders:[codexProvider]}:{})}]}
81
102
  await writeFile(join(directory,'host-executor.json'),JSON.stringify(host,null,2)+'\n',{mode:0o600,flag:'wx'})
82
103
  await writeFile(join(directory, 'agent.json'), JSON.stringify(agent,null,2)+'\n', {mode:0o600, flag:'wx'})
83
104
  return agent
@@ -96,7 +117,7 @@ export const listAgents = async (root: string) => {
96
117
 
97
118
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
98
119
  try {
99
- const {values:v} = parseArgs({options:{name:{type:'string'},purpose:{type:'string'},isolation:{type:'string'},'host-root':{type:'string'},'compose-file':{type:'string'},'relay-image':{type:'string'},list:{type:'boolean'},cli:{type:'string'},'register-cli':{type:'string'},'codex-provider':{type:'string'},'codex-provider-name':{type:'string'},'codex-base-url':{type:'string'},'codex-env-key':{type:'string'},'codex-model':{type:'string',multiple:true}}})
120
+ const {values:v} = parseArgs({options:{name:{type:'string'},purpose:{type:'string'},isolation:{type:'string'},'host-root':{type:'string'},'compose-file':{type:'string'},'relay-image':{type:'string'},'ledger-port':{type:'string'},list:{type:'boolean'},cli:{type:'string'},'register-cli':{type:'string'},'codex-provider':{type:'string'},'codex-provider-name':{type:'string'},'codex-base-url':{type:'string'},'codex-env-key':{type:'string'},'codex-model':{type:'string',multiple:true}}})
100
121
  if (v['register-cli']) console.log(JSON.stringify({cli:await installationCli('/installations',v['register-cli'])}))
101
122
  else if (v.list) console.log(JSON.stringify(await listAgents('/installations')))
102
123
  else {
@@ -104,7 +125,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
104
125
  for await (const chunk of process.stdin) { token+=chunk; if(token.length>512) throw new Error('Token input is too long.') }
105
126
  const providerValues=[v['codex-provider'],v['codex-provider-name'],v['codex-base-url'],v['codex-env-key'],v['codex-model']]
106
127
  const codexProvider=providerValues.some(Boolean)?validateCodexProvider({id:v['codex-provider']||'',name:v['codex-provider-name']||v['codex-provider']||'',baseUrl:v['codex-base-url']||'',envKey:v['codex-env-key']||'',models:v['codex-model']||[]}):undefined
107
- console.log(JSON.stringify(await createAgent({root:'/installations',hostRoot:v['host-root']||'',composeFile:v['compose-file']||'',name:v.name||'',purpose:v.purpose||'',token:token.trim(),cli:v.cli||'',image:v['relay-image'],isolation:v.isolation,codexProvider})))
128
+ console.log(JSON.stringify(await createAgent({root:'/installations',hostRoot:v['host-root']||'',composeFile:v['compose-file']||'',name:v.name||'',purpose:v.purpose||'',token:token.trim(),cli:v.cli||'',image:v['relay-image'],isolation:v.isolation,codexProvider,ledgerPort:v['ledger-port']===undefined?undefined:Number(v['ledger-port'])})))
108
129
  }
109
130
  } catch (error) { console.error((error as Error).message); process.exitCode=1 }
110
131
  }
@@ -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' }))