@jc_stack/ez-agents 0.1.0-beta.46 → 0.1.0-beta.48

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 (91) hide show
  1. package/.dockerignore +2 -0
  2. package/.env.example +3 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +38 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/Dockerfile +18 -10
  7. package/THIRD_PARTY_NOTICES.md +6 -3
  8. package/bin/ez +7 -0
  9. package/docker/broker-readiness.mjs +54 -0
  10. package/docker/broker-update-smoke.mjs +86 -0
  11. package/docker/engine-pnpm-workspace.yaml +4 -0
  12. package/docker/run.ts +1 -1
  13. package/docker/smoke.mjs +1 -1
  14. package/docs/application-channel.md +32 -1
  15. package/docs/architecture/ai-selection.md +26 -5
  16. package/docs/architecture/authority-boundaries.md +2 -2
  17. package/docs/development-and-testing.md +1 -1
  18. package/docs/docker-runtime.md +39 -0
  19. package/docs/isolated-plugin-update-readiness.md +66 -0
  20. package/docs/plugin-guide.md +1 -1
  21. package/docs/plugins.md +1 -1
  22. package/docs/releasing.md +5 -1
  23. package/docs/scheduling.md +117 -9
  24. package/docs/selective-monitoring.md +13 -0
  25. package/docs/upgrades.md +38 -1
  26. package/docs/work-preflight.md +9 -0
  27. package/package.json +4 -3
  28. package/pnpm-workspace.yaml +7 -0
  29. package/scripts/smoke-scheduler.ts +4 -1
  30. package/src/ai.ts +41 -4
  31. package/src/application-channel.ts +43 -3
  32. package/src/application-cli.ts +7 -1
  33. package/src/application-origin.ts +2 -0
  34. package/src/audio.ts +37 -22
  35. package/src/client-defaults.ts +3 -2
  36. package/src/config.ts +9 -0
  37. package/src/control-state.ts +3 -1
  38. package/src/delivery-socket-client.d.mts +2 -0
  39. package/src/delivery-socket-client.mjs +2 -0
  40. package/src/delivery-socket.ts +38 -1
  41. package/src/event-sources.ts +2 -2
  42. package/src/executor.ts +72 -1
  43. package/src/files.ts +47 -3
  44. package/src/host-executor.ts +40 -26
  45. package/src/index.ts +42 -17
  46. package/src/install-tools.mjs +18 -9
  47. package/src/menu.ts +4 -2
  48. package/src/model-policy.ts +4 -0
  49. package/src/plugin-broker.mjs +47 -4
  50. package/src/plugins/manager.mjs +11 -4
  51. package/src/run-evidence.ts +78 -0
  52. package/src/runs.ts +16 -2
  53. package/src/schedule-cli.ts +80 -10
  54. package/src/schedule-preflight.ts +23 -0
  55. package/src/scheduled-tasks.ts +16 -12
  56. package/src/scheduler.ts +102 -16
  57. package/src/scripts.ts +167 -0
  58. package/src/setup.ts +2 -2
  59. package/src/task-capability.ts +17 -1
  60. package/src/task-executor.ts +11 -8
  61. package/src/task-mcp.ts +20 -8
  62. package/src/tasks.ts +89 -9
  63. package/src/updates/runtime.mjs +53 -11
  64. package/src/updates/supervisor.mjs +23 -13
  65. package/test/ai.test.ts +85 -5
  66. package/test/application-channel.test.ts +16 -1
  67. package/test/audio.test.ts +19 -0
  68. package/test/channel-owner.test.ts +2 -2
  69. package/test/config.test.ts +8 -0
  70. package/test/desktop-bridge.test.ts +2 -1
  71. package/test/executor.test.ts +37 -0
  72. package/test/failure.test.ts +4 -4
  73. package/test/files.test.ts +44 -0
  74. package/test/host-executor.test.ts +49 -6
  75. package/test/install-json.test.mjs +21 -0
  76. package/test/install-tools.test.mjs +6 -5
  77. package/test/native-tasks.test.ts +1 -1
  78. package/test/plugin-broker.test.mjs +64 -0
  79. package/test/plugin-manager.test.mjs +20 -0
  80. package/test/private-speech-config.test.ts +22 -0
  81. package/test/run-evidence.test.ts +78 -0
  82. package/test/schedule-cli.test.ts +47 -3
  83. package/test/schedule-preflight.test.ts +49 -0
  84. package/test/scheduled-tasks.test.ts +2 -2
  85. package/test/scheduler-host.test.ts +21 -3
  86. package/test/scheduler-relay.test.ts +84 -4
  87. package/test/scheduler.test.ts +21 -1
  88. package/test/script-schedules.test.ts +219 -0
  89. package/test/task-native.test.ts +50 -12
  90. package/test/tasks.test.ts +97 -4
  91. package/test/updates.test.mjs +77 -18
package/src/setup.ts CHANGED
@@ -9,7 +9,7 @@ import { discoverDefaults } from './client-defaults.js'
9
9
  import { chatPreset } from './ai.js'
10
10
  import { ControlStore } from './control-state.js'
11
11
  import { loadControlConfig } from './config.js'
12
- import { EXECUTOR_REGISTRY, resolveExecutor, executorKey } from './executor.js'
12
+ import { EXECUTOR_REGISTRY, resolveExecutor, executorKey, claudeConfigHome } from './executor.js'
13
13
  import { desktopCodexPath } from './desktop-bridge.js'
14
14
 
15
15
  export const findExecutableInPath = async (
@@ -152,7 +152,7 @@ export const runCli = async (): Promise<void> => {
152
152
  const config = loadControlConfig()
153
153
  await new ControlStore(config.controlDir, config.pairingTtlMs).syncClientPresets(
154
154
  chatPreset(await readActiveExecutor(envFilePath)), await discoverDefaults(workspace,
155
- { codexHome: path.join(config.controlDir, 'cli', 'codex') }))
155
+ { codexHome: path.join(config.controlDir, 'cli', 'codex'), claudeHome: claudeConfigHome(config.controlDir) }))
156
156
  console.log(JSON.stringify({ workspace, created }))
157
157
  return
158
158
  }
@@ -1,7 +1,8 @@
1
- import { execFile } from 'node:child_process'
1
+ import { execFile, spawn } from 'node:child_process'
2
2
  import { promisify } from 'node:util'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
+ import {fileURLToPath} from 'node:url'
5
6
  import { executorEnvironment } from './executor.js'
6
7
  import type { TaskCapability } from './tasks.js'
7
8
  import {attachmentLimit,stageTaskAttachment,type TaskAttachment} from './task-attachments.js'
@@ -43,3 +44,18 @@ export async function runTaskCapability(toolsHome: string | undefined, capabilit
43
44
  if (code !== 0 || !stdout.trim()) throw new Error(`Channel capability failed with exit ${code}`)
44
45
  return {output:stdout}
45
46
  }
47
+
48
+ export async function launchTaskApplication(toolsHome: string | undefined, launch:{command:string;args:string[]}, input:unknown, runId:string):Promise<unknown> {
49
+ const isolated=!!process.env.EZ_PLUGIN_BROKER_SOCKET
50
+ if(!isolated && (!toolsHome || !path.isAbsolute(toolsHome)))throw Error('Installed tool registry required')
51
+ const config=!isolated ? JSON.parse(await readFile(path.join(toolsHome!,'config.json'),'utf8')) : undefined
52
+ // Fixed owner-configured command; literal private stdin, never a shell or model-selected args.
53
+ return new Promise((resolve,reject)=>{
54
+ const child=spawn(isolated ? process.execPath : path.join(toolsHome!,'bin','ez'),isolated ? [fileURLToPath(new URL('../bin/ez',import.meta.url)),'--task-application'] : [launch.command,...launch.args],{env:{...executorEnvironment(),EZ_RUN_ID:runId},...(config ? {cwd:config.workspace} : {}),stdio:['pipe','pipe','pipe']})
55
+ let output='';const timer=setTimeout(()=>{child.kill();reject(Error('Application launch timed out'))},30000)
56
+ child.stdout.on('data',chunk=>{output+=chunk;if(Buffer.byteLength(output)>16000){child.kill();reject(Error('Application launch output too large'))}})
57
+ child.stderr.resume();child.on('error',reject)
58
+ child.on('close',code=>{clearTimeout(timer);try{if(code!==0)throw Error('Application launch failed');const value=JSON.parse(output);const url=new URL(value.launch?.url);if(url.protocol!=='https:' || url.username || url.password || typeof value.launch?.expiresAt!=='number')throw Error('Invalid application launch receipt');resolve(value.launch)}catch(e){reject(e)}})
59
+ child.stdin.end(JSON.stringify(input))
60
+ })
61
+ }
@@ -6,14 +6,15 @@ import { fileURLToPath } from 'node:url'
6
6
  import { spawn, execFile } from 'node:child_process'
7
7
  import { promisify } from 'node:util'
8
8
  import { readRun } from './delivery-socket.js'
9
+ import {ApplicationBindings} from './application-channel.js'
9
10
  import { Tasks } from './tasks.js'
10
11
  import { executorEnvironment, executorInvocation, terminateJob, type ExecutorOptions } from './executor.js'
11
12
 
12
13
  // This adapter is deliberately version-pinned: a new native tool default needs
13
14
  // a fresh tool-inventory audit before external correspondence can use it.
14
- export const TASK_CODEX_VERSION = '0.153.4'
15
+ export const TASK_CODEX_VERSION = '0.160.1'
15
16
  export const taskDisabledFeatures = ['apps', 'browser_use', 'computer_use', 'in_app_browser', 'image_generation',
16
- 'memories', 'multi_agent', 'multi_agent_v2', 'hooks', 'shell_tool', 'unified_exec', 'code_mode', 'code_mode_host',
17
+ 'memories', 'goals', 'multi_agent', 'multi_agent_v2', 'hooks', 'shell_tool', 'unified_exec', 'code_mode', 'code_mode_host',
17
18
  'skill_search', 'skill_mcp_dependency_install', 'tool_suggest', 'workspace_dependencies', 'view_image']
18
19
  // Model catalog defaults can override disabled feature flags (for example,
19
20
  // code-only tools and v2 collaboration). Use the audited direct-tool surface.
@@ -23,8 +24,9 @@ export function taskModelCatalog(catalog: { models: Record<string, unknown>[] })
23
24
  apply_patch_tool_type: null, experimental_supported_tools: [], multi_agent_version: null,
24
25
  supports_search_tool: false, use_responses_lite: false })) };
25
26
  }
26
- export function taskArguments(directory: string, broker: string[], prompt: string, toolNames = ['context', 'send', 'note', 'report', 'complete'], selection: {model?:string;effort?:string} = {}) {
27
+ export function taskArguments(directory: string, broker: string[], prompt: string, toolNames = ['context', 'read_attachment', 'send', 'note', 'report', 'complete'], selection: {model?:string;effort?:string} = {}, brokerEnvironment: NodeJS.ProcessEnv = {}) {
27
28
  const preset = executionDefaults('codex', selection)
29
+ const brokerEnv={HOME:homedir(),...Object.fromEntries(['EZ_PLUGIN_BROKER_SOCKET','EZ_DELIVERY_SOCKET'].flatMap(key=>brokerEnvironment[key] ? [[key,brokerEnvironment[key]]] : []))}
28
30
  return ['exec', ...(preset.model ? ['--model',preset.model] : []), ...(preset.effort ? ['-c',`model_reasoning_effort=${JSON.stringify(preset.effort)}`] : []), '--skip-git-repo-check', '--ignore-user-config', '--ignore-rules', '--ephemeral', '--strict-config', '--json', '-C', directory,
29
31
  ...taskDisabledFeatures.flatMap(feature => ['--disable', feature]), '--enable', 'skip_host_skill_discovery',
30
32
  '-c', `model_catalog_json=${JSON.stringify(join(directory, '..', 'models.json'))}`,
@@ -33,7 +35,7 @@ export function taskArguments(directory: string, broker: string[], prompt: strin
33
35
  '-c', `permissions.ez-task.filesystem={":root"="deny",":minimal"="read",${JSON.stringify(directory)}="write"}`,
34
36
  '-c', 'permissions.ez-task.network.enabled=false',
35
37
  // 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)}}`,
38
+ '-c', `mcp_servers.ez={command=${JSON.stringify(broker[0])},args=${JSON.stringify(broker.slice(1))},env={${Object.entries(brokerEnv).map(([key,value])=>`${key}=${JSON.stringify(value)}`).join(',')}},required=true,enabled_tools=${JSON.stringify(toolNames)}}`,
37
39
  ...toolNames.flatMap(name => ['-c', `mcp_servers.ez.tools.${name}.approval_mode="approve"`]),
38
40
  '-'] // Literal input travels on stdin, including slash commands and leading options.
39
41
  }
@@ -41,9 +43,10 @@ export async function startTaskExecutor(options: ExecutorOptions) {
41
43
  const run = await readRun(options.controlDir, options.runId)
42
44
  if (!run || run.status !== 'running') throw new Error('No active task run')
43
45
  const task = await new Tasks(options.controlDir).authorize(run, false)
46
+ const applications=(run.application || task.anyConversation) ? [] : (await new ApplicationBindings(options.controlDir).list()).filter(b=>b.taskLaunch && (b.taskLaunch.tasks==="all" || b.taskLaunch.tasks.includes(task.id))).map(b=>b.id)
44
47
  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')
48
+ const toolNames = [...(task.anyConversation ? ['context','read_attachment','send'] : ['context','read_attachment','send','note','report','complete']),...capabilityNames,...(applications.length?["browser_link"]:[])]
49
+ if ((capabilityNames.length || (applications.length && !process.env.EZ_PLUGIN_BROKER_SOCKET)) && !options.toolsHome) throw new Error('Channel capabilities require an installed tool registry')
47
50
  const environment = executorEnvironment()
48
51
  const versionInvocation = executorInvocation('codex', ['--version'])
49
52
  const version = await promisify(execFile)(versionInvocation.command, versionInvocation.args, { env: environment })
@@ -60,9 +63,9 @@ export async function startTaskExecutor(options: ExecutorOptions) {
60
63
  : join(homedir(), '.codex', 'auth.json')
61
64
  await symlink(auth, join(home, 'auth.json'))
62
65
  const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
63
- fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? [])]
66
+ fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? []),JSON.stringify(applications)]
64
67
  const prompt = JSON.stringify({event: run.external ? 'correspondence_received' : 'task_activated', taskId: run.taskId})
65
- const invocation = executorInvocation('codex', taskArguments(directory, broker, prompt, toolNames, options))
68
+ const invocation = executorInvocation('codex', taskArguments(directory, broker, prompt, toolNames, options, Object.fromEntries(['EZ_PLUGIN_BROKER_SOCKET','EZ_DELIVERY_SOCKET'].flatMap(key=>environment[key] ? [[key,environment[key]]] : []))))
66
69
  const child = spawn(invocation.command, invocation.args, {
67
70
  cwd: directory, env: { ...environment, HOME: home, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32',
68
71
  })
package/src/task-mcp.ts CHANGED
@@ -1,12 +1,16 @@
1
+ import {randomBytes,createHash} from 'node:crypto'
1
2
  import { createInterface } from 'node:readline'
2
3
  import { taskCall } from './task-rpc.js'
3
- import { runTaskCapability } from './task-capability.js'
4
+ import { runTaskCapability,launchTaskApplication } from './task-capability.js'
4
5
  import type { TaskCapability } from './tasks.js'
5
- const [controlDir, runId, toolsHome, capabilityJson='[]'] = process.argv.slice(2)
6
+ const [controlDir, runId, toolsHome, capabilityJson='[]', applicationsJson='[]'] = process.argv.slice(2)
6
7
  const capabilities = JSON.parse(capabilityJson) as TaskCapability[]
8
+ const applications=JSON.parse(applicationsJson) as string[]
7
9
  const descriptions: Record<string, string> = {
10
+ ...(applications.length ? {browser_link:`Create a short-lived single-use browser conversation link for this discussion. Applications: ${applications.join(", ")}. Return the exact link as text to this same discussion; the link is a private bearer credential.`} : {}),
8
11
  context: 'Read the owner-approved purpose and shareable context, task notes, receipts, and untrusted correspondence.',
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.',
12
+ read_attachment: 'Read a JPEG/PNG/WebP image, an MP4 video as sampled frames (up to 120 seconds), or a TXT/Markdown/PDF document from an incomingId in this run context. Returns the image to native vision, or bounded extracted document text, with file provenance; no owner files are accessible.',
13
+ send: 'Send text, or set voice=true for a spoken reply (at most 1000 characters). Optionally use 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.',
10
14
  note: 'Save a task-scoped note. No owner files or memory are accessible.',
11
15
  report: 'Report task evidence or a blocker to the owner. This is a report, never an owner instruction.',
12
16
  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.',
@@ -15,8 +19,8 @@ const descriptions: Record<string, string> = {
15
19
  const tools = Object.entries(descriptions).map(([name, description]) => {
16
20
  const capability = name.startsWith('capability_')
17
21
  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,
22
+ type: 'object', properties: name==='browser_link' ? {application:{type:'string',enum:applications}} : name === 'context' ? {} : name === 'read_attachment' ? {incomingId:{type:'string',maxLength:100}} : capability ? {input:{type:'string',maxLength:1000}} : { text: { type: 'string', maxLength: 4096 }, ...(name === 'send' ? { voice: {type:'boolean'}, key: { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,80}$' }, attachmentId:{type:'string',pattern:'^[a-f0-9-]{36}$'} } : {}) },
23
+ required: name==='browser_link' ? ['application'] : name === 'context' ? [] : name === 'read_attachment' ? ['incomingId'] : capability ? ['input'] : name === 'send' ? ['text', 'key'] : ['text'], additionalProperties: false,
20
24
  } }
21
25
  })
22
26
  for await (const line of createInterface({ input: process.stdin })) {
@@ -33,16 +37,24 @@ for await (const line of createInterface({ input: process.stdin })) {
33
37
  if (!Object.hasOwn(descriptions, name)) throw new Error('Unknown task tool')
34
38
  const args = request.params.arguments ?? {}
35
39
  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')
40
+ if (Object.keys(args).some(key => name==='browser_link' ? key!=='application' : name === 'read_attachment' ? key !== 'incomingId' : capabilityId ? key !== 'input' : !['text', ...(name === 'send' ? ['key','attachmentId','voice'] : [])].includes(key))) throw new Error('Unexpected tool argument')
37
41
  try {
38
42
  let data: unknown
39
- if (capabilityId) {
43
+ if(name==='browser_link') {
44
+ if(!applications.includes(args.application))throw Error('Application is not authorized')
45
+ const token=randomBytes(32).toString('hex'),ticketHash=createHash('sha256').update(token).digest('hex')
46
+ const grant=await taskCall(controlDir,runId,'worker','application_launch',{application:args.application,ticketHash}) as {launch:{command:string;args:string[]};taskId:string;expiresAt:number}
47
+ data=await launchTaskApplication(toolsHome,grant.launch,{taskId:grant.taskId,taskToken:token,expiresAt:grant.expiresAt},runId)
48
+ } else if (capabilityId) {
40
49
  const begun = await taskCall(controlDir,runId,'worker','capability_begin',{id:capabilityId,input:args.input}) as {capability:TaskCapability;lease:string}
41
50
  const result = await runTaskCapability(toolsHome || undefined,begun.capability,args.input,{controlDir,runId,lease:begun.lease})
42
51
  await taskCall(controlDir,runId,'worker','capability_result',{id:capabilityId,input:args.input,lease:begun.lease,...('attachment' in result?{attachment:result.attachment}:{})})
43
52
  data = result.attachment ? {attachmentId:result.attachment.id,filename:result.attachment.filename,bytes:result.attachment.bytes} : result
44
53
  } else data = await taskCall(controlDir, runId, 'worker', name, args)
45
- result = { content: [{ type: 'text', text: JSON.stringify(data) }] }
54
+ if (name === 'read_attachment' && data && typeof data === 'object' && ('image' in data || 'images' in data)) {
55
+ const {image,images,...metadata} = data as {image?:{data:string;mimeType:string};images?:{data:string;mimeType:string}[];[key:string]:unknown}
56
+ result = { content: [{type:'text',text:JSON.stringify(metadata)}, ...(images ?? [image!]).map(value=>({type:'image',...value}))] }
57
+ } else result = { content: [{ type: 'text', text: JSON.stringify(data) }] }
46
58
  }
47
59
  catch (error) { result = { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Task tool failed' }] } }
48
60
  } else throw new Error('Unsupported MCP method')
package/src/tasks.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
1
+ import { mkdir, readFile, readdir, rename, writeFile, unlink } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { randomUUID, createHash } from 'node:crypto'
4
4
  import { ControlStore, sameOwner, ownerId, ownerEpoch, type Owner } from './control-state.js'
@@ -7,6 +7,8 @@ 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 {MAX_INCOMING_ATTACHMENT_BYTES,readChatAttachment} from './files.js'
11
+ import {ApplicationBindings} from './application-channel.js'
10
12
  import {readTaskAttachment,type TaskAttachment} from './task-attachments.js'
11
13
 
12
14
  export type TaskCapability = { id: string; description: string; command: string; args: string[]; output?:'file' }
@@ -23,7 +25,7 @@ export type Task = {
23
25
  purpose: string; context: string; createdAt: number; expiresAt: number
24
26
  state: 'pending' | 'active' | 'revoked' | 'completed'
25
27
  grant?: string; initialRunDispatched?: true
26
- notes: string[]; operations: Record<string, { text: string; attachmentId?:string; state: 'uncertain' | 'accepted'; receipt?: unknown }>
28
+ notes: string[]; operations: Record<string, { text: string; attachmentId?:string; voice?:true; state: 'uncertain' | 'accepted'; receipt?: unknown }>
27
29
  capabilityOperations?: Record<string, { capabilityId: string; inputHash: string; lease: string; attachment?:TaskAttachment; state: 'authorized' | 'completed' }>
28
30
  publicBudget?: PublicBudget
29
31
  }
@@ -45,7 +47,7 @@ export async function atomicTaskFile(file: string, value: unknown) {
45
47
  }
46
48
  export class Tasks {
47
49
  private work: Promise<unknown> = Promise.resolve()
48
- constructor(readonly controlDir: string) {}
50
+ constructor(readonly controlDir: string, private speech?: (text:string) => Promise<{buffer:Buffer;mimeType:string}>) {}
49
51
  private serial<T>(fn: () => Promise<T>): Promise<T> {
50
52
  const next = this.work.then(fn, fn); this.work = next.catch(() => {}); return next
51
53
  }
@@ -90,12 +92,16 @@ export class Tasks {
90
92
  }
91
93
  async authorize(run: RunRecord, checkProvider = true): Promise<Task> {
92
94
  const task = run.taskId ? await this.get(run.taskId) : null
93
- if (!task || (task.waitForIncoming && !run.external) || task.state !== 'active' || task.expiresAt <= Date.now() || run.version !== 2 ||
95
+ if (!task || (task.waitForIncoming && !run.external && !run.application?.taskAccess) || task.state !== 'active' || task.expiresAt <= Date.now() || run.version !== 2 ||
94
96
  !ownsRun(task.owner, run))
95
97
  throw new Error('Task is inactive or expired')
96
98
  const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
97
99
  if (!sameOwner(task.owner, owner) || (task.grant ? task.grant !== this.scopeHash(task) : !await this.approved(task)))
98
100
  throw new Error('Task approval is no longer valid')
101
+ if(run.application?.taskAccess) {
102
+ const access=await this.applicationAccess(run.application.taskAccess,run.application.bindingId,checkProvider)
103
+ if(access.task.id!==task.id)throw Error('Task application scope mismatch')
104
+ }
99
105
  if (checkProvider) await this.source(task)
100
106
  if (run.external && checkProvider) {
101
107
  if (run.external.sourceId !== task.sourceId || run.external.bindingId !== task.bindingId) throw new Error('Task origin mismatch')
@@ -106,6 +112,27 @@ export class Tasks {
106
112
  }
107
113
  return task
108
114
  }
115
+ async authorizeApplicationLaunch(runId:string,ticketHash:string) {
116
+ const run=await new RunStore(this.controlDir).get(runId)
117
+ if(!run || run.status!=='running' || run.application)throw Error('No active channel task')
118
+ const task=await this.authorize(run)
119
+ if(!/^[a-f0-9]{64}$/.test(ticketHash))throw Error('Invalid task launch')
120
+ const record=JSON.parse(await readFile(join(this.controlDir,'task-applications',ticketHash+'.json'),'utf8'))
121
+ const access=await this.applicationAccess(ticketHash,record.bindingId)
122
+ if(access.runId!==runId || access.task.id!==task.id)throw Error('Task launch admission mismatch')
123
+ const binding=(await new ApplicationBindings(this.controlDir).list()).find(b=>b.bindingId===record.bindingId)!
124
+ return {launch:binding.taskLaunch!,taskId:task.id,expiresAt:access.expiresAt}
125
+ }
126
+ async applicationAccess(ticketHash: string, bindingId: string, checkProvider=true) {
127
+ if(!/^[a-f0-9]{64}$/.test(ticketHash))throw Error('Invalid task access')
128
+ const record=JSON.parse(await readFile(join(this.controlDir,'task-applications',ticketHash+'.json'),'utf8'))
129
+ const task=await this.get(record.taskId), owner=(await new ControlStore(this.controlDir,900000).status()).owner
130
+ const binding=(await new ApplicationBindings(this.controlDir).list()).find(b=>b.bindingId===bindingId)
131
+ if(!task || task.state!=='active' || task.expiresAt<=Date.now() || record.expiresAt<=Date.now() || record.bindingId!==bindingId || record.grant!==this.scopeHash(task) || !binding?.taskLaunch || (binding.taskLaunch.tasks!=="all" && !binding.taskLaunch.tasks.includes(task.id)) || !sameOwner(binding.owner,owner) || !sameOwner(task.owner,owner) || (task.grant ? task.grant!==this.scopeHash(task) : !await this.approved(task)))throw Error('Task application access revoked or expired')
132
+ if(checkProvider)await this.source(task)
133
+ if(!bounded(record.contact,200) || (!task.anyConversation && record.contact!==task.conversationId))throw Error('Task application contact mismatch')
134
+ return {task,contact:record.contact,runId:record.runId as string,expiresAt:record.expiresAt as number,execution:record.execution as RunRecord['execution']}
135
+ }
109
136
  // Persist only the bounded grant, not transient approval/outbox state. Bind every
110
137
  // immutable authority field so later scope changes cannot reuse an old grant.
111
138
  private scopeHash(task: Task) {
@@ -258,14 +285,44 @@ export class Tasks {
258
285
  const run = await new RunStore(this.controlDir).get(runId)
259
286
  if (!run || run.status !== 'running') throw new Error('No active task run')
260
287
  const task = await this.authorize(run)
288
+ const access=run.application?.taskAccess ? await this.applicationAccess(run.application.taskAccess,run.application.bindingId) : undefined
261
289
  const incoming = run.external ? await new EventSources(this.controlDir).check(run.external, task.owner) : []
262
- const contact = task.anyConversation ? incoming[0]?.conversationId : task.conversationId
290
+ const contact = access?.contact ?? (task.anyConversation ? incoming[0]?.conversationId : task.conversationId)
263
291
  if (!contact || incoming.some(event => event.conversationId !== contact)) throw new Error('Task correspondence changed')
292
+ if(command==='application_launch') {
293
+ if(run.application || task.anyConversation || Object.keys(args).some(k=>!['application','ticketHash'].includes(k)) || typeof args.ticketHash!=='string' || !/^[a-f0-9]{64}$/.test(args.ticketHash))throw Error('Invalid task application launch')
294
+ const binding=(await new ApplicationBindings(this.controlDir).list()).find(b=>b.id===args.application && b.taskLaunch && sameOwner(b.owner,task.owner))
295
+ if(!binding?.taskLaunch || (binding.taskLaunch.tasks!=="all" && !binding.taskLaunch.tasks.includes(task.id)))throw Error('Task application launch is not authorized')
296
+ const root=join(this.controlDir,'task-applications');await mkdir(root,{recursive:true,mode:0o700})
297
+ let count=0
298
+ for(const file of await readdir(root))if(/^[a-f0-9]{64}\.json$/.test(file)) {
299
+ const value=await readFile(join(root,file),'utf8').then(JSON.parse,()=>null);if(!value || !(value.expiresAt>Date.now()))await unlink(join(root,file)).catch(()=>{});else count++
300
+ }
301
+ if(count>=128)throw Error('Too many task application launches')
302
+ const expiresAt=Math.min(Date.now()+20*60*1000,task.expiresAt)
303
+ await atomicTaskFile(join(root,args.ticketHash+'.json'),{taskId:task.id,bindingId:binding.bindingId,grant:this.scopeHash(task),contact,expiresAt,runId:run.id,execution:run.execution})
304
+ return {launch:binding.taskLaunch,taskId:task.id,expiresAt}
305
+ }
264
306
  if (command === 'context') {
265
307
  return { purpose: task.purpose, context: task.context, contact,
266
308
  waitForIncoming: task.waitForIncoming === true, capabilities: task.capabilities?.map(({id,description}) => ({id,description})),
267
309
  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,
268
- incoming }
310
+ incoming, ...(access ? {request:run.texts.join("\n")} : {}) }
311
+ }
312
+ if (command === 'read_attachment') {
313
+ if (!run.external || typeof args.incomingId !== 'string' || !run.external.eventIds.includes(args.incomingId) || !incoming.some(event => event.id === args.incomingId)) throw Error('Document is outside this incoming task run')
314
+ const source = (await new EventSources(this.controlDir).available(task.owner)).find(item => item.id === task.sourceId && item.bindingId === task.bindingId)
315
+ if (!source) throw Error('Task source changed')
316
+ const maximum = Math.ceil(MAX_INCOMING_ATTACHMENT_BYTES / 3) * 4
317
+ const file = await sourceCall(source.socketPath, 'task-document', {accountId:task.accountId,conversationId:contact,incomingId:args.incomingId}, maximum + 1000)
318
+ if (!file || typeof file.name !== 'string' || file.name.length > 255 || typeof file.data !== 'string' || file.data.length > maximum || !/^[a-f0-9]{64}$/.test(file.sha256)) throw Error('Invalid incoming document')
319
+ const bytes = Buffer.from(file.data,'base64')
320
+ if (bytes.toString('base64') !== file.data || createHash('sha256').update(bytes).digest('hex') !== file.sha256) throw Error('Incoming document changed')
321
+ const result = await readChatAttachment(this.controlDir,file.name,bytes)
322
+ const current = await new RunStore(this.controlDir).get(runId)
323
+ if (!current || current.status !== 'running') throw Error('Task run cancelled')
324
+ await this.authorize(current)
325
+ return {incomingId:args.incomingId,sha256:file.sha256,...result}
269
326
  }
270
327
  if (command === 'capability_begin' || command === 'capability_result') {
271
328
  const selected = task.capabilities?.find(item => item.id === args.id)
@@ -306,20 +363,35 @@ export class Tasks {
306
363
  return { queued: item.id }
307
364
  }
308
365
  if (command !== 'send' || typeof args.key !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(args.key)) throw new Error('Invalid task send')
366
+ if (args.voice !== undefined && typeof args.voice !== 'boolean') throw new Error('Invalid voice flag')
367
+ if (args.voice && (args.attachmentId !== undefined || args.text.length > 1000)) throw new Error('Voice replies require at most 1000 characters and no attachment')
309
368
  let attachment:TaskAttachment|undefined
310
369
  if(args.attachmentId!==undefined) {
311
370
  const operation=Object.entries(task.capabilityOperations??{}).find(([key,value])=>key.startsWith(`${run.id}_`)&&value.lease===args.attachmentId&&value.state==='completed')?.[1]
312
371
  attachment=operation?.attachment
313
372
  if(!attachment||attachment.runId!==run.id)throw Error('Attachment is not authorized for this run')
314
373
  }
374
+ if(access) {
375
+ if(args.voice || args.attachmentId!==undefined)throw Error('Application task replies support text only')
376
+ const key=`${run.id}_${args.key}`, prior=task.operations[key]
377
+ if(prior){if(prior.text!==args.text)throw Error('Message key already used for different text');return prior}
378
+ const item=await new RunStore(this.controlDir).enqueueMessage(run.id,args.text)
379
+ task.operations[key]={text:args.text,state:'accepted',receipt:{application:run.application!.bindingId,queued:item.id}}
380
+ await this.save(task);return task.operations[key]
381
+ }
315
382
  const key = task.untilRevoked ? `${run.id}_${args.key}` : args.key
316
383
  const providerKey = `${task.id}_${task.untilRevoked ? createHash('sha256').update(key).digest('hex') : key}`
317
384
  const prior = Object.hasOwn(task.operations, key) ? task.operations[key] : undefined
318
385
  if (prior) {
319
- if (prior.text !== args.text || prior.attachmentId!==args.attachmentId) throw new Error('Message key already used for different text or attachment')
386
+ if (prior.text !== args.text || prior.attachmentId!==args.attachmentId || Boolean(prior.voice)!==Boolean(args.voice)) throw new Error('Message key already used for different text or attachment')
320
387
  return prior // Uncertain sends are never blindly retried.
321
388
  }
322
389
  const source = await this.source(task)
390
+ if(args.voice) {
391
+ const head=await sourceCall(source.socketPath,'events-head')
392
+ if(head.taskVoice!==true)throw Error('This channel does not support voice replies')
393
+ if(!this.speech)throw Error('Speech synthesis is not configured')
394
+ }
323
395
  if(attachment) {
324
396
  const head=await sourceCall(source.socketPath,'events-head')
325
397
  if(head.taskAttachments!==true)throw Error('This channel does not support task attachments')
@@ -334,14 +406,22 @@ export class Tasks {
334
406
  task.operations=Object.fromEntries(Object.entries(task.operations).filter(([item,value])=>active.some(prefix=>item.startsWith(prefix))||value.state==='uncertain'))
335
407
  }
336
408
  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')
337
- const message={text:args.text,...(attachment?{attachmentId:attachment.id}:{})}
409
+ const message={text:args.text,...(args.voice?{voice:true as const}:{}),...(attachment?{attachmentId:attachment.id}:{})}
410
+ // Synthesis happens before the provider is contacted, so its failure is a
411
+ // definite non-send the engine may correct, not an uncertain operation.
412
+ const audio = args.voice ? await this.speech!(args.text) : undefined
413
+ if(audio && (audio.mimeType!=='audio/ogg' || audio.buffer.length>256000 || audio.buffer.subarray(0,4).toString()!=='OggS'))throw Error('Voice audio exceeds channel limits or has invalid format')
338
414
  task.operations = { ...task.operations, [key]: { ...message, state: 'uncertain' } }
339
415
  await this.save(task)
340
416
  try {
417
+ const current = await new RunStore(this.controlDir).get(run.id)
418
+ if (!current || current.status !== 'running') throw new Error('Task run ended before voice dispatch')
419
+ await this.authorize(current)
420
+ if ((await new RunStore(this.controlDir).get(run.id))?.status !== 'running') throw new Error('Task run ended before dispatch')
341
421
  if (task.expiresAt <= Date.now()) throw new Error('Task expired before dispatch')
342
422
  const receipt = await sourceCall(source.socketPath, 'task-send', {
343
423
  accountId: task.accountId, conversationId: contact, text: args.text, key: providerKey,
344
- ...(attachment?{attachment}:{}),
424
+ ...(attachment?{attachment}:{}), ...(audio?{audio:{data:audio.buffer.toString('base64'),mimeType:audio.mimeType}}:{}),
345
425
  })
346
426
  if (receipt.accountId !== task.accountId || receipt.conversationId !== contact || receipt.key !== providerKey || receipt.state !== 'accepted')
347
427
  throw new Error('Uncertain provider receipt')
@@ -1,3 +1,4 @@
1
+ import { latestCodexVersion } from '../install-tools.mjs';
1
2
  import { sharedIdentity } from '../plugins/shared.mjs';
2
3
  import * as fs from 'node:fs/promises';
3
4
  import { createWriteStream } from 'node:fs';
@@ -5,6 +6,7 @@ import path from 'node:path';
5
6
  import { pathToFileURL } from 'node:url';
6
7
  import { spawn } from 'node:child_process';
7
8
  import { parseEnv } from 'node:util';
9
+ import { tmpdir } from 'node:os';
8
10
  import { atomic, compose, snapshot, checkFolders } from '../plugins/manager.mjs';
9
11
  import { read, state, eligibility, jobPath, cleanupStaleBackups } from './control.mjs';
10
12
  import { bindUpdates } from './binding.mjs';
@@ -61,9 +63,42 @@ export async function relayServices(config) {
61
63
  const env = await fs.readFile(path.join(config.deploymentDir,'docker.env'),'utf8').catch(() => '');
62
64
  return parseEnv(env).EZ_EXECUTOR_TRANSPORT === 'local' ? ['relay','plugin-broker'] : ['relay'];
63
65
  }
64
- async function refreshBroker(config, run) {
65
- if ((await relayServices(config)).includes('plugin-broker'))
66
- await run('docker', [...relayArgs(config), 'restart', 'plugin-broker']);
66
+ // Explicitly rebind single-file mounts after atomic replacement. Pin the running image
67
+ // while recreating only the broker with this deployment's Compose identity.
68
+ export async function refreshBroker(config, run=execute, image) {
69
+ if (!(await relayServices(config)).includes('plugin-broker')) return null;
70
+ const args=relayArgs(config);
71
+ if(image===undefined) {
72
+ const id=(await run('docker',[...args,'ps','-q','plugin-broker'],{stdoutOnly:true,timeout:15000})).trim();
73
+ if(!/^[a-f0-9]{12,64}$/i.test(id))throw Error('Expected one installed isolated broker');
74
+ image=(await run('docker',['inspect','--format','{{.Image}}',id],{stdoutOnly:true,timeout:15000})).trim();
75
+ }
76
+ if(!/^sha256:[a-f0-9]{64}$/.test(image))throw Error('Cannot pin broker image');
77
+ const temporary=await fs.mkdtemp(path.join(tmpdir(),'ez-broker-refresh-'));
78
+ try {
79
+ const pin=path.join(temporary,'image.env');
80
+ await fs.writeFile(pin,`EZ_RELAY_IMAGE=${image}\n`,{mode:0o600});
81
+ await run('docker',[...args,'--env-file',pin,'up','-d','--no-deps','--force-recreate','--no-build','--pull','never','--wait','--wait-timeout','90','plugin-broker']);
82
+ const current=(await run('docker',[...args,'ps','-q','plugin-broker'],{stdoutOnly:true,timeout:15000})).trim();
83
+ if(!/^[a-f0-9]{12,64}$/i.test(current)||(await run('docker',['inspect','--format','{{.Image}}',current],{stdoutOnly:true,timeout:15000})).trim()!==image)
84
+ throw Error('Recreated broker image does not match the intended installed image');
85
+ const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
86
+ const home=path.dirname(hostConfig)===config.deploymentDir
87
+ ? (await read(hostConfig)).agents?.find(agent=>agent.workspace===config.workspace)?.toolsHome : undefined;
88
+ if(typeof home!=='string'||!path.isAbsolute(home))throw Error('Missing broker registry binding');
89
+ const files=[hostConfig,path.join(home,'registry.json'),path.join(home,'config.json')];
90
+ const hashes=await Promise.all(files.map(async file=>digest(await fs.readFile(file))));
91
+ // Supply trusted read-only code so rollback can check an older image that
92
+ // predates this diagnostic. Its validators still come from that image.
93
+ const diagnostic=await fs.readFile(new URL('../../docker/broker-readiness.mjs',import.meta.url),'utf8');
94
+ const probe=JSON.parse(await run('docker',[...args,'exec','-T','plugin-broker','node','--import','/app/node_modules/tsx/dist/loader.mjs',
95
+ '--input-type=module','-e',diagnostic,...hashes],{stdoutOnly:true,timeout:60000}));
96
+ if(probe.version!==1||probe.ready!==true||probe.hostSha256!==hashes[0]||probe.registrySha256!==hashes[1]||probe.configSha256!==hashes[2])
97
+ throw Error('Invalid isolated broker readiness readback');
98
+ // A concurrent writer invalidates the readback; never silently accept it.
99
+ for(let i=0;i<files.length;i++)if(digest(await fs.readFile(files[i]))!==hashes[i])throw Error('Broker binding changed during verification');
100
+ return probe;
101
+ }finally {await fs.rm(temporary,{recursive:true,force:true});}
67
102
  }
68
103
  const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
69
104
  async function healthEvidence(config,run) {
@@ -113,13 +148,15 @@ export async function perform(home,job,hooks) {
113
148
  await run(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'bin/ezenciel-agents.mjs'),'--version'],{cwd:root});
114
149
  try { await replaceGuidance(root, config.workspace, run); }
115
150
  catch(error) { console.error(`Workspace guidance replacement failed; upgrade continues: ${error.message}`); }
116
- // Preserve operator-owned native CLI pins from the effective deployment.
117
- // Building only the package defaults silently downgrades those clients.
151
+ // Resolve managed Codex at activation; old deployment pins cannot override latest.
152
+ // Preserve unrelated operator build arguments.
118
153
  const deployment=JSON.parse(await run('docker',[...relayArgs(config),'config','--format','json'],{stdoutOnly:true}));
119
154
  const args=deployment.services?.relay?.build?.args||{};
120
155
  if(typeof args!=='object'||Array.isArray(args))throw Error('Invalid relay build arguments');
121
- const buildArgs=Object.keys(args).sort().flatMap(key=>{
122
- const value=args[key];
156
+ job.codexVersion=await latestCodexVersion(run,job.packageManager);await save();
157
+ const effective={...args,CODEX_CLI_VERSION:job.codexVersion};
158
+ const buildArgs=Object.keys(effective).sort().flatMap(key=>{
159
+ const value=effective[key];
123
160
  if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)||typeof value!=='string'||value.includes('\0'))throw Error('Unresolved relay build argument');
124
161
  return ['--build-arg',`${key}=${value}`];
125
162
  });
@@ -140,7 +177,11 @@ export async function perform(home,job,hooks) {
140
177
  await textAtomic(envFile,env);
141
178
  await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),root);
142
179
  await hooks.startHost(root);
143
- await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build',...(await relayServices(config))]);
180
+ await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build','relay']);
181
+ if((await relayServices(config)).includes('plugin-broker')) {
182
+ const brokerImage=(await run('docker',['image','inspect','--format','{{.Id}}',image],{stdoutOnly:true})).trim();
183
+ job.brokerReadiness=await refreshBroker(config,run,brokerImage);
184
+ }
144
185
  } else {
145
186
  const old=next.old.record,r=await read(path.join(home,'registry.json'));
146
187
  const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
@@ -160,7 +201,7 @@ export async function perform(home,job,hooks) {
160
201
  r.plugins[job.target]=candidate;
161
202
  for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
162
203
  await atomic(path.join(home,'registry.json'),r);
163
- await refreshBroker(config,run);
204
+ job.brokerReadiness=await refreshBroker(config,run);
164
205
  job.runtimeVerified=running;
165
206
  }
166
207
  job.status='completed';job.endedAt=new Date().toISOString();await save();
@@ -192,12 +233,13 @@ export async function recover(home,job,hooks) {
192
233
  await textAtomic(path.join(config.deploymentDir,'docker.env'),job.rollback.env);
193
234
  await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),job.rollback.packageRoot);
194
235
  await hooks.startHost(job.rollback.packageRoot);
195
- if(!retained)await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build',...(await relayServices(config))]);
236
+ if(!retained)await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build','relay']);
237
+ job.rollbackBrokerReadiness=await refreshBroker(config,run,pinned);
196
238
  } else {
197
239
  const b=job.rollback;
198
240
  await run('docker',[...pluginArgs(b.record),'stop']);
199
241
  await atomic(b.record.compose,b.compose);await atomic(path.join(home,'registry.json'),b.registry);
200
- await refreshBroker(config,run);
242
+ job.rollbackBrokerReadiness=await refreshBroker(config,run);
201
243
  if(b.running)await run('docker',[...pluginArgs(b.record),'up','-d','--wait','--wait-timeout','90','--no-build']);
202
244
  }
203
245
  job.status='rolled-back';
@@ -38,6 +38,20 @@ export async function idle(control) {
38
38
  const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
39
39
  return !status||status.running===0;
40
40
  }
41
+ // Pause admission only after work is idle, and release it if a turn raced the
42
+ // check. A queued upgrade must never hold the owner's chat behind a long turn.
43
+ export async function withIdleUpgrade(control,job,signal,apply,{isIdle=idle,wait=sleep}={}) {
44
+ if(signal.aborted||!await isIdle(control))return;
45
+ const pause=path.join(control,'upgrade-pause.json');
46
+ await atomic(pause,{id:job.id});
47
+ try {
48
+ for(let n=0;n<3;n++) {
49
+ await wait(500);
50
+ if(signal.aborted||!await isIdle(control))return;
51
+ }
52
+ if(!signal.aborted)return await apply();
53
+ } finally {await fs.rm(pause,{force:true});}
54
+ }
41
55
  export async function queueAutomatic(home,available) {
42
56
  const existing=await jobs(home);
43
57
  if(existing.some(job=>['queued','applying','recovery-required'].includes(job.status)))return null;
@@ -103,19 +117,15 @@ export async function supervise(deployment,signal,{discover=check}={}) {
103
117
  if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
104
118
  const pending=(await jobs(home)).find(j=>j.status==='queued');
105
119
  if(pending) {
106
- await atomic(pause,{id:pending.id});
107
- // Relay admission is paused; drain the requesting turn and any already-claimed job.
108
- let quiet=0;
109
- while(!signal.aborted&&quiet<3){quiet=await idle(agent.controlDir)?quiet+1:0;await sleep(500);}
110
- if(signal.aborted)break;
111
- let result;
112
- try {
113
- result=await locked(home,async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost});});
114
- } catch(error) {
115
- // A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
116
- const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
117
- if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
118
- } finally {await fs.rm(pause,{force:true});}
120
+ const result=await withIdleUpgrade(agent.controlDir,pending,signal,async()=>{
121
+ try {
122
+ return await locked(home,async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost});});
123
+ } catch(error) {
124
+ // A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
125
+ const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
126
+ if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
127
+ }
128
+ });
119
129
  if(result?.status==='completed') {
120
130
  if(pending.target==='main')return;
121
131
  nextCheck=0;