@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
@@ -0,0 +1,354 @@
1
+ import { createServer, type Server, type Socket } from 'node:net'
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
3
+ import { rename, rm, writeFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { callDeliverySocket as callSocket, deliverySocketAlive as socketAlive, deliverySocketPath } from './delivery-socket-client.mjs'
6
+ import { RunStore, sentOutbox, type RunRecord } from './runs.js'
7
+ import { ApprovalStore } from './approval.js'
8
+ import { deliveredMessages } from './message-history.js'
9
+ import { readOnlyOwner, requireOwnerExecution } from './execution-authority.js'
10
+ import { telegramOwner } from './control-state.js'
11
+
12
+ // Stateless pipe transport. The relay owns the runs/outbox ledger in process
13
+ // memory; engine children, plugin children and the host executor reach it
14
+ // through this socket instead of control/ files. The socket lives on the
15
+ // shared control volume so container-colocated and host-side children use the
16
+ // same path. Binding it also guards single-relay ownership (replacing the old
17
+ // relay.lock flock): a second relay finds the address live and exits.
18
+ //
19
+ // Frames are newline-delimited JSON: {id, op, payload} -> {id, ok, result?,
20
+ // error?}. Every op re-verifies ownership server-side; request metadata is
21
+ // never trusted. A restart drops the ledger by design: in-flight sends report
22
+ // unknown rather than claiming success. The transport itself lives in
23
+ // delivery-socket-client.mjs so plain-Node tools share one implementation.
24
+
25
+ export { deliverySocketPath }
26
+
27
+ export type DeliverySocketOp =
28
+ | { op: 'ping' }
29
+ | { op: 'status' }
30
+ | { op: 'enqueue'; payload: Record<string, unknown> }
31
+ | { op: 'wait'; payload: { id: string; timeoutMs?: number } }
32
+ | { op: 'get'; payload: { runId: string } }
33
+ | { op: 'list'; payload?: Record<string, never> }
34
+ | { op: 'patch'; payload: { runId: string; change: Record<string, unknown> } }
35
+ | { op: 'authorize'; payload: { runId: string } }
36
+ | { op: 'receipt'; payload: { context: unknown; id: string } }
37
+ | { op: 'history'; payload: { runId: string; limit?: number; messageId?: number } }
38
+ | { op: 'deliveryStatus'; payload?: Record<string, never> }
39
+ | { op: 'approvalCheck'; payload: { actionId: string } }
40
+ | { op: 'latestReply'; payload?: Record<string, never> }
41
+
42
+ export type DeliveryHandler = (op: DeliverySocketOp) => Promise<unknown>
43
+
44
+ // Socket-first run reads for library modules that execute both inside the
45
+ // relay (shared memory) and in sibling processes (host executor,
46
+ // task executor). A relay answering on the shared-volume socket is
47
+ // authoritative; connect-level failures fall back to direct memory, which is
48
+ // empty in a fresh sibling process and therefore fails closed. Post-connect
49
+ // errors (unknown run, revoked owner) always propagate.
50
+ const connectFailure = (error: unknown): boolean =>
51
+ error instanceof Error && (/Delivery relay unavailable/.test(error.message) || /ECONNREFUSED|ENOENT/.test(error.message))
52
+
53
+ export const readRun = async (controlDir: string, runId: string): Promise<RunRecord | null> => {
54
+ try {
55
+ const run = await callDeliverySocket(socketPathFor(controlDir), { op: 'get', payload: { runId } }, 10_000)
56
+ return run as RunRecord
57
+ } catch (error) {
58
+ if (!connectFailure(error)) throw error
59
+ if (error instanceof Error && /Unknown run/.test(error.message)) return null
60
+ return new RunStore(controlDir).get(runId)
61
+ }
62
+ }
63
+
64
+ export const authorizeRun = async (controlDir: string, runId: string): Promise<RunRecord> => {
65
+ try {
66
+ const run = await callDeliverySocket(socketPathFor(controlDir), { op: 'authorize', payload: { runId } }, 10_000)
67
+ return run as RunRecord
68
+ } catch (error) {
69
+ if (!connectFailure(error)) throw error
70
+ return requireOwnerExecution(controlDir, runId)
71
+ }
72
+ }
73
+
74
+ const encode = (value: unknown): string => `${JSON.stringify(value)}\n`
75
+
76
+ export type DeliveryEndpoint = { host: '127.0.0.1'; port: number; token: string }
77
+ export type DeliveryServeOptions = { tcpPort?: number }
78
+ const endpointFile = 'delivery-endpoint.json'
79
+ const tokensMatch = (candidate: unknown, token: string): boolean =>
80
+ typeof candidate === 'string' && candidate.length === token.length && timingSafeEqual(Buffer.from(candidate), Buffer.from(token))
81
+
82
+ export const serveDeliverySocket = async (
83
+ controlDir: string,
84
+ handle: DeliveryHandler,
85
+ options: DeliveryServeOptions = {},
86
+ ): Promise<{ socketPath: string; endpoint: DeliveryEndpoint | null; stop: () => Promise<void> }> => {
87
+ const socketPath = deliverySocketPath(controlDir)
88
+ await rm(socketPath, { force: true }).catch(() => {})
89
+ const attach = (socket: Socket, token: string | null): void => {
90
+ let buffer = ''
91
+ socket.setEncoding('utf8')
92
+ socket.on('data', (chunk: string) => {
93
+ buffer += chunk
94
+ let newline: number
95
+ while ((newline = buffer.indexOf('\n')) >= 0) {
96
+ const line = buffer.slice(0, newline)
97
+ buffer = buffer.slice(newline + 1)
98
+ if (!line.trim()) continue
99
+ void (async () => {
100
+ let id: unknown = null
101
+ try {
102
+ const envelope = JSON.parse(line) as { id?: unknown; token?: unknown; op?: unknown; payload?: unknown }
103
+ id = envelope.id ?? null
104
+ // Loopback TCP callers authenticate per frame against the token in
105
+ // the owner-readable control directory; the filesystem channels
106
+ // (Unix socket) keep their existing peer trust. Every op still
107
+ // re-verifies owner authority server-side.
108
+ if (token !== null && !tokensMatch(envelope.token, token)) { socket.destroy(); return }
109
+ const { token: _token, ...request } = envelope
110
+ const result = await handle(request as DeliverySocketOp)
111
+ socket.write(encode({ id, ok: true, result: result ?? null }))
112
+ } catch (error) {
113
+ socket.write(encode({ id, ok: false, error: error instanceof Error ? error.message : String(error ?? 'Unknown error') }))
114
+ }
115
+ })()
116
+ }
117
+ })
118
+ }
119
+ const server: Server = createServer((socket: Socket) => attach(socket, null))
120
+ await new Promise<void>((resolve, reject) => {
121
+ server.once('error', reject)
122
+ server.listen(socketPath, () => {
123
+ server.removeListener('error', reject)
124
+ resolve()
125
+ })
126
+ })
127
+ // A live sibling relay already owns this agent. Refuse to split-brain.
128
+ server.on('error', () => {})
129
+ // A deployment that no longer publishes the endpoint must not leave a stale
130
+ // token/port behind for host CLIs to dial before failing.
131
+ if (options.tcpPort === undefined) await rm(path.join(controlDir, endpointFile), { force: true }).catch(() => {})
132
+ let tcp: Server | null = null
133
+ let endpoint: DeliveryEndpoint | null = null
134
+ if (options.tcpPort !== undefined) {
135
+ if (!Number.isSafeInteger(options.tcpPort) || options.tcpPort < 0 || options.tcpPort > 65535) throw new Error('Invalid delivery TCP port')
136
+ const token = randomBytes(32).toString('hex')
137
+ const listener: Server = createServer((socket: Socket) => attach(socket, token))
138
+ try {
139
+ await new Promise<void>((resolve, reject) => {
140
+ listener.once('error', reject)
141
+ listener.listen(options.tcpPort, '0.0.0.0', () => {
142
+ listener.removeListener('error', reject)
143
+ resolve()
144
+ })
145
+ })
146
+ } catch (error) {
147
+ await new Promise<void>((resolve) => server.close(() => resolve()))
148
+ throw error
149
+ }
150
+ listener.on('error', () => {})
151
+ const address = listener.address()
152
+ if (!address || typeof address === 'string') {
153
+ await new Promise<void>((resolve) => listener.close(() => resolve()))
154
+ await new Promise<void>((resolve) => server.close(() => resolve()))
155
+ throw new Error('Delivery TCP listener address unavailable')
156
+ }
157
+ tcp = listener
158
+ endpoint = { host: '127.0.0.1', port: address.port, token }
159
+ const file = path.join(controlDir, endpointFile)
160
+ const temporary = `${file}.${process.pid}.tmp`
161
+ await writeFile(temporary, JSON.stringify(endpoint), { mode: 0o600 })
162
+ await rename(temporary, file)
163
+ }
164
+ const tcpServer = tcp
165
+ const tcpEndpoint = endpoint
166
+ return {
167
+ socketPath,
168
+ endpoint,
169
+ stop: async () => {
170
+ await new Promise<void>((resolve) => server.close(() => resolve()))
171
+ await rm(socketPath, { force: true }).catch(() => {})
172
+ if (tcpServer) await new Promise<void>((resolve) => tcpServer.close(() => resolve()))
173
+ if (tcpEndpoint) await rm(path.join(controlDir, endpointFile), { force: true }).catch(() => {})
174
+ },
175
+ }
176
+ }
177
+
178
+ // Children derive the rendezvous from the agent control directory they were
179
+ // given; EZ_DELIVERY_SOCKET overrides it (tests, custom layouts).
180
+ export const socketPathFor = (controlDir: string, env: NodeJS.ProcessEnv = process.env): string =>
181
+ env.EZ_DELIVERY_SOCKET?.trim() || deliverySocketPath(controlDir)
182
+
183
+ export type LedgerHooks = {
184
+ // Wake the relay outbox pump after an enqueue. Test fixtures pass a noop
185
+ // when they drive delivery synchronously.
186
+ wake: () => void
187
+ // Live relay surface for the status op (polling, transport, version).
188
+ status: () => Promise<{ polling: boolean; applicationOnly: boolean; telegramConfigured: boolean; version: string }> | { polling: boolean; applicationOnly: boolean; telegramConfigured: boolean; version: string }
189
+ }
190
+
191
+ // Memory-ledger request handler shared by the relay and by test fixtures.
192
+ // Every op re-verifies ownership server-side; request metadata is never
193
+ // trusted. Unknown IDs, invalid payloads and revoked bindings fail closed.
194
+ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): DeliveryHandler => {
195
+ const runs = new RunStore(controlDir)
196
+ const approvals = new ApprovalStore(controlDir)
197
+ const nonEmptyString = (value: unknown): value is string =>
198
+ typeof value === 'string' && value.trim().length > 0
199
+ return async (op: DeliverySocketOp): Promise<unknown> => {
200
+ switch (op.op) {
201
+ case 'ping': return { ok: true }
202
+ case 'status': {
203
+ const all = await runs.list()
204
+ return {
205
+ ...(await hooks.status()),
206
+ at: Date.now(),
207
+ running: all.filter((run) => run.status === 'running').length,
208
+ queued: all.filter((run) => run.status === 'queued').length,
209
+ }
210
+ }
211
+ case 'enqueue': {
212
+ const envelope = (op.payload ?? {}) as Record<string, unknown>
213
+ if (envelope.kind === 'owner') {
214
+ if (!envelope.deliveryContext || typeof envelope.deliveryContext !== 'object') throw new Error('Delivery context is required')
215
+ const type = envelope.type
216
+ if (type !== 'message' && type !== 'document' && type !== 'voice') throw new Error('Unsupported owner delivery type')
217
+ const text = envelope.text
218
+ if (type === 'message' && !nonEmptyString(text)) throw new Error('Message content is required')
219
+ const item = await runs.enqueueOwnerDelivery(
220
+ envelope.deliveryContext as never,
221
+ {
222
+ type,
223
+ ...(nonEmptyString(text) ? { text: (text as string).trim() } : {}),
224
+ ...(typeof envelope.documentPath === 'string' ? { documentPath: envelope.documentPath } : {}),
225
+ ...(typeof envelope.voiceText === 'string' ? { voiceText: envelope.voiceText } : {}),
226
+ ...(Number.isSafeInteger(envelope.replyToMessageId) ? { replyToMessageId: envelope.replyToMessageId as number } : {}),
227
+ },
228
+ )
229
+ hooks.wake()
230
+ return { outbox_id: item.id, id: item.id, type: item.type }
231
+ }
232
+ if (typeof envelope.runId !== 'string' || !envelope.runId) throw new Error('Run ID is required')
233
+ const replyTo = Number.isSafeInteger(envelope.replyToMessageId) ? { replyToMessageId: envelope.replyToMessageId as number } : {}
234
+ const id = typeof envelope.id === 'string' && envelope.id ? { id: envelope.id } : {}
235
+ let item
236
+ switch (envelope.kind) {
237
+ case 'message': {
238
+ if (!nonEmptyString(envelope.text)) throw new Error('Message content is required')
239
+ item = await runs.enqueueMessage(envelope.runId, (envelope.text as string).trim(), { ...replyTo, ...id })
240
+ break
241
+ }
242
+ case 'document': {
243
+ if (typeof envelope.documentPath !== 'string' || !envelope.documentPath) throw new Error('Document path is required')
244
+ const caption = nonEmptyString(envelope.text) ? (envelope.text as string).trim() : undefined
245
+ item = await runs.enqueueDocument(envelope.runId, envelope.documentPath, { ...(caption ? { caption } : {}), ...replyTo })
246
+ break
247
+ }
248
+ case 'voice': {
249
+ if (!nonEmptyString(envelope.voiceText)) throw new Error('Voice text is required')
250
+ item = await runs.enqueueVoice(envelope.runId, (envelope.voiceText as string).trim(), replyTo)
251
+ break
252
+ }
253
+ case 'reaction': {
254
+ if (!nonEmptyString(envelope.emoji)) throw new Error('Reaction emoji is required')
255
+ item = await runs.enqueueReaction(envelope.runId, envelope.emoji as string)
256
+ break
257
+ }
258
+ case 'approval': {
259
+ if (!nonEmptyString(envelope.approvalPrompt) || !nonEmptyString(envelope.approvalActionId))
260
+ throw new Error('Approval prompt and action ID are required')
261
+ await approvals.requestApproval(envelope.approvalActionId as string, (envelope.approvalPrompt as string).trim(), envelope.runId)
262
+ item = await runs.enqueueApproval(envelope.runId, (envelope.approvalPrompt as string).trim(), envelope.approvalActionId as string, replyTo)
263
+ break
264
+ }
265
+ default: throw new Error('Unsupported delivery kind')
266
+ }
267
+ hooks.wake()
268
+ return { outbox_id: item.id, id: item.id, type: item.type }
269
+ }
270
+ case 'wait': {
271
+ const { id, timeoutMs } = (op.payload ?? {}) as { id?: unknown; timeoutMs?: unknown }
272
+ if (typeof id !== 'string' || !id) throw new Error('Outbox ID is required')
273
+ const receipt = await runs.waitForDelivery(id, typeof timeoutMs === 'number' && timeoutMs > 0 ? Math.min(timeoutMs, 130_000) : 120_000)
274
+ return { status: 'delivered', receipt }
275
+ }
276
+ case 'get': {
277
+ const runId = (op.payload as { runId?: unknown } | undefined)?.runId
278
+ if (typeof runId !== 'string' || !runId) throw new Error('Run ID is required')
279
+ const run = await runs.get(runId)
280
+ if (!run) throw new Error(`Unknown run ${runId}`)
281
+ return run
282
+ }
283
+ case 'list': return runs.list()
284
+ case 'patch': {
285
+ const payload = (op.payload ?? {}) as { runId?: unknown; change?: unknown }
286
+ if (typeof payload.runId !== 'string' || !payload.runId) throw new Error('Run ID is required')
287
+ if (!payload.change || typeof payload.change !== 'object') throw new Error('Patch change is required')
288
+ return runs.patch(payload.runId, payload.change as never)
289
+ }
290
+ case 'authorize': {
291
+ const runId = (op.payload as { runId?: unknown } | undefined)?.runId
292
+ if (typeof runId !== 'string' || !runId) throw new Error('Run ID is required')
293
+ return requireOwnerExecution(controlDir, runId)
294
+ }
295
+ case 'receipt': {
296
+ const payload = (op.payload ?? {}) as { context?: unknown; id?: unknown }
297
+ if (!payload.context || typeof payload.context !== 'object') throw new Error('Delivery context is required')
298
+ if (typeof payload.id !== 'string' || !payload.id) throw new Error('Outbox ID is required')
299
+ return runs.ownerDeliveryReceipt(payload.context as never, payload.id)
300
+ }
301
+ case 'history': {
302
+ const payload = (op.payload ?? {}) as { runId?: unknown; limit?: unknown; messageId?: unknown }
303
+ if (typeof payload.runId !== 'string' || !payload.runId) throw new Error('Run ID is required')
304
+ return deliveredMessages(controlDir, payload.runId, {
305
+ ...(payload.limit === undefined ? {} : { limit: payload.limit as number }),
306
+ ...(payload.messageId === undefined ? {} : { messageId: payload.messageId as number }),
307
+ })
308
+ }
309
+ case 'deliveryStatus': return runs.deliveryStatus()
310
+ case 'approvalCheck': {
311
+ const actionId = (op.payload as { actionId?: unknown } | undefined)?.actionId
312
+ if (typeof actionId !== 'string' || !actionId) throw new Error('Approval action ID is required')
313
+ const record = await approvals.getDecision(actionId)
314
+ return {
315
+ ok: true,
316
+ actionId,
317
+ decision: record?.decision || 'pending',
318
+ decidedAt: record?.decidedAt,
319
+ decidedBy: record?.decidedBy,
320
+ }
321
+ }
322
+ case 'latestReply': {
323
+ const owner = telegramOwner(await readOnlyOwner(controlDir))
324
+ if (!owner) return { reply: null }
325
+ let reply: { runId: string; messageIds: number[]; deliveredAt: string } | null = null
326
+ for (const item of sentOutbox(controlDir)) {
327
+ if (!/^tg_\d+$/.test(item.runId || '') || item.chatId !== owner.telegramChatId ||
328
+ (item.type && item.type !== 'message') || !Array.isArray((item.receipt as { messageIds?: unknown } | undefined)?.messageIds)) continue
329
+ const receipt = item.receipt as { messageIds: number[]; deliveredAt: string }
330
+ if (!receipt.messageIds.length || !receipt.messageIds.every((n) => Number.isSafeInteger(n) && n > 0)) continue
331
+ const delivered = Date.parse(receipt.deliveredAt)
332
+ if (!Number.isFinite(delivered) || delivered < Date.parse(owner.pairedAt) || delivered > Date.now()) continue
333
+ const run = await runs.get(item.runId!)
334
+ if (run?.status !== 'completed' || run.external || run.chatId !== owner.telegramChatId ||
335
+ !Number.isSafeInteger(run.telegramUserId) || run.telegramUserId! <= 0 ||
336
+ (owner.kind !== 'group' && run.telegramUserId !== owner.telegramUserId)) continue
337
+ if (!reply || delivered > Date.parse(reply.deliveredAt))
338
+ reply = { runId: item.runId!, messageIds: receipt.messageIds, deliveredAt: receipt.deliveredAt }
339
+ }
340
+ return { reply }
341
+ }
342
+ default: throw new Error(`Unsupported delivery op ${(op as { op?: unknown }).op}`)
343
+ }
344
+ }
345
+ }
346
+
347
+ export const deliverySocketAlive = (socketPath: string, timeoutMs = 2000): Promise<boolean> =>
348
+ socketAlive(socketPath, timeoutMs)
349
+
350
+ // Typed facade over the shared plain-JS client so relay/TS callers keep the
351
+ // closed DeliverySocketOp union.
352
+ export const callDeliverySocket = (socketPath: string, op: DeliverySocketOp, timeoutMs = 130_000): Promise<unknown> =>
353
+ callSocket(socketPath, op, timeoutMs)
354
+
@@ -1,7 +1,8 @@
1
1
  import { ApplicationBindings } from './application-channel.js'
2
- import { ControlStore } from './control-state.js'
3
2
  import { RunStore, type RunRecord } from './runs.js'
4
- import type { Owner } from './control-state.js'
3
+ import { validOwner, type Owner } from './control-state.js'
4
+ import { readFile } from 'node:fs/promises'
5
+ import path from 'node:path'
5
6
  import { ownsRun } from './identity.js'
6
7
 
7
8
  export const EXTERNAL_EXECUTION_BLOCK = 'external-execution-unavailable' as const
@@ -14,12 +15,27 @@ export function executionBlockReason(run: RunRecord, owner: Owner | null): strin
14
15
  if (run.taskId || run.external || run.id.startsWith('event_')) return EXTERNAL_EXECUTION_BLOCK
15
16
  }
16
17
 
17
- // Re-read core state at both launch boundaries. Request metadata and EZ_RUN_ID
18
- // are not proof of owner identity. Local host administrators remain trusted.
18
+ // Stateless pipe: intake authorizes the sender before spawn. The executor
19
+ // re-verifies owner binding with a read-only control-state.json read: zero
20
+ // control/ writes, fail-closed on missing/corrupt/revoked owner. Request
21
+ // metadata and EZ_RUN_ID are not proof of owner identity. Local host
22
+ // administrators remain trusted.
23
+ export const readOnlyOwner = async (controlDir: string): Promise<Owner | null> => {
24
+ try {
25
+ const parsed: unknown = JSON.parse(await readFile(path.join(controlDir, 'control-state.json'), 'utf8'))
26
+ const owner = (parsed as { owner?: unknown }).owner ?? null
27
+ return validOwner(owner) ? (owner as Owner) : null
28
+ } catch (error: unknown) {
29
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
30
+ throw error
31
+ }
32
+ }
33
+
19
34
  export async function requireOwnerExecution(controlDir: string, runId: string): Promise<RunRecord> {
20
35
  const run = await new RunStore(controlDir).get(runId)
21
36
  if (!run || run.status !== 'running') throw new Error('No active core run')
22
- const owner = (await new ControlStore(controlDir, 900_000).status()).owner
37
+ if (run.taskId || run.external || run.id.startsWith('event_')) throw new Error(`Execution blocked: ${EXTERNAL_EXECUTION_BLOCK}`)
38
+ const owner = await readOnlyOwner(controlDir)
23
39
  const reason = executionBlockReason(run, owner)
24
40
  if (reason) throw new Error(`Execution blocked: ${reason}`)
25
41
  if (run.application || run.delivery) await new ApplicationBindings(controlDir).authorize(run)
package/src/executor.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  import { executionDefaults } from './model-policy.js'
2
- import { Tasks } from './tasks.js'
3
- import { RunStore } from './runs.js'
4
2
  import { startTaskExecutor } from './task-executor.js'
5
- import { requireOwnerExecution } from './execution-authority.js'
3
+ import { deliverySocketPath } from './delivery-socket.js'
6
4
  import { mkdtemp, rm, writeFile, mkdir, symlink, readFile } from 'node:fs/promises'
7
5
  import { accessSync, constants as fsConstants } from 'node:fs'
8
6
  import { tmpdir, homedir } from 'node:os'
@@ -21,13 +19,18 @@ export type ExecutorOptions = {
21
19
  runId: string
22
20
  controlDir: string
23
21
  binDir: string
22
+ // Caller-owned trigger line appended to literal input. The core never reads
23
+ // the run ledger; admission and authorization happen before launch.
24
+ promptSuffix?: string
25
+ // Caller-owned routing flags. The core only spawns and streams.
26
+ taskRun?: boolean
27
+ nativeSession?: boolean
24
28
  toolsHome?: string
25
29
  sharedWorkspace?: string
26
30
  additionalWorkspaces?: string[]
27
31
  cli?: string
28
32
  sessionId?: string
29
33
  isResume?: boolean
30
- eventSource?: string
31
34
  model?: string
32
35
  effort?: string
33
36
  provider?: string
@@ -55,6 +58,8 @@ const allowedEnvironmentKeys = [
55
58
  'TERM',
56
59
  'TMPDIR',
57
60
  'USER',
61
+ 'EZ_PLUGIN_BROKER_SOCKET',
62
+ 'EZ_DELIVERY_SOCKET',
58
63
  ] as const
59
64
 
60
65
  export const executorEnvironment = (environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => {
@@ -76,6 +81,7 @@ export const executorJobEnv = (
76
81
  PATH: pathValue,
77
82
  EZ_RUN_ID: options.runId,
78
83
  EZ_CONTROL_DIR: options.controlDir,
84
+ EZ_DELIVERY_SOCKET: deliverySocketPath(options.controlDir),
79
85
  EZ_REPAIR_ENABLED: String(options.repairEnabled !== false),
80
86
  ...(options.toolsHome ? {BUILDX_CONFIG:path.join(options.toolsHome,'buildx')} : {}),
81
87
  }
@@ -97,7 +103,6 @@ export const resolveHostCommand = (command: string, pathValue = process.env.PATH
97
103
  }
98
104
  throw new Error(`Native CLI ${command} is not executable on the host PATH`)
99
105
  }
100
-
101
106
  export type CliAdapter = {
102
107
  name: string
103
108
  command: string
@@ -204,6 +209,30 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
204
209
  return args
205
210
  },
206
211
  },
212
+ pi: {
213
+ name: 'pi',
214
+ command: 'pi',
215
+ description: 'Pi coding agent',
216
+ buildArgs: (opts, _promptFile, promptText) => {
217
+ if (!opts.controlDir) throw new Error('Pi runs require a bound control directory for sessions')
218
+ if (opts.effort && !['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(opts.effort)) throw new Error('Invalid Pi thinking level')
219
+ const args = ['-p', '--mode', 'json', '--session-dir', path.join(opts.controlDir, 'cli', 'pi', 'sessions')]
220
+ // Owner-provisioned delivery skill (control/cli/pi/skills/ez-delivery).
221
+ // Loaded by path, never by prompt text, so the model cannot miss it.
222
+ try {
223
+ const skill = path.join(opts.controlDir, 'cli', 'pi', 'skills', 'ez-delivery', 'SKILL.md')
224
+ accessSync(skill, fsConstants.R_OK)
225
+ args.push('--skill', path.dirname(skill))
226
+ } catch { /* runs without the skill exactly as before */ }
227
+ // Bind Pi to this conversation, not the most recently used session in
228
+ // the shared directory. --session-id creates or resumes that exact ID.
229
+ if (opts.sessionId) args.push('--session-id', opts.sessionId)
230
+ if (opts.model) args.push('--model', opts.model)
231
+ if (opts.effort) args.push('--thinking', opts.effort)
232
+ args.push('--', promptText)
233
+ return args
234
+ },
235
+ },
207
236
  'codex-gui': {
208
237
  name: 'codex-gui',
209
238
  command: 'codex',
@@ -249,13 +278,34 @@ export const validateCodexProvider = (value: CodexProviderBinding): CodexProvide
249
278
  export const validateCodexProviderEnvironmentKey = (value: unknown): string => {
250
279
  const envKey = providerToken(value, 'environment key', /^[A-Z][A-Z0-9_]{1,63}$/, 64)
251
280
  if (allowedEnvironmentKeys.includes(envKey as typeof allowedEnvironmentKeys[number]) || envKey === 'CODEX_HOME' || envKey === 'NODE_OPTIONS' ||
252
- envKey.startsWith('EZ_') || envKey.startsWith('TELEGRAM_') || envKey.startsWith('PAGERDUTY_'))
281
+ envKey.startsWith('EZ_') || envKey.startsWith('TELEGRAM_'))
253
282
  throw new Error('Reserved Codex provider environment key')
254
283
  return envKey
255
284
  }
256
285
 
257
286
  export const resolveExecutor = (name?: string): CliAdapter => EXECUTOR_REGISTRY[executorKey(name)]
258
287
 
288
+ // Agent-bound OpenCode credentials live at cli/opencode/auth.json, mirroring
289
+ // the Codex binding. Returns the XDG data home (cli/) only when that binding
290
+ // exists; otherwise the host login (host transport) or the unauthenticated
291
+ // free tier (isolated relay) applies. Only a path is ever returned, never a
292
+ // secret.
293
+ export const opencodeDataHome = (controlDir: string): string | undefined => {
294
+ try {
295
+ accessSync(path.join(controlDir, 'cli', 'opencode', 'auth.json'), fsConstants.R_OK)
296
+ return path.join(controlDir, 'cli')
297
+ } catch { return undefined }
298
+ }
299
+
300
+ // Pi resolves its provider/model catalog from an agent dir (settings.json,
301
+ // auth.json). Only a path is ever returned, never a secret.
302
+ export const piAgentDir = (controlDir: string): string | undefined => {
303
+ try {
304
+ accessSync(path.join(controlDir, 'cli', 'pi', 'agent', 'auth.json'), fsConstants.R_OK)
305
+ return path.join(controlDir, 'cli', 'pi', 'agent')
306
+ } catch { return undefined }
307
+ }
308
+
259
309
  export const grokInvocation = (
260
310
  options: Pick<ExecutorOptions, 'workspace' | 'isResume'>,
261
311
  promptFile: string,
@@ -294,28 +344,19 @@ export const startExecutorJob = async (
294
344
  options: ExecutorOptions,
295
345
  ): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
296
346
  options = executionDefaults(executorKey(options.cli), options)
297
- if(options.runId.startsWith('r_schedule_') && !/^[a-zA-Z0-9_-]+$/.test(options.runId))throw new Error('Invalid native task run ID')
298
- const run = await new RunStore(options.controlDir).get(options.runId)
299
- if (options.codexSandbox !== undefined && (options.codexSandbox !== 'external' || process.env.EZ_EXECUTOR_TRANSPORT !== 'local' || !run || run.taskId || executorKey(options.cli) !== 'codex')) throw new Error('External Codex sandbox requires an owner-authorized native local run')
300
- if (run?.taskId) {
301
- if (run.status !== 'running') throw new Error('No active task run')
302
- await new Tasks(options.controlDir).authorize(run, process.env.EZ_EXECUTOR_TRANSPORT === 'host')
303
- if (process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
304
- } else await requireOwnerExecution(options.controlDir, options.runId)
305
- if (!run?.taskId && options.eventSource !== undefined) throw new Error('Execution blocked: external-execution-unavailable')
347
+ // Routing is caller-owned: a restricted task run goes to the task runner
348
+ // unless the host transport must ship it across the boundary first.
349
+ if (options.taskRun && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
350
+ if (options.nativeSession && !/^[a-zA-Z0-9_-]+$/.test(options.runId)) throw new Error('Invalid native task run ID')
351
+ if (options.codexSandbox !== undefined && (options.codexSandbox !== 'external' || process.env.EZ_EXECUTOR_TRANSPORT !== 'local' || options.taskRun || executorKey(options.cli) !== 'codex')) throw new Error('External Codex sandbox requires an owner-authorized native local run')
306
352
  const outputDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
307
353
  const key = executorKey(options.cli)
308
354
  const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
309
355
  const gui = !host && key === 'codex-gui'
310
- const nativeSession = !host && key === 'codex' && options.runId.startsWith('r_schedule_')
311
- // Chat-mode experiment: only direct chat input at the engine boundary.
312
- const applicationReminder = !host && (run?.application || run?.delivery)
313
- ? '\n\n[Application channel] This is an owner-authorized application conversation. Send text replies using ezenciel-agents-message; stdout alone is not delivered. Outgoing file delivery, reactions and approval controls are unsupported here. Domain tools can retrieve private context from ezenciel-agents-schedule context under run.application.context; do not expose credentials from that data. The application scope is ' + JSON.stringify((run.application ?? run.delivery)!.scope) + '.'
314
- : ''
315
- const chatReminder = !host && !run?.taskId && run?.messageId !== undefined
316
- ? '\n\n[Chat context] You are replying in chat. Send replies with ezenciel-agents-message --text "..."; your final answer alone is not delivered. Before lengthy tool or repository work, briefly acknowledge through that CLI. Keep chat responsive: use ezenciel-agents-schedule for long-running work and native subagents for useful independent parts. Decide when to delegate and what to send.'
317
- : ''
318
- const promptText = texts.join('\n\n') + applicationReminder + chatReminder
356
+ const nativeSession = !host && key === 'codex' && options.nativeSession === true
357
+ // Literal input plus the caller-provided trigger line. No coaching,
358
+ // tool/delegate/schedule advice, or workflow guidance.
359
+ const promptText = texts.join('\n\n') + (options.promptSuffix ?? '')
319
360
  const promptFile = path.join(outputDirectory, 'prompt.txt')
320
361
  await writeFile(promptFile, promptText, { encoding: 'utf8', mode: 0o600 })
321
362
 
@@ -366,6 +407,22 @@ export const startExecutorJob = async (
366
407
  }
367
408
  environment.CODEX_HOME = home
368
409
  }
410
+ if (!host && !gui && key === 'opencode') {
411
+ // Point OpenCode at the agent-bound data home (cli/opencode/auth.json)
412
+ // when the owner provisioned it. Host runs keep the installer login;
413
+ // without a binding the CLI falls back to its free tier. Fail closed at
414
+ // runtime with the provider error, never with a guessed credential.
415
+ const dataHome = opencodeDataHome(options.controlDir)
416
+ if (dataHome) environment.XDG_DATA_HOME = dataHome
417
+ }
418
+ if (!host && !gui && key === 'pi') {
419
+ // Point Pi at the agent-bound config dir (cli/pi/agent/auth.json) when
420
+ // the owner provisioned it. Without a binding Pi falls back to the relay
421
+ // home config. Fail closed at runtime with the provider error, never with
422
+ // a guessed credential.
423
+ const agentDir = piAgentDir(options.controlDir)
424
+ if (agentDir) environment.PI_CODING_AGENT_DIR = agentDir
425
+ }
369
426
  if (denies.length) {
370
427
  const profile = path.join(outputDirectory, 'workspace.sb')
371
428
  await writeFile(profile, macosWorkspaceProfile(denies, [