@jc_stack/ez-agents 0.1.0-beta.31 → 0.1.0-beta.33

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 (57) hide show
  1. package/AGENTS.md +47 -32
  2. package/CHANGELOG.md +34 -1
  3. package/CONTRIBUTING.md +29 -26
  4. package/README.md +36 -25
  5. package/docs/application-channel.md +29 -2
  6. package/docs/architecture/ai-selection.md +10 -1
  7. package/docs/architecture/authority-boundaries.md +64 -14
  8. package/docs/architecture/event-sources.md +12 -5
  9. package/docs/architecture/telegram-intake.md +1 -1
  10. package/docs/host-service.md +3 -0
  11. package/docs/plugin-contributions.md +6 -1
  12. package/docs/plugins.md +23 -0
  13. package/docs/responsive-channels.md +3 -0
  14. package/docs/selective-monitoring.md +55 -3
  15. package/docs/setup.md +2 -1
  16. package/docs/upgrades.md +6 -4
  17. package/package.json +1 -1
  18. package/src/application-channel.ts +40 -9
  19. package/src/application-origin.ts +4 -0
  20. package/src/approval.ts +19 -3
  21. package/src/codex-session.ts +2 -2
  22. package/src/desktop-bridge.ts +70 -5
  23. package/src/event-sources.ts +13 -6
  24. package/src/executor.ts +4 -2
  25. package/src/files.ts +12 -0
  26. package/src/host-executor-protocol.ts +2 -2
  27. package/src/host-executor.ts +20 -4
  28. package/src/index.ts +81 -34
  29. package/src/plugins/manager.mjs +52 -20
  30. package/src/runs.ts +19 -2
  31. package/src/task-attachments.ts +56 -0
  32. package/src/task-capability.ts +45 -0
  33. package/src/task-cli.ts +7 -4
  34. package/src/task-executor.ts +8 -4
  35. package/src/task-mcp.ts +25 -8
  36. package/src/tasks.ts +132 -26
  37. package/src/telegram-source.ts +34 -13
  38. package/src/updates/artifact.mjs +2 -2
  39. package/src/updates/control.mjs +11 -1
  40. package/src/updates/runtime.mjs +3 -1
  41. package/templates/agent-guidance.md +14 -0
  42. package/test/application-channel.test.ts +52 -1
  43. package/test/codex-session.test.ts +2 -1
  44. package/test/desktop-bridge.test.ts +98 -1
  45. package/test/event-sources.test.ts +68 -8
  46. package/test/executor.test.ts +2 -2
  47. package/test/host-executor.test.ts +32 -3
  48. package/test/intake-relay.test.ts +79 -2
  49. package/test/local-qa.test.mjs +7 -3
  50. package/test/plugin-manager.test.mjs +31 -0
  51. package/test/runs.test.ts +19 -0
  52. package/test/task-attachments.test.ts +77 -0
  53. package/test/task-capability.test.ts +48 -0
  54. package/test/task-native.test.ts +4 -1
  55. package/test/tasks.test.ts +53 -1
  56. package/test/telegram-source.test.ts +28 -1
  57. package/test/updates.test.mjs +24 -1
@@ -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)
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { scheduledTasksText } from './scheduled-tasks.js'
13
13
  import { taskWorkspace } from './task-workspace.js'
14
14
  import { queueUpdateAttention } from './update-attention.js'
15
15
  import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
16
+ import {removeTaskAttachments} from './task-attachments.js'
16
17
  import { dirname, join, basename } from 'node:path'
17
18
  import { fileURLToPath } from 'node:url'
18
19
  import path from 'node:path'
@@ -28,7 +29,7 @@ import { startExecutorJob, terminateJob } from './executor.js'
28
29
  import { RunStore, type RunRecord } from './runs.js'
29
30
  import { splitTelegramText } from './reply.js'
30
31
  import { markdownToTelegramHtml, escapeHtml } from './format.js'
31
- import { sanitizeFileName, stageIncomingFile, workspaceFile } from './files.js'
32
+ import { sanitizeFileName, stageChatAttachment, workspaceFile } from './files.js'
32
33
  import { transcribeAudio, synthesizeSpeech } from './audio.js'
33
34
  import { normalizeReactionEmoji } from './reaction.js'
34
35
  import { downloadTelegramFile } from './read-request.js'
@@ -108,7 +109,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
108
109
  }
109
110
 
110
111
  const applicationChannel = new ApplicationChannel({
111
- controlDir: config.controlDir, initial: aiMenu.initial,
112
+ controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
112
113
  aiControls: aiMenu,
113
114
  wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
114
115
  cancel: id => withStartLock(async () => {
@@ -153,7 +154,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
153
154
  return ids
154
155
  }
155
156
 
156
- const telegramSource = telegramEnabled ? new TelegramSource(config.controlDir, config.telegramBotToken.split(':')[0], sendChat) : null
157
+ const telegramSource = telegramEnabled ? new TelegramSource(config.controlDir, config.telegramBotToken.split(':')[0], sendChat,
158
+ async(chatId,text,bytes,filename)=>[(await bot!.api.sendDocument(chatId,new InputFile(bytes,filename),{caption:text})).message_id]) : null
157
159
 
158
160
  const startJob = async (run: RunRecord): Promise<void> => {
159
161
  await withStartLock(async () => {
@@ -161,19 +163,20 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
161
163
  // stat uses the effective UID; access uses the relay's isolated real UID.
162
164
  if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
163
165
  if ((await runs.get(run.id))?.status !== 'queued') return
164
- if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled) return
166
+ if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled && !run.external) return
165
167
  if (!run.scheduled && await runs.running(false)) return
166
168
  const owner = (await control.status()).owner
167
169
  if (!owner || !ownsRun(owner, run)) {
168
170
  await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
171
+ await releaseExternal(run)
169
172
  return
170
173
  }
171
174
  if (run.application || run.delivery) {
172
175
  try { await applicationChannel.bindings.authorize(run) }
173
- catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); return }
176
+ catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); await releaseExternal(run); return }
174
177
  }
175
178
  if (config.channelBackendUrl) {
176
- if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); return }
179
+ if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); await releaseExternal(run); return }
177
180
  activeBackend = true
178
181
  await runs.patch(run.id, { status: 'running', backendSubmitted: true })
179
182
  void dispatchChannel(config, run).then(async reply => {
@@ -201,18 +204,20 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
201
204
  try { events = await sources.check(run.external, owner); unavailableSources.delete(run.external.sourceId) } catch { unavailableSources.add(run.external.sourceId); return }
202
205
  if (!events.length) {
203
206
  await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() })
207
+ await releaseExternal(run)
204
208
  return
205
209
  }
206
210
  texts = events.map(event => JSON.stringify(event))
207
211
  }
208
212
  if (run.taskId) {
209
213
  try { await tasks.authorize(run) } catch {
210
- await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); return
214
+ await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); await releaseExternal(run); return
211
215
  }
212
216
  }
213
217
  const blockReason = run.taskId ? undefined : executionBlockReason(run, owner)
214
218
  if (blockReason) {
215
219
  await runs.patch(run.id, { status: 'cancelled', blockReason, endedAt: new Date().toISOString() })
220
+ await releaseExternal(run)
216
221
  return
217
222
  }
218
223
  try {
@@ -288,6 +293,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
288
293
  if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
289
294
  const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
290
295
  await runs.patch(started.id, { status: cancelled ? 'cancelled' : code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString(), exitCode: code, ...(code !== 0 && !cancelled ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
296
+ await releaseExternal(run)
291
297
  } catch (error) {
292
298
  await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
293
299
  console.error('Session completion failed', safeError(error))
@@ -313,6 +319,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
313
319
  activeTypingTimer = null
314
320
  }
315
321
  await runs.patch(run.id, { status: 'failed', failureReason: 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
322
+ await releaseExternal(run)
316
323
  console.error('run start failed', run.id, safeError(error))
317
324
  if (bot && !run.application && !run.delivery && run.chatId !== undefined) await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
318
325
  setImmediate(() => {
@@ -326,6 +333,22 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
326
333
  }
327
334
 
328
335
  const unavailableSources = new Set<string>()
336
+ const releaseExternal = async (run: RunRecord):Promise<boolean> => {
337
+ if (!run.external) return true
338
+ try {await removeTaskAttachments(config.controlDir,run.id)} catch {return false}
339
+ const current=await runs.get(run.id)
340
+ if (current?.externalReleased) {if(run.taskId)await runs.pruneTaskHistory(run.taskId);return true}
341
+ const task = run.taskId ? await tasks.get(run.taskId).catch(() => null) : null
342
+ const registered=(await sources.list()).find(source=>source.id===run.external!.sourceId&&source.bindingId===run.external!.bindingId)
343
+ const owner=task?.owner ?? (registered&&ownsRun(registered.owner,run)?registered.owner:(await control.status()).owner)
344
+ if(!owner)return false
345
+ try {
346
+ await sources.release(run.external,owner)
347
+ await runs.patch(run.id,{externalReleased:true})
348
+ if(run.taskId)await runs.pruneTaskHistory(run.taskId)
349
+ return true
350
+ } catch {unavailableSources.add(run.external.sourceId);return false}
351
+ }
329
352
  let sourceWork: Promise<void> | undefined
330
353
  const drainSources = (): Promise<void> => {
331
354
  if (sourceWork) return sourceWork
@@ -337,6 +360,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
337
360
  try {
338
361
  if (!ownsRun(owner, foreground)) throw new Error('Owner channel revoked')
339
362
  if (foreground.application) await applicationChannel.bindings.authorize(foreground)
363
+ if (foreground.taskId) {
364
+ const task = await tasks.authorize(foreground,false)
365
+ const ownerWaiting=(await inbox.status()).pending>0 || (await runs.list()).some(run=>run.status==='queued'&&!run.taskId&&!run.scheduled)
366
+ if (task.anyConversation && ownerWaiting) throw new Error('Owner input preempts public work')
367
+ }
340
368
  }
341
369
  catch { ownerStopped.add(activeChild); terminateJob(activeChild) }
342
370
  }
@@ -348,6 +376,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
348
376
  } catch { ownerStopped.add(child); terminateJob(child); continue }
349
377
  if (await scheduler.cancelled(id)) terminateJob(child)
350
378
  }
379
+ const pendingRelease=(await runs.list()).find(run=>run.external&&!run.externalReleased&&['completed','failed','cancelled'].includes(run.status))
380
+ if(pendingRelease)await releaseExternal(pendingRelease)
351
381
  if (!owner) return
352
382
  if (!config.channelBackendUrl) {
353
383
  await drainTaskRequests()
@@ -356,7 +386,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
356
386
  }
357
387
  if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
358
388
  }
359
- for (const source of !telegramOwner(owner) || config.channelBackendUrl ? [] : await sources.available(owner)) {
389
+ for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
360
390
  try {
361
391
  const batch = await sources.batch(source)
362
392
  unavailableSources.delete(source.id)
@@ -366,23 +396,37 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
366
396
  await sources.remember(source, batch)
367
397
  const groups = new Map<string, SourceEvent[]>()
368
398
  for (const event of batch.events) groups.set(event.conversationId, [...(groups.get(event.conversationId) || []), event])
399
+ const known = await runs.list()
400
+ const publicTaskIds = new Set((await tasks.list()).filter(task=>task.anyConversation).map(task=>task.id))
401
+ let publicOutstanding = known.filter(run => run.taskId && publicTaskIds.has(run.taskId) && ['queued','running'].includes(run.status)).length
402
+ let admitted = true
369
403
  for (const events of groups.values()) {
370
- const task = await tasks.match(source.id, source.bindingId, events)
371
- await runs.create({
372
- taskId: task?.id,
373
- id: eventRunId(source, events), chatId: owner.telegramChatId, telegramUserId: owner.telegramUserId,
374
- texts: [], execution: await durableWorkerChoice(),
375
- external: { sourceId: source.id, bindingId: source.bindingId, eventIds: events.map(e => e.id) },
376
- })
404
+ let task = await tasks.match(source.id, source.bindingId, events)
405
+ const id = eventRunId(source,events)
406
+ if (known.some(run => run.id === id)) continue
407
+ if (task?.anyConversation) {
408
+ const publicTaskId=task.id
409
+ if (publicOutstanding >= 8 || known.some(run => run.taskId === publicTaskId &&
410
+ run.external?.conversationId === events[0].conversationId && ['queued','running'].includes(run.status))) { admitted=false; continue }
411
+ const admission = await tasks.admitPublic(task.id)
412
+ if (admission === 'defer') { admitted=false; continue }
413
+ if (admission === 'revoked') task=undefined
414
+ }
415
+ const identity = owner.telegramUserId !== undefined && owner.telegramChatId !== undefined
416
+ ? {chatId:owner.telegramChatId,telegramUserId:owner.telegramUserId}
417
+ : {ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner)}
418
+ const created = await runs.create({taskId:task?.id,id,...identity,texts:[],execution:await durableWorkerChoice(),
419
+ external:{sourceId:source.id,bindingId:source.bindingId,conversationId:events[0].conversationId,eventIds:events.map(e=>e.id)}})
420
+ known.push(created); if(task?.anyConversation)publicOutstanding++
377
421
  }
378
422
  // Acknowledgement follows durable run creation; replay uses the saved batch.
379
- await sources.advance(source, batch.cursor)
423
+ if(admitted)await sources.advance(source, batch.cursor)
380
424
  })
381
425
  } catch { unavailableSources.add(source.id) }
382
426
  }
383
427
  await runs.running(true)
384
428
  if (!config.channelBackendUrl) await scheduler.tick(owner,runs)
385
- for (const run of (await runs.list()).filter(r => r.status === 'queued')) {
429
+ for (const run of (await runs.list()).filter(r => r.status === 'queued').sort((a,b)=>Number(Boolean(a.taskId))-Number(Boolean(b.taskId)))) {
386
430
  if (shuttingDown) break
387
431
  await startJob(run)
388
432
  }
@@ -673,6 +717,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
673
717
  for (const run of await runs.list()) {
674
718
  if (run.status !== 'queued' || run.backendSubmitted) continue
675
719
  await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() })
720
+ await releaseExternal(run)
676
721
  count++
677
722
  }
678
723
  })
@@ -683,26 +728,32 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
683
728
  // return after the authorized update has reached the atomic local journal.
684
729
  if (bot) {
685
730
  bot.use(async (ctx, next) => {
686
- if (ctx.callbackQuery && (await control.status()).owner?.kind === 'group') {
687
- if (!isOwner(ctx, (await control.status()).owner)) return
731
+ const currentOwner = (await control.status()).owner
732
+ if (ctx.callbackQuery && currentOwner?.kind === 'group') {
733
+ if (!isOwner(ctx, currentOwner)) return
688
734
  try {
689
735
  const member = await bot!.api.getChatMember(ctx.chat!.id, ctx.from!.id)
690
736
  if (!['creator', 'administrator', 'member'].includes(member.status) &&
691
737
  !(member.status === 'restricted' && member.is_member)) return
692
738
  } catch { throw new Error('Group membership verification unavailable; retry the update') }
693
739
  }
694
- if ((ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') &&
695
- !isOwner(ctx, (await control.status()).owner)) {
740
+ const externalText = !replay.has(ctx.update) && ctx.message?.text && !ctx.message.sender_chat && ctx.from && !ctx.from.is_bot && telegramOwner(currentOwner) && !isOwner(ctx,currentOwner)
741
+ if (externalText) {
742
+ await telegramSource!.start(currentOwner!)
743
+ const group = ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
744
+ const addressed = !group || ctx.message.reply_to_message?.from?.id === bot!.botInfo.id || Boolean(bot!.botInfo.username &&
745
+ new RegExp(`(^|\\s)@${bot!.botInfo.username}(?=\\s|$|[,.!?;:])`,'i').test(ctx.message.text!))
746
+ if (await telegramSource!.capture(ctx.update.update_id,ctx.message as import('grammy/types').Message.TextMessage,ctx.from!,addressed)) return
747
+ }
748
+ if ((ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') && !isOwner(ctx, currentOwner)) {
696
749
  if (config.channelBackendUrl) return
697
- const owner = telegramOwner((await control.status()).owner)
750
+ const owner = telegramOwner(currentOwner)
698
751
  const message = ctx.message
699
752
  if (!owner && message && !message.sender_chat && ctx.from && !ctx.from.is_bot) {
700
753
  await control.requestPairing(ctx.from.id, ctx.chat.id, ctx.chat.title)
701
754
  return
702
755
  }
703
756
  if (!owner || !message?.text || message.sender_chat || !ctx.from || ctx.from.is_bot) return
704
- await telegramSource!.start(owner)
705
- if (await telegramSource!.capture(ctx.update.update_id, message as import('grammy/types').Message.TextMessage, ctx.from)) return
706
757
  if (owner.kind === 'group') return
707
758
  if (ctx.from.id !== owner.telegramUserId) return
708
759
  if (replay.has(ctx.update)) {
@@ -859,12 +910,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
859
910
  const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
860
911
  const buffer = await downloadTelegramFile(fileUrl)
861
912
  const fileName = sanitizeFileName(basename(fileInfo.file_path) || 'photo.jpg')
862
- const staged = await stageIncomingFile(config.workspace, fileName, buffer)
863
- const caption = ctx.message.caption?.trim() || ''
864
- const prompt = `[Attached image staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
913
+ const staged = await stageChatAttachment(config.workspace, fileName, buffer, ctx.message.caption ?? '')
865
914
  collectItem({
866
- text: prompt,
867
- attachment: { path: staged.relativePath, type: staged.fileType },
915
+ text: staged.text,
916
+ attachment: staged.attachment,
868
917
  messageId: ctx.message.message_id,
869
918
  updateId: ctx.update.update_id,
870
919
  chatId: ctx.chat.id,
@@ -888,12 +937,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
888
937
  const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
889
938
  const buffer = await downloadTelegramFile(fileUrl)
890
939
  const fileName = sanitizeFileName(doc.file_name || basename(fileInfo.file_path) || 'document.bin')
891
- const staged = await stageIncomingFile(config.workspace, fileName, buffer)
892
- const caption = ctx.message.caption?.trim() || ''
893
- const prompt = `[Attached document staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
940
+ const staged = await stageChatAttachment(config.workspace, fileName, buffer, ctx.message.caption ?? '')
894
941
  collectItem({
895
- text: prompt,
896
- attachment: { path: staged.relativePath, type: staged.fileType },
942
+ text: staged.text,
943
+ attachment: staged.attachment,
897
944
  messageId: ctx.message.message_id,
898
945
  updateId: ctx.update.update_id,
899
946
  chatId: ctx.chat.id,
@@ -41,12 +41,36 @@ export async function bindToolDiscovery(home,workspace) {
41
41
  try {await fs.writeFile(tmp,next,{mode:0o600,flag:'wx'});await fs.rename(tmp,file);}finally{await fs.rm(tmp,{force:true});}
42
42
  }
43
43
  }
44
- export async function locked(home, fn) {
44
+ const invocationDirectory = home => path.join(home,'command-invocations');
45
+ async function activeInvocations(home) {
46
+ const directory=invocationDirectory(home),files=await fs.readdir(directory).catch(error=>{if(error.code==='ENOENT')return [];throw error;});
47
+ for(const file of files) {
48
+ if(!/^[0-9a-f-]{36}\.json$/.test(file))throw Error('Invalid command invocation lease; inspect before recovery');
49
+ const lease=await json(path.join(directory,file));
50
+ if(!Number.isSafeInteger(lease.pid)||lease.pid<1||typeof lease.container!=='string'||!lease.container)throw Error('Invalid command invocation lease; inspect before recovery');
51
+ try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH')throw error;}
52
+ const state=await run(['container','inspect',lease.container],{capture:true});
53
+ if(state.code===0)throw Error(`Stale command invocation lease; inspect container ${lease.container} before recovery`);
54
+ if(!/No such (?:object|container)/i.test(state.stderr+state.stdout))throw Error('Unable to verify a stale command invocation lease; inspect Docker before recovery');
55
+ await fs.rm(path.join(directory,file));
56
+ }
57
+ return false;
58
+ }
59
+ async function invocationLease(home,container) {
60
+ const directory=invocationDirectory(home);await privateDir(directory);
61
+ const file=path.join(directory,randomUUID()+'.json');await fs.writeFile(file,JSON.stringify({pid:process.pid,container}),{mode:0o600,flag:'wx'});
62
+ return async()=>{await fs.rm(file,{force:true});};
63
+ }
64
+ export async function locked(home, fn, {allowInvocations=false}={}) {
45
65
  const lock = path.join(home,'registry.lock');
46
66
  let handle;
47
67
  try { handle=await fs.open(lock,'wx',0o600); }
48
68
  catch(error) { if(error.code==='EEXIST') throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager'); throw error; }
49
- try { await handle.writeFile(JSON.stringify({pid:process.pid})); return await fn(); }
69
+ try {
70
+ await handle.writeFile(JSON.stringify({pid:process.pid}));
71
+ if(!allowInvocations&&await activeInvocations(home))throw Error('Plugin commands are active; retry the registry or lifecycle change after they finish');
72
+ return await fn();
73
+ }
50
74
  finally { await handle.close(); await fs.rm(lock); }
51
75
  }
52
76
  // Snapshot only explicitly packaged files. No symlinks, credentials inferred from cwd, or install scripts.
@@ -126,7 +150,13 @@ export function validate(m,d,files) {
126
150
  if(JSON.stringify(Object.keys(m.commands).sort())!==JSON.stringify(Object.keys(d.commands).sort())) throw Error('Command bindings must match manifest');
127
151
  for(const [alias,c] of Object.entries(m.commands)) {
128
152
  id(alias); if(reserved.has(alias)) throw Error('Reserved alias');
129
- keys(c,['executable','args','exposure']); strings(c.args); exposure(c.exposure);
153
+ keys(c,['executable','args','exposure','channelQuery','channelFile']); strings(c.args); exposure(c.exposure);
154
+ if(c.channelFile!==undefined && c.channelFile!==true) throw Error('channelFile must be true when declared');
155
+ if(c.channelFile && c.channelQuery)throw Error('Choose one channel output type');
156
+ if(c.channelQuery!==undefined && c.channelQuery!==true) throw Error('channelQuery must be true when declared');
157
+ if((c.channelQuery || c.channelFile) && (c.exposure?.receivesExternalContent!==true || c.exposure?.sendsExternally!==false ||
158
+ c.exposure?.changesRecords!==false || c.exposure?.requiresReview!==false))
159
+ throw Error('Channel queries must explicitly accept external input without sends, writes, or review');
130
160
  if(!files.has(c.executable)) throw Error('Missing package executable');
131
161
  const b=d.commands[alias];keys(b,['service','argv','suffix']);
132
162
  if(!d.services[b.service] || !strings(b.argv).length) throw Error('Invalid command service'); strings(b.suffix||[]);
@@ -270,7 +300,10 @@ export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,
270
300
  if(cancelled&&container) {
271
301
  try {
272
302
  await removeCommandContainer(container);
273
- } catch(error) {return reject(error);}
303
+ } catch(error) {
304
+ const uncertain=error instanceof Error?error:Error('Cancelled command container cleanup failed');
305
+ uncertain.containerCleanupUncertain=true;return reject(uncertain);
306
+ }
274
307
  }
275
308
  if(failure)return reject(failure);
276
309
  resolve({code:cancelled?130:code??(childSignal?130:1),stdout,stderr});});
@@ -306,7 +339,7 @@ export async function registry(home) {
306
339
  return r;
307
340
  }
308
341
  // The lock protects admission and compose refresh, never a persistent connection.
309
- export async function prepareCommand(home,alias,args,{revision,exclude,publish}={}) {
342
+ export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false}={}) {
310
343
  strings(args);
311
344
  return locked(home,async()=>{
312
345
  const config=await json(path.join(home,'config.json')),r=await registry(home);
@@ -317,8 +350,9 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish}=
317
350
  const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
318
351
  await atomic(record.compose,await compose(config,record,secrets,home));
319
352
  const container=`${record.project}-call-${randomUUID()}`;
320
- return {container,plugin:record.manifest.id,revision:record.revision,argv:[...composeArgs(record),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])]};
321
- });
353
+ const release=invocation?await invocationLease(home,container):undefined;
354
+ return {container,plugin:record.manifest.id,revision:record.revision,argv:[...composeArgs(record),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])],release};
355
+ },{allowInvocations:true});
322
356
  }
323
357
  export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
324
358
  if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
@@ -509,17 +543,15 @@ export async function main(args) {
509
543
  emit({ok:true,plugin:name,action,dataPreserved:true});
510
544
  });
511
545
  }
512
- return locked(home,async()=>{
513
- const config=await json(path.join(home,'config.json'));
514
- const r=await registry(home),record=r.plugins[r.commands[group]],binding=record?.deployment.commands[group];
515
- if(!binding)throw Error('Unknown registered CLI');
516
- await checkFolders(config,record);
517
- const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
518
- await atomic(record.compose,await compose(config,record,secrets,home));
519
- // Docker exec does not reliably forward cancellation to the in-container process.
520
- // Run each client as a one-shot Compose container; docker compose run forwards signals.
521
- const name=`${record.project}-call-${randomUUID()}`;
522
- const result=await run([...composeArgs(record),'run','--rm','--no-deps','-T','--name',name,'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[group].args,...args.slice(1),...(binding.suffix||[])],{container:name});
523
- process.exitCode=result.code;
524
- });
546
+ const command=await prepareCommand(home,group,args.slice(1),{invocation:true});
547
+ try {
548
+ // Docker exec does not reliably forward cancellation to the in-container process.
549
+ // Run each client as a one-shot Compose container; docker compose run forwards signals.
550
+ const result=await run(command.argv,{container:command.container});
551
+ process.exitCode=result.code;
552
+ await command.release();
553
+ } catch(error) {
554
+ if(error?.containerCleanupUncertain!==true)await command.release();
555
+ throw error;
556
+ }
525
557
  }
package/src/runs.ts CHANGED
@@ -42,6 +42,7 @@ export type RunRecord = {
42
42
  nativeSessionId?: string
43
43
  scheduled?: ScheduledOrigin
44
44
  external?: ExternalOrigin
45
+ externalReleased?: true
45
46
  application?: ApplicationOrigin
46
47
  delivery?: { bindingId: string; scope: string }
47
48
  }
@@ -89,6 +90,7 @@ const isRun = (value: unknown): value is RunRecord => {
89
90
  (candidate.scheduled === undefined || validScheduledOrigin(candidate.scheduled)) &&
90
91
  (candidate.blockReason === undefined || ['owner-mismatch', 'external-execution-unavailable'].includes(candidate.blockReason)) &&
91
92
  (candidate.external === undefined || validOrigin(candidate.external)) &&
93
+ (candidate.externalReleased === undefined || candidate.external !== undefined && candidate.externalReleased === true) &&
92
94
  (candidate.id.startsWith('r_app_') === (candidate.application !== undefined)) &&
93
95
  (candidate.application === undefined || (validApplicationOrigin(candidate.application) && candidate.external === undefined && candidate.scheduled === undefined && candidate.taskId === undefined && !candidate.replyOnly)) &&
94
96
  (candidate.delivery === undefined || (!!candidate.scheduled && validApplicationOrigin({...candidate.delivery, requestId: candidate.id}) && candidate.application === undefined)) &&
@@ -186,7 +188,7 @@ export class RunStore {
186
188
 
187
189
  async patch(
188
190
  id: string,
189
- change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview'>>,
191
+ change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased'>>,
190
192
  ): Promise<RunRecord> {
191
193
  const prior = this.changes.get(id) || Promise.resolve()
192
194
  const work = prior.catch(() => {}).then(async () => {
@@ -219,6 +221,11 @@ export class RunStore {
219
221
  return runs.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
220
222
  }
221
223
 
224
+ async pruneTaskHistory(taskId:string,keep=100):Promise<void>{
225
+ const terminal=(await this.list()).filter(run=>run.taskId===taskId&&['completed','failed','cancelled'].includes(run.status)&&(run.external===undefined||run.externalReleased===true)).sort((a,b)=>a.createdAt.localeCompare(b.createdAt))
226
+ for(const run of terminal.slice(0,Math.max(0,terminal.length-keep)))await rm(this.runPath(run.id),{force:true})
227
+ }
228
+
222
229
  async running(background?: boolean): Promise<RunRecord | undefined> {
223
230
  const runs = await this.list()
224
231
  let first: RunRecord | undefined
@@ -231,7 +238,8 @@ export class RunStore {
231
238
  }
232
239
 
233
240
  async nextQueued(background?: boolean): Promise<RunRecord | undefined> {
234
- return (await this.list()).find((run) => run.status === 'queued' && (background === undefined || Boolean(run.scheduled) === background))
241
+ const queued = (await this.list()).filter((run) => run.status === 'queued' && (background === undefined || Boolean(run.scheduled) === background))
242
+ return queued.find(run => !run.taskId) ?? queued[0]
235
243
  }
236
244
 
237
245
  async deliveryStatus(): Promise<{ failed: number; unknown: number }> {
@@ -440,6 +448,15 @@ export class RunStore {
440
448
  return [...messages.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map(item => ({ id: item.id, text: item.text! }))
441
449
  }
442
450
 
451
+ async applicationApprovals(runId:string):Promise<{id:string;prompt:string;state:string}[]> {
452
+ assertId(runId);await this.ensure();const result=[] as {id:string;prompt:string;state:string}[]
453
+ for(const name of await readdir(this.outboxDir))if(name.startsWith(`${runId}_`)&&name.endsWith('.json')&&!name.includes('.tmp')&&!name.endsWith('.failed.json'))try{
454
+ const item=JSON.parse(await readFile(path.join(this.outboxDir,name),'utf8')) as OutboxItem
455
+ if(item.runId===runId&&item.type==='approval'&&item.approvalActionId&&item.approvalPrompt)result.push({id:item.approvalActionId,prompt:item.approvalPrompt,state:name.endsWith('.sent.json')?'delivered':'pending'})
456
+ }catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error}
457
+ return result
458
+ }
459
+
443
460
  async claimOutbox(id: string): Promise<boolean> {
444
461
  assertId(id)
445
462
  const from = path.join(this.outboxDir, `${id}.json`)
@@ -0,0 +1,56 @@
1
+ import {mkdir,lstat,open,rm,readdir} from 'node:fs/promises'
2
+ import {constants} from 'node:fs'
3
+ import {join,basename} from 'node:path'
4
+ import {createHash} from 'node:crypto'
5
+
6
+ export const attachmentLimit = 20 * 1024 * 1024
7
+ export type TaskAttachment = {runId:string;id:string;filename:string;bytes:number;sha256:string}
8
+ const hash = (data:Buffer) => createHash('sha256').update(data).digest('hex')
9
+ function directory(control:string,runId:string) {
10
+ if(!/^[a-zA-Z0-9_-]{1,160}$/.test(runId))throw Error('Invalid attachment run')
11
+ return join(control,'task-files',runId)
12
+ }
13
+ function validate(file:TaskAttachment) {
14
+ if(!file || !/^[a-f0-9-]{36}$/.test(file.id) || typeof file.filename!=='string' || !file.filename || file.filename!==basename(file.filename) || /[\\\x00-\x1f\x7f]/.test(file.filename) || file.filename.length>200 ||
15
+ !Number.isSafeInteger(file.bytes)||file.bytes<1||file.bytes>attachmentLimit||!/^[a-f0-9]{64}$/.test(file.sha256))throw Error('Invalid task attachment')
16
+ }
17
+ async function safeDirectory(control:string,runId:string,create=false) {
18
+ const dir=directory(control,runId)
19
+ for(const entry of [join(control,'task-files'),dir]) {
20
+ if(create)await mkdir(entry,{mode:0o700}).catch(e=>{if(e.code!=='EEXIST')throw e})
21
+ const stat=await lstat(entry);if(!stat.isDirectory()||stat.isSymbolicLink())throw Error('Unsafe attachment directory')
22
+ }
23
+ return dir
24
+ }
25
+ export async function stageTaskAttachment(control:string,runId:string,id:string,input:string,data:Buffer):Promise<TaskAttachment> {
26
+ const file={runId,id,filename:basename(input),bytes:data.length,sha256:hash(data)};validate(file)
27
+ const dir=await safeDirectory(control,runId,true)
28
+ // The task broker serializes its calls; bound the entire run, including unsent files.
29
+ let total=0
30
+ for(const entry of await readdir(dir)) {
31
+ const stat=await lstat(join(dir,entry))
32
+ if(!stat.isFile()||stat.isSymbolicLink())throw Error('Unsafe staged attachment')
33
+ if(entry!==id)total+=stat.size
34
+ }
35
+ if(total+data.length>attachmentLimit)throw Error('Task attachments exceed the 20 MiB run limit')
36
+ const fd=await open(join(dir,id),constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600).catch(async error=>{
37
+ if(error.code!=='EEXIST')throw error
38
+ await readTaskAttachment(control,file);return null
39
+ })
40
+ if(!fd)return file
41
+ try{await fd.writeFile(data)}finally{await fd.close()}
42
+ return file
43
+ }
44
+ export async function readTaskAttachment(control:string,file:TaskAttachment) {
45
+ validate(file)
46
+ const dir=await safeDirectory(control,file.runId)
47
+ const fd=await open(join(dir,file.id),constants.O_RDONLY|constants.O_NOFOLLOW)
48
+ try {
49
+ const stat=await fd.stat();if(!stat.isFile()||stat.size!==file.bytes)throw Error('Attachment changed')
50
+ const data=await fd.readFile();if(hash(data)!==file.sha256)throw Error('Attachment changed')
51
+ return data
52
+ }finally{await fd.close()}
53
+ }
54
+ export async function removeTaskAttachments(control:string,runId:string) {
55
+ await rm(directory(control,runId),{recursive:true,force:true})
56
+ }
@@ -0,0 +1,45 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { readFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { executorEnvironment } from './executor.js'
6
+ import type { TaskCapability } from './tasks.js'
7
+ import {attachmentLimit,stageTaskAttachment,type TaskAttachment} from './task-attachments.js'
8
+
9
+ const execute = promisify(execFile)
10
+
11
+ export async function assertChannelQuery(toolsHome: string | undefined, capability: TaskCapability) {
12
+ if (!toolsHome || !path.isAbsolute(toolsHome)) throw new Error('Channel capabilities require an installed tool registry')
13
+ const registry = JSON.parse(await readFile(path.join(toolsHome,'registry.json'),'utf8')) as {
14
+ commands?: Record<string,string>, plugins?: Record<string,{manifest?:{commands?:Record<string,{channelQuery?:boolean,channelFile?:boolean,exposure?:Record<string,boolean>}>}}>
15
+ }
16
+ const command = registry.plugins?.[registry.commands?.[capability.command] ?? '']?.manifest?.commands?.[capability.command]
17
+ const exposure = command?.exposure
18
+ if ((capability.output==='file'?command?.channelFile:command?.channelQuery) !== true || exposure?.receivesExternalContent !== true || exposure.sendsExternally !== false ||
19
+ exposure.changesRecords !== false || exposure.requiresReview !== false)
20
+ throw new Error(`Capability ${capability.id} is not an installed read-only channel query`)
21
+ }
22
+
23
+ export async function runTaskCapability(toolsHome: string | undefined, capability: TaskCapability, input: string, staging?:{controlDir:string;runId:string;lease:string}):Promise<{output:string;attachment?:never}|{attachment:TaskAttachment;output?:never}> {
24
+ await assertChannelQuery(toolsHome,capability)
25
+ const home = toolsHome!
26
+ const args = [capability.command, ...capability.args.map(value => value === '{input}' ? input : value)]
27
+ if(capability.output==='file') {
28
+ if(!staging)throw Error('File capability requires task staging')
29
+ const result=await execute(path.join(home,'bin','ez'),args,{encoding:'buffer',env:executorEnvironment(),timeout:30000,maxBuffer:attachmentLimit})
30
+ const attachment=await stageTaskAttachment(staging.controlDir,staging.runId,staging.lease,input,result.stdout)
31
+ return {attachment}
32
+ }
33
+ let stdout = '', code = 0
34
+ try {
35
+ const result = await execute(path.join(home,'bin','ez'),args,{encoding:'utf8',env:executorEnvironment(),timeout:15000,maxBuffer:131072})
36
+ stdout = result.stdout
37
+ } catch (error) {
38
+ const failure = error as Error & {stdout?:string;code?:number|string}
39
+ stdout = typeof failure.stdout === 'string' ? failure.stdout : ''
40
+ code = typeof failure.code === 'number' ? failure.code : 1
41
+ }
42
+ if (Buffer.byteLength(stdout) > 131072) throw new Error('Channel capability output is too large')
43
+ if (code !== 0 || !stdout.trim()) throw new Error(`Channel capability failed with exit ${code}`)
44
+ return {output:stdout}
45
+ }