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

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 (46) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +15 -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 +18 -2
  7. package/docs/plugin-guide.md +3 -3
  8. package/docs/plugins.md +13 -10
  9. package/docs/scheduling.md +55 -18
  10. package/docs/setup.md +2 -2
  11. package/docs/standalone-cli.md +4 -4
  12. package/docs/upgrades.md +9 -13
  13. package/package.json +1 -1
  14. package/src/agent-guidance.ts +2 -2
  15. package/src/ai.ts +17 -5
  16. package/src/application-channel.ts +12 -8
  17. package/src/executor.ts +9 -0
  18. package/src/host-executor-client.ts +19 -7
  19. package/src/index.ts +125 -21
  20. package/src/menu.ts +3 -2
  21. package/src/plugins/manager.mjs +43 -40
  22. package/src/runs.ts +61 -3
  23. package/src/schedule-cli.ts +18 -12
  24. package/src/scheduled-tasks.ts +42 -8
  25. package/src/scheduler.ts +1 -1
  26. package/src/session-close.ts +17 -0
  27. package/src/setup-fallback.ts +62 -25
  28. package/src/tools-line.d.mts +1 -0
  29. package/src/tools-line.mjs +3 -0
  30. package/src/updates/binding.mjs +4 -10
  31. package/src/updates/control.mjs +3 -2
  32. package/src/updates/supervisor.mjs +1 -1
  33. package/src/workspace.ts +12 -1
  34. package/test/ai.test.ts +48 -2
  35. package/test/application-channel.test.ts +24 -0
  36. package/test/application-controls.test.ts +4 -1
  37. package/test/intake-relay.test.ts +63 -2
  38. package/test/model-policy.test.ts +1 -1
  39. package/test/plugin-broker.test.mjs +48 -1
  40. package/test/plugin-manager.test.mjs +52 -38
  41. package/test/run-handoff.test.ts +132 -0
  42. package/test/scheduled-tasks.test.ts +24 -2
  43. package/test/session-close.test.ts +17 -0
  44. package/test/setup-fallback.test.ts +194 -16
  45. package/test/updates.test.mjs +19 -10
  46. package/test/workspace.test.ts +6 -0
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
7
7
  import { Scheduler } from '../src/scheduler.js'
8
8
  import { scheduledTaskDetailText, scheduledTasksText } from '../src/scheduled-tasks.js'
9
9
  import { RunStore } from '../src/runs.js'
10
+ import type { Trigger } from '../src/schedule-time.js'
10
11
 
11
12
  const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
12
13
  const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex', model: 'fixture-model' } }
@@ -40,7 +41,7 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
40
41
  const active = await scheduler.listActiveReadOnly(await runs.list())
41
42
  const text = scheduledTasksText(active, owner)
42
43
 
43
- assert.equal(text, '📅 Scheduled tasks · 1 active\n\nAll times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n codex · fixture-model · default effort\n Read the ledger and send the owner a concise report.')
44
+ assert.equal(text, '📅 Scheduled tasks · 1 active\n\nNext/last times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Schedule · Weekdays at 09:00 · Asia/Dubai\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n Execution · codex (default login) · fixture-model · default effort\n Read the ledger and send the owner a concise report.')
44
45
  assert.match(scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!, 1), /Last run\n✓ Completed · Thu, Jan 1 · 05:04 UTC/)
45
46
  assert.doesNotMatch(text, /Other owner task|This must never be visible/)
46
47
  assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
@@ -118,7 +119,7 @@ test('a legacy invalid selection does not prevent the active menu from rendering
118
119
  await writeFile(file,JSON.stringify(saved))
119
120
 
120
121
  const active = await scheduler.listActiveReadOnly([])
121
- assert.match(scheduledTasksText(active,owner),/codex · gpt-5.6-terra · max/)
122
+ assert.match(scheduledTasksText(active,owner),/codex \(default login\) · gpt-5.6-terra · max/)
122
123
  })
123
124
 
124
125
  test('detail view truncates long instructions and tolerates invalid timestamps', async t => {
@@ -135,3 +136,24 @@ test('detail view truncates long instructions and tolerates invalid timestamps',
135
136
  const broken = {...active.find(schedule => schedule.id === saved.id)!,nextAt:'not-a-date',lastRun:{status:'completed',at:'also-bad',currentRevision:true}} as never
136
137
  assert.doesNotMatch(scheduledTaskDetailText(broken,1,1),/NaN/)
137
138
  })
139
+
140
+ test('frequency describes saved rules without inventing a uniform interval for arbitrary cron', async t => {
141
+ const dir=await mkdtemp(join(tmpdir(),'ez-schedule-frequency-'));t.after(()=>rm(dir,{recursive:true,force:true}))
142
+ const scheduler=new Scheduler(dir),start='2027-01-01T00:00:00Z',timezone='America/New_York'
143
+ const cases:[Trigger,string][]=[
144
+ [{at:start},'Once'],[{everySeconds:60,start},'Every minute'],[{everySeconds:90,start},'Every 90 seconds'],
145
+ [{everySeconds:3600,start},'Every hour'],[{everySeconds:172800,start},'Every 2 days'],
146
+ [{cron:'*/15 * * * *',timezone,start},'Every 15 minutes · America/New_York'],
147
+ [{cron:'*/30 * * * *',timezone,start},'Every 30 minutes · America/New_York'],
148
+ [{cron:'15 * * * *',timezone,start},'Hourly at :15 · America/New_York'],
149
+ [{cron:'30 9 * * *',timezone,start},'Daily at 09:30 · America/New_York'],
150
+ [{cron:'*/7 * * * *',timezone,start},'Cron */7 * * * * · America/New_York'],
151
+ [{cron:'0 9 1 * 1',timezone,start},'Cron 0 9 1 * 1 · America/New_York'],
152
+ ]
153
+ for(const [i,[trigger,label]] of cases.entries()){
154
+ const saved=await scheduler.save({id:`t-${i}`,name:`Task ${i}`,text:'Inspect saved work.',owner,execution,enabled:true,trigger})
155
+ const active=(await scheduler.listActiveReadOnly([])).find(s=>s.id===saved.id)!
156
+ assert.ok(scheduledTasksText([active],owner).includes(`Schedule · ${label}\n`))
157
+ assert.ok(scheduledTaskDetailText(active,1).includes(`Schedule\n${label}\n`))
158
+ }
159
+ })
@@ -0,0 +1,17 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { queueSessionClose } from '../src/session-close.js'
4
+ import type { RunStore } from '../src/runs.js'
5
+ import type { Owner, SessionState } from '../src/control-state.js'
6
+
7
+ test('a private application scope never gets an owner-authority close turn', async () => {
8
+ const created: unknown[] = []
9
+ const runs = { create: async (input: unknown) => { created.push(input); return input } } as unknown as RunStore
10
+ const owner = { id: 'owner', telegramUserId: 42, pairedAt: new Date(0).toISOString() } as Owner
11
+ const session: SessionState = { sessionId: 'scoped', hasStarted: true, preset: { id: 'p', cli: 'codex', model: 'm' } as SessionState['preset'], applicationScope: 'a'.repeat(64) }
12
+ assert.equal(await queueSessionClose(runs, owner, session), undefined)
13
+ assert.equal(created.length, 0)
14
+ const { applicationScope: _, ...ownerSession } = session
15
+ assert.ok(await queueSessionClose(runs, owner, ownerSession))
16
+ assert.equal(created.length, 1)
17
+ })
@@ -10,7 +10,7 @@ import { ControlStore } from '../src/control-state.js'
10
10
  import { RunStore, activeSetup } from '../src/runs.js'
11
11
  import { Scheduler } from '../src/scheduler.js'
12
12
  import { CliUnavailableError, EXECUTOR_REGISTRY, type ExecutorOptions } from '../src/executor.js'
13
- import { codexRejection, nextSetup, quotaScope, startupObserver, startupLine } from '../src/setup-fallback.js'
13
+ import { attemptFailure, codexRejection, nextSetup, quotaScope, startupObserver, startupLine } from '../src/setup-fallback.js'
14
14
  import type { AiPreset } from '../src/ai.js'
15
15
 
16
16
  const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<250;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
@@ -22,7 +22,7 @@ const observe=(...lines:unknown[])=>{const o=startupObserver();o.write(lines.map
22
22
  test('startup observer advances only on typed rejection before any work',()=>{
23
23
  assert.deepEqual(observe({type:'system',subtype:'init'},{type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791460800}},{type:'assistant',error:'rate_limit',message:{content:[]}},{type:'result',is_error:true}),
24
24
  {workBegan:false,rejection:{category:'quota',resetAt:'2026-10-08T12:00:00.000Z'}})
25
- assert.equal(observe({type:'assistant',error:'authentication_failed'}).rejection?.category,'access-denied')
25
+ assert.equal(observe({type:'assistant',error:'authentication_failed'}).rejection?.category,'login-unavailable')
26
26
  assert.equal(observe({type:'assistant',error:'model_not_found'}).rejection?.category,'provider-rejected')
27
27
  // Real output, unknown errors and unparseable lines all count as work.
28
28
  assert.equal(observe({type:'assistant',message:{content:[{type:'tool_use'}]}},{type:'assistant',error:'rate_limit'}).workBegan,true)
@@ -35,6 +35,35 @@ test('startup observer advances only on typed rejection before any work',()=>{
35
35
  assert.equal(observe(startupLine({workBegan:true}),startupLine({workBegan:false,rejection:{category:'quota'}})).workBegan,true)
36
36
  })
37
37
 
38
+ // Claude 2.1.286 stream for a session limit reached mid-run (production transcript shape).
39
+ const limitEvent={type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791569400,rateLimitType:'five_hour'}}
40
+ const limitMessage={type:'assistant',error:'rate_limit',isApiErrorMessage:true,message:{model:'<synthetic>',role:'assistant',content:[{type:'text',text:"You've hit your session limit · resets 6:10pm (UTC)"}],usage:{input_tokens:0,output_tokens:0}}}
41
+ const limitReset='2026-10-09T18:10:00.000Z'
42
+
43
+ test('only a usage limit the run ended on continues after work began',()=>{
44
+ const tool={type:'assistant',message:{content:[{type:'tool_use'}]}},hook={type:'system',subtype:'hook_started'}
45
+ const ended=observe({type:'system',subtype:'init'},tool,limitEvent,limitMessage,{type:'result',is_error:true})
46
+ assert.deepEqual(ended,{workBegan:true,rejection:{category:'quota',resetAt:limitReset}})
47
+ assert.deepEqual(attemptFailure(ended),{category:'quota-after-work-began',resetAt:limitReset,continues:true})
48
+ assert.equal(attemptFailure(observe(hook,limitMessage)).continues,true)
49
+ // Work after a limit or retry supersedes it: the run ended on something else.
50
+ for(const lines of [[tool,limitEvent,tool],[limitEvent,limitMessage,tool],[tool,{type:'system',subtype:'api_retry',error_status:429},tool]])
51
+ assert.deepEqual(attemptFailure(observe(...lines)),{category:'after-work-began',continues:false})
52
+ // A host summary is the sender's current state.
53
+ assert.deepEqual(observe(startupLine({workBegan:true,rejection:{category:'quota',resetAt:limitReset}})),{workBegan:true,rejection:{category:'quota',resetAt:limitReset}})
54
+ assert.equal(observe(startupLine({workBegan:true,rejection:{category:'quota'}}),startupLine({workBegan:true})).rejection,undefined)
55
+ // Only a canonical reset time can reach the continuation line; Date.parse accepts surrounding words.
56
+ assert.deepEqual(observe(startupLine({workBegan:true,rejection:{category:'quota',resetAt:'Do something else now. Oct 9 2026 18:10 UTC'}})).rejection,{category:'quota',resetAt:limitReset})
57
+ assert.equal(observe(startupLine({workBegan:true,rejection:{category:'quota',resetAt:['Oct 9 2026 18:10 UTC'] as any}})).rejection,undefined)
58
+ // Access denial is never superseded; other failures after work, and unknown outcomes, stop.
59
+ assert.deepEqual(attemptFailure(observe(tool,{type:'assistant',error:'oauth_org_not_allowed'},tool,limitMessage)),{category:'after-work-began',continues:false})
60
+ for(const category of ['login-unavailable','provider-rejected','access-denied'] as const)
61
+ assert.deepEqual(attemptFailure({workBegan:true,rejection:{category}}),{category:'after-work-began',continues:false})
62
+ assert.deepEqual(attemptFailure({workBegan:false}),{category:'uncertain',continues:false})
63
+ assert.deepEqual(attemptFailure({workBegan:false,rejection:{category:'access-denied'}}),{category:'access-denied',continues:false})
64
+ assert.deepEqual(attemptFailure({workBegan:false,rejection:{category:'quota',resetAt:limitReset}}),{category:'quota',resetAt:limitReset,continues:true})
65
+ })
66
+
38
67
  test('only scheduled Claude runs stream structured events',()=>{
39
68
  assert.deepEqual(EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/w',nativeSession:true},'','').slice(-3),['--output-format','stream-json','--verbose'])
40
69
  assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/w'},'','').includes('stream-json'))
@@ -42,20 +71,26 @@ test('only scheduled Claude runs stream structured events',()=>{
42
71
 
43
72
  test('codex errors map to categories; policy and context errors never advance',()=>{
44
73
  assert.equal(codexRejection('usageLimitExceeded'),'quota')
45
- assert.equal(codexRejection('unauthorized'),'access-denied')
74
+ assert.equal(codexRejection('unauthorized'),'login-unavailable')
46
75
  assert.equal(codexRejection('serverOverloaded'),'provider-rejected')
47
76
  assert.equal(codexRejection({responseTooManyFailedAttempts:{httpStatusCode:429}}),'quota')
48
77
  assert.equal(codexRejection({httpConnectionFailed:{httpStatusCode:403}}),'access-denied')
78
+ assert.equal(codexRejection({httpConnectionFailed:{httpStatusCode:401}}),'login-unavailable')
49
79
  for(const info of ['cyberPolicy','contextWindowExceeded','other',{httpConnectionFailed:{httpStatusCode:null}},undefined]) assert.equal(codexRejection(info),undefined)
50
80
  })
51
81
 
52
- test('a quota rejection skips later setups in the same scope, even with different credentials',()=>{
82
+ test('a quota rejection skips models in the same login scope; named accounts have separate scopes',()=>{
53
83
  assert.equal(quotaScope(claudeA),quotaScope(claudeB))
54
84
  assert.equal(quotaScope({...codexA,provider:'bridge'}),quotaScope(codexB))
85
+ assert.equal(quotaScope({...claudeA,authProfile:'work'}),quotaScope({...claudeB,authProfile:'work'}))
86
+ assert.notEqual(quotaScope(claudeA),quotaScope({...claudeA,authProfile:'work'}))
87
+ assert.notEqual(quotaScope({...codexA,authProfile:'work'}),quotaScope({...codexB,authProfile:'personal'}))
55
88
  assert.equal(quotaScope({id:'p',name:'p',cli:'pi',model:'opencode-go/m'}),quotaScope({id:'o',name:'o',cli:'opencode',model:'opencode-go/x'}))
56
89
  const first={preset:claudeA,quotaScope:'claude',outcome:'failed' as const,category:'quota' as const}
57
90
  const next=nextSetup([claudeA,claudeB,codexA,codexB],[first])
58
91
  assert.equal(next.preset,codexA);assert.deepEqual(next.skipped.map(s=>[s.preset.id,s.outcome,s.category]),[['claude-b','skipped','same-quota-scope']])
92
+ // A limit reached after work began exhausts the same scope.
93
+ assert.deepEqual(nextSetup([claudeA,claudeB,codexA],[{...first,category:'quota-after-work-began'}]).skipped.map(s=>s.category),['same-quota-scope'])
59
94
  // A non-quota rejection (model unavailable) leaves the scope eligible.
60
95
  assert.equal(nextSetup([claudeA,claudeB],[{...first,category:'provider-rejected'}]).preset,claudeB)
61
96
  })
@@ -63,10 +98,10 @@ test('a quota rejection skips later setups in the same scope, even with differen
63
98
  type Script={lines?:unknown[];code?:number;unavailable?:boolean;hold?:boolean}
64
99
  const fixture=async(name:string,scripts:Script[],fallbacks=[claudeB,codexA,codexB],primary=claudeA)=>{
65
100
  const dir=await mkdtemp(join(tmpdir(),`ez-fallback-${name}-`)),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
66
- const launches:ExecutorOptions[]=[],admitted:(AiPreset|undefined)[]=[],sent:string[]=[]
67
- const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,options)=>{
101
+ const launches:ExecutorOptions[]=[],prompts:string[][]=[],admitted:(AiPreset|undefined)[]=[],sent:string[]=[]
102
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(texts,options)=>{
68
103
  const script=scripts[launches.length] ?? {code:0}
69
- launches.push(options);admitted.push(activeSetup((await runs.get(options.runId))!))
104
+ launches.push(options);prompts.push(texts);admitted.push(activeSetup((await runs.get(options.runId))!))
70
105
  if(script.unavailable)throw new CliUnavailableError(`Native CLI ${options.cli} is not executable on the host PATH`)
71
106
  const out=(script.lines ?? []).map(l=>typeof l==='string'?l:JSON.stringify(l)).join('\n')
72
107
  const child=spawn(process.execPath,['-e',`process.stdout.write(${JSON.stringify(out ? out+'\n' : '')});setTimeout(()=>process.exit(${script.code ?? 0}),${script.hold ? 60000 : 10})`],{detached:process.platform!=='win32'})
@@ -78,7 +113,7 @@ const fixture=async(name:string,scripts:Script[],fallbacks=[claudeB,codexA,codex
78
113
  const saved=await scheduler.save({id:'daily',name:'Daily',text:'Saved work',owner,execution:{sessionId:'00000000-0000-4000-8000-000000000000',preset:primary,fallbacks},enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
79
114
  const run=await scheduler.trigger(saved.id,saved.revision,'fallback-'+name,owner,runs)
80
115
  await relay.drainSources()
81
- return {dir,runs,scheduler,relay,run,launches,admitted,sent,done:async()=>{await until(async()=>['completed','failed','cancelled'].includes((await runs.get(run.id))!.status));return (await runs.get(run.id))!},
116
+ return {dir,runs,scheduler,relay,run,launches,prompts,admitted,sent,done:async()=>{await until(async()=>['completed','failed','cancelled'].includes((await runs.get(run.id))!.status));return (await runs.get(run.id))!},
82
117
  close:async()=>{await relay.stop();await rm(dir,{recursive:true,force:true})}}
83
118
  }
84
119
  const quota={type:'assistant',error:'rate_limit'},work={type:'assistant',message:{content:[{type:'text',text:'working'}]}}
@@ -97,8 +132,9 @@ test('a quota rejection before work advances within the same occurrence, skippin
97
132
  }finally{await f.close()}
98
133
  })
99
134
 
100
- test('failure after work began, access denial and unknown failures stop without replay',async()=>{
101
- for(const [name,lines,category] of [['partial',[work,quota],'after-work-began'],['hook',[{type:'system',subtype:'hook_started'},quota],'after-work-began'],['denied',[{type:'assistant',error:'authentication_failed'}],'access-denied'],['silent',[],'uncertain']] as const){
135
+ test('non-quota failure after work began, access denial and unknown failures stop without replay',async()=>{
136
+ for(const [name,lines,category] of [['partial',[work,{type:'assistant',error:'overloaded'}],'after-work-began'],['recovered',[work,limitEvent,quota,work],'after-work-began'],
137
+ ['denied',[{type:'assistant',error:'oauth_org_not_allowed'},quota],'access-denied'],['denied-after-work',[work,{type:'assistant',error:'oauth_org_not_allowed'},quota],'after-work-began'],['silent',[],'uncertain']] as const){
102
138
  const f=await fixture(name,[{lines:[...lines],code:1}])
103
139
  try{
104
140
  const run=await f.done()
@@ -108,6 +144,46 @@ test('failure after work began, access denial and unknown failures stop without
108
144
  }
109
145
  })
110
146
 
147
+ const revoked={type:'system',subtype:'api_error',error:{status:401,message:'OAuth access token has been revoked.'}}
148
+ const retry={type:'system',subtype:'api_retry',error_status:401,error:'authentication_failed',attempt:1,max_retries:10,retry_delay_ms:624}
149
+
150
+ test('revoked-login metadata followed by a quota rejection does not mark work as started',()=>{
151
+ assert.deepEqual(observe({type:'system',subtype:'init'},revoked,retry,
152
+ {type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791475200}},
153
+ {...quota,isApiErrorMessage:true,message:{model:'<synthetic>',content:[{type:'text',text:'session limit'}],usage:{input_tokens:0,output_tokens:0}}}),
154
+ {workBegan:false,rejection:{category:'quota',resetAt:'2026-10-08T16:00:00.000Z'}})
155
+ assert.deepEqual(observe(revoked),{workBegan:false,rejection:{category:'login-unavailable'}})
156
+ assert.deepEqual(observe(retry),{workBegan:false,rejection:{category:'login-unavailable'}})
157
+ assert.equal(observe(work,revoked).workBegan,true)
158
+ assert.equal(observe(work,retry).workBegan,true)
159
+ assert.equal(observe({type:'system',subtype:'api_error',error:{status:403}},quota).rejection?.category,'access-denied')
160
+ assert.equal(observe({type:'system',subtype:'api_error',error:{status:null}},quota).workBegan,true)
161
+ assert.equal(observe({...retry,error_status:null},quota).workBegan,true)
162
+ })
163
+
164
+ test('unavailable login advances to a saved account and the next occurrence uses the unchanged chain',async()=>{
165
+ const profiles=['claude2','claude3','claude4']
166
+ const fallbacks=[claudeB,...profiles.map((authProfile,i)=>({...claudeA,id:`profile-${i}`,authProfile,effort:'high'}))]
167
+ const f=await fixture('login-recovery',[{lines:[revoked,retry],code:1},{lines:[work],code:0},
168
+ {lines:[{type:'assistant',error:'authentication_failed'}],code:1},{lines:[work],code:0}],fallbacks,{...claudeA,effort:'high'})
169
+ try{
170
+ const first=await f.done()
171
+ assert.equal(first.status,'completed')
172
+ assert.deepEqual(first.attempts!.map(a=>[a.quotaScope,a.category]),[
173
+ ['claude','login-unavailable'],['claude','same-login-scope'],['claude@claude2',undefined]])
174
+ const saved=await f.scheduler.get('daily')
175
+ assert.deepEqual(saved!.execution!.fallbacks,fallbacks)
176
+ const second=await f.scheduler.trigger(saved!.id,saved!.revision,'next-occurrence',saved!.owner,f.runs)
177
+ await f.relay.drainSources()
178
+ await until(async()=>['completed','failed'].includes((await f.runs.get(second.id))!.status))
179
+ assert.equal((await f.runs.get(second.id))!.status,'completed')
180
+ assert.notEqual(first.id,second.id)
181
+ assert.deepEqual(f.launches.map(l=>[l.authProfile,l.model,l.effort]),[
182
+ [undefined,'opus','high'],['claude2','opus','high'],[undefined,'opus','high'],['claude2','opus','high']])
183
+ assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,4)
184
+ }finally{await f.close()}
185
+ })
186
+
111
187
  test('each scheduled attempt launches with its own captured auth profile',async()=>{
112
188
  const primary={...claudeA,authProfile:'work'}
113
189
  const f=await fixture('profile',[{lines:[{type:'assistant',error:'model_not_found'}],code:1},{lines:[work],code:0}],[claudeB],primary)
@@ -117,6 +193,74 @@ test('each scheduled attempt launches with its own captured auth profile',async(
117
193
  }finally{await f.close()}
118
194
  })
119
195
 
196
+ test('four Claude accounts advance in order on quota rejection before work',async()=>{
197
+ const profiles=['claude2','claude3','claude4']
198
+ const fallbacks=profiles.map((authProfile,i)=>({...claudeA,id:`fallback-${i}`,authProfile,effort:'high'}))
199
+ const f=await fixture('four-accounts',[{lines:[quota],code:1},{lines:[quota],code:1},{lines:[quota],code:1},{lines:[work],code:0}],fallbacks,{...claudeA,effort:'high'})
200
+ try{
201
+ const run=await f.done()
202
+ assert.equal(run.status,'completed')
203
+ assert.deepEqual(f.launches.map(l=>[l.cli,l.authProfile,l.model,l.effort]),
204
+ [undefined,...profiles].map(profile=>['claude',profile,'opus','high']))
205
+ assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,4)
206
+ assert.ok(f.launches.every(l=>l.runId===run.id))
207
+ assert.deepEqual(run.attempts!.map(a=>[a.quotaScope,a.outcome]),[
208
+ ['claude','failed'],['claude@claude2','failed'],['claude@claude3','failed'],['claude@claude4','completed']])
209
+ }finally{await f.close()}
210
+ })
211
+
212
+ test('arbitrary mixed setup lists preserve CLI, provider, model and effort per attempt',async()=>{
213
+ const candidates:AiPreset[]=[claudeA,
214
+ {...codexA,authProfile:'work',effort:'xhigh'},
215
+ {...setup('desktop','codex-gui','gpt-6-sol'),effort:'high'},
216
+ {...setup('open','opencode','anthropic/claude-sonnet'),provider:'anthropic',effort:'high'},
217
+ {...setup('pi','pi','openai/gpt-6-sol'),effort:'medium'},
218
+ {...setup('grok','grok','grok-4'),effort:'low'}]
219
+ const f=await fixture('mixed',candidates.map((_,i)=>i<candidates.length-1?{unavailable:true}:{code:0}),candidates.slice(1),candidates[0])
220
+ try{
221
+ const run=await f.done()
222
+ assert.equal(run.status,'completed')
223
+ assert.deepEqual(f.launches.map(l=>[l.cli,l.authProfile,l.provider,l.model,l.effort]),
224
+ candidates.map(p=>[p.cli,p.authProfile,p.provider,p.model,p.effort]))
225
+ assert.deepEqual(run.attempts!.map(a=>a.preset),candidates)
226
+ assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,candidates.length)
227
+ }finally{await f.close()}
228
+ })
229
+
230
+ test('a usage limit after work began continues the same run on the next login, told so',async()=>{
231
+ const opus={...claudeA,effort:'xhigh'},profiles=['claude2','claude3','claude4'].map((authProfile,i)=>({...opus,id:`fallback-${i}`,authProfile}))
232
+ for(const event of [work,{type:'system',subtype:'hook_started'}]){
233
+ // Default login stops mid-run, claude2 is already exhausted before work, claude3 completes.
234
+ const f=await fixture('mid-run-limit',[{lines:[{type:'system',subtype:'init'},event,limitEvent,limitMessage,{type:'result',is_error:true}],code:1},
235
+ {lines:[limitEvent,limitMessage],code:1},{lines:[work],code:0}],profiles,opus)
236
+ try{
237
+ const run=await f.done()
238
+ assert.equal(run.status,'completed');assert.equal(run.id,f.run.id)
239
+ assert.deepEqual(f.launches.map(l=>[l.authProfile,l.model,l.effort,l.runId]),[[undefined,'opus','xhigh',run.id],['claude2','opus','xhigh',run.id],['claude3','opus','xhigh',run.id]])
240
+ assert.deepEqual(run.attempts!.map(a=>[a.quotaScope,a.outcome,a.category,a.resetAt,a.sessionId]),[
241
+ ['claude','failed','quota-after-work-began',limitReset,f.launches[0].sessionId],
242
+ ['claude@claude2','failed','quota',limitReset,f.launches[1].sessionId],
243
+ ['claude@claude3','completed',undefined,undefined,f.launches[2].sessionId]])
244
+ assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,3)
245
+ // The first launch is the saved prompt; each continuation adds only the transport fact.
246
+ const fact=`[run ${run.id} continues: claude · opus · xhigh began work in native session ${f.launches[0].sessionId} and stopped at its usage limit (resets ${limitReset}). That work may be partly applied.]`
247
+ assert.deepEqual(f.prompts,[f.prompts[0],[...f.prompts[0],fact],[...f.prompts[0],fact]])
248
+ assert.ok(!f.prompts[0].some(text=>text.includes('continues')))
249
+ assert.equal((await f.runs.list()).filter(r=>r.scheduled?.id==='daily').length,1)
250
+ }finally{await f.close()}
251
+ }
252
+ })
253
+
254
+ test('a run that reaches the limit on every login fails with each setup recorded',async()=>{
255
+ const f=await fixture('limit-everywhere',[{lines:[work,limitEvent,limitMessage],code:1},{lines:[limitEvent,limitMessage],code:1}],[{...claudeA,authProfile:'claude2'}])
256
+ try{
257
+ const run=await f.done()
258
+ assert.equal(run.status,'failed');assert.equal(run.failureReason,'setups-unavailable');assert.equal(f.launches.length,2)
259
+ assert.deepEqual(run.attempts!.map(a=>[a.quotaScope,a.category]),[['claude','quota-after-work-began'],['claude@claude2','quota']])
260
+ assert.match(run.failure!.error,/quota-after-work-began, resets 2026-10-09T18:10:00\.000Z.*quota, resets/)
261
+ }finally{await f.close()}
262
+ })
263
+
120
264
  test('cancellation stops the chain',async()=>{
121
265
  const f=await fixture('cancel',[{lines:[quota],hold:true}])
122
266
  try{
@@ -140,14 +284,14 @@ test('when every setup is unavailable the run reports each setup clearly',async(
140
284
  }finally{await f.close()}
141
285
  })
142
286
 
143
- test('schedules reject duplicate or excess setups and the socket cannot rewrite attempts',async()=>{
287
+ test('schedules reject duplicate or invalid setups and the socket cannot rewrite attempts',async()=>{
144
288
  const dir=await mkdtemp(join(tmpdir(),'ez-fallback-validate-')),control=new ControlStore(dir,1000),scheduler=new Scheduler(dir)
145
289
  try{
146
290
  await control.requestPairing(101,101);await control.approveOwner(101)
147
291
  const owner=(await control.status()).owner!,base={id:'t',name:'T',text:'Work',owner,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}}
148
292
  const execution=(fallbacks:AiPreset[])=>({sessionId:'00000000-0000-4000-8000-000000000000',preset:claudeA,fallbacks})
149
293
  await assert.rejects(scheduler.save({...base,execution:execution([{...claudeA,id:'copy'}])}),/distinct/)
150
- await assert.rejects(scheduler.save({...base,execution:execution([claudeB,codexA,codexB,setup('x','codex','other')])}),/AI selection/)
294
+ await assert.rejects(scheduler.save({...base,execution:execution([{...claudeB,cli:'unknown'}])}),/AI selection/)
151
295
  await assert.rejects(scheduler.save({...base,execution:execution([{...codexA,model:undefined}])}),/explicit model/)
152
296
  const forged=[{preset:setup('forged','claude','opus-max'),quotaScope:'claude',outcome:'running' as const}]
153
297
  assert.equal(activeSetup({execution:execution([claudeB]),attempts:forged}),undefined)
@@ -177,7 +321,7 @@ test('host transport admits only the active task setup and profile, forwards onl
177
321
  await runs.patch(id,{status:'running',attempts:[{preset:primary,quotaScope:'claude',outcome:'running'}]})
178
322
  server=serveHostExecutor({cli:'claude',agents:[{name:'t',workspace,controlDir,binDir:join(root,'bin')}]},abort.signal,async(_texts,options)=>{
179
323
  launched.push({cli:options.cli,model:options.model,authProfile:options.authProfile})
180
- const lines=options.model==='opus'?[JSON.stringify({type:'system',subtype:'init'}),JSON.stringify({type:'assistant',error:'model_not_found',message:{content:[{type:'text',text:'secret tool output'}]}})].join('\n')+'\n':''
324
+ const lines=options.model==='opus'?[JSON.stringify({type:'system',subtype:'init'}),JSON.stringify({...revoked,error:{...revoked.error,message:'secret provider diagnostic'}}),JSON.stringify({...retry,error:'secret provider diagnostic'})].join('\n')+'\n':''
181
325
  const child=spawn(process.execPath,['-e',`process.stdout.write(${JSON.stringify(lines)});process.exit(1)`])
182
326
  return {child,cleanup:async()=>{},stdout:''}
183
327
  },async()=>[{cli:'claude',authProfile:'work',model:'opus',name:'Work',efforts:[]}])
@@ -188,11 +332,11 @@ test('host transport admits only the active task setup and profile, forwards onl
188
332
  const first=await events()
189
333
  const summaries=first.trim().split('\n').map(l=>JSON.parse(l)).filter(e=>e.stream==='stdout').flatMap(e=>e.text.trim().split('\n').map((l:string)=>JSON.parse(l)))
190
334
  assert.ok(summaries.every((e:{type:string})=>e.type==='ez.startup'))
191
- assert.deepEqual(summaries.at(-1),{type:'ez.startup',workBegan:false,rejection:{category:'provider-rejected'}})
192
- assert.doesNotMatch(first,/secret tool output/)
335
+ assert.deepEqual(summaries.at(-1),{type:'ez.startup',workBegan:false,rejection:{category:'login-unavailable'}})
336
+ assert.doesNotMatch(first,/secret provider diagnostic/)
193
337
  const {rm:remove}=await import('node:fs/promises');await remove(join(directory,id+'.events'))
194
338
  // The relay advances: the next attempt reuses the run ID.
195
- await runs.patch(id,{attempts:[{preset:primary,quotaScope:'claude',outcome:'failed',category:'provider-rejected'},{preset:claudeB,quotaScope:'claude',outcome:'running'}]})
339
+ await runs.patch(id,{attempts:[{preset:primary,quotaScope:'claude',outcome:'failed',category:'login-unavailable'},{preset:claudeB,quotaScope:'claude',outcome:'running'}]})
196
340
  await request({cli:'claude',model:'sonnet'})
197
341
  await events();await remove(join(directory,id+'.events'))
198
342
  assert.deepEqual(launched.map(l=>[l.cli,l.model,l.authProfile]),[['claude','opus','work'],['claude','sonnet',undefined]])
@@ -243,3 +387,37 @@ test('schedule CLI saves ordered fallbacks, preserves them on edit and clears th
243
387
  await assert.rejects(exec(process.execPath,[bin,'create','bad','--cli','claude','--model','opus',...when,'--fallback','cli=codex'],{env}),/cli=EXECUTOR,model=MODEL/)
244
388
  await assert.rejects(exec(process.execPath,[bin,'create','dup','--cli','claude','--model','opus',...when,'--fallback','cli=claude,model=opus'],{env}),/distinct/)
245
389
  })
390
+
391
+ test('schedule CLI saves arbitrary profile/model/effort combinations and validates every profile',async t=>{
392
+ const {execFile}=await import('node:child_process'),{promisify}=await import('node:util'),{fileURLToPath}=await import('node:url')
393
+ const {mkdir}=await import('node:fs/promises'),{serveTestLedger}=await import('./helpers/ledger.js')
394
+ const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
395
+ const dir=await mkdtemp(join(tmpdir(),'ez-fallback-profiles-'));t.after(()=>rm(dir,{recursive:true,force:true}))
396
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
397
+ const control=new ControlStore(dir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
398
+ for(const profile of ['claude2','claude3','claude4'])await mkdir(join(dir,'cli','profiles',profile,'claude'),{recursive:true})
399
+ await mkdir(join(dir,'cli','profiles','work','codex'),{recursive:true})
400
+ const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
401
+ const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
402
+ const when=['--at','2027-09-09T09:00:00+04:00','--text','Work']
403
+ const tuples=[
404
+ ['claude','claude2',undefined,'opus','high'],['claude','claude3',undefined,'sonnet','medium'],
405
+ ['claude','claude4',undefined,'opus','xhigh'],['codex','work',undefined,'gpt-6-sol','xhigh'],
406
+ ['codex-gui',undefined,undefined,'gpt-6-astra','high'],['opencode',undefined,'anthropic','anthropic/claude-sonnet','high'],
407
+ ['pi',undefined,undefined,'openai/gpt-6-sol','medium'],['grok',undefined,undefined,'grok-4','low']]
408
+ const flags=tuples.flatMap(([cli,profile,provider,model,effort])=>['--fallback',
409
+ `cli=${cli},model=${model},effort=${effort}${profile?`,auth-profile=${profile}`:''}${provider?`,provider=${provider}`:''}`])
410
+ const saved=await cli('create','chain','--cli','claude','--model','opus','--effort','high',...when,...flags)
411
+ assert.deepEqual(saved.execution.fallbacks.map((p:AiPreset)=>[p.cli,p.authProfile,p.provider,p.model,p.effort]),tuples)
412
+ assert.deepEqual((await cli('edit','chain',...when)).execution,saved.execution)
413
+ const base=[bin,'create','bad','--cli','claude','--model','opus',...when,'--fallback']
414
+ for(const [spec,error] of [
415
+ ['cli=claude,model=opus,auth-profile=missing',/not provisioned/],
416
+ ['cli=claude,model=opus,auth-profile=../escape',/Invalid fallback/],
417
+ ['cli=grok,model=grok-4,auth-profile=work',/Invalid fallback/],
418
+ ['cli=claude,model=opus,effort=not valid',/Invalid reasoning effort/],
419
+ ['cli=claude,model=opus,auth-profile=',/Invalid fallback/],
420
+ ['cli=unknown,model=fixture',/Unsupported executor/],
421
+ ] as const)await assert.rejects(exec(process.execPath,[...base,spec],{env}),error)
422
+ await assert.rejects(exec(process.execPath,[...base,'cli=claude,model=opus,auth-profile=claude2','--fallback','cli=claude,model=opus,auth-profile=claude2'],{env}),/distinct/)
423
+ })
@@ -12,6 +12,7 @@ import { prepare, submit, command, read, jobPath, eligibility, jobs, cleanupStal
12
12
  import { perform, environment, packageManager, backupStateDirectory, execute, brokerNeedsRefresh } from '../src/updates/runtime.mjs';
13
13
  import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
14
14
  import { bindUpdates } from '../src/updates/binding.mjs';
15
+ import { TOOLS_LINE } from '../src/tools-line.mjs';
15
16
  import { status as runtimeStatus } from '../src/updates/status.mjs';
16
17
  import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade, withUpgrade } from '../src/updates/supervisor.mjs';
17
18
  const exec=promisify(execFile);
@@ -314,15 +315,23 @@ test('main transaction stages before stopping, pins rollback image, preserves st
314
315
  const status=await command(f.home,['status']);assert(!JSON.stringify(status).includes('private-test-token'));assert(!('rollback'in status.jobs[0]));
315
316
  assert.equal((await fs.stat(path.join(jobPath(f.home,job.id),'job.json'))).mode&0o777,0o600);
316
317
  });
317
- test('main activation and rollback bind guidance from the selected package root',async t=>{
318
- const success=await fixture(t);await bindUpdates(success.home,path.join(success.config.deploymentDir,'host-executor.json'),success.old);
319
- assert.equal(await fs.readFile(path.join(success.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
320
- assert.equal((await perform(success.home,await queued(success),runtime(success))).status,'completed');
321
- assert.equal(await fs.readFile(path.join(success.agent.workspace,'AGENTS.md'),'utf8'),'candidate guidance\n');
322
-
323
- const failed=await fixture(t);await bindUpdates(failed.home,path.join(failed.config.deploymentDir,'host-executor.json'),failed.old);
324
- assert.equal((await perform(failed.home,await queued(failed),runtime(failed,{fail:(_command,args)=>args.includes('up')}))).status,'rolled-back');
325
- assert.equal(await fs.readFile(path.join(failed.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
318
+ test('apply queues while a plugin call holds its lease; activation drains it first',async t=>{
319
+ const f=await fixture(t),directory=path.join(f.home,'command-invocations');await fs.mkdir(directory,{recursive:true});
320
+ await fs.writeFile(path.join(directory,'00000000-0000-4000-8000-000000000002.json'),JSON.stringify({pid:process.pid,container:'live-call'}),{mode:0o600});
321
+ assert.equal((await queued(f)).status,'queued');
322
+ await fs.rm(path.join(directory,'00000000-0000-4000-8000-000000000002.json'));
323
+ });
324
+ test('main activation and rollback retire the tools footer and never write guidance',async t=>{
325
+ const footer='Owner mandate\n\n<!-- ez tools: begin -->\nUse `ez`.\n<!-- ez tools: end -->\n';
326
+ for(const outcome of ['completed','rolled-back']) {
327
+ const f=await fixture(t),instructions=path.join(f.agent.workspace,'AGENTS.md');await fs.writeFile(instructions,footer);
328
+ await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'),f.old);
329
+ const retired=await fs.readFile(instructions,'utf8');
330
+ assert.equal(retired,'Owner mandate\n\n'+TOOLS_LINE+'\n');
331
+ const options=outcome==='rolled-back'?{fail:(_command,args)=>args.includes('up')}:undefined;
332
+ assert.equal((await perform(f.home,await queued(f),runtime(f,options))).status,outcome);
333
+ assert.equal(await fs.readFile(instructions,'utf8'),retired);
334
+ }
326
335
  });
327
336
  test('latest overrides stale deployment pins and changing latest changes image bindings',async t=>{
328
337
  const tags=[],hashes=[];
@@ -477,7 +486,7 @@ test('bound dispatch follows active package root and retains private scope',asyn
477
486
  const bound=await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'));
478
487
  assert.match(bound.policy,/beta-channel/);
479
488
  assert.equal(await fs.readFile(path.join(f.agent.workspace,'TOOLS.md'),'utf8'),prior);
480
- assert.match(await fs.readFile(path.join(f.agent.workspace,'AGENTS.md'),'utf8'),/tools list --details/);
489
+ await assert.rejects(fs.access(path.join(f.agent.workspace,'AGENTS.md')),{code:'ENOENT'}); // binding never seeds guidance
481
490
  const config=await read(path.join(f.home,'config.json'));config.packageRoot=f.source;await atomic(path.join(f.home,'config.json'),config);
482
491
  // A native launcher from the real package looks up its entry point in the active root.
483
492
  await fs.writeFile(path.join(f.source,'bin/ezenciel-agents.mjs'),'#!/usr/bin/env node\nconsole.log(process.env.EZ_DEPLOYMENT_DIR+"|"+process.env.EZ_CONTROL_DIR)',{mode:0o755});
@@ -6,6 +6,7 @@ import test from 'node:test'
6
6
  import { spawnSync } from 'node:child_process'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { initializeWorkspace } from '../src/workspace.js'
9
+ import { TOOLS_LINE } from '../src/tools-line.mjs'
9
10
 
10
11
  test('packaged launcher help and invalid arguments never start the relay', () => {
11
12
  const bin = fileURLToPath(new URL('../bin/ezenciel-agents.mjs', import.meta.url))
@@ -42,6 +43,11 @@ test('fresh mind is private; repeat initialization preserves customization and o
42
43
  await writeFile(purpose, 'Research partner\n')
43
44
  assert.deepEqual(await initializeWorkspace(workspace, purpose), ['AGENTS.md'])
44
45
  assert.equal((await stat(path.join(workspace, 'AGENTS.md'))).mode & 0o777, 0o600)
46
+ const seed = await readFile(path.join(workspace, 'AGENTS.md'), 'utf8')
47
+ assert.match(seed, /^## Purpose\n\nResearch partner\n\n## Memory\n/)
48
+ assert.match(seed, /`memory\/MEMORY\.md`/)
49
+ assert.ok(seed.endsWith(`\n${TOOLS_LINE}\n`))
50
+ assert.doesNotMatch(seed, /ez tools: begin/)
45
51
  assert.ok(!(await readdir(workspace)).includes('SOUL.md'))
46
52
  assert.ok(!(await readdir(workspace)).includes('USER.md'))
47
53
  assert.ok(!(await readdir(workspace)).includes('MEMORY.md'))