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

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.
@@ -2,7 +2,8 @@ import { assertEffort } from './model-policy.js'
2
2
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
3
3
  import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
- import { isPreset, persistedPreset, presetProvider, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
5
+ import { isPreset, persistedPreset, presetProvider, sameEngine, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
6
+ import { isAuthProfile } from './auth-profile.js'
6
7
 
7
8
  export type Owner = {
8
9
  id?: string
@@ -58,6 +59,8 @@ export type SessionState = {
58
59
  sessionId: string
59
60
  hasStarted: boolean
60
61
  cli?: string
62
+ // Bound with cli at creation: a native session never resumes under another home.
63
+ authProfile?: string
61
64
  nativeSessionId?: string
62
65
  title?: string
63
66
  archived?: boolean
@@ -129,7 +132,8 @@ const isState = (value: unknown): value is ControlState => {
129
132
  (s.archived === undefined || typeof s.archived === 'boolean') &&
130
133
  (s.applicationScope === undefined || /^[a-f0-9]{64}$/.test(s.applicationScope)) &&
131
134
  (s.telegramShared === undefined || typeof s.telegramShared === 'boolean') &&
132
- (s.preset === undefined || (isPreset(s.preset) && s.preset.cli === s.cli))
135
+ (s.authProfile === undefined || (isAuthProfile(s.authProfile) && s.cli !== undefined)) &&
136
+ (s.preset === undefined || (isPreset(s.preset) && sameEngine(s.preset, s)))
133
137
  return (
134
138
  candidate.version === 1 &&
135
139
  Array.isArray(candidate.pending) &&
@@ -150,9 +154,11 @@ const isState = (value: unknown): value is ControlState => {
150
154
  export const sessionTitle = (session: SessionState): string =>
151
155
  session.title || `Conversation ${session.sessionId.slice(0, 8)}`
152
156
 
157
+ const engineOf = (preset: AiPreset) => ({ cli: preset.cli, ...(preset.authProfile ? { authProfile: preset.authProfile } : {}) })
158
+
153
159
  const rememberPreset = (state: ControlState) => {
154
160
  const preset = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
155
- if (state.activeSession && preset && state.activeSession.cli === preset.cli)
161
+ if (state.activeSession && preset && sameEngine(state.activeSession, preset))
156
162
  state.activeSession.preset = preset
157
163
  }
158
164
 
@@ -486,8 +492,8 @@ export class ControlStore {
486
492
  const ai = state.ai
487
493
  // Older sessions did not record their model. Reuse a known preset for the
488
494
  // same CLI; never resume an engine ID through a different client.
489
- const preset = session.preset ?? ai?.presets.find(p => p.cli === session.cli)
490
- if (!ai || !preset || preset.cli !== session.cli)
495
+ const preset = session.preset ?? ai?.presets.find(p => sameEngine(p, session))
496
+ if (!ai || !preset || !sameEngine(preset, session))
491
497
  throw new Error('This older conversation has no saved AI binding. Start a new conversation.')
492
498
  if (session.hasStarted && ['codex', 'codex-gui', 'opencode'].includes(session.cli!) && !session.nativeSessionId)
493
499
  throw new Error('This conversation has no native session ID. Start a new conversation.')
@@ -537,10 +543,11 @@ export class ControlStore {
537
543
  const state = await this.readState()
538
544
  await requireControlGuard(state, guard)
539
545
  if (expectedSession !== undefined && (state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Conversation changed. Refresh controls before trying again.')
546
+ const fallback = state.ai?.presets.find((p) => p.id === state.ai!.defaultId)
540
547
  const next: SessionState = {
541
548
  sessionId: crypto.randomUUID(),
542
549
  hasStarted: false,
543
- cli: state.ai?.presets.find((p) => p.id === state.ai!.defaultId)?.cli,
550
+ ...(fallback ? engineOf(fallback) : {}),
544
551
  }
545
552
  rememberPreset(state)
546
553
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
@@ -624,25 +631,25 @@ export class ControlStore {
624
631
  const settledPreset = settledAi?.presets.find((p) => p.id === settledAi.selectedId)
625
632
  const session = settled.activeSession
626
633
  if (settledPreset && session && (session.cli || session.hasStarted) &&
627
- !(session.cli === settledPreset.cli && session.hasStarted && session.preset &&
634
+ !(sameEngine(session, settledPreset) && session.hasStarted && session.preset &&
628
635
  presetProvider(session.preset) !== presetProvider(settledPreset)) &&
629
- (session.cli !== settledPreset.cli || JSON.stringify(session.preset) === JSON.stringify(settledPreset)) &&
636
+ (!sameEngine(session, settledPreset) || JSON.stringify(session.preset) === JSON.stringify(settledPreset)) &&
630
637
  Boolean(session.title || session.hasStarted || !title?.trim()))
631
638
  return { sessionId: session.sessionId, preset: settledPreset }
632
639
  return this.withLock(async () => {
633
640
  const state = await this.readState()
634
641
  state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id, recentIds: [] }
635
642
  const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
636
- state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
637
- if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
638
- if (state.activeSession.cli === preset.cli) {
643
+ state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset) }
644
+ if (!state.activeSession.cli && !state.activeSession.hasStarted) Object.assign(state.activeSession, engineOf(preset))
645
+ if (sameEngine(state.activeSession, preset)) {
639
646
  const previous = state.activeSession.preset
640
647
  // A provider change starts a separate native context. Model and effort
641
648
  // changes are native turn settings within the existing conversation.
642
649
  if (state.activeSession.hasStarted && previous &&
643
650
  presetProvider(previous) !== presetProvider(preset)) {
644
651
  ;(state.sessions ??= []).push(state.activeSession)
645
- state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset }
652
+ state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset }
646
653
  } else {
647
654
  state.activeSession.preset = preset
648
655
  }
@@ -675,14 +682,14 @@ export class ControlStore {
675
682
  state.ai!.selectedId = session.preset!.id
676
683
  }
677
684
  if (previous?.preset) {
678
- if (requested && requested.cli !== previous.cli) throw new Error('Application request conflicts with existing session engine')
685
+ if (requested && !sameEngine(requested, previous)) throw new Error('Application request conflicts with existing session engine')
679
686
  if (requested) previous.preset = requested
680
687
  activate(previous)
681
688
  if (shareTelegram || requested) await this.writeState(state)
682
689
  return { sessionId: previous.sessionId, preset: previous.preset }
683
690
  }
684
691
  const preset = requested ?? state.ai.presets.find(item => item.id === state.ai!.selectedId)!
685
- const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset, applicationScope: scope }
692
+ const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset, applicationScope: scope }
686
693
  state.sessions.push(session)
687
694
  activate(session)
688
695
  await this.writeState(state)
@@ -705,7 +712,7 @@ export class ControlStore {
705
712
  throw new Error('Application scope is shared; use shared controls')
706
713
  const nextPreset = preset ?? state.ai?.presets.find(item => item.id === state.ai!.defaultId)
707
714
  if (!nextPreset || !isPreset(nextPreset)) throw new Error('Invalid application AI selection')
708
- if (preset && previous?.cli === preset.cli) {
715
+ if (preset && previous && sameEngine(previous, preset)) {
709
716
  previous.preset = persistedPreset(preset)
710
717
  await this.writeState(state)
711
718
  return previous
@@ -714,7 +721,7 @@ export class ControlStore {
714
721
  // the old immutable session ID; private history stays absent from /chats.
715
722
  if (previous) previous.archived = true
716
723
  const next: SessionState = {sessionId:crypto.randomUUID(), hasStarted:false,
717
- cli:nextPreset.cli, preset:persistedPreset(nextPreset), applicationScope:scope}
724
+ ...engineOf(nextPreset), preset:persistedPreset(nextPreset), applicationScope:scope}
718
725
  ;(state.sessions ??= []).push(next)
719
726
  await this.writeState(state)
720
727
  return next
@@ -725,8 +732,8 @@ export class ControlStore {
725
732
  const state = await this.status()
726
733
  const session = state.activeSession?.sessionId === choice.sessionId ? state.activeSession
727
734
  : state.sessions?.find((s) => s.sessionId === choice.sessionId)
728
- if (!session || session.cli !== choice.preset.cli)
729
- throw new Error('Conversation has no matching CLI binding. Use New conversation explicitly; files are preserved.')
735
+ if (!session?.cli || !sameEngine(session, choice.preset))
736
+ throw new Error('Conversation has no matching CLI binding for this client and auth profile. Use New conversation explicitly; files are preserved.')
730
737
  if (session.hasStarted && ['codex', 'codex-gui', 'opencode'].includes(session.cli) && !session.nativeSessionId)
731
738
  throw new Error('Native session ID missing. Use New conversation explicitly; no automatic reset.')
732
739
  return session
@@ -774,16 +781,16 @@ export class ControlStore {
774
781
  assertEffort(preset.effort, preset.model, preset.cli)
775
782
  if ((state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Menu expired. Open Choose AI again.')
776
783
  const current = ai.presets.find((p) => p.id === ai.selectedId)!
777
- if (state.activeSession && (current.cli !== preset.cli || presetProvider(current) !== presetProvider(preset) || !state.activeSession.cli) && !fresh) return false
784
+ if (state.activeSession && (!sameEngine(current, preset) || presetProvider(current) !== presetProvider(preset) || !state.activeSession.cli) && !fresh) return false
778
785
  if (fresh || !state.activeSession) {
779
786
  rememberPreset(state)
780
787
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
781
- state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset: persistedPreset(preset) }
788
+ state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset: persistedPreset(preset) }
782
789
  }
783
790
  ai.selectedId = id
784
791
  // Persist the conversation's choice now, even if the owner switches chats
785
792
  // or restarts before sending another message. Queued choices are snapshots.
786
- if (state.activeSession.cli === preset.cli && (!state.activeSession.preset || presetProvider(state.activeSession.preset) === presetProvider(preset)))
793
+ if (sameEngine(state.activeSession, preset) && (!state.activeSession.preset || presetProvider(state.activeSession.preset) === presetProvider(preset)))
787
794
  state.activeSession.preset = persistedPreset(preset)
788
795
  ai.recentIds = [id, ...(ai.recentIds ?? []).filter((recentId) => recentId !== id)].slice(0, 3)
789
796
  await this.writeState(state)
@@ -318,6 +318,8 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
318
318
  const payload = (op.payload ?? {}) as { runId?: unknown; change?: unknown }
319
319
  if (typeof payload.runId !== 'string' || !payload.runId) throw new Error('Run ID is required')
320
320
  if (!payload.change || typeof payload.change !== 'object') throw new Error('Patch change is required')
321
+ // Setup attempts select what the host admits; only the relay records them.
322
+ if ('attempts' in payload.change) throw new Error('Setup attempts are relay-owned')
321
323
  return runs.patch(payload.runId, payload.change as never)
322
324
  }
323
325
  case 'taskApplication': {
package/src/executor.ts CHANGED
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'
12
12
  import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
13
13
  import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
14
14
  import { Scripts, type ScriptRunRef } from './scripts.js'
15
+ import { claudeAuthEnvironment, cliHome, provisionedHome } from './auth-profile.js'
15
16
 
16
17
  export type ExecutorOptions = {
17
18
  repairEnabled?: boolean
@@ -35,6 +36,8 @@ export type ExecutorOptions = {
35
36
  model?: string
36
37
  effort?: string
37
38
  provider?: string
39
+ // Named owner-provisioned credential home; unset keeps the agent default.
40
+ authProfile?: string
38
41
  codexSandbox?: 'external'
39
42
  codexAutoCompactTokens?: number
40
43
  codexProvider?: CodexProviderBinding
@@ -95,6 +98,10 @@ export const executorJobEnv = (
95
98
 
96
99
  export const grokJobEnv = executorJobEnv
97
100
 
101
+ // The selected native CLI is not installed or not executable. Nothing ran, so a
102
+ // scheduled occurrence may advance to its next setup.
103
+ export class CliUnavailableError extends Error {}
104
+
98
105
  // Package binDir is Ez tools (ezenciel-agents-message). Native CLIs come from the
99
106
  // host PATH so one agent's wrapper cannot retarget another agent's CODEX_HOME.
100
107
  export const resolveHostCommand = (command: string, pathValue = process.env.PATH): string => {
@@ -107,14 +114,14 @@ export const resolveHostCommand = (command: string, pathValue = process.env.PATH
107
114
  return candidate
108
115
  } catch { /* try the next PATH entry */ }
109
116
  }
110
- throw new Error(`Native CLI ${command} is not executable on the host PATH`)
117
+ throw new CliUnavailableError(`Native CLI ${command} is not executable on the host PATH`)
111
118
  }
112
119
  export type CliAdapter = {
113
120
  name: string
114
121
  command: string
115
122
  description: string
116
123
  buildArgs: (
117
- options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox' | 'codexProvider'> & { controlDir?: string },
124
+ options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox' | 'codexProvider' | 'nativeSession'> & { controlDir?: string },
118
125
  promptFile: string,
119
126
  promptText: string,
120
127
  ) => string[]
@@ -161,6 +168,9 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
161
168
  args.push('--session-id', opts.sessionId)
162
169
  }
163
170
  args.push('--print', '--dangerously-skip-permissions')
171
+ // Scheduled runs stream structured events so core can tell a provider
172
+ // rejection before work from a failure after work began.
173
+ if (opts.nativeSession) args.push('--output-format', 'stream-json', '--verbose')
164
174
  for (const directory of [opts.controlDir, opts.sharedWorkspace, ...(opts.additionalWorkspaces ?? []), opts.toolsHome])
165
175
  if (directory) args.push('--add-dir', directory)
166
176
  if (opts.model) args.push('--model', opts.model)
@@ -294,7 +304,7 @@ export const opencodeDataHome = (controlDir: string): string | undefined => {
294
304
  }
295
305
 
296
306
  // Agent-bound Claude Code configuration directory (CLAUDE_CONFIG_DIR).
297
- export const claudeConfigHome = (controlDir: string): string => path.join(controlDir, 'cli', 'claude')
307
+ export const claudeConfigHome = (controlDir: string, authProfile?: string): string => cliHome(controlDir, 'claude', authProfile)
298
308
 
299
309
  // Pi resolves its provider/model catalog from an agent dir (settings.json,
300
310
  // auth.json). Only a path is ever returned, never a secret.
@@ -336,6 +346,8 @@ export const startExecutorJob = async (
336
346
  ): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
337
347
  if (options.script && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
338
348
  options = executionDefaults(executorKey(options.cli), options)
349
+ // Named credential homes are owner-provisioned; fail closed before any launch state.
350
+ if (options.authProfile !== undefined) await provisionedHome(options.controlDir, executorKey(options.cli), options.authProfile)
339
351
  // Routing is caller-owned: a restricted task run goes to the task runner
340
352
  // unless the host transport must ship it across the boundary first.
341
353
  if (options.taskRun && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
@@ -377,7 +389,7 @@ export const startExecutorJob = async (
377
389
  }
378
390
  if (!host && !gui && command === 'codex') {
379
391
  // Share the existing authentication, never the user's memory/config/sessions.
380
- const base = path.join(options.controlDir, 'cli', 'codex')
392
+ const base = await provisionedHome(options.controlDir, 'codex', options.authProfile)
381
393
  const home = nativeSession ? path.join(base,'tasks',options.runId) : base
382
394
  await mkdir(home, {recursive:true,mode:0o700})
383
395
  if(nativeSession){
@@ -387,9 +399,10 @@ export const startExecutorJob = async (
387
399
  try{await writeFile(path.join(home,'config.toml'),await readFile(path.join(base,'config.toml')),{flag:'wx',mode:0o600})}
388
400
  catch(error){if(!['ENOENT','EEXIST'].includes((error as NodeJS.ErrnoException).code || ''))throw error}
389
401
  }
390
- // Isolated agents keep auth in this CODEX_HOME. Host-capable may link the
391
- // installer login. Never replace an existing binding.
392
- const authLinks = process.env.EZ_ISOLATION === 'isolated'
402
+ // Isolated agents and named profiles keep auth in this CODEX_HOME. The
403
+ // host-capable default may link the installer login. Never replace an
404
+ // existing binding.
405
+ const authLinks = process.env.EZ_ISOLATION === 'isolated' || options.authProfile !== undefined
393
406
  ? nativeSession ? [[path.join(home, 'auth.json'), path.join(base, 'auth.json')]] : []
394
407
  : [[path.join(base, 'auth.json'), path.join(homedir(), '.codex', 'auth.json')],
395
408
  ...(nativeSession ? [[path.join(home, 'auth.json'), path.join(base, 'auth.json')]] : [])]
@@ -402,28 +415,18 @@ export const startExecutorJob = async (
402
415
  if (!host && !gui && key === 'claude') {
403
416
  // Mirror the Codex binding: agent-bound settings, memory, plugins and
404
417
  // sessions; never the user's personal ~/.claude configuration.
405
- const home = claudeConfigHome(options.controlDir)
418
+ const home = await provisionedHome(options.controlDir, 'claude', options.authProfile)
406
419
  await mkdir(home, {recursive:true,mode:0o700})
407
- environment.CLAUDE_CONFIG_DIR = home
408
- // Host-capable agents share the installer login only. An empty secure
409
- // storage dir makes Claude use its default credential store (macOS
420
+ // Host-capable default homes share the installer login only. An empty
421
+ // secure storage dir makes Claude use its default credential store (macOS
410
422
  // Keychain entry or ~/.claude/.credentials.json) in place, so token
411
423
  // refreshes stay shared rather than diverging through a copy. Isolated
412
- // agents keep their own login inside CLAUDE_CONFIG_DIR. The host executor
413
- // runs unlabelled, so this matches the Codex auth-link check, not
414
- // resolveIsolation().
415
- if (process.env.EZ_ISOLATION !== 'isolated') environment.CLAUDE_SECURESTORAGE_CONFIG_DIR = ''
416
- // Optional owner-provisioned long-lived token (`claude setup-token`). It
417
- // never refreshes, so one file can be copied to every agent without the
418
- // agents invalidating each other's OAuth session.
419
- const token = await readFile(path.join(home, 'oauth-token'), 'utf8').then(value => value.trim(), (error: NodeJS.ErrnoException) => {
420
- if (error.code === 'ENOENT') return undefined
421
- throw error
422
- })
423
- if (token !== undefined) {
424
- if (!/^[A-Za-z0-9._-]{20,512}$/.test(token)) throw new Error('Invalid Claude token binding')
425
- environment.CLAUDE_CODE_OAUTH_TOKEN = token
426
- }
424
+ // agents and named profiles keep their own login inside CLAUDE_CONFIG_DIR.
425
+ // The host executor runs unlabelled, so this matches the Codex auth-link
426
+ // check, not resolveIsolation(). An optional owner-provisioned long-lived
427
+ // token (`claude setup-token`) never refreshes, so one file can be copied
428
+ // to every agent without them invalidating each other's OAuth session.
429
+ Object.assign(environment, await claudeAuthEnvironment(home, options.authProfile))
427
430
  }
428
431
  if (!host && !gui && key === 'opencode') {
429
432
  // Point OpenCode at the agent-bound data home (cli/opencode/auth.json)
@@ -7,9 +7,11 @@ import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath }
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, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
10
+ import { CliUnavailableError, startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
11
+ import { activeSetup } from './runs.js'
12
+ import { startupLine, startupObserver } from './setup-fallback.js'
11
13
  import { parseIsolationClass, type IsolationClass } from './isolation.js'
12
- import { readModels, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
14
+ import { readModels, sameEngine, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
13
15
  import type { ChildProcess } from 'node:child_process'
14
16
  import { packageVersion } from './version.js'
15
17
  import { installedPluginVersions } from './software-status.js'
@@ -48,6 +50,8 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
48
50
  new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
49
51
  throw new Error('Each agent requires a separate workspace and control directory')
50
52
  const active = new Map<string, ChildProcess>()
53
+ // A fallback attempt reuses its run ID; claim it only after the prior attempt's cleanup.
54
+ const claimed = new Set<string>()
51
55
  const tasks = new Set<Promise<void>>()
52
56
  let catalogRefresh: Promise<void> | undefined
53
57
  const locks: string[] = []
@@ -148,6 +152,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
148
152
  for (const file of await readdir(directory)) {
149
153
  if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
150
154
  const id=file.slice(0,-13)
155
+ if (claimed.has(path.join(directory,id))) continue
151
156
  let run
152
157
  try {
153
158
  run = await readRun(agent.controlDir, id)
@@ -170,6 +175,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
170
175
  activeWorkspaces.set(agent, workspace)
171
176
  }
172
177
  workspace.lanes.set(id, scheduled)
178
+ claimed.add(base)
173
179
  // Keep plugin workspace invocations excluded until all native jobs finish.
174
180
  const releaseWorkspace = async () => {
175
181
  workspace.lanes.delete(id)
@@ -179,11 +185,12 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
179
185
  }
180
186
  }
181
187
  try { await rename(base+'.request.json',base+'.running.json') }
182
- catch (error) { await releaseWorkspace(); throw error }
188
+ catch (error) { claimed.delete(base); await releaseWorkspace(); throw error }
183
189
  const task=(async()=>{
184
190
  let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
185
191
  let cancellation: ReturnType<typeof setInterval> | undefined
186
192
  let writes=Promise.resolve()
193
+ let observeClaude=false
187
194
  const emit=(event:unknown)=>{writes=writes.then(()=>appendFile(base+'.events',JSON.stringify(event)+'\n',{mode:0o600}))}
188
195
  try {
189
196
  try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
@@ -203,12 +210,20 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
203
210
  script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
204
211
  job=await launch(request.texts,options)
205
212
  } else {
213
+ const cli = opts.cli || installation.cli
206
214
  if (run?.scheduled) {
207
- assertScheduledModel(run.execution?.preset.model)
208
- if (opts.model !== run.execution!.preset.model) throw new Error('Scheduled run model does not match its saved task model')
215
+ const setup = activeSetup(run)
216
+ assertScheduledModel(setup?.model)
217
+ if (opts.model !== setup!.model || cli !== setup!.cli) throw new Error('Scheduled run model does not match its saved task model')
218
+ observeClaude = cli === 'claude'
209
219
  }
210
- const cli = opts.cli || installation.cli
211
220
  resolveExecutor(cli)
221
+ // The credential home comes from the authorized run's captured
222
+ // choice; a request can never move it to another account.
223
+ const captured = run?.scheduled ? activeSetup(run) : run?.execution?.preset
224
+ if ((opts.authProfile !== undefined || (captured?.cli === cli && captured.authProfile !== undefined)) &&
225
+ !(captured && sameEngine(captured, {cli, authProfile: opts.authProfile})))
226
+ throw new Error('Run auth profile does not match its captured AI selection')
212
227
  const provider = cli === 'codex' && opts.provider
213
228
  ? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
214
229
  : undefined
@@ -221,17 +236,24 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
221
236
  // Admission and execution must use the same catalog snapshot. A
222
237
  // transient native-client probe failure must not reject a model
223
238
  // that the host just advertised to the relay and application.
224
- if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
239
+ if (cli !== installation.cli || opts.authProfile !== undefined) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,authProfile:opts.authProfile,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
225
240
  const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
226
241
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
227
- sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
242
+ sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,authProfile:opts.authProfile,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
228
243
  promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
229
244
  job=await launch(request.texts,options)
230
245
  }
231
246
  active.set(base,job.child)
232
247
  await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
233
248
  if(signal.aborted)terminateJob(job.child)
234
- job.child.stdout?.on('data',chunk=>emit({stream:'stdout',text:chunk.toString()}))
249
+ // Scheduled Claude streams every event; forward only the startup summary.
250
+ const startup = observeClaude ? startupObserver() : undefined
251
+ let summary = ''
252
+ job.child.stdout?.setEncoding('utf8').on('data',(chunk:string)=>{
253
+ if (!startup) return emit({stream:'stdout',text:chunk})
254
+ startup.write(chunk)
255
+ if (startup.summary() !== summary) { summary = startup.summary(); emit({stream:'stdout',text:summary+'\n'}) }
256
+ })
235
257
  job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
236
258
  cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{})},250)
237
259
  const code=await new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
@@ -239,7 +261,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
239
261
  await job.cleanup()
240
262
  job = undefined
241
263
  emit({stream:'exit',code})
242
- } catch (error) { emit({stream:'stderr',text:'Host CLI execution failed: '+redactFailure(error instanceof Error ? error.message : 'Unknown error')+'\n'}); emit({stream:'exit',code:1}) }
264
+ } catch (error) {
265
+ if (error instanceof CliUnavailableError) emit({stream:'stdout',text:startupLine({workBegan:false,rejection:{category:'cli-unavailable'}})+'\n'})
266
+ emit({stream:'stderr',text:'Host CLI execution failed: '+redactFailure(error instanceof Error ? error.message : 'Unknown error')+'\n'}); emit({stream:'exit',code:1}) }
243
267
  finally {
244
268
  if(cancellation)clearInterval(cancellation)
245
269
  await job?.cleanup().catch(() => {})
@@ -249,6 +273,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
249
273
  await rm(base+'.cancel',{force:true})
250
274
  active.delete(base)
251
275
  await releaseWorkspace()
276
+ claimed.delete(base)
252
277
  }
253
278
  })()
254
279
  tasks.add(task); void task.finally(()=>tasks.delete(task))