@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
package/src/task-cli.ts CHANGED
@@ -3,14 +3,17 @@ import { readFile } from 'node:fs/promises'
3
3
  import { taskCall } from './task-rpc.js'
4
4
  const { values, positionals } = parseArgs({ allowPositionals: true, options: {
5
5
  source: { type: 'string' }, contact: { type: 'string' }, purpose: { type: 'string' },
6
- 'context-file': { type: 'string' }, hours: { type: 'string' }, id: { type: 'string' }, help: { type: 'boolean' }, 'incoming-only': { type: 'boolean' }, 'until-revoked': { type: 'boolean' },
6
+ 'context-file': { type: 'string' }, 'capability-file': { type: 'string' }, hours: { type: 'string' }, id: { type: 'string' }, help: { type: 'boolean' },
7
+ 'incoming-only': { type: 'boolean' }, 'until-revoked': { type: 'boolean' }, 'any-conversation': { type: 'boolean' },
7
8
  } })
8
- if (values.help) { console.log('ezenciel-agents-task propose --source NAME --contact EXACT_ID --purpose TEXT --context-file FILE --hours 24 [--incoming-only [--until-revoked]] | list | revoke --id TASK_ID'); console.log(await readFile(new URL('../docs/selective-monitoring.md', import.meta.url), 'utf8')) }
9
+ if (values.help) { console.log('ezenciel-agents-task propose --source NAME (--contact EXACT_ID | --any-conversation) --purpose TEXT --context-file FILE [--capability-file FILE] --hours 24 [--incoming-only [--until-revoked]] | list | revoke --id TASK_ID'); console.log(await readFile(new URL('../docs/selective-monitoring.md', import.meta.url), 'utf8')) }
9
10
  else {
10
11
  if (!process.env.EZ_CONTROL_DIR || !process.env.EZ_RUN_ID) throw new Error('Run from the current owner turn')
12
+ const anyConversation = values['any-conversation'] === true
11
13
  console.log(JSON.stringify(await taskCall(process.env.EZ_CONTROL_DIR, process.env.EZ_RUN_ID, 'owner', positionals[0], {
12
- sourceId: values.source, conversationId: values.contact, purpose: values.purpose,
14
+ sourceId: values.source, conversationId: anyConversation ? '*' : values.contact, purpose: values.purpose,
13
15
  context: values['context-file'] ? await readFile(values['context-file'], 'utf8') : undefined,
14
- untilRevoked: values['until-revoked'], waitForIncoming: values['incoming-only'], hours: Number(values.hours || 24), taskId: values.id,
16
+ capabilities: values['capability-file'] ? JSON.parse(await readFile(values['capability-file'], 'utf8')) : undefined,
17
+ anyConversation, untilRevoked: anyConversation || values['until-revoked'], waitForIncoming: anyConversation || values['incoming-only'], hours: Number(values.hours || 24), taskId: values.id,
15
18
  })))
16
19
  }
@@ -32,14 +32,18 @@ export function taskArguments(directory: string, broker: string[], prompt: strin
32
32
  '-c', 'default_permissions="ez-task"',
33
33
  '-c', `permissions.ez-task.filesystem={":root"="deny",":minimal"="read",${JSON.stringify(directory)}="write"}`,
34
34
  '-c', 'permissions.ez-task.network.enabled=false',
35
- '-c', `mcp_servers.ez={command=${JSON.stringify(broker[0])},args=${JSON.stringify(broker.slice(1))},required=true,enabled_tools=${JSON.stringify(toolNames)}}`,
35
+ // The trusted broker needs the host's CLI configuration; the model keeps its isolated HOME.
36
+ '-c', `mcp_servers.ez={command=${JSON.stringify(broker[0])},args=${JSON.stringify(broker.slice(1))},env={HOME=${JSON.stringify(homedir())}},required=true,enabled_tools=${JSON.stringify(toolNames)}}`,
36
37
  ...toolNames.flatMap(name => ['-c', `mcp_servers.ez.tools.${name}.approval_mode="approve"`]),
37
38
  '-'] // Literal input travels on stdin, including slash commands and leading options.
38
39
  }
39
40
  export async function startTaskExecutor(options: ExecutorOptions) {
40
41
  const run = await new RunStore(options.controlDir).get(options.runId)
41
42
  if (!run || run.status !== 'running') throw new Error('No active task run')
42
- await new Tasks(options.controlDir).authorize(run, false)
43
+ const task = await new Tasks(options.controlDir).authorize(run, false)
44
+ const capabilityNames = (task.capabilities ?? []).map(item => `capability_${item.id}`)
45
+ const toolNames = [...(task.anyConversation ? ['context','send'] : ['context','send','note','report','complete']),...capabilityNames]
46
+ if (capabilityNames.length && !options.toolsHome) throw new Error('Channel capabilities require an installed tool registry')
43
47
  const environment = executorEnvironment()
44
48
  const version = await promisify(execFile)('codex', ['--version'], { env: environment })
45
49
  if (version.stdout.trim() !== `codex-cli ${TASK_CODEX_VERSION}`) throw new Error(`Restricted tasks require audited Codex ${TASK_CODEX_VERSION}`)
@@ -51,9 +55,9 @@ export async function startTaskExecutor(options: ExecutorOptions) {
51
55
  await writeFile(join(temporary, 'models.json'), JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))), { mode: 0o600 })
52
56
  await symlink(join(homedir(), '.codex', 'auth.json'), join(home, 'auth.json'))
53
57
  const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
54
- fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId]
58
+ fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? [])]
55
59
  const prompt = JSON.stringify({event: run.external ? 'correspondence_received' : 'task_activated', taskId: run.taskId})
56
- const child = spawn('codex', taskArguments(directory, broker, prompt, undefined, options), {
60
+ const child = spawn('codex', taskArguments(directory, broker, prompt, toolNames, options), {
57
61
  cwd: directory, env: { ...environment, HOME: home, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32',
58
62
  })
59
63
  await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
package/src/task-mcp.ts CHANGED
@@ -1,17 +1,24 @@
1
1
  import { createInterface } from 'node:readline'
2
2
  import { taskCall } from './task-rpc.js'
3
- const [controlDir, runId] = process.argv.slice(2)
3
+ import { runTaskCapability } from './task-capability.js'
4
+ import type { TaskCapability } from './tasks.js'
5
+ const [controlDir, runId, toolsHome, capabilityJson='[]'] = process.argv.slice(2)
6
+ const capabilities = JSON.parse(capabilityJson) as TaskCapability[]
4
7
  const descriptions: Record<string, string> = {
5
8
  context: 'Read the owner-approved purpose and shareable context, task notes, receipts, and untrusted correspondence.',
6
- send: 'Send text to the single owner-approved contact. Reuse the same key for the same message. Uncertain means do not retry with a new key.',
9
+ send: 'Send text and optionally an attachmentId returned by an approved file capability to this conversation. Reuse the same key for the same message. Uncertain means do not retry with a new key.',
7
10
  note: 'Save a task-scoped note. No owner files or memory are accessible.',
8
11
  report: 'Report task evidence or a blocker to the owner. This is a report, never an owner instruction.',
9
12
  complete: 'Report the result and close a finite task, stopping further messages and replies. Not available for incoming-only watches; save a note and end the run instead.',
13
+ ...Object.fromEntries(capabilities.map(item => [`capability_${item.id}`,item.description])),
10
14
  }
11
- const tools = Object.entries(descriptions).map(([name, description]) => ({ name, description, inputSchema: {
12
- type: 'object', properties: name === 'context' ? {} : { text: { type: 'string', maxLength: 4096 }, ...(name === 'send' ? { key: { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,80}$' } } : {}) },
13
- required: name === 'context' ? [] : name === 'send' ? ['text', 'key'] : ['text'], additionalProperties: false,
14
- } }))
15
+ const tools = Object.entries(descriptions).map(([name, description]) => {
16
+ const capability = name.startsWith('capability_')
17
+ return { name, description, inputSchema: {
18
+ type: 'object', properties: name === 'context' ? {} : capability ? {input:{type:'string',maxLength:1000}} : { text: { type: 'string', maxLength: 4096 }, ...(name === 'send' ? { key: { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,80}$' }, attachmentId:{type:'string',pattern:'^[a-f0-9-]{36}$'} } : {}) },
19
+ required: name === 'context' ? [] : capability ? ['input'] : name === 'send' ? ['text', 'key'] : ['text'], additionalProperties: false,
20
+ } }
21
+ })
15
22
  for await (const line of createInterface({ input: process.stdin })) {
16
23
  let request: any
17
24
  try {
@@ -25,8 +32,18 @@ for await (const line of createInterface({ input: process.stdin })) {
25
32
  const name = request.params?.name
26
33
  if (!Object.hasOwn(descriptions, name)) throw new Error('Unknown task tool')
27
34
  const args = request.params.arguments ?? {}
28
- if (Object.keys(args).some(key => !['text', ...(name === 'send' ? ['key'] : [])].includes(key))) throw new Error('Unexpected tool argument')
29
- try { result = { content: [{ type: 'text', text: JSON.stringify(await taskCall(controlDir, runId, 'worker', name, args)) }] } }
35
+ const capabilityId = typeof name === 'string' && name.startsWith('capability_') ? name.slice(11) : undefined
36
+ if (Object.keys(args).some(key => capabilityId ? key !== 'input' : !['text', ...(name === 'send' ? ['key','attachmentId'] : [])].includes(key))) throw new Error('Unexpected tool argument')
37
+ try {
38
+ let data: unknown
39
+ if (capabilityId) {
40
+ const begun = await taskCall(controlDir,runId,'worker','capability_begin',{id:capabilityId,input:args.input}) as {capability:TaskCapability;lease:string}
41
+ const result = await runTaskCapability(toolsHome || undefined,begun.capability,args.input,{controlDir,runId,lease:begun.lease})
42
+ await taskCall(controlDir,runId,'worker','capability_result',{id:capabilityId,input:args.input,lease:begun.lease,...('attachment' in result?{attachment:result.attachment}:{})})
43
+ data = result.attachment ? {attachmentId:result.attachment.id,filename:result.attachment.filename,bytes:result.attachment.bytes} : result
44
+ } else data = await taskCall(controlDir, runId, 'worker', name, args)
45
+ result = { content: [{ type: 'text', text: JSON.stringify(data) }] }
46
+ }
30
47
  catch (error) { result = { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Task tool failed' }] } }
31
48
  } else throw new Error('Unsupported MCP method')
32
49
  process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n')
package/src/tasks.ts CHANGED
@@ -1,22 +1,42 @@
1
1
  import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { randomUUID, createHash } from 'node:crypto'
4
- import { ControlStore, type Owner } from './control-state.js'
4
+ import { ControlStore, sameOwner, ownerId, ownerEpoch, type Owner } from './control-state.js'
5
5
  import { ApprovalStore } from './approval.js'
6
6
  import { EventSources, sourceCall, type SourceEvent } from './event-sources.js'
7
7
  import { RunStore, type RunRecord } from './runs.js'
8
8
  import { requireOwnerExecution } from './execution-authority.js'
9
9
  import { ownsRun } from './identity.js'
10
+ import {readTaskAttachment,type TaskAttachment} from './task-attachments.js'
10
11
 
12
+ export type TaskCapability = { id: string; description: string; command: string; args: string[]; output?:'file' }
13
+ type PublicBudget = {windowStartedAt:number;runs:number;responses:number;totalRuns:number;totalResponses:number}
14
+ const validPublicBudget = (value: unknown): value is PublicBudget => {
15
+ if (!value || typeof value !== 'object') return false
16
+ const budget = value as Record<string, unknown>
17
+ return Object.keys(budget).length === 5 && ['windowStartedAt','runs','responses','totalRuns','totalResponses']
18
+ .every(key => Number.isSafeInteger(budget[key]) && Number(budget[key]) >= 0)
19
+ }
11
20
  export type Task = {
12
- version: 1 | 2 | 3; waitForIncoming?: true; untilRevoked?: true; unwatchPending?: true; id: string; runId: string; owner: Owner
21
+ version: 1 | 2 | 3 | 4; waitForIncoming?: true; untilRevoked?: true; anyConversation?: true; capabilities?: TaskCapability[]; unwatchPending?: true; id: string; runId: string; owner: Owner
13
22
  sourceId: string; bindingId: string; accountId: string; conversationId: string
14
23
  purpose: string; context: string; createdAt: number; expiresAt: number
15
24
  state: 'pending' | 'active' | 'revoked' | 'completed'
16
- notes: string[]; operations: Record<string, { text: string; state: 'uncertain' | 'accepted'; receipt?: unknown }>
25
+ notes: string[]; operations: Record<string, { text: string; attachmentId?:string; state: 'uncertain' | 'accepted'; receipt?: unknown }>
26
+ capabilityOperations?: Record<string, { capabilityId: string; inputHash: string; lease: string; attachment?:TaskAttachment; state: 'authorized' | 'completed' }>
27
+ publicBudget?: PublicBudget
17
28
  }
18
29
  const idOK = (v: unknown): v is string => typeof v === 'string' && /^task_[a-f0-9]{32}$/.test(v)
19
30
  const bounded = (v: unknown, max: number): v is string => typeof v === 'string' && v.trim().length > 0 && v.length <= max
31
+ const capability = (value: unknown): value is TaskCapability => {
32
+ const item = value as TaskCapability | undefined
33
+ return !!item && typeof item === 'object' && Object.keys(item).every(key => ['id','description','command','args','output'].includes(key)) &&
34
+ (item.output===undefined||item.output==='file') &&
35
+ typeof item.id === 'string' && /^[a-z][a-z0-9_]{0,31}$/.test(item.id) && bounded(item.description,200) && !/[\x00-\x1f\x7f]/.test(item.description) &&
36
+ typeof item.command === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(item.command) && Array.isArray(item.args) && item.args.length >= 2 && item.args.length <= 20 &&
37
+ item.args.every(arg => typeof arg === 'string' && arg.length <= 200 && !/[\x00-\x1f\x7f]/.test(arg)) && item.args.at(-2) === '--' && item.args.at(-1) === '{input}' &&
38
+ item.args.filter(arg => arg === '{input}').length === 1
39
+ }
20
40
  export async function atomicTaskFile(file: string, value: unknown) {
21
41
  const temporary = `${file}.${randomUUID()}.tmp`
22
42
  await writeFile(temporary, JSON.stringify(value), { mode: 0o600, flag: 'wx' })
@@ -33,11 +53,14 @@ export class Tasks {
33
53
  if (!idOK(id)) throw new Error('Invalid task ID')
34
54
  try {
35
55
  const task: Task = JSON.parse(await readFile(join(this.directory, `${id}.json`), 'utf8'))
36
- if (!((task.version === 1 && task.waitForIncoming === undefined && task.untilRevoked === undefined) || (task.version === 2 && task.waitForIncoming === true && task.untilRevoked === undefined) || (task.version === 3 && task.waitForIncoming === true && task.untilRevoked === true && task.expiresAt === 8640000000000000)) || task.id !== id || !bounded(task.sourceId, 100) || !bounded(task.bindingId, 100) ||
56
+ if (!((task.version === 1 && task.waitForIncoming === undefined && task.untilRevoked === undefined && task.anyConversation === undefined) || (task.version === 2 && task.waitForIncoming === true && task.untilRevoked === undefined && task.anyConversation === undefined) || (task.version === 3 && task.waitForIncoming === true && task.untilRevoked === true && task.anyConversation === undefined && task.expiresAt === 8640000000000000) ||
57
+ (task.version === 4 && task.waitForIncoming === true && task.untilRevoked === true && task.anyConversation === true && task.conversationId === '*' && task.expiresAt === 8640000000000000)) ||
58
+ (task.capabilities !== undefined && (!Array.isArray(task.capabilities) || task.capabilities.length < 1 || task.capabilities.length > 8 || new Set(task.capabilities.map(item => item.id)).size !== task.capabilities.length || task.capabilities.some(item => !capability(item)))) || task.id !== id || !bounded(task.sourceId, 100) || !bounded(task.bindingId, 100) ||
37
59
  !bounded(task.accountId, 200) || !bounded(task.conversationId, 200) || !bounded(task.purpose, 1000) ||
38
60
  !bounded(task.context, 6000) || !Number.isFinite(task.createdAt) || !Number.isFinite(task.expiresAt) ||
39
61
  !['pending', 'active', 'revoked', 'completed'].includes(task.state) || !Array.isArray(task.notes) ||
40
- !task.operations || typeof task.operations !== 'object' || !task.owner) throw new Error('Invalid task record')
62
+ !task.operations || typeof task.operations !== 'object' || (task.capabilityOperations !== undefined && (!task.capabilityOperations || typeof task.capabilityOperations !== 'object')) ||
63
+ (task.anyConversation && !validPublicBudget(task.publicBudget)) || !task.owner) throw new Error('Invalid task record')
41
64
  return task
42
65
  } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error }
43
66
  }
@@ -55,8 +78,7 @@ export class Tasks {
55
78
  }
56
79
  private async source(task: Task) {
57
80
  const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
58
- if (!owner || owner.telegramUserId !== task.owner.telegramUserId || owner.telegramChatId !== task.owner.telegramChatId)
59
- throw new Error('Task owner is no longer paired')
81
+ if (!owner || !sameOwner(task.owner, owner)) throw new Error('Task owner is no longer paired')
60
82
  const source = (await new EventSources(this.controlDir).available(owner)).find(s => s.id === task.sourceId && s.bindingId === task.bindingId)
61
83
  if (!source) throw new Error('Task source was removed or replaced')
62
84
  const head = await sourceCall(source.socketPath, 'events-head')
@@ -66,27 +88,43 @@ export class Tasks {
66
88
  async authorize(run: RunRecord, checkProvider = true): Promise<Task> {
67
89
  const task = run.taskId ? await this.get(run.taskId) : null
68
90
  if (!task || (task.waitForIncoming && !run.external) || task.state !== 'active' || task.expiresAt <= Date.now() || run.version !== 2 ||
69
- run.chatId !== task.owner.telegramChatId || run.telegramUserId !== task.owner.telegramUserId)
91
+ !ownsRun(task.owner, run))
70
92
  throw new Error('Task is inactive or expired')
71
93
  const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
72
94
  const approval = await new ApprovalStore(this.controlDir).getDecision(task.id)
73
- if (!owner || owner.telegramUserId !== task.owner.telegramUserId || owner.telegramChatId !== task.owner.telegramChatId ||
74
- approval?.decision !== 'approved' || !ownsRun(owner, {telegramUserId: approval.decidedBy!, chatId: task.owner.telegramChatId}) || approval.runId !== task.runId || approval.prompt !== this.prompt(task))
95
+ const approvedByOwner = approval?.version === 2
96
+ ? approval.decidedOwnerId === ownerId(task.owner) && approval.decidedOwnerEpoch === ownerEpoch(task.owner)
97
+ : !!owner && ownsRun(owner,{telegramUserId:approval?.decidedBy!,chatId:task.owner.telegramChatId})
98
+ if (!sameOwner(task.owner, owner) || approval?.decision !== 'approved' || !approvedByOwner || approval.runId !== task.runId || approval.prompt !== this.prompt(task))
75
99
  throw new Error('Task approval is no longer valid')
76
100
  if (checkProvider) await this.source(task)
77
101
  if (run.external && checkProvider) {
78
102
  if (run.external.sourceId !== task.sourceId || run.external.bindingId !== task.bindingId) throw new Error('Task origin mismatch')
79
103
  const events = await new EventSources(this.controlDir).check(run.external, task.owner)
80
- if (events.length !== run.external.eventIds.length || events.some(e => e.conversationId !== task.conversationId || e.receivedAt < task.createdAt))
104
+ if (events.length !== run.external.eventIds.length || new Set(events.map(event => event.conversationId)).size !== 1 || events.some(e =>
105
+ (!task.anyConversation && e.conversationId !== task.conversationId) || e.receivedAt < task.createdAt))
81
106
  throw new Error('Task correspondence no longer matches')
82
107
  }
83
108
  return task
84
109
  }
85
110
  async match(sourceId: string, bindingId: string, events: SourceEvent[]) {
111
+ if (!events.length || new Set(events.map(event => event.conversationId)).size !== 1) return
86
112
  const matches = (await this.list()).filter(t => t.state === 'active' && t.expiresAt > Date.now() &&
87
- t.sourceId === sourceId && t.bindingId === bindingId && events.every(e => e.conversationId === t.conversationId && e.receivedAt >= t.createdAt))
113
+ t.sourceId === sourceId && t.bindingId === bindingId && events.every(e =>
114
+ (t.anyConversation || e.conversationId === t.conversationId) && e.receivedAt >= t.createdAt))
88
115
  return matches.length === 1 ? matches[0] : undefined
89
116
  }
117
+ async admitPublic(id:string):Promise<'admitted'|'defer'|'revoked'> {
118
+ return this.serial(async()=>{
119
+ const task=await this.get(id)
120
+ if(!task?.anyConversation||task.state!=='active')return 'revoked'
121
+ const now=Date.now(),budget=task.publicBudget!
122
+ if(now-budget.windowStartedAt>=3600000){budget.windowStartedAt=now;budget.runs=0;budget.responses=0}
123
+ if(budget.totalRuns>=1000||budget.totalResponses>=1000){task.state='revoked';task.unwatchPending=true;await this.save(task);await this.unwatch(task);return 'revoked'}
124
+ if(budget.runs>=60)return 'defer'
125
+ budget.runs++;budget.totalRuns++;await this.save(task);return 'admitted'
126
+ })
127
+ }
90
128
  private async unwatch(task: Task) {
91
129
  const source=await this.source(task)
92
130
  await sourceCall(source.socketPath,'task-unwatch',{accountId:task.accountId,conversationId:task.conversationId})
@@ -116,7 +154,9 @@ export class Tasks {
116
154
  })
117
155
  }
118
156
  private prompt(task: Task) {
119
- return `Allow this messaging task?${task.waitForIncoming ? '\nWait for incoming messages; do not initiate contact.' : ''}\nSource: ${task.sourceId}\nAccount: ${task.accountId}\nContact: ${task.conversationId}\nPurpose: ${task.purpose}\nShared context (all may be disclosed to this contact):\n${task.context}\n${task.untilRevoked ? 'Enabled until owner revocation.' : `Expires: ${new Date(task.expiresAt).toISOString()}`}\nText messages only. No payments, files, other contacts, or settings changes.`
157
+ const audience = task.anyConversation ? `Any human conversation received by source ${task.sourceId}` : `Contact: ${task.conversationId}`
158
+ const capabilities = task.capabilities ? `\nApproved capabilities (their output may be disclosed):\n${task.capabilities.map(item => `- ${item.id}: ${item.description}; ez ${item.command} ${item.args.join(' ')}`).join('\n')}` : ''
159
+ return `Allow this messaging task?${task.waitForIncoming ? '\nWait for incoming messages; do not initiate contact.' : ''}\nSource: ${task.sourceId}\nAccount: ${task.accountId}\nAudience: ${audience}\nPurpose: ${task.purpose}\nShared context (all may be disclosed to this audience):\n${task.context}${capabilities}\n${task.anyConversation ? 'Budget: 60 conversations and 60 responses per hour; 1000 total, then owner review.' : ''}\n${task.untilRevoked ? 'Enabled until owner revocation.' : `Expires: ${new Date(task.expiresAt).toISOString()}`}\n${task.capabilities?.some(item=>item.output==='file') ? 'Text and files returned by the approved file capabilities only. No payments, owner memory, unlisted capabilities, or settings changes.' : 'Text messages only. No payments, files, owner memory, unlisted capabilities, or settings changes.'}`
120
160
  }
121
161
  async ownerCall(runId: string, command: string, args: Record<string, unknown>) {
122
162
  return this.serial(async () => {
@@ -130,12 +170,28 @@ export class Tasks {
130
170
  task.state = 'revoked'
131
171
  if(task.untilRevoked) task.unwatchPending=true
132
172
  await this.save(task)
173
+ const runStore = new RunStore(this.controlDir)
174
+ for (const candidate of await runStore.list()) if(candidate.taskId===task.id && candidate.status==='queued') {
175
+ await runStore.patch(candidate.id,{status:'cancelled',endedAt:new Date().toISOString()})
176
+ if(candidate.external)try {
177
+ await new EventSources(this.controlDir).release(candidate.external,task.owner)
178
+ await runStore.patch(candidate.id,{externalReleased:true})
179
+ } catch { /* The relay retries unreleased terminal events. */ }
180
+ }
181
+ await runStore.pruneTaskHistory(task.id)
133
182
  if(task.unwatchPending) await this.unwatch(task)
134
183
  return { id: task.id, state: task.state }
135
184
  }
136
185
  if (command !== 'propose') throw new Error('Unknown owner task command')
137
186
  if (args.waitForIncoming !== undefined && typeof args.waitForIncoming !== 'boolean') throw new Error('Invalid incoming-only option')
138
187
  if (args.untilRevoked !== undefined && (typeof args.untilRevoked !== 'boolean' || (args.untilRevoked && args.waitForIncoming !== true))) throw new Error('Persistent permission requires incoming-only mode')
188
+ if (args.anyConversation !== undefined && typeof args.anyConversation !== 'boolean') throw new Error('Invalid any-conversation option')
189
+ const capabilities = args.capabilities === undefined ? undefined : args.capabilities
190
+ if (capabilities !== undefined && (!Array.isArray(capabilities) || capabilities.length < 1 || capabilities.length > 8 || new Set(capabilities.map((item: any) => item?.id)).size !== capabilities.length || capabilities.some(item => !capability(item))))
191
+ throw new Error('Supply one to eight valid channel capabilities')
192
+ if (capabilities && args.waitForIncoming !== true) throw new Error('Channel capabilities require incoming-only mode')
193
+ if (args.anyConversation && (args.conversationId !== '*' || args.waitForIncoming !== true || args.untilRevoked !== true))
194
+ throw new Error('Any-conversation access requires contact *, incoming-only, and until-revoked')
139
195
  if (!bounded(args.sourceId, 100) || !bounded(args.conversationId, 200) || !bounded(args.purpose, 1000) || !bounded(args.context, 6000) ||
140
196
  typeof args.hours !== 'number' || !Number.isFinite(args.hours) || args.hours <= 0 || args.hours > 72) throw new Error('Invalid task proposal (maximum 72 hours)')
141
197
  const owner = (await new ControlStore(this.controlDir, 900000).status()).owner!
@@ -143,10 +199,12 @@ export class Tasks {
143
199
  if (!source) throw new Error('Unknown source')
144
200
  const head = await sourceCall(source.socketPath, 'events-head')
145
201
  if (args.untilRevoked && head.persistentWatch !== true) throw new Error('Source needs persistent-watch support before enabling an ongoing conversation')
202
+ if (args.anyConversation && head.wildcardWatch !== true) throw new Error('This channel source does not support any-conversation access')
146
203
  if (head.taskProtocol !== 'message-v1' || !bounded(head.accountId, 200)) throw new Error('Source does not support task messaging')
147
- if ((await this.list()).some(t => ((['active', 'pending'].includes(t.state) && t.expiresAt > Date.now()) || t.unwatchPending) && t.sourceId === source.id && t.conversationId === args.conversationId))
204
+ if ((await this.list()).some(t => ((['active', 'pending'].includes(t.state) && t.expiresAt > Date.now()) || t.unwatchPending) && t.sourceId === source.id &&
205
+ (t.conversationId === args.conversationId || t.anyConversation || args.anyConversation)))
148
206
  throw new Error('This contact already has a task; complete or revoke it first')
149
- const task: Task = { version: args.untilRevoked ? 3 : args.waitForIncoming ? 2 : 1, ...(args.untilRevoked ? {untilRevoked:true as const} : {}), ...(args.waitForIncoming ? { waitForIncoming: true as const } : {}), id: `task_${randomUUID().replaceAll('-', '')}`, runId, owner, sourceId: source.id,
207
+ const task: Task = { version: args.anyConversation ? 4 : args.untilRevoked ? 3 : args.waitForIncoming ? 2 : 1, ...(args.untilRevoked ? {untilRevoked:true as const} : {}), ...(args.waitForIncoming ? { waitForIncoming: true as const } : {}), ...(args.anyConversation ? {anyConversation:true as const,publicBudget:{windowStartedAt:Date.now(),runs:0,responses:0,totalRuns:0,totalResponses:0}} : {}), ...(capabilities ? {capabilities} : {}), id: `task_${randomUUID().replaceAll('-', '')}`, runId, owner, sourceId: source.id,
150
208
  bindingId: source.bindingId, accountId: head.accountId, conversationId: args.conversationId, purpose: args.purpose,
151
209
  context: args.context, createdAt: Date.now(), expiresAt: args.untilRevoked ? 8640000000000000 : Date.now() + args.hours * 3600000, state: 'pending', notes: [], operations: {} }
152
210
  if (this.prompt(task).length > 3500) throw new Error('Proposal is too long for owner review; shorten the shared context')
@@ -162,46 +220,94 @@ export class Tasks {
162
220
  const run = await new RunStore(this.controlDir).get(runId)
163
221
  if (!run || run.status !== 'running') throw new Error('No active task run')
164
222
  const task = await this.authorize(run)
223
+ const incoming = run.external ? await new EventSources(this.controlDir).check(run.external, task.owner) : []
224
+ const contact = task.anyConversation ? incoming[0]?.conversationId : task.conversationId
225
+ if (!contact || incoming.some(event => event.conversationId !== contact)) throw new Error('Task correspondence changed')
165
226
  if (command === 'context') {
166
- const incoming = run.external ? await new EventSources(this.controlDir).check(run.external, task.owner) : []
167
- if (incoming.some(e => e.conversationId !== task.conversationId || e.receivedAt < task.createdAt)) throw new Error('Task correspondence changed')
168
- return { purpose: task.purpose, context: task.context, contact: task.conversationId,
169
- waitForIncoming: task.waitForIncoming === true,
227
+ return { purpose: task.purpose, context: task.context, contact,
228
+ waitForIncoming: task.waitForIncoming === true, capabilities: task.capabilities?.map(({id,description}) => ({id,description})),
170
229
  expiresAt: task.untilRevoked ? null : task.expiresAt, notes: task.notes, operations: task.untilRevoked ? Object.fromEntries(Object.entries(task.operations).filter(([key])=>key.startsWith(`${run.id}_`))) : task.operations,
171
230
  incoming }
172
231
  }
232
+ if (command === 'capability_begin' || command === 'capability_result') {
233
+ const selected = task.capabilities?.find(item => item.id === args.id)
234
+ if (!selected || !bounded(args.input, 1000)) throw new Error('Channel capability is not authorized or has invalid input')
235
+ const inputHash = createHash('sha256').update(args.input).digest('hex')
236
+ const key = `${run.id}_${selected.id}_${inputHash}`
237
+ const prior = task.capabilityOperations?.[key]
238
+ if (command === 'capability_result') {
239
+ if (!prior || prior.lease !== args.lease) throw new Error('Channel capability lease is invalid')
240
+ if(selected.output==='file') {
241
+ const file=args.attachment as TaskAttachment
242
+ if(file?.runId!==run.id||file?.id!==prior.lease)throw Error('Attachment lease mismatch')
243
+ await readTaskAttachment(this.controlDir,file)
244
+ prior.attachment=file
245
+ }else if(args.attachment!==undefined)throw Error('Text capability cannot register an attachment')
246
+ prior.state = 'completed'; await this.save(task); return {accepted:true}
247
+ }
248
+ if (prior) return {capability:selected,lease:prior.lease}
249
+ const active=(await new RunStore(this.controlDir).list()).filter(item=>item.taskId===task.id&&['queued','running'].includes(item.status)).map(item=>`${item.id}_`)
250
+ task.capabilityOperations = Object.fromEntries(Object.entries(task.capabilityOperations ?? {}).filter(([item]) => active.some(prefix=>item.startsWith(prefix))))
251
+ if (Object.keys(task.capabilityOperations ?? {}).filter(item => item.startsWith(`${run.id}_`)).length >= 8) throw new Error('Channel capability limit reached')
252
+ const operation = {capabilityId:selected.id,inputHash,lease:randomUUID(),state:'authorized' as const}
253
+ task.capabilityOperations = {...task.capabilityOperations,[key]:operation}
254
+ await this.save(task)
255
+ return {capability:selected,lease:operation.lease}
256
+ }
173
257
  if (!bounded(args.text, 4096)) throw new Error('Supply text (maximum 4096 characters)')
174
258
  if (command === 'note') {
259
+ if (task.anyConversation) throw new Error('Any-conversation tasks do not retain cross-conversation notes')
175
260
  if (task.untilRevoked) while (task.notes.join('').length + args.text.length > 16000) task.notes.shift()
176
261
  if (task.notes.join('').length + args.text.length > 16000) throw new Error('Task notes are full')
177
262
  task.notes.push(args.text); await this.save(task); return { saved: true }
178
263
  }
179
264
  if (command === 'complete' && task.waitForIncoming) throw new Error('This incoming-only watch stays active until expiry or owner revocation. Save a note and end this run; do not close the watch after replying.')
180
265
  if (command === 'report' || command === 'complete') {
181
- const item = await new RunStore(this.controlDir).enqueueMessage(run.id, `Task ${task.id} (${task.conversationId}) reports:\n${args.text}`)
266
+ const item = await new RunStore(this.controlDir).enqueueMessage(run.id, `Task ${task.id} (${contact}) reports:\n${args.text}`)
182
267
  if (command === 'complete') { task.state = 'completed'; await this.save(task) }
183
268
  return { queued: item.id }
184
269
  }
185
270
  if (command !== 'send' || typeof args.key !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(args.key)) throw new Error('Invalid task send')
271
+ let attachment:TaskAttachment|undefined
272
+ if(args.attachmentId!==undefined) {
273
+ const operation=Object.entries(task.capabilityOperations??{}).find(([key,value])=>key.startsWith(`${run.id}_`)&&value.lease===args.attachmentId&&value.state==='completed')?.[1]
274
+ attachment=operation?.attachment
275
+ if(!attachment||attachment.runId!==run.id)throw Error('Attachment is not authorized for this run')
276
+ }
186
277
  const key = task.untilRevoked ? `${run.id}_${args.key}` : args.key
187
278
  const providerKey = `${task.id}_${task.untilRevoked ? createHash('sha256').update(key).digest('hex') : key}`
188
279
  const prior = Object.hasOwn(task.operations, key) ? task.operations[key] : undefined
189
280
  if (prior) {
190
- if (prior.text !== args.text) throw new Error('Message key already used for different text')
281
+ if (prior.text !== args.text || prior.attachmentId!==args.attachmentId) throw new Error('Message key already used for different text or attachment')
191
282
  return prior // Uncertain sends are never blindly retried.
192
283
  }
284
+ const source = await this.source(task)
285
+ if(attachment) {
286
+ const head=await sourceCall(source.socketPath,'events-head')
287
+ if(head.taskAttachments!==true)throw Error('This channel does not support task attachments')
288
+ if(Number.isSafeInteger(head.attachmentCaptionLimit)&&args.text.length>head.attachmentCaptionLimit)throw Error(`Attachment caption exceeds channel limit of ${head.attachmentCaptionLimit}`)
289
+ await readTaskAttachment(this.controlDir,attachment)
290
+ }
291
+ if(task.anyConversation){const budget=task.publicBudget!,now=Date.now();if(now-budget.windowStartedAt>=3600000){budget.windowStartedAt=now;budget.runs=0;budget.responses=0}
292
+ if(budget.responses>=60||budget.totalResponses>=1000)throw new Error('Public response budget reached; owner review is required')
293
+ budget.responses++;budget.totalResponses++;await this.save(task)}
294
+ if(task.untilRevoked) {
295
+ const active=(await new RunStore(this.controlDir).list()).filter(item=>item.taskId===task.id&&['queued','running'].includes(item.status)).map(item=>`${item.id}_`)
296
+ task.operations=Object.fromEntries(Object.entries(task.operations).filter(([item,value])=>active.some(prefix=>item.startsWith(prefix))||value.state==='uncertain'))
297
+ }
193
298
  if (Object.keys(task.operations).filter(k=>!task.untilRevoked || k.startsWith(`${run.id}_`)).length >= 30) throw new Error('Task message limit reached; report to the owner')
194
- task.operations = { ...task.operations, [key]: { text: args.text, state: 'uncertain' } }
299
+ const message={text:args.text,...(attachment?{attachmentId:attachment.id}:{})}
300
+ task.operations = { ...task.operations, [key]: { ...message, state: 'uncertain' } }
195
301
  await this.save(task)
196
- const source = await this.source(task)
197
302
  try {
198
303
  if (task.expiresAt <= Date.now()) throw new Error('Task expired before dispatch')
199
304
  const receipt = await sourceCall(source.socketPath, 'task-send', {
200
- accountId: task.accountId, conversationId: task.conversationId, text: args.text, key: providerKey,
305
+ accountId: task.accountId, conversationId: contact, text: args.text, key: providerKey,
306
+ ...(attachment?{attachment}:{}),
201
307
  })
202
- if (receipt.accountId !== task.accountId || receipt.conversationId !== task.conversationId || receipt.key !== providerKey || receipt.state !== 'accepted')
308
+ if (receipt.accountId !== task.accountId || receipt.conversationId !== contact || receipt.key !== providerKey || receipt.state !== 'accepted')
203
309
  throw new Error('Uncertain provider receipt')
204
- task.operations = { ...task.operations, [key]: { text: args.text, state: 'accepted', receipt } }
310
+ task.operations = { ...task.operations, [key]: { ...message, state: 'accepted', receipt } }
205
311
  await this.save(task)
206
312
  } catch { /* Preserve uncertain across timeouts, crashes, and malformed receipts. */ }
207
313
  return task.operations[key]
@@ -7,6 +7,7 @@ import { atomicTaskFile } from './tasks.js'
7
7
  import { EventSources, type SourceEvent } from './event-sources.js'
8
8
  import type { Owner } from './control-state.js'
9
9
  import type { Message, User } from 'grammy/types'
10
+ import {readTaskAttachment,type TaskAttachment} from './task-attachments.js'
10
11
 
11
12
  // Provider transport only. The existing Tasks grant remains the execution and
12
13
  // disclosure authority, exactly as for a registered WhatsApp source.
@@ -15,7 +16,7 @@ export class TelegramSource {
15
16
  private server?: Server
16
17
  private pending?: Promise<void>
17
18
  private serial: Promise<unknown> = Promise.resolve()
18
- constructor(private controlDir: string, private accountId: string, private send: (chatId: number, text: string) => Promise<number[]>) {
19
+ constructor(private controlDir: string, private accountId: string, private send: (chatId: number, text: string) => Promise<number[]>, private sendFile?: (chatId:number,text:string,data:Buffer,filename:string)=>Promise<number[]>) {
19
20
  this.socketPath = join(tmpdir(), `ez-tg-${createHash('sha256').update(`${controlDir}:${accountId}`).digest('hex').slice(0,16)}.sock`)
20
21
  }
21
22
  private get directory() { return join(this.controlDir, 'telegram-source', createHash('sha256').update(this.accountId).digest('hex')) }
@@ -43,15 +44,21 @@ export class TelegramSource {
43
44
  await new EventSources(this.controlDir).register('telegram',this.socketPath,owner)
44
45
  }
45
46
  async stop() { if(this.server) await new Promise<void>(resolve=>this.server!.close(()=>resolve())); await rm(this.socketPath,{force:true}) }
46
- capture(updateId: number, message: Message.TextMessage, sender: User): Promise<boolean> {
47
- const work=this.serial.then(()=>this.captureMessage(updateId,message,sender));this.serial=work.catch(()=>{});return work
47
+ capture(updateId: number, message: Message.TextMessage, sender: User, wildcardEligible = true): Promise<boolean> {
48
+ const work=this.serial.then(()=>this.captureMessage(updateId,message,sender,wildcardEligible));this.serial=work.catch(()=>{});return work
48
49
  }
49
- private async captureMessage(updateId: number, message: Message.TextMessage, sender: User): Promise<boolean> {
50
+ private async captureMessage(updateId: number, message: Message.TextMessage, sender: User, wildcardEligible: boolean): Promise<boolean> {
50
51
  const watches=await this.read('watches.json',{})
51
- if (!(watches[String(message.chat.id)]>Date.now())) return false
52
+ const now=Date.now(),direct=watches[String(message.chat.id)]>now,wildcard=watches['*']>now
53
+ if (!direct && !wildcard) return false
54
+ // The active public watch owns admission; consume ineligible group chatter
55
+ // without turning an owner's message into legacy group discovery.
56
+ if (!direct && !wildcardEligible) return true
52
57
  const event: SourceEvent={id:`tg_${String(message.chat.id).replace('-','n')}_${message.message_id}`,conversationId:String(message.chat.id),receivedAt:message.date*1000,
53
58
  text:JSON.stringify({updateId,senderId:sender.id,senderName:sender.first_name,messageId:message.message_id,text:message.text})}
54
59
  if (!await this.read(`${event.id}.json`,null)) {
60
+ const files=(await readdir(this.directory)).filter(file=>/^tg_(?:n)?\d+_\d+\.json$/.test(file))
61
+ if(files.length>=100)return true
55
62
  const cursor=(await this.read('cursor.json',0))+1
56
63
  if(!Number.isSafeInteger(cursor)||cursor<1)throw new Error('Invalid event cursor')
57
64
  await atomicTaskFile(join(this.directory,'cursor.json'),cursor)
@@ -60,9 +67,14 @@ export class TelegramSource {
60
67
  return true
61
68
  }
62
69
  async call(command: string,args: Record<string,any>) {
63
- if(command==='events-head') return {cursor:0,accountId:this.accountId,taskProtocol:'message-v1',persistentWatch:true}
70
+ if(command==='events-head') return {cursor:0,accountId:this.accountId,taskProtocol:'message-v1',persistentWatch:true,wildcardWatch:true,releaseEvents:true,...(this.sendFile?{taskAttachments:true,attachmentCaptionLimit:1024}:{})}
71
+ if(command==='events-release') {
72
+ if(!Array.isArray(args.ids)||args.ids.length>10||args.ids.some((id:unknown)=>typeof id!=='string'||!/^tg_(?:n)?\d+_\d+$/.test(id)))throw new Error('Invalid IDs')
73
+ for(const id of args.ids)await rm(join(this.directory,`${id}.json`),{force:true})
74
+ return {released:true}
75
+ }
64
76
  if(command==='events' || command==='events-check') {
65
- const files=(await readdir(this.directory)).filter(f=>/^tg_n\d+_\d+\.json$/.test(f))
77
+ const files=(await readdir(this.directory)).filter(f=>/^tg_(?:n)?\d+_\d+\.json$/.test(f))
66
78
  const events=(await Promise.all(files.map(f=>this.read(f,null)))).sort((a,b)=>a.cursor-b.cursor)
67
79
  if(command==='events-check') {
68
80
  if(!Array.isArray(args.ids)||args.ids.length>10)throw new Error('Invalid IDs')
@@ -72,7 +84,9 @@ export class TelegramSource {
72
84
  const batch=events.filter(e=>e.cursor>args.after).slice(0,10)
73
85
  return {events:batch,cursor:batch.at(-1)?.cursor??args.after}
74
86
  }
75
- if(args.accountId!==this.accountId || typeof args.conversationId!=='string' || !/^-\d+$/.test(args.conversationId) || !Number.isSafeInteger(Number(args.conversationId))) throw new Error('Invalid Telegram binding')
87
+ const wildcard=args.conversationId==='*'
88
+ const concrete=typeof args.conversationId==='string' && /^-?[1-9]\d*$/.test(args.conversationId) && Number.isSafeInteger(Number(args.conversationId))
89
+ if(args.accountId!==this.accountId || (!wildcard && !concrete)) throw new Error('Invalid Telegram binding')
76
90
  if(command==='task-unwatch') {
77
91
  const watches=await this.read('watches.json',{});delete watches[args.conversationId]
78
92
  await atomicTaskFile(join(this.directory,'watches.json'),watches);return {watching:false}
@@ -82,13 +96,20 @@ export class TelegramSource {
82
96
  const watches=await this.read('watches.json',{});watches[args.conversationId]=args.expiresAt
83
97
  await atomicTaskFile(join(this.directory,'watches.json'),watches);return {watching:true}
84
98
  }
85
- if(command!=='task-send'||typeof args.text!=='string'||!args.text.trim()||args.text.length>4096||typeof args.key!=='string'||!/^[a-zA-Z0-9_-]{1,240}$/.test(args.key))throw new Error('Invalid send')
86
- const watches=await this.read('watches.json',{});if(!(watches[args.conversationId]>Date.now()))throw new Error('Watch expired')
99
+ if(command!=='task-send'||wildcard||typeof args.text!=='string'||!args.text.trim()||args.text.length>4096||typeof args.key!=='string'||!/^[a-zA-Z0-9_-]{1,240}$/.test(args.key))throw new Error('Invalid send')
100
+ const watches=await this.read('watches.json',{});if(!(watches[args.conversationId]>Date.now())&&!(watches['*']>Date.now()))throw new Error('Watch expired')
87
101
  const file=`send_${args.key}.json`,prior=await this.read(file,null)
88
- if(prior) {if(prior.text!==args.text||prior.conversationId!==args.conversationId)throw new Error('Key reused');return prior}
89
- const receipt={accountId:this.accountId,conversationId:args.conversationId,key:args.key,text:args.text,state:'uncertain',receiptId:[] as number[]}
102
+ if(prior) {if(prior.text!==args.text||prior.conversationId!==args.conversationId||JSON.stringify(prior.attachment)!==JSON.stringify(args.attachment))throw new Error('Key reused');return prior}
103
+ const attachment=args.attachment as TaskAttachment|undefined
104
+ if(attachment && (!this.sendFile||args.text.length>1024))throw Error('Document delivery requires a supported channel and a caption of at most 1024 characters')
105
+ const bytes=attachment?await readTaskAttachment(this.controlDir,attachment):undefined
106
+ const receiptFiles=(await readdir(this.directory)).filter(name=>/^send_[a-zA-Z0-9_-]{1,240}\.json$/.test(name))
107
+ const accepted=(await Promise.all(receiptFiles.map(async name=>({name,value:await this.read(name,null)})))).filter(item=>item.value?.state==='accepted').sort((a,b)=>(a.value.createdAt??'').localeCompare(b.value.createdAt??''))
108
+ for(const item of accepted.slice(0,Math.max(0,receiptFiles.length-99)))await rm(join(this.directory,item.name),{force:true})
109
+ if(receiptFiles.length-accepted.length>=100)throw new Error('Too many uncertain Telegram sends')
110
+ const receipt={accountId:this.accountId,conversationId:args.conversationId,key:args.key,text:args.text,...(attachment?{attachment}:{}),state:'uncertain',createdAt:new Date().toISOString(),receiptId:[] as number[]}
90
111
  await atomicTaskFile(join(this.directory,file),receipt)
91
- receipt.receiptId=await this.send(Number(args.conversationId),args.text);receipt.state='accepted'
112
+ receipt.receiptId=attachment?await this.sendFile!(Number(args.conversationId),args.text,bytes!,attachment.filename):await this.send(Number(args.conversationId),args.text);receipt.state='accepted'
92
113
  await atomicTaskFile(join(this.directory,file),receipt);return receipt
93
114
  }
94
115
  }
@@ -59,8 +59,8 @@ export async function extract(buffer,destination) {
59
59
  await fs.mkdir(destination,{recursive:true,mode:0o700});
60
60
  for(const f of files) {
61
61
  const dest=path.join(destination,f.relative);
62
- if(f.type==='5'){await fs.mkdir(dest,{recursive:true,mode:0o755});continue;}
63
- await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});await fs.writeFile(dest,f.data,{flag:'wx',mode:f.mode});
62
+ if(f.type==='5'){await fs.mkdir(dest,{recursive:true,mode:0o755});await fs.chmod(dest,0o755);continue;}
63
+ await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});await fs.chmod(path.dirname(dest),0o755);await fs.writeFile(dest,f.data,{flag:'wx',mode:f.mode});await fs.chmod(dest,f.mode);
64
64
  }
65
65
  }
66
66
  export async function registryVersion(name,tag='latest') {
@@ -44,6 +44,14 @@ export async function check(home) {
44
44
  }
45
45
  return results;
46
46
  }
47
+ const canonical=value=>Array.isArray(value)?value.map(canonical):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,canonical(value[key])])):value;
48
+ const same=(left,right)=>JSON.stringify(canonical(left))===JSON.stringify(canonical(right));
49
+ function additiveCommands(previous,next) {
50
+ const before={...previous,commands:{}},after={...next,commands:{}};
51
+ if(!same(before,after))return false;
52
+ for(const [name,command] of Object.entries(previous.commands||{}))if(!same(next.commands?.[name],command))return false;
53
+ return Object.values(next.commands||{}).every(command=>next.services?.[command.service]);
54
+ }
47
55
  export async function eligibility(home,target,root,automatic) {
48
56
  const old=await installed(home,target),pkg=await read(path.join(root,'package.json')),kind=target==='main'?'main':'plugin';
49
57
  if(pkg.name!==old.pkg.name)throw Error('Package identity mismatch');
@@ -54,7 +62,9 @@ export async function eligibility(home,target,root,automatic) {
54
62
  if(kind==='plugin') {
55
63
  const s=await snapshot(root);revision=s.revision;
56
64
  if(s.manifest.id!==target||s.manifest.version!==pkg.version)throw Error('Plugin identity/version mismatch');
57
- if(JSON.stringify(s.deployment)!==JSON.stringify(old.record.deployment))throw Error('Deployment permissions/layout changed; a separately reviewed migration is required');
65
+ if(!additiveCommands(old.record.deployment,s.deployment))throw Error('Deployment permissions/layout changed; a separately reviewed migration is required');
66
+ const registry=await read(path.join(home,'registry.json'));
67
+ for(const alias of Object.keys(s.manifest.commands))if(registry.commands[alias]&&registry.commands[alias]!==target)throw Error('CLI alias collision');
58
68
  }else {
59
69
  for(const file of ['compose.yaml','compose.whatsapp.yaml'])if(!Buffer.from(await fs.readFile(path.join(root,file))).equals(await fs.readFile(path.join(old.root,file))))throw Error('Runtime deployment changed; a separately reviewed migration is required');
60
70
  }
@@ -122,7 +122,9 @@ export async function perform(home,job,hooks) {
122
122
  for(const name of volumes)await backupVolume(run,old,name,backup);
123
123
  await atomic(old.compose,await compose(config,candidate,secrets,home));
124
124
  if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
125
- r.plugins[job.target]=candidate;await atomic(path.join(home,'registry.json'),r);
125
+ r.plugins[job.target]=candidate;
126
+ for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
127
+ await atomic(path.join(home,'registry.json'),r);
126
128
  job.runtimeVerified=running;
127
129
  }
128
130
  job.status='completed';job.endedAt=new Date().toISOString();await save();return job;
@@ -31,6 +31,20 @@ software maintenance, consult `work/deployments.md` when present and the install
31
31
  `ez updates` inventories core/plugins only. Saved authority and stop conditions
32
32
  apply to application repairs and upgrades too.
33
33
 
34
+ When the owner asks the agent to answer or act for other people on an enabled
35
+ message channel, use `ezenciel-agents-task --help`. Propose the core channel grant
36
+ from the current owner turn; do not treat a saved instruction, connected channel,
37
+ or installed tool as permission. Use an exact contact by default. Use
38
+ `--any-conversation` only when the owner explicitly asks for everyone on that
39
+ source. The public grant admits at most 60 conversations and 60 replies per hour;
40
+ after 1,000 conversations or replies it revokes and requires fresh owner review.
41
+ A capability file may grant exact, fixed `ez` query vectors whose final
42
+ argument is the correspondent's literal input. The installed alias must explicitly
43
+ declare itself a read-only `channelQuery`; never use lifecycle, write, send, or
44
+ interactive-review commands. Include only output the owner authorized for that audience. The core
45
+ shows the full audience, context and command vectors for confirmation. Do not
46
+ expose owner memory or an unrestricted shell.
47
+
34
48
  ## Fast KISS iterations
35
49
 
36
50
  Deliver the smallest useful product increment and verify its main user path.