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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +67 -0
  5. package/Dockerfile +7 -9
  6. package/README.md +10 -13
  7. package/bin/ez +97 -0
  8. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  9. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  10. package/bin/ezenciel-agents.mjs +1 -1
  11. package/compose.yaml +40 -4
  12. package/docker/entrypoint.sh +1 -1
  13. package/docker/healthcheck.mjs +12 -4
  14. package/docker/run.ts +5 -6
  15. package/docker/smoke.mjs +28 -6
  16. package/docs/application-channel.md +32 -10
  17. package/docs/architecture/authority-boundaries.md +2 -2
  18. package/docs/channel-backend.md +4 -3
  19. package/docs/docker-runtime.md +79 -11
  20. package/docs/local-qa.md +2 -2
  21. package/docs/plugin-guide.md +202 -0
  22. package/docs/plugins.md +48 -2
  23. package/docs/releasing.md +13 -0
  24. package/docs/responsive-channels.md +1 -1
  25. package/docs/scheduling.md +4 -4
  26. package/docs/setup.md +11 -5
  27. package/docs/upgrades.md +46 -23
  28. package/package.json +5 -5
  29. package/scripts/smoke-scheduler.ts +10 -3
  30. package/scripts/smoke.ts +14 -11
  31. package/src/agent-guidance.ts +6 -9
  32. package/src/agent-install.ts +28 -7
  33. package/src/application-channel.ts +78 -7
  34. package/src/application-cli.ts +2 -2
  35. package/src/approval-cli.ts +14 -7
  36. package/src/approval.ts +27 -37
  37. package/src/channel-backend.ts +2 -3
  38. package/src/config.ts +12 -27
  39. package/src/control-state.ts +175 -8
  40. package/src/conversation-menu.ts +6 -17
  41. package/src/delivery-socket-client.d.mts +4 -0
  42. package/src/delivery-socket-client.mjs +104 -0
  43. package/src/delivery-socket.ts +354 -0
  44. package/src/execution-authority.ts +21 -5
  45. package/src/executor.ts +20 -23
  46. package/src/files.ts +38 -11
  47. package/src/host-executor.ts +28 -14
  48. package/src/inbox.ts +15 -49
  49. package/src/index.ts +95 -52
  50. package/src/install-tools.mjs +97 -22
  51. package/src/ledger-endpoint.d.mts +2 -0
  52. package/src/ledger-endpoint.mjs +17 -0
  53. package/src/menu.ts +7 -2
  54. package/src/message-history.ts +13 -18
  55. package/src/message.ts +35 -18
  56. package/src/plugin-broker.mjs +396 -0
  57. package/src/plugins/connection.mjs +2 -2
  58. package/src/plugins/manager.mjs +132 -23
  59. package/src/plugins/native-tasks.mjs +2 -2
  60. package/src/prompt-suffix.ts +12 -0
  61. package/src/react.ts +6 -4
  62. package/src/runs.ts +156 -243
  63. package/src/schedule-cli.ts +29 -10
  64. package/src/scheduled-tasks.ts +53 -10
  65. package/src/scheduler.ts +20 -7
  66. package/src/service.ts +3 -3
  67. package/src/software-status.ts +36 -4
  68. package/src/task-executor.ts +2 -2
  69. package/src/task-workspace.ts +4 -3
  70. package/src/tasks.ts +1 -1
  71. package/src/telegram-provisioning-cli.ts +22 -0
  72. package/src/telegram-provisioning.ts +214 -0
  73. package/src/updates/control.mjs +77 -7
  74. package/src/updates/runtime.mjs +28 -8
  75. package/src/updates/status.mjs +25 -3
  76. package/src/updates/supervisor.mjs +15 -16
  77. package/src/version.ts +20 -0
  78. package/src/workspace.ts +3 -9
  79. package/templates/failure-review.md +1 -1
  80. package/templates/maintainer-purpose.md +1 -1
  81. package/test/agent-guidance.test.ts +14 -80
  82. package/test/agent-install.test.ts +14 -0
  83. package/test/ai.test.ts +36 -0
  84. package/test/application-channel.test.ts +61 -13
  85. package/test/application-only.test.ts +14 -13
  86. package/test/channel-backend.test.ts +3 -7
  87. package/test/channel-delivery.test.ts +4 -1
  88. package/test/channel-owner.test.ts +6 -3
  89. package/test/codex-context.test.ts +2 -2
  90. package/test/config.test.ts +38 -43
  91. package/test/control-state.test.ts +83 -4
  92. package/test/conversation-menu.test.ts +2 -4
  93. package/test/delivery-transport.test.ts +72 -0
  94. package/test/docker-runtime.test.ts +27 -14
  95. package/test/engine-handoff.test.ts +7 -6
  96. package/test/event-sources.test.ts +2 -2
  97. package/test/execution-authority.test.ts +9 -9
  98. package/test/executor.test.ts +10 -11
  99. package/test/failure.test.ts +12 -5
  100. package/test/files.test.ts +2 -2
  101. package/test/helpers/ledger.ts +18 -0
  102. package/test/host-executor.test.ts +74 -0
  103. package/test/inbox.test.ts +8 -10
  104. package/test/install-config.test.ts +1 -0
  105. package/test/install-tools.test.mjs +74 -7
  106. package/test/intake-relay.test.ts +58 -11
  107. package/test/message-history.test.ts +39 -47
  108. package/test/native-tasks.test.ts +1 -1
  109. package/test/plugin-broker.test.mjs +172 -0
  110. package/test/plugin-manager.test.mjs +119 -2
  111. package/test/reaction.test.ts +4 -0
  112. package/test/relay.test.ts +5 -7
  113. package/test/repair-policy.test.ts +1 -4
  114. package/test/schedule-cli.test.ts +14 -9
  115. package/test/scheduled-tasks.test.ts +29 -6
  116. package/test/scheduler-host.test.ts +5 -5
  117. package/test/scheduler-relay.test.ts +15 -9
  118. package/test/security.test.ts +6 -3
  119. package/test/software-status.test.ts +21 -3
  120. package/test/telegram-provisioning.test.ts +160 -0
  121. package/test/updates.test.mjs +93 -13
  122. package/test/workspace.test.ts +4 -1
  123. package/bin/ezenciel-agents-watch.mjs +0 -8
  124. package/compose.workforce-watch.yaml +0 -33
  125. package/docker/recovery.ts +0 -11
  126. package/docs/pagerduty.md +0 -42
  127. package/docs/workforce-watch.md +0 -101
  128. package/src/pagerduty.ts +0 -109
  129. package/src/reply-context.ts +0 -43
  130. package/src/update-attention.ts +0 -18
  131. package/src/workforce-watch-cli.ts +0 -14
  132. package/src/workforce-watch.ts +0 -155
  133. package/templates/agent/AGENTS.md +0 -5
  134. package/templates/agent-guidance.md +0 -17
  135. package/test/pagerduty.test.ts +0 -104
  136. package/test/update-attention.test.ts +0 -20
  137. package/test/workforce-watch.test.ts +0 -180
@@ -1,8 +1,8 @@
1
- import { installAgentGuidance } from './agent-guidance.js'
2
1
  import { redactFailure } from './failure.js'
3
- import { RunStore } from './runs.js'
4
2
  import { Tasks } from './tasks.js'
5
- import { requireOwnerExecution } from './execution-authority.js'
3
+ import { ControlStore } from './control-state.js'
4
+ import { authorizeRun, readRun } from './delivery-socket.js'
5
+ import { runPromptSuffix } from './prompt-suffix.js'
6
6
  import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
7
7
  import path from 'node:path'
8
8
  import { isHostRunId } from './host-executor-protocol.js'
@@ -37,8 +37,14 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
37
37
  const catalog = async (agent: HostBinding) => {
38
38
  const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
39
39
  const providers = (agent.codexProviders ?? []).map(validateCodexProvider)
40
- const declared = providers.flatMap(provider => provider.models.map(model => ({cli:'codex',provider:provider.id,model,name:`${provider.name} · ${model}`.slice(0,80),efforts:[]})))
41
- return [...discovered, ...declared]
40
+ const declared = providers.flatMap(provider => provider.models.map(model => {
41
+ const native = discovered.find(candidate => candidate.cli === 'codex' && !candidate.provider && candidate.model === model)
42
+ return {cli:'codex',provider:provider.id,model,name:`${provider.name} · ${model}`.slice(0,80),efforts:native?.efforts ?? []}
43
+ }))
44
+ const declaredModels = new Set(declared.map(model => model.model))
45
+ const isReplacedByProvider = (model: {cli:string; model?:string}) =>
46
+ model.model !== undefined && declaredModels.has(model.model) && model.cli === 'codex'
47
+ return [...discovered.filter(model => !isReplacedByProvider(model)), ...declared]
42
48
  }
43
49
  try {
44
50
  for (const agent of installation.agents) {
@@ -71,8 +77,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
71
77
  catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
72
78
  await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
73
79
  locks.push(lock)
74
- await installAgentGuidance(agent.workspace)
75
- await writeFile(path.join(directory,'models.json'),JSON.stringify(await catalog(agent)),{mode:0o600})
80
+ const models = await catalog(agent)
81
+ await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
82
+ const modelsFile=path.join(directory,'models.json')
83
+ await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
76
84
  // A host crash is terminal for a claimed job. Never replay an action.
77
85
  for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
78
86
  const base=path.join(directory,file.slice(0,-13))
@@ -88,27 +96,32 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
88
96
  if (agent.toolsHome) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
89
97
  }
90
98
  let catalogAt=Date.now()
99
+ const catalogSeen=new Map<HostBinding,string>()
91
100
  while (!signal.aborted) {
92
101
  const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
93
102
  if(parent) { try { process.kill(parent,0) } catch { break } }
94
103
  if(Date.now()-catalogAt>30000){
95
104
  for(const agent of installation.agents){
96
- const models=JSON.stringify(await catalog(agent))
105
+ const catalogModels = await catalog(agent)
106
+ const models=JSON.stringify(catalogModels)
107
+ if(catalogSeen.get(agent)===models)continue
108
+ await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(catalogModels)
97
109
  const file=path.join(agent.controlDir,'host-executor/models.json')
98
110
  await writeFile(file+'.tmp',models,{mode:0o600});await rename(file+'.tmp',file)
111
+ catalogSeen.set(agent,models)
99
112
  }
100
113
  catalogAt=Date.now()
101
114
  }
102
115
  for (const agent of installation.agents) {
103
116
  const directory=path.join(agent.controlDir,'host-executor')
104
- await writeFile(path.join(directory,'heartbeat.tmp'),JSON.stringify({at:Date.now(),pid:process.pid,version:packageVersion,plugins:await installedPluginVersions(agent.toolsHome)}),{mode:0o600})
117
+ await writeFile(path.join(directory,'heartbeat.tmp'),JSON.stringify({at:Date.now(),pid:process.pid,version:packageVersion,platform:process.platform,arch:process.arch,plugins:await installedPluginVersions(agent.toolsHome)}),{mode:0o600})
105
118
  await rename(path.join(directory,'heartbeat.tmp'),path.join(directory,'heartbeat.json'))
106
119
  for (const file of await readdir(directory)) {
107
120
  if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
108
121
  const id=file.slice(0,-13)
109
122
  let run
110
123
  try {
111
- run = await new RunStore(agent.controlDir).get(id)
124
+ run = await readRun(agent.controlDir, id)
112
125
  if(id.startsWith('r_schedule_') && !run?.scheduled) throw new Error('Missing scheduled run')
113
126
  } catch {
114
127
  await appendFile(path.join(directory,id+'.events'),JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
@@ -131,11 +144,11 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
131
144
  try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
132
145
  const request=JSON.parse(await readFile(base+'.running.json','utf8'))
133
146
  if (!Array.isArray(request.texts) || request.texts.some((text:unknown)=>typeof text!=='string')) throw new Error('Invalid job')
134
- const run = await new RunStore(agent.controlDir).get(path.basename(base))
147
+ const run = await readRun(agent.controlDir, path.basename(base))
135
148
  if (run?.taskId) {
136
149
  if (run.status !== 'running') throw new Error('No active task run')
137
150
  await new Tasks(agent.controlDir).authorize(run, false)
138
- } else await requireOwnerExecution(agent.controlDir, path.basename(base))
151
+ } else await authorizeRun(agent.controlDir, path.basename(base))
139
152
  const opts=request.options as ExecutorOptions
140
153
  const cli = opts.cli || installation.cli
141
154
  resolveExecutor(cli)
@@ -149,9 +162,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
149
162
  if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
150
163
  throw new Error('Selected Codex provider is required for this model')
151
164
  if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},await catalog(agent))
152
- 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,
165
+ const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.controlDir,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
153
166
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
154
- sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider}
167
+ sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
168
+ promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
155
169
  job=await launch(request.texts,options)
156
170
  active.set(base,job.child)
157
171
  await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
package/src/inbox.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
- import { join } from 'node:path'
3
1
  import type { Update } from 'grammy/types'
4
2
  import { isExecutionChoice, type ExecutionChoice } from './ai.js'
5
3
  import { isOwner } from './identity.js'
@@ -21,58 +19,26 @@ type Entry = { update: Update; receivedAt: number; execution?: ExecutionChoice }
21
19
  export type InboxBatch = { id: string; entries: Entry[]; status: 'pending' | 'failed' | 'cancelled' }
22
20
  type State = { version: 1; seen: number[]; waiting: Entry[]; batches: InboxBatch[] }
23
21
 
24
- // One relay writer. Serialize read/modify/rename, including commands arriving during STT.
22
+ // Stateless pipe: process memory only. Intake dedup lives in the relay that
23
+ // polls Telegram, so no other process reads this journal. A restart drops
24
+ // unprocessed updates by design; Telegram redelivery is the replay mechanism.
25
+ const statesByDirectory = new Map<string, State>()
26
+
27
+ const stateFor = (directory: string): State => {
28
+ let state = statesByDirectory.get(directory)
29
+ if (!state) { state = { version: 1, seen: [], waiting: [], batches: [] }; statesByDirectory.set(directory, state) }
30
+ return state
31
+ }
32
+
25
33
  export class InboxStore {
26
34
  private lock: Promise<unknown> = Promise.resolve()
27
- private readonly file: string
28
35
  constructor(
29
36
  private readonly directory: string,
30
37
  private readonly now = Date.now,
31
- ) {
32
- this.file = join(directory, 'inbox.json')
33
- }
34
-
35
- private async read(): Promise<State> {
36
- try {
37
- const state = JSON.parse(await readFile(this.file, 'utf8')) as State
38
- const entry = (e: Entry) =>
39
- e && Number.isSafeInteger(e.update?.update_id) && Number.isFinite(e.receivedAt) &&
40
- (e.execution === undefined || isExecutionChoice(e.execution))
41
- if (
42
- state.version !== 1 ||
43
- !Array.isArray(state.seen) ||
44
- !state.seen.every(Number.isSafeInteger) ||
45
- !Array.isArray(state.waiting) ||
46
- !state.waiting.every(entry) ||
47
- !Array.isArray(state.batches) ||
48
- !state.batches.every(
49
- (b) =>
50
- /^tg_\d+$/.test(b.id) &&
51
- ['pending', 'failed', 'cancelled'].includes(b.status) &&
52
- Array.isArray(b.entries) &&
53
- b.entries.length > 0 &&
54
- b.entries.every(entry),
55
- )
56
- )
57
- throw new Error('Invalid inbox shape')
58
- return state
59
- } catch (error) {
60
- if ((error as NodeJS.ErrnoException).code === 'ENOENT')
61
- return { version: 1, seen: [], waiting: [], batches: [] }
62
- throw new Error('Inbox unreadable; restore the local journal before accepting messages')
63
- }
64
- }
38
+ ) {}
65
39
 
66
40
  private change<T>(work: (state: State) => T): Promise<T> {
67
- const next = this.lock.then(async () => {
68
- const state = await this.read()
69
- const result = work(state)
70
- await mkdir(this.directory, { recursive: true, mode: 0o700 })
71
- const temporary = `${this.file}.${process.pid}.tmp`
72
- await writeFile(temporary, JSON.stringify(state), { mode: 0o600 })
73
- await rename(temporary, this.file)
74
- return result
75
- })
41
+ const next = this.lock.then(async () => work(stateFor(this.directory)))
76
42
  this.lock = next.catch(() => {})
77
43
  return next
78
44
  }
@@ -163,12 +129,12 @@ export class InboxStore {
163
129
 
164
130
  async pending(id: string): Promise<boolean> {
165
131
  await this.lock
166
- return (await this.read()).batches.some((b) => b.id === id && b.status === 'pending')
132
+ return stateFor(this.directory).batches.some((b) => b.id === id && b.status === 'pending')
167
133
  }
168
134
 
169
135
  async status(): Promise<{ pending: number; failed: number }> {
170
136
  await this.lock
171
- const state = await this.read()
137
+ const state = stateFor(this.directory)
172
138
  return {
173
139
  pending:
174
140
  state.waiting.length +
package/src/index.ts CHANGED
@@ -4,14 +4,15 @@ import { TelegramSource } from './telegram-source.js'
4
4
  import { Tasks } from './tasks.js'
5
5
  import { taskRequests } from './task-rpc.js'
6
6
  import { executionBlockReason } from './execution-authority.js'
7
+ import { serveDeliverySocket, createLedgerHandler, deliverySocketAlive, deliverySocketPath } from './delivery-socket.js'
8
+ import { packageVersion } from './version.js'
7
9
  import {authorizeDeliveryContext} from './delivery-context.mjs'
8
10
  import { dispatchChannel } from './channel-backend.js'
9
11
  import { randomUUID } from 'node:crypto'
10
12
  import { stat } from 'node:fs/promises'
11
13
  import { Scheduler } from './scheduler.js'
12
- import { scheduledTasksText } from './scheduled-tasks.js'
14
+ import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
13
15
  import { taskWorkspace } from './task-workspace.js'
14
- import { queueUpdateAttention } from './update-attention.js'
15
16
  import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
16
17
  import {removeTaskAttachments} from './task-attachments.js'
17
18
  import { dirname, join, basename } from 'node:path'
@@ -37,14 +38,13 @@ import { createConversationMenu } from './conversation-menu.js'
37
38
  import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
38
39
  import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
39
40
  import { discoverDefaults } from './client-defaults.js'
40
- import { initializeWorkspace } from './workspace.js'
41
+ import { runPromptSuffix } from './prompt-suffix.js'
41
42
  import { softwareStatus } from './software-status.js'
42
- import { PagerDutyStocksMonitor } from './pagerduty.js'
43
43
 
44
44
  export const createRelay = (config: Config, launch = startExecutorJob) => {
45
45
  const safeError = (error: unknown): string => {
46
46
  let message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
47
- for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey, config.pagerDutyRoutingKey]) {
47
+ for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
48
48
  if (secret) message = message.replaceAll(secret, '[redacted]')
49
49
  }
50
50
  return message
@@ -70,19 +70,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
70
70
  let taskTimer: ReturnType<typeof setInterval> | undefined
71
71
  let drainTimer: ReturnType<typeof setInterval> | undefined
72
72
  const codexHome = join(config.controlDir, 'cli', 'codex')
73
- const conversationMenu = createConversationMenu(control, runs)
73
+ const conversationMenu = createConversationMenu(control)
74
74
  const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
75
75
  const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
76
76
  const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
77
- const pagerDuty = config.pagerDutyRoutingKey && config.pagerDutyStocksHealthUrl
78
- ? new PagerDutyStocksMonitor({
79
- routingKey: config.pagerDutyRoutingKey,
80
- healthUrl: config.pagerDutyStocksHealthUrl,
81
- pollMs: config.pagerDutyPollMs!,
82
- failureThreshold: config.pagerDutyFailureThreshold!,
83
- onError: (error) => console.error('PagerDuty Stocks health check failed', safeError(error)),
84
- })
85
- : undefined
86
77
 
87
78
  let activeTypingTimer: ReturnType<typeof setInterval> | null = null
88
79
  let activeBackend = false
@@ -112,6 +103,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
112
103
  controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
113
104
  aiControls: aiMenu,
114
105
  wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
106
+ createTelegramPairing: async (bindingId, owner) => {
107
+ const username = bot?.botInfo?.username
108
+ if (!username || !/^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(username))
109
+ throw new Error('Telegram connection is unavailable')
110
+ const pairing = await control.createApplicationTelegramPairing(bindingId, owner)
111
+ if (!pairing) return { connected: true } as const
112
+ return {
113
+ connected: false as const,
114
+ url: `https://t.me/${username}?start=${pairing.token}`,
115
+ expiresAt: pairing.expiresAt,
116
+ }
117
+ },
118
+ telegramAvailable: () => Boolean(bot?.botInfo?.username),
115
119
  cancel: id => withStartLock(async () => {
116
120
  const run = await runs.get(id)
117
121
  if (run?.status === 'queued') await runs.patch(id, {status:'cancelled',endedAt:new Date().toISOString()})
@@ -176,7 +180,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
176
180
  catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); await releaseExternal(run); return }
177
181
  }
178
182
  if (config.channelBackendUrl) {
179
- if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); await releaseExternal(run); return }
183
+ if (run.external || run.taskId || run.scheduled) { await runs.patch(run.id, { status: 'failed' }); await releaseExternal(run); return }
180
184
  activeBackend = true
181
185
  await runs.patch(run.id, { status: 'running', backendSubmitted: true })
182
186
  void dispatchChannel(config, run).then(async reply => {
@@ -224,14 +228,13 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
224
228
  const launchStarted = performance.now()
225
229
  const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
226
230
  if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
227
- const maintenanceWakeup = run.id.startsWith('r_update_')
228
- const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled || maintenanceWakeup)
231
+ const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
229
232
  const session = startsOwnSession
230
233
  ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
231
234
  : await control.executionSession(started.execution!)
232
235
  const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
233
236
  const { child, cleanup } = await launch(texts, {
234
- workspace: run.scheduled ? await taskWorkspace(config.workspace,run.id) : config.workspace,
237
+ workspace: run.scheduled ? await taskWorkspace(config.controlDir,run.id) : config.workspace,
235
238
  timeoutMs: config.executorTimeoutMs,
236
239
  repairEnabled: config.repairEnabled,
237
240
  runId: started.id,
@@ -245,8 +248,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
245
248
  codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
246
249
  sessionId: session.nativeSessionId || session.sessionId,
247
250
  isResume: session.hasStarted,
248
- eventSource: run.external?.sourceId,
249
- onSession: run.external || run.taskId || maintenanceWakeup ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
251
+ promptSuffix: runPromptSuffix(started),
252
+ taskRun: Boolean(run.taskId),
253
+ nativeSession: Boolean(run.scheduled),
254
+ onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
250
255
  })
251
256
  const executionStarted = performance.now()
252
257
  // Attach before disk writes: a fast child can close while PID persistence
@@ -385,7 +390,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
385
390
  for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
386
391
  try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
387
392
  }
388
- if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
389
393
  }
390
394
  for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
391
395
  try {
@@ -449,10 +453,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
449
453
  if (message?.media_group_id) item.text = `[Telegram album: ${message.media_group_id}]\n${item.text}`
450
454
  if (message && (message.chat.type === 'group' || message.chat.type === 'supergroup'))
451
455
  item.text = `[Telegram sender ${message.from?.id}, name ${JSON.stringify(message.from?.first_name)}]\n${item.text}`
452
- if (message && !message.text && message.reply_to_message) {
453
- const quoted = message.reply_to_message
454
- item.text = `[Quoted message ${quoted.message_id}]: ${quoted.text || quoted.caption || '[media]'}\n\n${item.text}`
455
- }
456
456
  collected.push(item)
457
457
  }
458
458
  const scheduleIntake = () => {
@@ -565,7 +565,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
565
565
  ])
566
566
  console.info('run reaction sent', { run_id: item.runId, emoji })
567
567
  } else if (item.type === 'document' && item.documentPath) {
568
- const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.workspace,origin.id) : config.workspace, item.documentPath)
568
+ const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.controlDir,origin.id) : config.workspace, item.documentPath)
569
569
  await paceSend()
570
570
  await authorizeChannelDelivery()
571
571
  attemptedDelivery = true
@@ -623,11 +623,34 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
623
623
  })().finally(() => { outboxWork = undefined }))
624
624
  }
625
625
 
626
+ // Delivery socket: cross-process producers (engine children, plugin
627
+ // children, host executor, CLIs) enqueue and query the memory ledger
628
+ // through the shared handler in delivery-socket.ts. The pump below stays
629
+ // the single delivery executor; the socket only admits work into it.
630
+ let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
631
+ const handleDeliveryOp = createLedgerHandler(config.controlDir, {
632
+ wake: () => { void drainOutbox() },
633
+ status: () => ({
634
+ polling: Boolean(bot?.isRunning()),
635
+ applicationOnly: !telegramEnabled,
636
+ telegramConfigured: config.telegramBotToken !== '',
637
+ version: packageVersion,
638
+ }),
639
+ })
640
+
626
641
  const checkOwner = async (ctx: Context): Promise<boolean> => {
627
642
  if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
628
643
  const state = await control.status()
629
644
  if (!telegramOwner(state.owner)) {
630
645
  if (ctx.chat?.type !== 'private') return false
646
+ const applicationToken = ctx.message?.text?.trim().match(/^\/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]{43})$/)?.[1]
647
+ if (applicationToken) {
648
+ const linked = await applicationChannel.claimTelegramConnection(applicationToken, ctx.from.id, ctx.chat.id)
649
+ await ctx.reply(linked
650
+ ? 'Telegram is connected to your Ez agent. You can keep chatting here.'
651
+ : 'This Telegram connection link expired or is no longer valid. Create a new one in the connected application.', { link_preview_options: { is_disabled: true } })
652
+ return false
653
+ }
631
654
  const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
632
655
  if (result === 'requested')
633
656
  await ctx.reply('Owner approval is pending. Confirm this request through the local setup assistant.')
@@ -654,18 +677,29 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
654
677
  const commands = [...mainCommands, ...(launcher ? [{ command: launcher.command, description: launcher.label }] : [])]
655
678
  const controlCommand = (text?: string) => /^\/rename(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
656
679
  ? '/rename' : text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
680
+ const tasksCommand = (text?: string): number | undefined | null => {
681
+ const match = text?.trim().match(/^\/tasks(?:@[a-zA-Z0-9_]+)?(?:\s+([1-9]\d*))?$/)
682
+ return match ? (match[1] ? Number(match[1]) : undefined) : null
683
+ }
684
+ const isTasksCommand = (text?: string): boolean => /^\/tasks(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
657
685
  const statusKeyboard = () => new InlineKeyboard()
658
686
  .text('Stop active work', 'menu:stop').text('Cancel queue', 'menu:cancel').row()
659
- .text('Retry failed incoming message', 'menu:retry').row()
660
- .text('Scheduled tasks', 'menu:scheduled-tasks')
687
+ .text('Retry failed incoming message', 'menu:retry')
661
688
  const scheduledTasks = async () => {
662
689
  const owner = (await control.status()).owner
663
690
  if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
664
691
  // This intentionally uses the reader that does not create a schedules directory.
665
- return scheduledTasksText(await scheduler.listActiveReadOnly(await runs.list()), owner)
692
+ const active = await scheduler.listActiveReadOnly(await runs.list())
693
+ return { owner, active, text: scheduledTasksText(active, owner) }
666
694
  }
667
- const replyScheduledTasks = async (ctx: Context) => {
668
- for (const part of splitTelegramText(await scheduledTasks())) await ctx.reply(part)
695
+ const replyScheduledTasks = async (ctx: Context, number?: number) => {
696
+ const result = await scheduledTasks()
697
+ const text = typeof result === 'string' ? result : number === undefined ? result.text : (() => {
698
+ const owned = ownedScheduledTasks(result.active, result.owner)
699
+ const schedule = owned[number - 1]
700
+ return schedule ? scheduledTaskDetailText(schedule, number, owned.length) : `Scheduled task ${number} was not found. Use /tasks to list active tasks.`
701
+ })()
702
+ for (const part of splitTelegramText(text)) await ctx.reply(part)
669
703
  }
670
704
  const statusText = async () => {
671
705
  const running = await runs.running(false)
@@ -694,7 +728,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
694
728
  '🟢 Ez is online',
695
729
  '',
696
730
  'System',
697
- ...await softwareStatus(config.controlDir),
731
+ ...await softwareStatus(config.controlDir, config.isolation),
698
732
  `AI: ${selected.name} (${presetLabel(displayedSelected)})`,
699
733
  '',
700
734
  'Work',
@@ -771,7 +805,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
771
805
  }
772
806
  const message = ctx.message
773
807
  const command = controlCommand(message?.text)
774
- if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) return next()
808
+ if ((command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) || tasksCommand(message?.text) !== null || isTasksCommand(message?.text)) return next()
775
809
  const ordinary = message && (message.text || message.photo || message.document || message.voice)
776
810
  const approval = ctx.callbackQuery?.data?.startsWith('approval:')
777
811
  if (!ordinary && !approval) return next()
@@ -815,6 +849,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
815
849
  return
816
850
  }
817
851
 
852
+ const taskNumber = tasksCommand(ctx.message.text)
853
+ if (taskNumber !== null) {
854
+ await replyScheduledTasks(ctx, taskNumber)
855
+ return
856
+ }
857
+ if (isTasksCommand(ctx.message.text)) {
858
+ await ctx.reply('Usage: /tasks [number]. Use /tasks to list active tasks.')
859
+ return
860
+ }
861
+
818
862
  if (text === '/help') {
819
863
  await ctx.reply([...commands, ...aliases.filter((a) => !commands.some((c) => c.command === a.command))]
820
864
  .map((c) => `/${c.command} — ${c.description}`).join('\n'))
@@ -881,19 +925,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
881
925
  return
882
926
  }
883
927
 
884
- // Quoted reply context forwarding
885
- let promptText = ctx.message.text
886
- if (ctx.message.reply_to_message) {
887
- const quoted = ctx.message.reply_to_message
888
- const quotedText = ('text' in quoted && quoted.text) || ('caption' in quoted && quoted.caption) || ''
889
- const quotedSender = quoted.from?.is_bot ? 'Agent' : 'Owner'
890
- if (quotedText) {
891
- promptText = `[Quoted message from ${quotedSender} (ID: ${quoted.message_id})]: "${quotedText}"\n\n${promptText}`
892
- }
893
- }
894
-
895
928
  collectItem({
896
- text: promptText,
929
+ text: ctx.message.text,
897
930
  messageId: ctx.message.message_id,
898
931
  updateId: ctx.update.update_id,
899
932
  chatId: ctx.chat.id,
@@ -911,7 +944,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
911
944
  const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
912
945
  const buffer = await downloadTelegramFile(fileUrl)
913
946
  const fileName = sanitizeFileName(basename(fileInfo.file_path) || 'photo.jpg')
914
- const staged = await stageChatAttachment(config.workspace, fileName, buffer, ctx.message.caption ?? '')
947
+ const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
915
948
  collectItem({
916
949
  text: staged.text,
917
950
  attachment: staged.attachment,
@@ -938,7 +971,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
938
971
  const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
939
972
  const buffer = await downloadTelegramFile(fileUrl)
940
973
  const fileName = sanitizeFileName(doc.file_name || basename(fileInfo.file_path) || 'document.bin')
941
- const staged = await stageChatAttachment(config.workspace, fileName, buffer, ctx.message.caption ?? '')
974
+ const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
942
975
  collectItem({
943
976
  text: staged.text,
944
977
  attachment: staged.attachment,
@@ -1104,6 +1137,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1104
1137
  shuttingDown = true
1105
1138
  runtimeStarted = false
1106
1139
  pollingAbort.abort()
1140
+ await deliveryServer?.stop().catch(() => {})
1141
+ deliveryServer = null
1107
1142
  await applicationChannel.stop()
1108
1143
  wakePollRetry?.()
1109
1144
  if (intakeTimer) clearTimeout(intakeTimer)
@@ -1119,7 +1154,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1119
1154
  })
1120
1155
  await Promise.all(completions)
1121
1156
  await Promise.all([taskWork, outboxWork].map(work => work?.catch(() => {})))
1122
- pagerDuty?.stop()
1123
1157
  try {
1124
1158
  if (bot?.isRunning()) await bot.stop()
1125
1159
  } finally {
@@ -1130,10 +1164,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1130
1164
  const start = async () => {
1131
1165
  let failed = false
1132
1166
  try {
1133
- await initializeWorkspace(config.workspace)
1167
+ // Workspace seeding and managed-block replacement happen at install or
1168
+ // upgrade only; relay startup never writes the agent workspace.
1169
+ // The delivery socket is the cross-process ledger transport and the
1170
+ // single-relay guard (replacing relay.lock): a live sibling answers the
1171
+ // ping and this relay refuses to split-brain the agent.
1172
+ if (await deliverySocketAlive(deliverySocketPath(config.controlDir)).catch(() => false))
1173
+ throw new Error(`Another relay owns ${config.controlDir}; stop it before starting a second one`)
1174
+ const deliveryTcpPort = Number(process.env.EZ_DELIVERY_TCP_PORT ?? '')
1175
+ deliveryServer = await serveDeliverySocket(config.controlDir, handleDeliveryOp, {
1176
+ ...(Number.isSafeInteger(deliveryTcpPort) && deliveryTcpPort >= 1024 && deliveryTcpPort <= 65535 ? { tcpPort: deliveryTcpPort } : {}),
1177
+ })
1134
1178
  if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
1135
1179
  runtimeStarted = true
1136
- pagerDuty?.start()
1137
1180
  const owner = (await control.status()).owner
1138
1181
  if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
1139
1182
  await scheduler.recover(runs)
@@ -1210,7 +1253,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1210
1253
  }
1211
1254
  }
1212
1255
  }
1213
- return { bot: bot!, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
1256
+ return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
1214
1257
  }
1215
1258
 
1216
1259
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {