@jc_stack/ez-agents 0.1.0-beta.29 → 0.1.0-beta.32

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 (55) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +41 -1
  3. package/README.md +36 -25
  4. package/docs/application-channel.md +29 -2
  5. package/docs/architecture/ai-selection.md +10 -1
  6. package/docs/architecture/authority-boundaries.md +64 -14
  7. package/docs/architecture/event-sources.md +12 -5
  8. package/docs/architecture/telegram-intake.md +1 -1
  9. package/docs/host-service.md +3 -0
  10. package/docs/plugin-contributions.md +2 -0
  11. package/docs/plugins.md +23 -0
  12. package/docs/responsive-channels.md +3 -0
  13. package/docs/selective-monitoring.md +55 -3
  14. package/docs/setup.md +2 -1
  15. package/package.json +1 -1
  16. package/src/application-channel.ts +40 -9
  17. package/src/application-origin.ts +4 -0
  18. package/src/approval.ts +19 -3
  19. package/src/codex-session.ts +2 -2
  20. package/src/desktop-bridge.ts +70 -5
  21. package/src/event-sources.ts +13 -6
  22. package/src/executor.ts +4 -2
  23. package/src/files.ts +12 -0
  24. package/src/host-executor-protocol.ts +2 -2
  25. package/src/host-executor.ts +20 -4
  26. package/src/index.ts +81 -34
  27. package/src/plugins/manager.mjs +52 -20
  28. package/src/runs.ts +19 -2
  29. package/src/task-attachments.ts +56 -0
  30. package/src/task-capability.ts +45 -0
  31. package/src/task-cli.ts +7 -4
  32. package/src/task-executor.ts +8 -4
  33. package/src/task-mcp.ts +25 -8
  34. package/src/tasks.ts +132 -26
  35. package/src/telegram-source.ts +34 -13
  36. package/src/updates/artifact.mjs +2 -2
  37. package/src/updates/binding.mjs +1 -1
  38. package/templates/agent-guidance.md +24 -0
  39. package/test/agent-guidance.test.ts +8 -0
  40. package/test/application-channel.test.ts +52 -1
  41. package/test/codex-session.test.ts +2 -1
  42. package/test/desktop-bridge.test.ts +98 -1
  43. package/test/event-sources.test.ts +68 -8
  44. package/test/executor.test.ts +2 -2
  45. package/test/host-executor.test.ts +32 -3
  46. package/test/intake-relay.test.ts +79 -2
  47. package/test/local-qa.test.mjs +7 -3
  48. package/test/plugin-manager.test.mjs +31 -0
  49. package/test/runs.test.ts +19 -0
  50. package/test/task-attachments.test.ts +77 -0
  51. package/test/task-capability.test.ts +48 -0
  52. package/test/task-native.test.ts +4 -1
  53. package/test/tasks.test.ts +53 -1
  54. package/test/telegram-source.test.ts +28 -1
  55. package/test/updates.test.mjs +3 -2
@@ -7,7 +7,10 @@ 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'
9
9
  import { isPreset, type AiPreset, type ModelChoice } from './ai.js'
10
+ import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
10
11
  import { assertEffort } from './model-policy.js'
12
+ import { ApprovalStore } from './approval.js'
13
+ import { Tasks } from './tasks.js'
11
14
 
12
15
  const hash = (value: string) => createHash('sha256').update(value).digest('hex')
13
16
  export const applicationScope = (bindingId: string, scope: string) => hash(JSON.stringify([bindingId, scope]))
@@ -68,7 +71,7 @@ export class ApplicationChannel {
68
71
  private admissions = Promise.resolve()
69
72
  readonly bindings: ApplicationBindings
70
73
  constructor(private options: {
71
- controlDir: string; initial: AiPreset
74
+ controlDir: string; workspace?: string; initial: AiPreset
72
75
  wake: () => void
73
76
  cancel: (id: string) => Promise<void>
74
77
  aiControls?: {
@@ -192,8 +195,8 @@ export class ApplicationChannel {
192
195
  if ('followTelegram' in rest) throw new Error('Invalid application request: choose one conversation option')
193
196
  input = {...rest, followTelegram: followOwner}
194
197
  }
195
- const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; model?: unknown; effort?: unknown } }
196
- if (!value || !applicationId(value.requestId) || !applicationId(value.scope) || typeof value.text !== 'string' || !value.text.trim() || value.text.length > 16000 || Object.keys(value).some(key => !['requestId','scope','text','context','expectedNativeSessionId','activateTelegram','followTelegram','ai'].includes(key))) throw new Error('Invalid application request')
198
+ const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; model?: unknown; effort?: unknown } }
199
+ if (!value || !applicationId(value.requestId) || !applicationId(value.scope) || typeof value.text !== 'string' || (!value.text.trim() && value.attachment === undefined) || value.text.length > 16000 || Object.keys(value).some(key => !['requestId','scope','text','attachment','context','expectedNativeSessionId','activateTelegram','followTelegram','ai'].includes(key))) throw new Error('Invalid application request')
197
200
  if (value.expectedNativeSessionId !== undefined && (typeof value.expectedNativeSessionId !== 'string' || !/^[a-zA-Z0-9_-]{1,160}$/.test(value.expectedNativeSessionId))) throw new Error('Invalid application native session assertion')
198
201
  if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
199
202
  if (value.followTelegram !== undefined && typeof value.followTelegram !== 'boolean') throw new Error('Invalid application Telegram following')
@@ -205,7 +208,17 @@ export class ApplicationChannel {
205
208
  assertEffort(candidate.effort, candidate.model, candidate.cli)
206
209
  requestedPreset = candidate
207
210
  }
208
- const application = { bindingId, requestId: value.requestId, scope: value.scope, ...(value.followTelegram ? { followTelegram: true } : {}), ...(value.context === undefined ? {} : { context: value.context }) }
211
+ let bytes: Buffer | undefined
212
+ if (value.attachment !== undefined) {
213
+ const attachment = value.attachment
214
+ if (!attachment || typeof attachment.name !== 'string' || !attachment.name.length || attachment.name.length > 255 ||
215
+ typeof attachment.data !== 'string' || attachment.data.length > Math.ceil(MAX_INCOMING_ATTACHMENT_BYTES / 3) * 4 ||
216
+ Object.keys(attachment).some(key => !['name', 'data'].includes(key))) throw new Error('Invalid application attachment')
217
+ bytes = Buffer.from(attachment.data, 'base64')
218
+ if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || bytes.toString('base64') !== attachment.data) throw new Error('Invalid application attachment encoding or size')
219
+ }
220
+ const attachmentHash = bytes ? hash(JSON.stringify([value.attachment!.name, bytes.toString('base64')])) : undefined
221
+ const application = { bindingId, requestId: value.requestId, scope: value.scope, ...(attachmentHash ? {inputText: value.text, attachmentHash} : {}), ...(value.followTelegram ? { followTelegram: true } : {}), ...(value.context === undefined ? {} : { context: value.context }) }
209
222
  if (!validApplicationOrigin(application)) throw new Error('Invalid application context')
210
223
  const binding = (await this.bindings.list()).find(item => item.bindingId === bindingId)
211
224
  if (!binding) throw new Error('Application authority revoked')
@@ -215,13 +228,18 @@ export class ApplicationChannel {
215
228
  if (existing) {
216
229
  await this.bindings.authorize(existing)
217
230
  if (requestedPreset && (existing.execution?.preset.cli !== requestedPreset.cli || existing.execution?.preset.model !== requestedPreset.model || existing.execution?.preset.effort !== requestedPreset.effort)) throw new Error('Application request ID conflicts with prior AI selection')
218
- if (Boolean(existing.application?.followTelegram) !== Boolean(value.followTelegram) || existing.application?.scope !== value.scope || existing.texts[0] !== value.text) throw new Error('Application request ID conflicts with prior scope or text')
231
+ if (Boolean(existing.application?.followTelegram) !== Boolean(value.followTelegram) || existing.application?.scope !== value.scope || (existing.application?.inputText ?? existing.texts[0]) !== value.text || existing.application?.attachmentHash !== attachmentHash) throw new Error('Application request ID conflicts with prior scope or text')
219
232
  return existing // Retried context never replaces already admitted capabilities.
220
233
  }
221
234
  const control = new ControlStore(this.options.controlDir, 900000)
222
235
  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
238
+ let run: RunRecord
239
+ try {
223
240
  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)
224
- const run = await this.runs.create({ id, ownerId: ownerId(binding.owner), ownerEpoch: ownerEpoch(binding.owner), texts: [value.text], execution, application })
241
+ run = await this.runs.create({ id, ownerId: ownerId(binding.owner), ownerEpoch: ownerEpoch(binding.owner), texts: [staged?.text ?? value.text], execution, application }) }
242
+ catch (error) { if (staged) await unlink(staged.fullPath).catch(() => {}); throw error }
225
243
  this.options.wake()
226
244
  return run
227
245
  })
@@ -234,11 +252,11 @@ export class ApplicationChannel {
234
252
  await this.bindings.authorize(run)
235
253
  const state = await new ControlStore(this.options.controlDir, 900000).status()
236
254
  const session = state.activeSession?.sessionId === run.execution?.sessionId ? state.activeSession : state.sessions?.find(item => item.sessionId === run.execution?.sessionId)
237
- return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.scheduled?.originRunId ? {originRunId:run.scheduled.originRunId} : {}), id: run.id, scope: (run.application ?? run.delivery)!.scope, status: run.status, messages: await this.runs.applicationMessages(run.id), ...(run.status === 'failed' ? { error: 'Agent execution failed; inspect the core run' } : {}) }
255
+ return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.scheduled?.originRunId ? {originRunId:run.scheduled.originRunId} : {}), id: run.id, scope: (run.application ?? run.delivery)!.scope, status: run.status, messages: await this.runs.applicationMessages(run.id), approvals:await this.runs.applicationApprovals(run.id), ...(run.status === 'failed' ? { error: 'Agent execution failed; inspect the core run' } : {}) }
238
256
  }
239
257
  async deliver(run: RunRecord, item: OutboxItem): Promise<void> {
240
258
  await this.bindings.authorize(run)
241
- if (item.type && item.type !== 'message') throw new Error('Application channel supports text messages only')
259
+ if (item.type && !['message','approval'].includes(item.type)) throw new Error('Application channel supports text messages and approvals only')
242
260
  await this.runs.markOutboxSent(item.id)
243
261
  }
244
262
  private async route(request: IncomingMessage, response: ServerResponse) {
@@ -277,12 +295,25 @@ export class ApplicationChannel {
277
295
  }
278
296
  if (request.method === 'POST' && path === '/v1/runs') {
279
297
  const chunks: Buffer[] = []; let size = 0
280
- for await (const chunk of request) { size += chunk.length; if (size > 65536) throw new Error('Application request too large'); chunks.push(chunk) }
298
+ for await (const chunk of request) { size += chunk.length; if (size > Math.ceil(MAX_INCOMING_ATTACHMENT_BYTES / 3) * 4 + 256 * 1024) throw new Error('Application request too large'); chunks.push(chunk) }
281
299
  const input = JSON.parse(Buffer.concat(chunks).toString('utf8'))
282
300
  if (applicationId(input?.requestId)) admissionId = `r_app_${hash(JSON.stringify([binding.bindingId, input.requestId]))}`
283
301
  const run = await this.submit(binding.bindingId, input)
284
302
  send(202, await this.snapshot(binding.bindingId, run.id)); return
285
303
  }
304
+ const approvalMatch=path.match(/^\/v1\/approvals\/(task_[a-f0-9]{32})$/)
305
+ if(approvalMatch && request.method==='POST') {
306
+ const approval=await new ApprovalStore(this.options.controlDir).getDecision(approvalMatch[1])
307
+ const run=approval?.runId ? await this.runs.get(approval.runId) : null
308
+ if(!run || run.application?.bindingId!==binding.bindingId)throw new Error('Unknown application approval')
309
+ await this.bindings.authorize(run)
310
+ const chunks:Buffer[]=[];let size=0;for await(const chunk of request){size+=chunk.length;if(size>1024)throw new Error('Invalid application approval');chunks.push(chunk)}
311
+ const input=JSON.parse(Buffer.concat(chunks).toString('utf8'))
312
+ if(!input || !['approved','denied'].includes(input.decision) || Object.keys(input).some(key=>key!=='decision'))throw new Error('Invalid application approval')
313
+ await new ApprovalStore(this.options.controlDir).recordOwnerDecision(approvalMatch[1],input.decision,binding.owner)
314
+ if(!await new Tasks(this.options.controlDir).decide(approvalMatch[1]))throw new Error('Unknown application approval')
315
+ send(200,{id:approvalMatch[1],decision:input.decision});return
316
+ }
286
317
  const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
287
318
  if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
288
319
  await this.snapshot(binding.bindingId, match[1])
@@ -3,6 +3,8 @@ export type ApplicationOrigin = {
3
3
  scope: string
4
4
  requestId: string
5
5
  followTelegram?: boolean
6
+ inputText?: string
7
+ attachmentHash?: string
6
8
  context?: Record<string, unknown>
7
9
  }
8
10
  export const applicationId = (value: unknown): value is string =>
@@ -11,5 +13,7 @@ export const validApplicationOrigin = (value: unknown): value is ApplicationOrig
11
13
  const origin = value as ApplicationOrigin | undefined
12
14
  return !!origin && /^[a-f0-9-]{36}$/.test(origin.bindingId) && applicationId(origin.scope) && applicationId(origin.requestId) &&
13
15
  (origin.followTelegram === undefined || typeof origin.followTelegram === 'boolean') &&
16
+ (origin.inputText === undefined || (typeof origin.inputText === 'string' && origin.inputText.length <= 16000)) &&
17
+ (origin.attachmentHash === undefined || /^[a-f0-9]{64}$/.test(origin.attachmentHash)) &&
14
18
  (origin.context === undefined || (!!origin.context && typeof origin.context === 'object' && !Array.isArray(origin.context) && JSON.stringify(origin.context).length <= 48000))
15
19
  }
package/src/approval.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { assertId } from './identity.js'
4
+ import { ownerEpoch, ownerId, type Owner } from './control-state.js'
4
5
 
5
6
  export type ApprovalDecision = 'pending' | 'approved' | 'denied'
6
7
 
7
8
  export type ApprovalRecord = {
8
- version: 1
9
+ version: 1 | 2
9
10
  actionId: string
10
11
  runId?: string
11
12
  prompt: string
@@ -14,19 +15,22 @@ export type ApprovalRecord = {
14
15
  decidedAt?: string
15
16
  decidedBy?: number
16
17
  decisionUpdateId?: number
18
+ decidedOwnerId?: string
19
+ decidedOwnerEpoch?: string
17
20
  }
18
21
 
19
22
  const isApprovalRecord = (value: unknown): value is ApprovalRecord => {
20
23
  if (!value || typeof value !== 'object') return false
21
24
  const candidate = value as Partial<ApprovalRecord>
22
25
  return (
23
- candidate.version === 1 &&
26
+ [1,2].includes(candidate.version ?? 0) &&
24
27
  typeof candidate.actionId === 'string' &&
25
28
  /^[a-zA-Z0-9_-]{1,40}$/.test(candidate.actionId) &&
26
29
  typeof candidate.prompt === 'string' &&
27
30
  ['pending', 'approved', 'denied'].includes(candidate.decision ?? '') &&
28
31
  typeof candidate.createdAt === 'string' &&
29
- Number.isFinite(Date.parse(candidate.createdAt))
32
+ Number.isFinite(Date.parse(candidate.createdAt)) &&
33
+ (candidate.version === 1 || typeof candidate.decidedOwnerId === 'string' && typeof candidate.decidedOwnerEpoch === 'string')
30
34
  )
31
35
  }
32
36
 
@@ -105,6 +109,18 @@ export class ApprovalStore {
105
109
  return record
106
110
  }
107
111
 
112
+ async recordOwnerDecision(actionId: string, decision: 'approved' | 'denied', owner: Owner): Promise<ApprovalRecord> {
113
+ await this.ensure()
114
+ const existing = await this.getDecision(actionId)
115
+ if (!existing) throw new Error('Unknown approval request')
116
+ if (existing.decision !== 'pending') throw new Error('Approval already decided')
117
+ if (Date.now() - Date.parse(existing.createdAt) > 900_000) throw new Error('Approval expired')
118
+ 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)
121
+ return record
122
+ }
123
+
108
124
  async getDecision(actionId: string): Promise<ApprovalRecord | null> {
109
125
  try {
110
126
  const parsed: unknown = JSON.parse(await readFile(this.filePath(actionId), 'utf8'))
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
5
5
  import path from 'node:path'
6
6
  import { terminateJob } from './executor.js'
7
7
 
8
- type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;model?:string;effort?:string;prompt:string;codexSandbox?:'external'}
8
+ type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external'}
9
9
  type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
10
10
 
11
11
  // Keep Codex's native session alive. Codex itself starts goal continuation turns;
@@ -72,7 +72,7 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
72
72
  send({method:'initialized',params:{}})
73
73
  const result=await request('thread/start',{
74
74
  cwd:options.workspace,approvalPolicy:'never',sandbox:options.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write',model:options.model,
75
- config:{project_root_markers:['AGENTS.md','.git'],'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[])],
75
+ config:{project_root_markers:['AGENTS.md','.git'],'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[]),...(options.additionalWorkspaces ?? [])],
76
76
  'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{})},
77
77
  })
78
78
  threadId=result.thread?.id
@@ -3,6 +3,7 @@ import { executionDefaults } from './model-policy.js'
3
3
  import { access, constants } from 'node:fs/promises'
4
4
  import { createHash, randomBytes } from 'node:crypto'
5
5
  import { createConnection, type Socket } from 'node:net'
6
+ import { execFile } from 'node:child_process'
6
7
  import { homedir } from 'node:os'
7
8
  import { delimiter, join } from 'node:path'
8
9
  import { fileURLToPath } from 'node:url'
@@ -29,6 +30,7 @@ export type DesktopClient = {
29
30
  request: (method: string, params?: unknown) => Promise<Record<string, unknown>>
30
31
  notify: (method: string, params?: unknown) => void
31
32
  wait: (match: (message: Record<string, unknown>) => boolean, timeoutMs: number) => Promise<Record<string, unknown>>
33
+ setServerRequestContext: (threadId?: string, turnId?: string) => void
32
34
  close: () => void
33
35
  }
34
36
 
@@ -57,6 +59,8 @@ export const desktopCodexPath = async (home = homedir(), envPath = process.env.P
57
59
  const writableRoots = (options: DesktopTurnOptions): string[] =>
58
60
  [options.controlDir, options.toolsHome].filter((value): value is string => Boolean(value))
59
61
 
62
+ const approvalPolicy = (scheduledDesktop: boolean): 'on-request' | 'never' => scheduledDesktop ? 'on-request' : 'never'
63
+
60
64
  const sendFrame = (socket: Socket, text: string) => {
61
65
  const payload = Buffer.from(text)
62
66
  const mask = randomBytes(4)
@@ -71,6 +75,29 @@ const sendFrame = (socket: Socket, text: string) => {
71
75
  socket.write(Buffer.concat([header, mask, masked]))
72
76
  }
73
77
 
78
+ export const desktopServerRequestResult = (
79
+ message: Record<string, unknown>,
80
+ context?: { threadId: string; turnId?: string },
81
+ ): Record<string, unknown> => {
82
+ if (message.method === 'mcpServer/elicitation/request') {
83
+ const params = message.params as { threadId?: unknown; turnId?: unknown; _meta?: Record<string, unknown> } | undefined
84
+ const meta = params?._meta
85
+ const toolParams = meta?.tool_params as Record<string, unknown> | undefined
86
+ const origin = toolParams?.origin
87
+ const browserOrigin = meta?.connector_id === 'browser-use' && meta.codex_approval_kind === 'mcp_tool_call' &&
88
+ meta.codex_request_type === 'approval_request' && meta.tool_name === 'access_browser_origin' &&
89
+ typeof origin === 'string' && /^https?:\/\//.test(origin) && meta.origin === origin
90
+ const chromeApp = meta?.connector_id === 'computer-use' && meta.codex_approval_kind === 'mcp_tool_call' &&
91
+ meta.codex_request_type !== 'approval_request' && meta.tool_name !== 'start_audio_recording' &&
92
+ toolParams?.app === 'com.google.Chrome'
93
+ if (context && params?.threadId === context.threadId && typeof params.turnId === 'string' &&
94
+ (!context.turnId || params.turnId === context.turnId) && (browserOrigin || chromeApp))
95
+ return { action: 'accept', content: null, _meta: { persist: 'session' } }
96
+ return { action: 'decline', content: null, _meta: null }
97
+ }
98
+ return { decision: 'decline' }
99
+ }
100
+
74
101
  export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopClient => {
75
102
  let buffer = Buffer.concat(pending)
76
103
  let nextId = 1
@@ -79,6 +106,7 @@ export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopCli
79
106
  const failedWaits = new Set<() => void>()
80
107
  const notifications: Record<string, unknown>[] = []
81
108
  const watchers: Array<(message: Record<string, unknown>) => void> = []
109
+ let serverRequestContext: { threadId: string; turnId?: string } | undefined
82
110
  const deliver = (message: Record<string, unknown>) => {
83
111
  const id = message.id
84
112
  if (typeof id === 'number' && replies.has(id) && (message.result !== undefined || message.error)) {
@@ -89,8 +117,13 @@ export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopCli
89
117
  else reply.resolve(message.result as Record<string, unknown>)
90
118
  return
91
119
  }
92
- if (typeof id === 'number' && typeof message.method === 'string') {
93
- sendFrame(socket, JSON.stringify({ id, result: { decision: 'approved' } }))
120
+ if ((typeof id === 'number' || typeof id === 'string') && typeof message.method === 'string') {
121
+ const result = desktopServerRequestResult(message, serverRequestContext)
122
+ if (result.action === 'accept' && serverRequestContext && !serverRequestContext.turnId) {
123
+ const turnId = (message.params as { turnId?: unknown } | undefined)?.turnId
124
+ if (typeof turnId === 'string') serverRequestContext.turnId = turnId
125
+ }
126
+ sendFrame(socket, JSON.stringify({ id, result }))
94
127
  return
95
128
  }
96
129
  notifications.push(message)
@@ -168,6 +201,9 @@ export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopCli
168
201
  failedWaits.add(fail)
169
202
  watchers.push(watcher)
170
203
  }),
204
+ setServerRequestContext: (threadId, turnId) => {
205
+ serverRequestContext = threadId ? { threadId, turnId } : undefined
206
+ },
171
207
  close: () => socket.destroy(),
172
208
  }
173
209
  }
@@ -200,6 +236,32 @@ export const connectDesktop = (socketPath = desktopControlSocket()): Promise<Des
200
236
  socket.on('data', onData)
201
237
  })
202
238
 
239
+ const startDesktopDaemon = async (
240
+ home = homedir(),
241
+ envPath = process.env.PATH || '',
242
+ run: typeof execFile = execFile,
243
+ ): Promise<void> => {
244
+ const codex = await desktopCodexPath(home, envPath)
245
+ if (!codex) throw new Error(DESKTOP_UNAVAILABLE)
246
+ await new Promise<void>((resolve, reject) => run(codex, ['app-server', 'daemon', 'start'], {
247
+ env: { HOME: home, PATH: envPath }, timeout: 15_000, maxBuffer: 64 * 1024,
248
+ }, (error) => error ? reject(new Error(DESKTOP_UNAVAILABLE)) : resolve()))
249
+ }
250
+
251
+ export const connectManagedDesktop = async (
252
+ connect: typeof connectDesktop = connectDesktop,
253
+ start: typeof startDesktopDaemon = startDesktopDaemon,
254
+ ): Promise<DesktopClient> => {
255
+ try { return await connect() }
256
+ catch {
257
+ // The native Codex daemon owns its lifecycle and is idempotent. New desktop
258
+ // builds do not necessarily create the control socket merely by opening the
259
+ // app, so ask Codex to restore its own bridge before failing closed.
260
+ await start()
261
+ return connect()
262
+ }
263
+ }
264
+
203
265
  export const runDesktopTurn = async (
204
266
  options: DesktopTurnOptions,
205
267
  io: { connect?: typeof connectDesktop; emit?: (line: string) => void; signal?: AbortSignal } = {},
@@ -208,7 +270,8 @@ export const runDesktopTurn = async (
208
270
  const emit = io.emit ?? ((line: string) => process.stdout.write(`${line}\n`))
209
271
  let client: DesktopClient | undefined
210
272
  try {
211
- client = await (io.connect ?? connectDesktop)()
273
+ const scheduledDesktop = options.runId.startsWith('r_schedule_')
274
+ client = await (io.connect ?? connectManagedDesktop)()
212
275
  await client.request('initialize', { clientInfo: { name: 'ezenciel-agents', title: 'ez', version: '1' } })
213
276
  client.notify('initialized', {})
214
277
  const roots = writableRoots(options)
@@ -227,7 +290,7 @@ export const runDesktopTurn = async (
227
290
  const started = await client.request('thread/start', {
228
291
  cwd: options.workspace,
229
292
  config,
230
- approvalPolicy: 'never',
293
+ approvalPolicy: approvalPolicy(scheduledDesktop),
231
294
  sandbox: 'workspace-write',
232
295
  model: options.model,
233
296
  serviceName: 'ezenciel-agents',
@@ -238,17 +301,19 @@ export const runDesktopTurn = async (
238
301
  }
239
302
  if (!nativeThread(threadId)) throw new Error(DESKTOP_UNAVAILABLE)
240
303
  emit(JSON.stringify({ type: 'thread.started', thread_id: threadId }))
304
+ client.setServerRequestContext(scheduledDesktop ? threadId! : undefined)
241
305
  const turn = await client.request('turn/start', {
242
306
  threadId,
243
307
  input: [{ type: 'text', text: options.prompt }],
244
308
  model: options.model,
245
309
  effort: options.effort,
246
310
  cwd: options.workspace,
247
- approvalPolicy: 'never',
311
+ approvalPolicy: approvalPolicy(scheduledDesktop),
248
312
  sandboxPolicy: { type: 'workspaceWrite', writableRoots: roots, networkAccess: Boolean(options.toolsHome) },
249
313
  })
250
314
  const turnId = (turn.turn as { id?: string } | undefined)?.id
251
315
  if (!turnId) throw new Error(DESKTOP_UNAVAILABLE)
316
+ if (scheduledDesktop) client.setServerRequestContext(threadId!, turnId)
252
317
  const interrupt = () => { void client?.request('turn/interrupt', { threadId, turnId }).catch(() => {}) }
253
318
  io.signal?.addEventListener('abort', interrupt, { once: true })
254
319
  if (io.signal?.aborted) interrupt()
@@ -2,21 +2,22 @@ import { mkdir, open, readFile, rename, unlink, lstat } from 'node:fs/promises'
2
2
  import { join, isAbsolute } from 'node:path'
3
3
  import { randomUUID, createHash } from 'node:crypto'
4
4
  import { request } from 'node:http'
5
- import type { Owner } from './control-state.js'
5
+ import { sameOwner, validOwner, type Owner } from './control-state.js'
6
6
 
7
- export type ExternalOrigin = { sourceId: string; bindingId: string; eventIds: string[] }
7
+ export type ExternalOrigin = { sourceId: string; bindingId: string; conversationId?: string; eventIds: string[] }
8
8
  export type SourceEvent = { id: string; conversationId: string; receivedAt: number; text: string }
9
9
  export type EventSource = { id: string; bindingId: string; socketPath: string; initialCursor: number; owner: Owner }
10
10
  const identifier = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(s)
11
11
  export const validOrigin = (o: unknown): o is ExternalOrigin => {
12
12
  const v = o as ExternalOrigin | undefined
13
- return !!v && identifier(v.sourceId) && identifier(v.bindingId) && Array.isArray(v.eventIds) && v.eventIds.length > 0 && v.eventIds.length <= 10 && v.eventIds.every(identifier)
13
+ return !!v && identifier(v.sourceId) && identifier(v.bindingId) &&
14
+ (v.conversationId === undefined || typeof v.conversationId === 'string' && v.conversationId.length > 0 && v.conversationId.length <= 200) &&
15
+ Array.isArray(v.eventIds) && v.eventIds.length > 0 && v.eventIds.length <= 10 && v.eventIds.every(identifier)
14
16
  }
15
17
  const cursorOK = (v: unknown): v is number => Number.isSafeInteger(v) && Number(v) >= 0
16
18
  export const validEvents = (v: unknown): v is SourceEvent[] => Array.isArray(v) && v.length <= 10 && v.every(e =>
17
19
  identifier(e?.id) && typeof e.conversationId === 'string' && e.conversationId.length > 0 && e.conversationId.length <= 200 &&
18
20
  Number.isFinite(e.receivedAt) && typeof e.text === 'string' && e.text.length <= 16000) && new Set(v.map(e => e.id)).size === v.length
19
- const sameOwner = (a: Owner, b: Owner) => a.telegramUserId === b.telegramUserId && a.telegramChatId === b.telegramChatId
20
21
  async function read<T>(path: string, fallback: T): Promise<T> {
21
22
  try { return JSON.parse(await readFile(path, 'utf8')) as T } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return fallback; throw new Error('Unreadable event-source state') }
22
23
  }
@@ -49,8 +50,7 @@ export class EventSources {
49
50
  const value = await read<{ version: number; sources: EventSource[] }>(this.registry, { version: 1, sources: [] })
50
51
  if (value.version !== 1 || !Array.isArray(value.sources) || value.sources.some(s => !identifier(s.id) || !identifier(s.bindingId) ||
51
52
  typeof s.socketPath !== 'string' || !isAbsolute(s.socketPath) || !cursorOK(s.initialCursor) ||
52
- !Number.isSafeInteger(s.owner?.telegramUserId) || s.owner.telegramUserId! <= 0 || !Number.isSafeInteger(s.owner.telegramChatId) ||
53
- (s.owner.kind === 'group' ? s.owner.telegramChatId! >= 0 : s.owner.kind !== undefined || s.owner.telegramChatId! <= 0)) ||
53
+ !validOwner(s.owner)) ||
54
54
  new Set(value.sources.map(s => s.id)).size !== value.sources.length) throw new Error('Invalid event-source registry')
55
55
  return value.sources
56
56
  }
@@ -106,6 +106,13 @@ export class EventSources {
106
106
  if (!validEvents(result?.events) || result.events.some((e: SourceEvent) => !origin.eventIds.includes(e.id))) throw new Error('Invalid event recheck')
107
107
  return result.events
108
108
  }
109
+ async release(origin: ExternalOrigin, owner: Owner): Promise<void> {
110
+ if (!validOrigin(origin)) return
111
+ const source = (await this.available(owner)).find(s => s.id === origin.sourceId && s.bindingId === origin.bindingId)
112
+ if (!source) return
113
+ const head = await sourceCall(source.socketPath, 'events-head')
114
+ if (head.releaseEvents === true) await sourceCall(source.socketPath, 'events-release', {ids:origin.eventIds})
115
+ }
109
116
  }
110
117
  export const eventRunId = (source: EventSource, events: SourceEvent[]) => 'event_' + createHash('sha256')
111
118
  .update(JSON.stringify([source.bindingId, events.map(e => e.id)])).digest('hex')
package/src/executor.ts CHANGED
@@ -21,6 +21,7 @@ export type ExecutorOptions = {
21
21
  binDir: string
22
22
  toolsHome?: string
23
23
  sharedWorkspace?: string
24
+ additionalWorkspaces?: string[]
24
25
  cli?: string
25
26
  sessionId?: string
26
27
  isResume?: boolean
@@ -75,7 +76,7 @@ export type CliAdapter = {
75
76
  command: string
76
77
  description: string
77
78
  buildArgs: (
78
- options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'codexAutoCompactTokens' | 'codexSandbox'> & { controlDir?: string },
79
+ options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox'> & { controlDir?: string },
79
80
  promptFile: string,
80
81
  promptText: string,
81
82
  ) => string[]
@@ -94,6 +95,7 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
94
95
  }
95
96
  if (opts.controlDir) args.push('--add-dir', opts.controlDir)
96
97
  if (opts.sharedWorkspace) args.push('--add-dir', opts.sharedWorkspace)
98
+ for (const workspace of opts.additionalWorkspaces ?? []) args.push('--add-dir', workspace)
97
99
  if (opts.toolsHome) args.push('--add-dir', opts.toolsHome, '-c', 'sandbox_workspace_write.network_access=true')
98
100
  if (opts.model) args.push('--model', opts.model)
99
101
  if (opts.effort) args.push('-c', `model_reasoning_effort=${JSON.stringify(opts.effort)}`)
@@ -248,7 +250,7 @@ export const startExecutorJob = async (
248
250
  const nativeSession = !host && key === 'codex' && options.runId.startsWith('r_schedule_')
249
251
  // Chat-mode experiment: only direct chat input at the engine boundary.
250
252
  const applicationReminder = !host && (run?.application || run?.delivery)
251
- ? '\n\n[Application channel] This is an owner-authorized application conversation. Send text replies using ezenciel-agents-message; stdout alone is not delivered. Attachments/reactions/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) + '.'
253
+ ? '\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) + '.'
252
254
  : ''
253
255
  const chatReminder = !host && !run?.taskId && run?.messageId !== undefined
254
256
  ? '\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.'
package/src/files.ts CHANGED
@@ -73,3 +73,15 @@ export const workspaceFile = async (workspace: string, file: string, regular = t
73
73
  if (regular && !(await stat(target)).isFile()) throw new Error('Expected a regular file')
74
74
  return target
75
75
  }
76
+
77
+ // Canonical native-engine attachment metadata for every inbound channel.
78
+ export const MAX_INCOMING_ATTACHMENT_BYTES = 10 * 1024 * 1024
79
+ export const stageChatAttachment = async (workspace: string, name: string, bytes: Buffer, comment: string) => {
80
+ const type = detectFileType(bytes)
81
+ if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || type === 'unknown' ||
82
+ (type === 'text' && !/\.(txt|md|markdown)$/i.test(name))) throw new Error('Invalid application attachment: unsupported type or size')
83
+ const staged = await stageIncomingFile(workspace, name, bytes)
84
+ const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : 'document'
85
+ return { text: `[Attached ${kind} staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
86
+ attachment: {path: staged.relativePath, type: staged.fileType}, fullPath: staged.fullPath }
87
+ }
@@ -1,2 +1,2 @@
1
- // Telegram batches, registered plugin events and locally created runs cross this transport.
2
- export const isHostRunId = (id: string): boolean => /^(?:r_[a-zA-Z0-9_]+|tg_\d+|event_[a-f0-9]{64})$/.test(id)
1
+ // Telegram batches (including scheduler recovery), registered plugin events and locally created runs cross this transport.
2
+ export const isHostRunId = (id: string): boolean => /^(?:r_[a-zA-Z0-9_]+|tg_(?:\d+|replay_\d+_\d{8})|event_[a-f0-9]{64})$/.test(id)
@@ -13,11 +13,14 @@ import type { ChildProcess } from 'node:child_process'
13
13
  import { taskWorkspace } from './task-workspace.js'
14
14
  import { packageVersion } from './version.js'
15
15
  import { installedPluginVersions } from './software-status.js'
16
+ import { processSnapshot } from './process-tree.js'
16
17
 
17
18
  export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
18
- export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
19
+ export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
19
20
  export type HostInstallation = { cli: string; agents: HostBinding[] }
20
21
 
22
+ const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
23
+
21
24
  export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob) => {
22
25
  resolveExecutor(installation.cli)
23
26
  if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
@@ -27,12 +30,17 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
27
30
  const tasks = new Set<Promise<void>>()
28
31
  const locks: string[] = []
29
32
  const sharedWorkspaces = new Map<HostBinding, string>()
33
+ const additionalWorkspaces = new Map<HostBinding, string[]>()
30
34
  const catalog = (agent: HostBinding) => readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
31
35
  try {
32
36
  for (const agent of installation.agents) {
33
37
  if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
34
38
  if (agent.sharedWorkspace && !path.isAbsolute(agent.sharedWorkspace)) throw new Error('Shared workspace requires an absolute path')
35
39
  if (agent.sharedWorkspace) sharedWorkspaces.set(agent, await realpath(agent.sharedWorkspace))
40
+ if (agent.additionalWorkspaces) {
41
+ if (!Array.isArray(agent.additionalWorkspaces) || agent.additionalWorkspaces.some(workspace=>typeof workspace!=='string' || !path.isAbsolute(workspace))) throw new Error('Additional workspaces require absolute paths')
42
+ additionalWorkspaces.set(agent,await Promise.all(agent.additionalWorkspaces.map(workspace=>realpath(workspace))))
43
+ }
36
44
  if (agent.toolsHome) {
37
45
  if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
38
46
  const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
@@ -41,9 +49,17 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
41
49
  const directory = path.join(agent.controlDir,'host-executor')
42
50
  await mkdir(directory,{recursive:true,mode:0o700})
43
51
  const lock=path.join(directory,'worker.lock')
44
- try { const prior=JSON.parse(await readFile(lock,'utf8')); try { process.kill(prior.pid,0); throw new Error('Host executor already running') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }; await rm(lock) }
52
+ try {
53
+ const prior=JSON.parse(await readFile(lock,'utf8'))
54
+ const currentStart=await processStart(prior.pid)
55
+ if (!prior.started || !currentStart || prior.started===currentStart) {
56
+ try { process.kill(prior.pid,0); throw new Error('Host executor already running') }
57
+ catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
58
+ }
59
+ await rm(lock)
60
+ }
45
61
  catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
46
- await writeFile(lock,JSON.stringify({pid:process.pid}),{mode:0o600,flag:'wx'})
62
+ await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
47
63
  locks.push(lock)
48
64
  await installAgentGuidance(agent.workspace)
49
65
  await writeFile(path.join(directory,'models.json'),JSON.stringify(await catalog(agent)),{mode:0o600})
@@ -114,7 +130,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
114
130
  const cli = opts.cli || installation.cli
115
131
  resolveExecutor(cli)
116
132
  if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,model:opts.model,effort:opts.effort},await catalog(agent))
117
- const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.workspace,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,cli,
133
+ const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.workspace,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
118
134
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
119
135
  sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model:opts.model,effort:opts.effort,codexAutoCompactTokens:opts.codexAutoCompactTokens}
120
136
  job=await launch(request.texts,options)