@jc_stack/ez-agents 0.1.0-beta.33 → 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 (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +42 -5
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. package/test/workforce-watch.test.ts +0 -180
package/src/files.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile, realpath, stat } from 'node:fs/promises'
1
+ import { mkdir, readFile, writeFile, realpath, stat } from 'node:fs/promises'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import path from 'node:path'
4
4
 
@@ -43,28 +43,55 @@ export const sanitizeFileName = (name: string): string => {
43
43
  return base || 'file'
44
44
  }
45
45
 
46
+ // Runtime inbound staging lives under the agent control directory, never the
47
+ // workspace mind folder. The engine resolves the absolute path regardless of
48
+ // its working directory; the prompt carries no workspace-relative prefix.
46
49
  export const stageIncomingFile = async (
47
- workspaceDir: string,
50
+ stagingRoot: string,
48
51
  fileName: string,
49
52
  bytes: Buffer,
50
53
  ): Promise<{ relativePath: string; fullPath: string; fileType: DetectedFileType }> => {
51
54
  const fileType = detectFileType(bytes)
52
55
  if (fileType === 'unknown' || bytes.length > 20 * 1024 * 1024)
53
56
  throw new Error('Unsupported or oversized attachment')
54
- const inboxDir = path.join(workspaceDir, 'inbox')
55
- await mkdir(inboxDir, { recursive: true, mode: 0o700 })
57
+ const attachmentsDir = path.join(stagingRoot, 'attachments')
58
+ await mkdir(attachmentsDir, { recursive: true, mode: 0o700 })
56
59
  const sanitized = sanitizeFileName(fileName)
57
60
  const targetName = `${randomUUID()}_${sanitized}`
58
- await workspaceFile(workspaceDir, 'inbox', false)
59
- const fullPath = path.join(inboxDir, targetName)
61
+ await workspaceFile(stagingRoot, 'attachments', false)
62
+ const fullPath = path.join(attachmentsDir, targetName)
60
63
  await writeFile(fullPath, bytes, { mode: 0o600 })
61
64
  return {
62
- relativePath: path.join('inbox', targetName),
65
+ relativePath: path.join('attachments', targetName),
63
66
  fullPath,
64
67
  fileType,
65
68
  }
66
69
  }
67
70
 
71
+ // Reads a staged attachment produced by stageIncomingFile. Relative paths
72
+ // resolve under the first containing root (backward compatible with
73
+ // workspace inbox staging); absolute paths must stay inside one of the roots.
74
+ export const readStagedAttachment = async (roots: (string | undefined)[], file: string): Promise<Buffer> => {
75
+ const candidates = [...new Set(roots.filter((root): root is string => Boolean(root)))]
76
+ if (!path.isAbsolute(file)) {
77
+ for (const root of candidates) {
78
+ try { return await readFile(await workspaceFile(root, file)) } catch {}
79
+ }
80
+ throw new Error('File is outside the workspace')
81
+ }
82
+ const target = await realpath(file).catch(() => { throw new Error('File is outside the workspace') })
83
+ for (const root of candidates) {
84
+ const base = await realpath(root).catch(() => null)
85
+ if (!base) continue
86
+ const relative = path.relative(base, target)
87
+ if (!relative.startsWith('..') && !path.isAbsolute(relative) && relative !== '') {
88
+ if (!(await stat(target)).isFile()) throw new Error('Expected a regular file')
89
+ return readFile(target)
90
+ }
91
+ }
92
+ throw new Error('File is outside the workspace')
93
+ }
94
+
68
95
  export const workspaceFile = async (workspace: string, file: string, regular = true): Promise<string> => {
69
96
  const root = await realpath(workspace)
70
97
  const target = await realpath(path.resolve(root, file))
@@ -76,12 +103,12 @@ export const workspaceFile = async (workspace: string, file: string, regular = t
76
103
 
77
104
  // Canonical native-engine attachment metadata for every inbound channel.
78
105
  export const MAX_INCOMING_ATTACHMENT_BYTES = 10 * 1024 * 1024
79
- export const stageChatAttachment = async (workspace: string, name: string, bytes: Buffer, comment: string) => {
106
+ export const stageChatAttachment = async (stagingRoot: string, name: string, bytes: Buffer, comment: string) => {
80
107
  const type = detectFileType(bytes)
81
108
  if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || type === 'unknown' ||
82
109
  (type === 'text' && !/\.(txt|md|markdown)$/i.test(name))) throw new Error('Invalid application attachment: unsupported type or size')
83
- const staged = await stageIncomingFile(workspace, name, bytes)
110
+ const staged = await stageIncomingFile(stagingRoot, name, bytes)
84
111
  const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : 'document'
85
- return { text: `[Attached ${kind} staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
86
- attachment: {path: staged.relativePath, type: staged.fileType}, fullPath: staged.fullPath }
112
+ return { text: `[Attached ${kind} staged at ${staged.fullPath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
113
+ attachment: {path: staged.fullPath, type: staged.fileType}, fullPath: staged.fullPath }
87
114
  }
@@ -1,13 +1,14 @@
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'
9
9
  import { fileURLToPath } from 'node:url'
10
- import { startExecutorJob, terminateJob, resolveExecutor, type ExecutorOptions } from './executor.js'
10
+ import { startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
11
+ import { parseIsolationClass, type IsolationClass } from './isolation.js'
11
12
  import { readModels, validateSelection } from './ai.js'
12
13
  import type { ChildProcess } from 'node:child_process'
13
14
  import { taskWorkspace } from './task-workspace.js'
@@ -16,12 +17,14 @@ import { installedPluginVersions } from './software-status.js'
16
17
  import { processSnapshot } from './process-tree.js'
17
18
 
18
19
  export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
19
- export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
20
- export type HostInstallation = { cli: string; agents: HostBinding[] }
20
+ export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute>; pluginFolderRoots?: Record<string,string[]>; codexProviders?: CodexProviderBinding[] }
21
+ export type HostInstallation = { cli: string; isolation?: IsolationClass; agents: HostBinding[] }
21
22
 
22
23
  const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
23
24
 
24
25
  export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob) => {
26
+ if (installation.isolation !== undefined) parseIsolationClass(installation.isolation)
27
+ if (installation.isolation === 'isolated') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
25
28
  resolveExecutor(installation.cli)
26
29
  if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
27
30
  new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
@@ -31,10 +34,23 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
31
34
  const locks: string[] = []
32
35
  const sharedWorkspaces = new Map<HostBinding, string>()
33
36
  const additionalWorkspaces = new Map<HostBinding, string[]>()
34
- const catalog = (agent: HostBinding) => readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
37
+ const catalog = async (agent: HostBinding) => {
38
+ const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
39
+ const providers = (agent.codexProviders ?? []).map(validateCodexProvider)
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]
48
+ }
35
49
  try {
36
50
  for (const agent of installation.agents) {
37
51
  if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
52
+ const providers=(agent.codexProviders ?? []).map(validateCodexProvider)
53
+ if (new Set(providers.map(provider=>provider.id)).size !== providers.length) throw new Error('Codex provider IDs must be unique')
38
54
  if (agent.sharedWorkspace && !path.isAbsolute(agent.sharedWorkspace)) throw new Error('Shared workspace requires an absolute path')
39
55
  if (agent.sharedWorkspace) sharedWorkspaces.set(agent, await realpath(agent.sharedWorkspace))
40
56
  if (agent.additionalWorkspaces) {
@@ -61,8 +77,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
61
77
  catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
62
78
  await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
63
79
  locks.push(lock)
64
- await installAgentGuidance(agent.workspace)
65
- 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)
66
84
  // A host crash is terminal for a claimed job. Never replay an action.
67
85
  for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
68
86
  const base=path.join(directory,file.slice(0,-13))
@@ -78,27 +96,32 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
78
96
  if (agent.toolsHome) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
79
97
  }
80
98
  let catalogAt=Date.now()
99
+ const catalogSeen=new Map<HostBinding,string>()
81
100
  while (!signal.aborted) {
82
101
  const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
83
102
  if(parent) { try { process.kill(parent,0) } catch { break } }
84
103
  if(Date.now()-catalogAt>30000){
85
104
  for(const agent of installation.agents){
86
- 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)
87
109
  const file=path.join(agent.controlDir,'host-executor/models.json')
88
110
  await writeFile(file+'.tmp',models,{mode:0o600});await rename(file+'.tmp',file)
111
+ catalogSeen.set(agent,models)
89
112
  }
90
113
  catalogAt=Date.now()
91
114
  }
92
115
  for (const agent of installation.agents) {
93
116
  const directory=path.join(agent.controlDir,'host-executor')
94
- 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})
95
118
  await rename(path.join(directory,'heartbeat.tmp'),path.join(directory,'heartbeat.json'))
96
119
  for (const file of await readdir(directory)) {
97
120
  if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
98
121
  const id=file.slice(0,-13)
99
122
  let run
100
123
  try {
101
- run = await new RunStore(agent.controlDir).get(id)
124
+ run = await readRun(agent.controlDir, id)
102
125
  if(id.startsWith('r_schedule_') && !run?.scheduled) throw new Error('Missing scheduled run')
103
126
  } catch {
104
127
  await appendFile(path.join(directory,id+'.events'),JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
@@ -121,18 +144,28 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
121
144
  try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
122
145
  const request=JSON.parse(await readFile(base+'.running.json','utf8'))
123
146
  if (!Array.isArray(request.texts) || request.texts.some((text:unknown)=>typeof text!=='string')) throw new Error('Invalid job')
124
- const run = await new RunStore(agent.controlDir).get(path.basename(base))
147
+ const run = await readRun(agent.controlDir, path.basename(base))
125
148
  if (run?.taskId) {
126
149
  if (run.status !== 'running') throw new Error('No active task run')
127
150
  await new Tasks(agent.controlDir).authorize(run, false)
128
- } else await requireOwnerExecution(agent.controlDir, path.basename(base))
151
+ } else await authorizeRun(agent.controlDir, path.basename(base))
129
152
  const opts=request.options as ExecutorOptions
130
153
  const cli = opts.cli || installation.cli
131
154
  resolveExecutor(cli)
132
- if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,model:opts.model,effort:opts.effort},await catalog(agent))
133
- const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.workspace,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
155
+ const provider = cli === 'codex' && opts.provider
156
+ ? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
157
+ : undefined
158
+ if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
159
+ const model = opts.model
160
+ if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
161
+ if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
162
+ if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
163
+ throw new Error('Selected Codex provider is required for this model')
164
+ if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},await catalog(agent))
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,
134
166
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
135
- sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model:opts.model,effort:opts.effort,codexAutoCompactTokens:opts.codexAutoCompactTokens}
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)}
136
169
  job=await launch(request.texts,options)
137
170
  active.set(base,job.child)
138
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 +