@jc_stack/ez-agents 0.1.0-beta.49 → 0.1.0-beta.51

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 (58) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +9 -5
  4. package/bin/ez +16 -7
  5. package/docs/architecture/ai-selection.md +4 -0
  6. package/docs/docker-runtime.md +28 -2
  7. package/docs/plugin-guide.md +3 -3
  8. package/docs/plugins.md +13 -10
  9. package/docs/scheduling.md +62 -22
  10. package/docs/setup.md +2 -2
  11. package/docs/standalone-cli.md +4 -4
  12. package/docs/upgrades.md +16 -15
  13. package/package.json +4 -2
  14. package/src/agent-guidance.ts +2 -2
  15. package/src/ai-cli.ts +4 -2
  16. package/src/ai.ts +17 -5
  17. package/src/application-channel.ts +12 -8
  18. package/src/control-state.ts +10 -4
  19. package/src/delivery-socket-client.mjs +15 -1
  20. package/src/delivery-socket.ts +18 -4
  21. package/src/executor.ts +63 -10
  22. package/src/host-executor-client.ts +23 -8
  23. package/src/host-executor.ts +63 -32
  24. package/src/index.ts +157 -35
  25. package/src/menu.ts +3 -2
  26. package/src/plugins/manager.mjs +43 -40
  27. package/src/plugins/workspace-lease.mjs +1 -1
  28. package/src/runs.ts +67 -3
  29. package/src/schedule-cli.ts +24 -15
  30. package/src/scheduled-tasks.ts +53 -17
  31. package/src/scheduler.ts +7 -8
  32. package/src/session-close.ts +17 -0
  33. package/src/setup-fallback.ts +62 -25
  34. package/src/tools-line.d.mts +1 -0
  35. package/src/tools-line.mjs +3 -0
  36. package/src/updates/binding.mjs +4 -10
  37. package/src/updates/control.mjs +3 -2
  38. package/src/updates/runtime.mjs +25 -9
  39. package/src/updates/supervisor.mjs +105 -16
  40. package/src/workspace.ts +12 -1
  41. package/test/ai-cli.test.ts +23 -1
  42. package/test/ai.test.ts +72 -4
  43. package/test/application-channel.test.ts +24 -0
  44. package/test/application-controls.test.ts +4 -1
  45. package/test/delivery-transport.test.ts +41 -2
  46. package/test/executor.test.ts +32 -4
  47. package/test/host-executor.test.ts +70 -3
  48. package/test/intake-relay.test.ts +71 -3
  49. package/test/model-policy.test.ts +1 -1
  50. package/test/plugin-broker.test.mjs +48 -1
  51. package/test/plugin-manager.test.mjs +52 -38
  52. package/test/run-handoff.test.ts +136 -0
  53. package/test/schedule-cli.test.ts +24 -1
  54. package/test/scheduled-tasks.test.ts +45 -15
  55. package/test/session-close.test.ts +17 -0
  56. package/test/setup-fallback.test.ts +194 -16
  57. package/test/updates.test.mjs +55 -12
  58. package/test/workspace.test.ts +6 -0
package/src/ai.ts CHANGED
@@ -12,7 +12,6 @@ import { AUTH_PROFILE_CLIS, claudeAuthEnvironment, cliHome, isAuthProfile, listA
12
12
  export type AiPreset = { id: string; name: string; cli: string; provider?: string; authProfile?: string; model?: string; effort?: string }
13
13
  // Optional ordered fallbacks are tried only for a scheduled occurrence that never started work.
14
14
  export type ExecutionChoice = { sessionId: string; preset: AiPreset; fallbacks?: AiPreset[] }
15
- export const MAX_FALLBACKS = 3
16
15
  export type ModelChoice = { cli: string; provider?: string; authProfile?: string; model?: string; name: string; efforts: string[] }
17
16
  type Engine = { cli?: string; authProfile?: string }
18
17
  // A native conversation belongs to one client and one credential home.
@@ -34,7 +33,7 @@ export const isPreset = (p: unknown): p is AiPreset => {
34
33
  export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
35
34
  const c = v as ExecutionChoice | undefined
36
35
  return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset) &&
37
- (c.fallbacks === undefined || (Array.isArray(c.fallbacks) && c.fallbacks.length > 0 && c.fallbacks.length <= MAX_FALLBACKS && c.fallbacks.every(isPreset))))
36
+ (c.fallbacks === undefined || (Array.isArray(c.fallbacks) && c.fallbacks.length > 0 && c.fallbacks.every(isPreset))))
38
37
  }
39
38
  export const presetLabel = (p: AiPreset) => `${p.cli}${p.authProfile ? `@${p.authProfile}` : ''}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
40
39
  // OpenCode encodes its provider in the native provider/model identifier.
@@ -264,6 +263,21 @@ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env):
264
263
  return validateOpencodeProviders([...new Set(raw.split(',').map((p) => p.trim()).filter(Boolean))])
265
264
  }
266
265
 
266
+ // Shared by native menus and application controls; a rejected choice never mutates state.
267
+ export const catalogSelection = (p: {cli?: unknown; provider?: unknown; authProfile?: unknown; model?: unknown; effort?: unknown}, catalog: ModelChoice[]): ModelChoice => {
268
+ const candidate = {...p, id: 'selection', name: 'Selection'}
269
+ if (!isPreset(candidate)) throw new Error('Invalid AI selection fields')
270
+ const model = catalog.find(m => sameEngine(m, candidate) && m.provider === candidate.provider && m.model === candidate.model)
271
+ if (!model) {
272
+ if (!catalog.some(m => sameEngine(m, candidate)))
273
+ throw new Error(`No models advertised for ${candidate.cli}${candidate.authProfile ? `@${candidate.authProfile}` : ''} in the installed client catalog; current selection unchanged.`)
274
+ throw new Error(`Model ${candidate.model ?? '(client default)'} is not in the installed client catalog for ${candidate.cli}; current selection unchanged.`)
275
+ }
276
+ if (candidate.effort !== undefined && !model.efforts.includes(candidate.effort))
277
+ throw new Error(`Effort ${candidate.effort} is not supported for ${candidate.cli} ${candidate.model ?? '(client default)'}. Supported efforts: ${model.efforts.join(', ') || 'none (omit effort)'}; current selection unchanged.`)
278
+ return model
279
+ }
280
+
267
281
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
268
282
  assertEffort(p.effort, p.model, p.cli)
269
283
  if (!isPreset(p)) throw new Error('This CLI is not installed.')
@@ -273,9 +287,7 @@ export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], ava
273
287
  if (!catalog.some((m) => sameEngine(m, p))) throw new Error('This auth profile is not provisioned for the selected CLI.')
274
288
  return
275
289
  }
276
- const model = catalog.find((m) => sameEngine(m, p) && m.provider === p.provider && m.model === p.model)
277
- if (!model || (p.effort && !model.efforts.includes(p.effort)))
278
- throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
290
+ catalogSelection(p, catalog)
279
291
  }
280
292
 
281
293
  export type AuthProfileStatus = { cli: string; authProfile: string | null; provisioned: boolean; sharesHostLogin: boolean
@@ -2,11 +2,11 @@ import { createServer, type Server, type IncomingMessage, type ServerResponse }
2
2
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
3
3
  import { mkdir, readFile, writeFile, rename, open, unlink, stat, chown } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
- import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
5
+ import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, type SessionState, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
6
6
  import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
7
7
  import { ownsRun } from './identity.js'
8
8
  import { applicationId, validApplicationOrigin } from './application-origin.js'
9
- import { isPreset, sameChoice, sameEngine, type AiPreset, type ModelChoice } from './ai.js'
9
+ import { isPreset, sameChoice, catalogSelection, type AiPreset, type ModelChoice } from './ai.js'
10
10
  import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
11
11
  import { assertEffort } from './model-policy.js'
12
12
  import { ApprovalStore } from './approval.js'
@@ -97,6 +97,8 @@ export class ApplicationChannel {
97
97
  constructor(private options: {
98
98
  controlDir: string; workspace?: string; isolation?: string; initial: AiPreset
99
99
  wake: () => void
100
+ // Queues the replaced conversation's silent close turn (session-close.ts).
101
+ closeSession?: (session: SessionState | null) => Promise<void>
100
102
  cancel: (id: string) => Promise<void>
101
103
  speech?: (text: string, language: 'en' | 'es') => Promise<{ buffer: Buffer; mimeType: string }>
102
104
  createTelegramPairing?: (bindingId: string, owner: Owner) => Promise<{ connected: true } | { connected: false; url: string; expiresAt: string }>
@@ -155,7 +157,7 @@ export class ApplicationChannel {
155
157
  const owner = await this.sharedBinding(bindingId)
156
158
  const controls = this.options.aiControls
157
159
  if (!controls) throw new Error('Application controls unavailable')
158
- const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown}
160
+ const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; authProfile?: unknown; model?: unknown; effort?: unknown}
159
161
  if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
160
162
  !(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
161
163
  const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
@@ -168,7 +170,11 @@ export class ApplicationChannel {
168
170
  const live = (await this.bindings.list()).find(item=>item.bindingId===bindingId)
169
171
  if (!live?.shareTelegram || !sameOwner(owner, live.owner)) throw new Error('Application authority revoked')
170
172
  }}
171
- if (value.action === 'new') await control.resetSession(expected, guard)
173
+ if (value.action === 'new') {
174
+ const previous = await control.getActiveSession()
175
+ await control.resetSession(expected, guard)
176
+ await this.options.closeSession?.(previous)
177
+ }
172
178
  if (value.action === 'switch') {
173
179
  if (typeof value.sessionId !== 'string') throw new Error('Invalid application conversation')
174
180
  // switchSession enforces the same visibility/engine restrictions as /chats.
@@ -181,8 +187,7 @@ export class ApplicationChannel {
181
187
  await controls.select(preset, expected, guard)
182
188
  }
183
189
  if (value.action === 'model') {
184
- const model = (await controls.catalog()).find(item => sameEngine(item, value as {cli?:string;authProfile?:string}) && item.provider === value.provider && item.model === value.model)
185
- if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
190
+ const model = catalogSelection(value, await controls.catalog())
186
191
  await this.sharedBinding(bindingId)
187
192
  const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
188
193
  await this.sharedBinding(bindingId)
@@ -231,8 +236,7 @@ export class ApplicationChannel {
231
236
  await controls.validate(preset)
232
237
  }
233
238
  if (value.action==='model') {
234
- const model = (await controls.catalog()).find(item=>sameEngine(item,value as {cli?:string;authProfile?:string}) && item.provider===value.provider && item.model===value.model)
235
- if (!model || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
239
+ const model = catalogSelection(value, await controls.catalog())
236
240
  preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
237
241
  }
238
242
  await control.changeApplicationSession(hashedScope,guard,preset)
@@ -763,10 +763,16 @@ export class ControlStore {
763
763
  await requireControlGuard(state, guard)
764
764
  if (!state.ai) throw new Error('AI settings not initialized')
765
765
  // Auto-detected client defaults come and go with installed clients; only
766
- // owner-saved choices count toward the cap.
767
- if (state.ai.presets.filter((p) => !p.id.startsWith('detected_')).length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
768
- throw new Error('Keep it small: at most 12 saved AIs.')
769
- state.ai.presets = [...state.ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
766
+ // owner-saved choices count toward the cap. Conversations keep their own
767
+ // preset copy, so at the cap the oldest unreferenced choice makes room.
768
+ const ai = state.ai
769
+ const saved = ai.presets.filter((p) => !p.id.startsWith('detected_'))
770
+ if (saved.length >= 12 && !ai.presets.some((p) => p.id === preset.id)) {
771
+ const kept = new Set([ai.selectedId, ai.defaultId, ...(ai.recentIds ?? [])])
772
+ const oldest = saved.find((p) => !kept.has(p.id))!
773
+ ai.presets = ai.presets.filter((p) => p.id !== oldest.id)
774
+ }
775
+ ai.presets = [...ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
770
776
  await this.writeState(state)
771
777
  })
772
778
  }
@@ -78,7 +78,7 @@ const readEndpoint = async controlDir => {
78
78
  } catch { return null }
79
79
  }
80
80
 
81
- export const callDeliverySocket = async (socketPath, op, timeoutMs = 130_000) => {
81
+ const callOnce = async (socketPath, op, timeoutMs) => {
82
82
  const id = `${Date.now().toString(36)}_${Math.floor(Math.random() * 0xffffff).toString(16)}`
83
83
  try {
84
84
  return await exchange(() => createConnection(socketPath), { id, ...op }, timeoutMs, socketPath)
@@ -104,3 +104,17 @@ export const deliverySocketAlive = async (socketPath, timeoutMs = 2000) => {
104
104
  return false
105
105
  }
106
106
  }
107
+
108
+ // Only retry connection failures during a declared runtime swap. No frame was
109
+ // accepted in that case. Timeouts, provider failures and uncertain writes fail.
110
+ export const callDeliverySocket = async (socketPath, op, timeoutMs = 130_000) => {
111
+ const until=Date.now()+120000;
112
+ for(;;){
113
+ try{return await callOnce(socketPath,op,timeoutMs);}
114
+ catch(error){
115
+ if(!error?.connectFailure||op.op!=='enqueue'||Date.now()>=until)throw error;
116
+ try{await readFile(path.join(path.dirname(socketPath),'upgrade-pause.json'),'utf8');}catch{throw error;}
117
+ await new Promise(resolve=>setTimeout(resolve,250));
118
+ }
119
+ }
120
+ };
@@ -83,7 +83,7 @@ export const authorizeRun = async (controlDir: string, runId: string): Promise<R
83
83
  const encode = (value: unknown): string => `${JSON.stringify(value)}\n`
84
84
 
85
85
  export type DeliveryEndpoint = { host: '127.0.0.1'; port: number; token: string }
86
- export type DeliveryServeOptions = { tcpPort?: number }
86
+ export type DeliveryServeOptions = { tcpPort?: number; beforeClose?: () => Promise<void> }
87
87
  const endpointFile = 'delivery-endpoint.json'
88
88
  const tokensMatch = (candidate: unknown, token: string): boolean =>
89
89
  typeof candidate === 'string' && candidate.length === token.length && timingSafeEqual(Buffer.from(candidate), Buffer.from(token))
@@ -94,6 +94,8 @@ export const serveDeliverySocket = async (
94
94
  options: DeliveryServeOptions = {},
95
95
  ): Promise<{ socketPath: string; endpoint: DeliveryEndpoint | null; stop: () => Promise<void> }> => {
96
96
  const socketPath = deliverySocketPath(controlDir)
97
+ let closing=false
98
+ const admissions=new Set<Promise<void>>()
97
99
  await rm(socketPath, { force: true }).catch(() => {})
98
100
  const attach = (socket: Socket, token: string | null): void => {
99
101
  let buffer = ''
@@ -105,7 +107,8 @@ export const serveDeliverySocket = async (
105
107
  const line = buffer.slice(0, newline)
106
108
  buffer = buffer.slice(newline + 1)
107
109
  if (!line.trim()) continue
108
- void (async () => {
110
+ let admission=false
111
+ const work=(async () => {
109
112
  let id: unknown = null
110
113
  try {
111
114
  const envelope = JSON.parse(line) as { id?: unknown; token?: unknown; op?: unknown; payload?: unknown }
@@ -116,12 +119,19 @@ export const serveDeliverySocket = async (
116
119
  // re-verifies owner authority server-side.
117
120
  if (token !== null && !tokensMatch(envelope.token, token)) { socket.destroy(); return }
118
121
  const { token: _token, ...request } = envelope
122
+ if(request.op==='enqueue'){
123
+ if(closing)throw Error('Delivery relay is shutting down; message was not accepted')
124
+ admission=true
125
+ }
119
126
  const result = await handle(request as DeliverySocketOp)
120
127
  socket.write(encode({ id, ok: true, result: result ?? null }))
121
128
  } catch (error) {
122
129
  socket.write(encode({ id, ok: false, error: error instanceof Error ? error.message : String(error ?? 'Unknown error') }))
123
130
  }
124
131
  })()
132
+ // Only finite admission handlers are a drain barrier; a receipt wait
133
+ // completes after the relay's delivery pump flushes the accepted items.
134
+ if(admission){admissions.add(work);void work.finally(()=>admissions.delete(work))}
125
135
  }
126
136
  })
127
137
  }
@@ -176,9 +186,13 @@ export const serveDeliverySocket = async (
176
186
  socketPath,
177
187
  endpoint,
178
188
  stop: async () => {
179
- await new Promise<void>((resolve) => server.close(() => resolve()))
189
+ closing=true
190
+ const closed=Promise.all([new Promise<void>((resolve) => server.close(() => resolve())),
191
+ ...(tcpServer ? [new Promise<void>((resolve) => tcpServer!.close(() => resolve()))] : [])])
192
+ await Promise.all(admissions)
193
+ await options.beforeClose?.()
194
+ await closed
180
195
  await rm(socketPath, { force: true }).catch(() => {})
181
- if (tcpServer) await new Promise<void>((resolve) => tcpServer.close(() => resolve()))
182
196
  if (tcpEndpoint) await rm(path.join(controlDir, endpointFile), { force: true }).catch(() => {})
183
197
  },
184
198
  }
package/src/executor.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { executionDefaults } from './model-policy.js'
2
2
  import { startTaskExecutor } from './task-executor.js'
3
3
  import { deliverySocketPath } from './delivery-socket.js'
4
- import { mkdtemp, rm, writeFile, mkdir, symlink, readFile } from 'node:fs/promises'
4
+ import { mkdtemp, rm, writeFile, mkdir, symlink, readFile, rename } from 'node:fs/promises'
5
+ import { randomUUID } from 'node:crypto'
5
6
  import { accessSync, openSync, closeSync, constants as fsConstants } from 'node:fs'
6
7
  import { tmpdir, homedir } from 'node:os'
7
8
  import path from 'node:path'
@@ -13,9 +14,17 @@ import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
13
14
  import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
14
15
  import { Scripts, type ScriptRunRef } from './scripts.js'
15
16
  import { claudeAuthEnvironment, cliHome, provisionedHome } from './auth-profile.js'
17
+ import { isHostRunId } from './host-executor-protocol.js'
18
+
19
+ const hostCancelFiles = new WeakMap<ChildProcess, string>()
20
+ const hostCancelFile = (controlDir: string, runId: string): string => {
21
+ if (!isHostRunId(runId)) throw new Error('Invalid host executor run ID')
22
+ return path.join(controlDir, 'host-executor', runId + '.cancel')
23
+ }
16
24
 
17
25
  export type ExecutorOptions = {
18
26
  repairEnabled?: boolean
27
+ catalogTransport?: 'host' | 'local'
19
28
  workspace: string
20
29
  timeoutMs: number
21
30
  runId: string
@@ -79,7 +88,7 @@ export const executorEnvironment = (environment: NodeJS.ProcessEnv = process.env
79
88
  }
80
89
 
81
90
  export const executorJobEnv = (
82
- options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'> & { workspace?: string },
91
+ options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled' | 'catalogTransport'> & { workspace?: string },
83
92
  environment: NodeJS.ProcessEnv = process.env,
84
93
  ): NodeJS.ProcessEnv => {
85
94
  const base = executorEnvironment(environment)
@@ -88,6 +97,7 @@ export const executorJobEnv = (
88
97
  ...base,
89
98
  PATH: pathValue,
90
99
  EZ_RUN_ID: options.runId,
100
+ EZ_AI_CATALOG: options.catalogTransport ?? (environment.EZ_EXECUTOR_TRANSPORT==='host' ? 'host' : 'local'),
91
101
  EZ_CONTROL_DIR: options.controlDir,
92
102
  ...(options.workspace ? { EZ_AGENT_WORKSPACE: options.workspace } : {}),
93
103
  EZ_DELIVERY_SOCKET: deliverySocketPath(options.controlDir),
@@ -340,22 +350,33 @@ export const executorInvocation = (command: string, args: string[]) => {
340
350
  : { command, args }
341
351
  }
342
352
 
353
+ // The isolated worker uses the same file transport as the host worker, but
354
+ // stays in its own container. A published worker binding must never fall back
355
+ // to local execution when its heartbeat expires.
356
+ export const usesFileTransport = async (controlDir: string, env: NodeJS.ProcessEnv = process.env): Promise<boolean> => {
357
+ if (env.EZ_CONTAINER_EXECUTOR === '1') return false
358
+ if (env.EZ_EXECUTOR_TRANSPORT === 'host') return true
359
+ try { return JSON.parse(await readFile(path.join(controlDir,'host-executor/heartbeat.json'),'utf8')).containerExecutor === true }
360
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; return false }
361
+ }
362
+
343
363
  export const startExecutorJob = async (
344
364
  texts: string[],
345
365
  options: ExecutorOptions,
346
366
  ): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
347
- if (options.script && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
367
+ const host = await usesFileTransport(options.controlDir)
368
+ const cancelFile = host ? hostCancelFile(options.controlDir, options.runId) : undefined
369
+ if (options.script && !host) return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
348
370
  options = executionDefaults(executorKey(options.cli), options)
349
371
  // Named credential homes are owner-provisioned; fail closed before any launch state.
350
372
  if (options.authProfile !== undefined) await provisionedHome(options.controlDir, executorKey(options.cli), options.authProfile)
351
373
  // Routing is caller-owned: a restricted task run goes to the task runner
352
374
  // unless the host transport must ship it across the boundary first.
353
- if (options.taskRun && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
375
+ if (options.taskRun && !host) return startTaskExecutor(options)
354
376
  if (options.nativeSession && !/^[a-zA-Z0-9_-]+$/.test(options.runId)) throw new Error('Invalid native task run ID')
355
377
  if (options.codexSandbox !== undefined && (options.codexSandbox !== 'external' || process.env.EZ_EXECUTOR_TRANSPORT !== 'local' || options.taskRun || executorKey(options.cli) !== 'codex')) throw new Error('External Codex sandbox requires an owner-authorized native local run')
356
378
  const outputDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
357
379
  const key = executorKey(options.cli)
358
- const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
359
380
  const gui = !host && key === 'codex-gui'
360
381
  const nativeSession = !host && key === 'codex' && options.nativeSession === true
361
382
  // Literal input plus the caller-provided trigger line. No coaching,
@@ -458,6 +479,7 @@ export const startExecutorJob = async (
458
479
  stdio: ['pipe', 'pipe', 'pipe'],
459
480
  detached: process.platform !== 'win32',
460
481
  })
482
+ if (cancelFile) hostCancelFiles.set(child, cancelFile)
461
483
  await new Promise<void>((resolve, reject) => {
462
484
  child.once('spawn', resolve)
463
485
  child.once('error', reject)
@@ -500,6 +522,17 @@ export const startExecutorJob = async (
500
522
 
501
523
  export const startGrokJob = startExecutorJob
502
524
 
525
+ // Watch a host-transport run a previous relay submitted; it is never resubmitted.
526
+ export const attachHostJob = async (controlDir: string, runId: string): Promise<ChildProcess> => {
527
+ const cancelFile = hostCancelFile(controlDir, runId)
528
+ const invocation = executorInvocation(process.execPath, ['--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
529
+ fileURLToPath(new URL('./host-executor-client.ts', import.meta.url)), controlDir, runId, '--attach'])
530
+ const child = spawn(invocation.command, invocation.args, { env: executorEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' })
531
+ hostCancelFiles.set(child, cancelFile)
532
+ await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
533
+ return child
534
+ }
535
+
503
536
  // Spawn a verified registered script with the executor identity, environment
504
537
  // allowlist and workspace confinement. No shell, model, prompt or native session.
505
538
  export const startScriptJob = async (
@@ -543,11 +576,27 @@ export const nativeSessionId = (cli: string, line: string): string | undefined =
543
576
  } catch {}
544
577
  }
545
578
 
546
- const terminating = new WeakSet<ChildProcess>()
547
- export const terminateJob = (child: ChildProcess, inspect = processSnapshot): void => {
548
- if (!child.pid || child.exitCode !== null || child.signalCode !== null || terminating.has(child)) return
549
- terminating.add(child)
550
- void (async () => {
579
+ const terminating = new WeakMap<ChildProcess, Promise<boolean>>()
580
+ export const terminateJob = (child: ChildProcess, inspect = processSnapshot): Promise<boolean> => {
581
+ const pending = terminating.get(child)
582
+ if (pending) return pending
583
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
584
+ const work = (async () => {
585
+ const cancelFile = hostCancelFiles.get(child)
586
+ if (cancelFile) {
587
+ // The observer may still be loading, or unable to handle a signal. Commit
588
+ // the native worker's stop request before terminating that observer.
589
+ const temporary = cancelFile + '.' + process.pid + '.' + randomUUID() + '.tmp'
590
+ try {
591
+ await mkdir(path.dirname(cancelFile), { recursive: true, mode: 0o700 })
592
+ await writeFile(temporary, '', { mode: 0o600, flag: 'wx' })
593
+ await rename(temporary, cancelFile)
594
+ } catch (error) {
595
+ await rm(temporary, { force: true }).catch(() => {})
596
+ console.error('Cannot request host executor cancellation; observer remains active', error)
597
+ return false
598
+ }
599
+ }
551
600
  const targets = new Set([child.pid!])
552
601
  // Native tool terminals can start separate process groups. Capture ancestry
553
602
  // before stopping the CLI, while those children still have their parent.
@@ -575,5 +624,9 @@ export const terminateJob = (child: ChildProcess, inspect = processSnapshot): vo
575
624
  void processSnapshot().then(current => signal(matchingProcessIds(identities, current), 'SIGKILL'))
576
625
  .catch(error => console.error('Cannot inspect executor descendants for escalation', error))
577
626
  }, 3000)
627
+ return true
578
628
  })()
629
+ terminating.set(child, work)
630
+ void work.then(stopped => { if (!stopped) terminating.delete(child) })
631
+ return work
579
632
  }
@@ -1,17 +1,29 @@
1
1
  // File transport across the Docker/host boundary. The host chooses the CLI,
2
2
  // workspace and environment; a request cannot choose a command or credentials.
3
- import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises'
3
+ import { mkdir, readFile, writeFile, rename, rm, stat } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import { isHostRunId } from './host-executor-protocol.js'
6
- const [control, id] = process.argv.slice(2)
7
- if (!control || !isHostRunId(id || '')) throw new Error('Invalid executor binding')
6
+ const [control, id, mode] = process.argv.slice(2)
7
+ if (!control || !isHostRunId(id || '') || (mode !== undefined && mode !== '--attach')) throw new Error('Invalid executor binding')
8
8
  const directory = path.join(control, 'host-executor')
9
9
  await mkdir(directory, {recursive:true, mode:0o700})
10
- let input = ''
11
- for await (const chunk of process.stdin) input += chunk
12
10
  const base = path.join(directory, id)
13
- await writeFile(base+'.tmp', input, {mode:0o600, flag:'wx'})
14
- await rename(base+'.tmp', base+'.request.json')
11
+ if (mode === '--attach') {
12
+ // A previous relay submitted this run: watch it, never resubmit. Without a
13
+ // request, claim or event record its outcome is unknown.
14
+ // Check in lifecycle order: claim renames the request, and the host writes
15
+ // the exit event before removing the claim, so a live run is always seen.
16
+ const exists = (suffix: string) => stat(base+suffix).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error })
17
+ if (!await exists('.request.json') && !await exists('.running.json') && !await exists('.events')) {
18
+ console.error('Handed-off host run has no request, claim or result; its outcome is unknown')
19
+ process.exit(1)
20
+ }
21
+ } else {
22
+ let input = ''
23
+ for await (const chunk of process.stdin) input += chunk
24
+ await writeFile(base+'.tmp', input, {mode:0o600, flag:'wx'})
25
+ await rename(base+'.tmp', base+'.request.json')
26
+ }
15
27
  let interrupted = false
16
28
  for (const signal of ['SIGTERM','SIGINT'] as const) process.once(signal, () => {
17
29
  if (interrupted) return
@@ -38,7 +50,10 @@ try {
38
50
  // Drain completion first. A brief missing file at the shared-filesystem
39
51
  // boundary must not cancel a healthy job or hide its terminal result.
40
52
  try {
41
- const heartbeat = JSON.parse(await readFile(path.join(directory,'heartbeat.json'),'utf8'))
53
+ const claimed = await stat(base+'.running.json').then(()=>true,error=>{if(error.code==='ENOENT')return false;throw error})
54
+ const heartbeat = claimed
55
+ ? {at:(await stat(base+'.events')).mtimeMs}
56
+ : JSON.parse(await readFile(path.join(directory,'heartbeat.json'),'utf8'))
42
57
  if (!Number.isFinite(heartbeat.at)) throw new Error('Invalid host CLI heartbeat')
43
58
  lastHeartbeat = heartbeat.at
44
59
  } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }