@jc_stack/ez-agents 0.1.0-beta.26 → 0.1.0-beta.28

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 (128) hide show
  1. package/.env.example +10 -1
  2. package/AGENTS.md +40 -9
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +31 -1
  5. package/Dockerfile +1 -0
  6. package/README.md +84 -12
  7. package/bin/ezenciel-agents-application +2 -0
  8. package/bin/ezenciel-agents-application.mjs +16 -0
  9. package/compose.yaml +8 -0
  10. package/docker/entrypoint.sh +20 -2
  11. package/docker/healthcheck.mjs +1 -1
  12. package/docker/run.ts +3 -3
  13. package/docker/smoke.mjs +41 -2
  14. package/docs/application-channel.md +366 -0
  15. package/docs/architecture/ai-selection.md +12 -15
  16. package/docs/docker-runtime.md +29 -0
  17. package/docs/host-service.md +5 -8
  18. package/docs/local-qa.md +1 -1
  19. package/docs/managed-applications.md +68 -0
  20. package/docs/plugin-catalog.md +1 -0
  21. package/docs/plugin-connection.md +76 -0
  22. package/docs/plugins.md +54 -5
  23. package/docs/repair.md +26 -25
  24. package/docs/responsive-channels.md +13 -55
  25. package/docs/scheduling.md +40 -36
  26. package/docs/setup.md +11 -21
  27. package/docs/standalone-cli.md +2 -2
  28. package/docs/upgrades.md +43 -18
  29. package/package.json +8 -4
  30. package/src/agent-guidance.ts +32 -3
  31. package/src/ai-cli.ts +5 -1
  32. package/src/ai.ts +6 -28
  33. package/src/application-channel.ts +308 -0
  34. package/src/application-cli.ts +41 -0
  35. package/src/application-client.mjs +87 -0
  36. package/src/application-origin.ts +15 -0
  37. package/src/codex-session.ts +7 -10
  38. package/src/config.ts +23 -5
  39. package/src/control-state.ts +274 -21
  40. package/src/conversation-menu.ts +89 -0
  41. package/src/delivery-context.d.mts +5 -0
  42. package/src/delivery-context.mjs +25 -0
  43. package/src/desktop-bridge.ts +11 -43
  44. package/src/event-sources.ts +2 -2
  45. package/src/execution-authority.ts +2 -0
  46. package/src/executor.ts +29 -58
  47. package/src/host-executor.ts +11 -9
  48. package/src/identity.ts +11 -3
  49. package/src/index.ts +191 -93
  50. package/src/menu.ts +76 -55
  51. package/src/message-history.ts +52 -0
  52. package/src/message-send.ts +1 -1
  53. package/src/message.ts +49 -7
  54. package/src/model-policy.ts +5 -15
  55. package/src/owner.ts +7 -1
  56. package/src/plugins/connection-artifacts.mjs +31 -0
  57. package/src/plugins/connection.mjs +124 -0
  58. package/src/plugins/manager.mjs +93 -23
  59. package/src/plugins/native-tasks.d.mts +4 -0
  60. package/src/plugins/native-tasks.mjs +66 -0
  61. package/src/plugins/workspace-lease.d.mts +3 -0
  62. package/src/plugins/workspace-lease.mjs +44 -0
  63. package/src/repair-policy.ts +0 -8
  64. package/src/reply-context.ts +3 -29
  65. package/src/runs.ts +67 -9
  66. package/src/schedule-cli.ts +33 -15
  67. package/src/scheduled-tasks.ts +20 -21
  68. package/src/scheduler.ts +55 -22
  69. package/src/task-executor.ts +4 -5
  70. package/src/task-workspace.ts +2 -11
  71. package/src/update-attention.ts +1 -1
  72. package/src/updates/binding.mjs +2 -6
  73. package/src/updates/control.mjs +4 -0
  74. package/src/updates/supervisor.mjs +10 -4
  75. package/src/web-launcher.ts +19 -0
  76. package/src/workspace.ts +3 -1
  77. package/templates/agent/AGENTS.md +13 -55
  78. package/templates/agent-guidance.md +90 -37
  79. package/templates/deployments.md +24 -0
  80. package/templates/failure-review.md +6 -0
  81. package/templates/maintainer-purpose.md +12 -6
  82. package/test/agent-guidance.test.ts +29 -39
  83. package/test/ai-cli.test.ts +9 -0
  84. package/test/ai.test.ts +66 -22
  85. package/test/application-channel.test.ts +283 -0
  86. package/test/application-client.test.mjs +84 -0
  87. package/test/application-controls.test.ts +224 -0
  88. package/test/application-only.test.ts +100 -0
  89. package/test/busy-reply-relay.test.ts +11 -7
  90. package/test/channel-delivery.test.ts +63 -0
  91. package/test/channel-owner.test.ts +161 -0
  92. package/test/client-defaults.test.ts +1 -1
  93. package/test/codex-session.test.ts +18 -10
  94. package/test/config.test.ts +16 -1
  95. package/test/connection-artifacts.test.mjs +32 -0
  96. package/test/conversation-menu.test.ts +67 -0
  97. package/test/conversations.test.ts +84 -0
  98. package/test/desktop-bridge.test.ts +17 -11
  99. package/test/engine-handoff.test.ts +73 -0
  100. package/test/event-sources.test.ts +5 -8
  101. package/test/executor.test.ts +68 -16
  102. package/test/failure.test.ts +64 -0
  103. package/test/host-executor.test.ts +58 -17
  104. package/test/install-config.test.ts +1 -1
  105. package/test/intake-relay.test.ts +169 -25
  106. package/test/message-history.test.ts +127 -0
  107. package/test/model-policy.test.ts +23 -48
  108. package/test/native-tasks.test.ts +36 -0
  109. package/test/plugin-connection.test.mjs +124 -0
  110. package/test/plugin-manager.test.mjs +70 -10
  111. package/test/repair-policy.test.ts +8 -12
  112. package/test/runs.test.ts +13 -0
  113. package/test/runtime-identity.test.mjs +18 -0
  114. package/test/schedule-cli.test.ts +34 -5
  115. package/test/scheduled-tasks.test.ts +79 -8
  116. package/test/scheduler.test.ts +30 -1
  117. package/test/task-native.test.ts +5 -2
  118. package/test/update-attention.test.ts +1 -2
  119. package/test/updates.test.mjs +44 -5
  120. package/test/workspace.test.ts +2 -3
  121. package/scripts/smoke-busy-reply.ts +0 -58
  122. package/src/reply-executor.ts +0 -55
  123. package/src/reply-mcp.ts +0 -23
  124. package/templates/agent/TOOLS.md +0 -105
  125. package/templates/chat-guidance.md +0 -23
  126. package/templates/standalone-tools.md +0 -20
  127. package/templates/updates.md +0 -45
  128. package/test/reply.test.ts +0 -159
@@ -0,0 +1,84 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { ControlStore, sessionTitle } from '../src/control-state.js'
7
+ import { initialPreset } from '../src/ai.js'
8
+ import { EXECUTOR_REGISTRY } from '../src/executor.js'
9
+
10
+ const fixture = async (work: (store: ControlStore, dir: string) => Promise<void>) => {
11
+ const dir = await mkdtemp(join(tmpdir(), 'ez-conversations-'))
12
+ try { await work(new ControlStore(dir, 1000), dir) }
13
+ finally { await rm(dir, { recursive: true, force: true }) }
14
+ }
15
+
16
+ test('switching after restart restores native context and model while queued choices stay pinned', async () => fixture(async (store, dir) => {
17
+ const first = await store.captureChoice(initialPreset('codex'), 'Client launch')
18
+ await store.saveNativeSession(first.sessionId, 'native_first')
19
+ await store.savePreset({ id: 'second', name: 'Second', cli: 'grok', model: 'fixture' })
20
+ await store.selectPreset('second', first.sessionId, true)
21
+ const second = await store.captureChoice(initialPreset('grok'), 'Holiday planning')
22
+ await store.markSessionStarted(second.sessionId)
23
+ const restarted = new ControlStore(dir, 1000)
24
+ await restarted.switchSession(first.sessionId)
25
+ assert.deepEqual(await restarted.captureChoice(initialPreset('grok')), first)
26
+ const session = await restarted.executionSession(first)
27
+ const args = EXECUTOR_REGISTRY.codex.buildArgs({ workspace: dir, isResume: session.hasStarted, sessionId: session.nativeSessionId }, '', '')
28
+ assert.equal(args[args.indexOf('resume') + 1], 'native_first')
29
+ assert.equal((await restarted.executionSession(second)).sessionId, second.sessionId)
30
+ await restarted.switchSession(second.sessionId)
31
+ assert.deepEqual(await restarted.captureChoice(initialPreset('codex')), second)
32
+ assert.equal((await restarted.listSessions()).length, 2)
33
+ }))
34
+
35
+ test('archive hides without deleting, late completions keep their binding, restore survives restart', async () => fixture(async (store, dir) => {
36
+ const first = await store.captureChoice(initialPreset('codex'), 'Topic one')
37
+ await store.archiveSession(first.sessionId, true)
38
+ assert.equal(await store.getActiveSession(), null)
39
+ await store.saveNativeSession(first.sessionId, 'native_late')
40
+ const second = await store.captureChoice(initialPreset('codex'), 'Topic two')
41
+ assert.notEqual(second.sessionId, first.sessionId)
42
+ await assert.rejects(store.switchSession(first.sessionId), /unavailable/)
43
+ const restarted = new ControlStore(dir, 1000)
44
+ assert.equal((await restarted.executionSession(first)).nativeSessionId, 'native_late')
45
+ await restarted.archiveSession(first.sessionId, false)
46
+ await restarted.switchSession(first.sessionId)
47
+ assert.equal((await restarted.executionSession(first)).nativeSessionId, 'native_late')
48
+ assert.equal((await restarted.listSessions()).length, 2)
49
+ }))
50
+
51
+ test('titles are bounded and rename is durable; malformed IDs and unsupported metadata fail closed', async () => fixture(async (store, dir) => {
52
+ const first = await store.captureChoice(initialPreset('grok'), ' Topic\n one ')
53
+ assert.equal(sessionTitle((await store.getActiveSession())!), 'Topic one')
54
+ await store.captureChoice(initialPreset('grok'), 'second message')
55
+ assert.equal(sessionTitle((await store.getActiveSession())!), 'Topic one')
56
+ await store.renameSession('<Client & launch>')
57
+ assert.equal(sessionTitle((await new ControlStore(dir, 1000).getActiveSession())!), '<Client & launch>')
58
+ await assert.rejects(store.renameSession('x'.repeat(81)), /1–80/)
59
+ await assert.rejects(store.switchSession('../escape'), /unavailable/)
60
+ await assert.rejects(store.archiveSession('../escape', true), /unavailable/)
61
+ assert.equal((await store.getActiveSession())!.sessionId, first.sessionId)
62
+ const file = join(dir, 'control-state.json')
63
+ const state = JSON.parse(await readFile(file, 'utf8'))
64
+ state.activeSession.preset.cli = 'sh'
65
+ await writeFile(file, JSON.stringify(state))
66
+ await assert.rejects(store.listSessions(), /unsupported shape/)
67
+ }))
68
+
69
+ test('legacy sessions retain IDs; unbound and latest-only engines cannot resume a different context', async () => fixture(async (store, dir) => {
70
+ const legacy = await store.ensureActiveSession()
71
+ await store.markSessionStarted(legacy.sessionId)
72
+ await store.captureChoice(initialPreset('grok'))
73
+ await store.resetSession()
74
+ await assert.rejects(store.switchSession(legacy.sessionId), /binding/)
75
+ assert.match(sessionTitle((await store.listSessions()).find(s => s.sessionId === legacy.sessionId)!), /Conversation /)
76
+ await store.archiveSession(legacy.sessionId, true)
77
+ const agy = { id: 'agy-test', name: 'AGY', cli: 'agy' }
78
+ await store.savePreset(agy)
79
+ await store.selectPreset(agy.id, (await store.getActiveSession())!.sessionId, true)
80
+ const old = await store.captureChoice(agy)
81
+ await store.markSessionStarted(old.sessionId)
82
+ await store.resetSession()
83
+ await assert.rejects(store.switchSession(old.sessionId), /latest conversation/)
84
+ }))
@@ -5,7 +5,7 @@ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
5
5
  import { tmpdir } from 'node:os'
6
6
  import path from 'node:path'
7
7
  import { EventEmitter } from 'node:events'
8
- import { DESKTOP_UNAVAILABLE, desktopJobPrompt, runDesktopTurn, type DesktopClient } from '../src/desktop-bridge.js'
8
+ import { DESKTOP_UNAVAILABLE, runDesktopTurn, type DesktopClient } from '../src/desktop-bridge.js'
9
9
  import { executorKey, nativeSessionId, startExecutorJob } from '../src/executor.js'
10
10
  import { initialPreset, isPreset, readModels } from '../src/ai.js'
11
11
 
@@ -40,16 +40,6 @@ const fakeClient = (script: Array<Record<string, unknown>>): DesktopClient & { c
40
40
  return client
41
41
  }
42
42
 
43
- test('desktop prompt carries run identity and tool paths, never a bot token', () => {
44
- const prompt = desktopJobPrompt('r_gui', ['hello'], undefined, '/tmp/bin', '/tmp/control')
45
- assert.match(prompt, /EZ_RUN_ID=r_gui/)
46
- assert.match(prompt, /EZ_CONTROL_DIR=\/tmp\/control/)
47
- assert.match(prompt, /PATH=\/tmp\/bin:\$PATH/)
48
- assert.match(prompt, /ezenciel-agents-message/)
49
- assert.ok(!prompt.includes('TELEGRAM_BOT_TOKEN'))
50
- assert.ok(!prompt.includes('token'))
51
- })
52
-
53
43
  test('codex-gui is a distinct preset and catalog entry', async () => {
54
44
  assert.equal(executorKey('codex-gui'), 'codex-gui')
55
45
  assert.equal(initialPreset('codex-gui').cli, 'codex-gui')
@@ -176,3 +166,19 @@ test('unlimited desktop waits reject on disconnect and do not miss an early comp
176
166
  assert.equal((await early.wait(m=>m.method==='turn/completed',0)).method,'turn/completed')
177
167
  early.close()
178
168
  })
169
+
170
+ test('desktop fresh and resumed turns bind current run environment without prompt prose',async()=>{
171
+ for(const isResume of [false,true]) {
172
+ const text=' /goal audit list of files and give me a simple list with filenames\n'
173
+ const client=fakeClient([{result:{}},{result:{thread:{id:'native-env'}}},...(!isResume?[{result:{}}]:[]),{result:{turn:{id:'env-turn'}},notify:[{method:'turn/completed',params:{turn:{id:'env-turn',status:'completed'}}}]}])
174
+ const original=client.request;let nativeConfig:any,submitted:any
175
+ client.request=async(method,params)=>{if(method===`thread/${isResume?'resume':'start'}`)nativeConfig=(params as any).config;if(method==='turn/start')submitted=params;return original(method,params)}
176
+ assert.equal(await runDesktopTurn({workspace:'/mind',controlDir:'/control',binDir:'/bin',runId:'r_current',repairEnabled:false,prompt:text,isResume,sessionId:'native-env'},{connect:async()=>client,emit:()=>{}}),0)
177
+ assert.deepEqual(submitted.input,[{type:'text',text}])
178
+ assert.equal(nativeConfig['shell_environment_policy.inherit'],'none')
179
+ assert.equal(nativeConfig['shell_environment_policy.set'].EZ_RUN_ID,'r_current')
180
+ assert.equal(nativeConfig['shell_environment_policy.set'].EZ_CONTROL_DIR,'/control')
181
+ assert.equal(nativeConfig['shell_environment_policy.set'].EZ_REPAIR_ENABLED,'false')
182
+ assert.equal(nativeConfig['shell_environment_policy.set'].TELEGRAM_BOT_TOKEN,undefined)
183
+ }
184
+ })
@@ -0,0 +1,73 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import { startExecutorJob } from '../src/executor.js'
7
+ import { ownerRun } from './helpers/owner-run.js'
8
+ import { initializeWorkspace } from '../src/workspace.js'
9
+ import { RunStore } from '../src/runs.js'
10
+
11
+ // Capture the actual subprocess input, not a prompt-building helper.
12
+ test('owner, resumed and native scheduled subprocesses receive literal input and isolated run bindings', async t => {
13
+ const root=await mkdtemp(path.join(tmpdir(),'ez-literal-'))
14
+ t.after(()=>rm(root,{recursive:true,force:true}))
15
+ const bin=path.join(root,'bin'),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
16
+ await mkdir(bin);await initializeWorkspace(workspace)
17
+ const fixture=`#!${process.execPath}
18
+ const fs=require('fs'), args=process.argv.slice(2);
19
+ const capture=prompt=>fs.writeFileSync('capture.json',JSON.stringify({prompt,args,env:{run:process.env.EZ_RUN_ID,control:process.env.EZ_CONTROL_DIR,repair:process.env.EZ_REPAIR_ENABLED,secret:process.env.TELEGRAM_BOT_TOKEN}}));
20
+ if(args[0]==='app-server'){
21
+ const send=x=>process.stdout.write(JSON.stringify(x)+'\\n');
22
+ require('readline').createInterface({input:process.stdin}).on('line',line=>{
23
+ const q=JSON.parse(line);if(!q.id)return;
24
+ if(q.method==='initialize')send({id:q.id,result:{}});
25
+ if(q.method==='thread/start')send({id:q.id,result:{thread:{id:'fixture-thread'}}});
26
+ if(q.method==='turn/start'){
27
+ capture(q.params.input[0].text);send({id:q.id,result:{turn:{id:'one'}}});
28
+ send({method:'turn/started',params:{threadId:'fixture-thread',turn:{id:'one'}}});
29
+ send({method:'turn/completed',params:{threadId:'fixture-thread',turn:{id:'one',status:'completed'}}});
30
+ }
31
+ if(q.method==='thread/goal/get')send({id:q.id,result:{goal:null}});
32
+ });
33
+ }else capture(args.includes('--prompt-file')?fs.readFileSync(args[args.indexOf('--prompt-file')+1],'utf8'):args.some(a=>a.startsWith('--print='))?args.find(a=>a.startsWith('--print=')).slice(8):args.includes('--print')||(args[0]==='exec'&&args.at(-1)==='-')?fs.readFileSync(0,'utf8'):args.at(-1));
34
+ `
35
+ for(const name of ['codex','grok','agy','claude','opencode'])await writeFile(path.join(bin,name),fixture,{mode:0o700})
36
+ const previous={PATH:process.env.PATH,TELEGRAM_BOT_TOKEN:process.env.TELEGRAM_BOT_TOKEN,EZ_EXECUTOR_TRANSPORT:process.env.EZ_EXECUTOR_TRANSPORT}
37
+ process.env.PATH=bin+path.delimiter+process.env.PATH;process.env.TELEGRAM_BOT_TOKEN='do-not-inherit';delete process.env.EZ_EXECUTOR_TRANSPORT
38
+ try {
39
+ for(const cli of ['codex','grok','agy','claude','opencode'])for(const isResume of [false,true])for(const text of [' /goal audit list of files and give me a simple list with filenames\n','--help','-','resume']) {
40
+ const runId='r_'+cli+'_'+String(isResume)+'_'+Buffer.from(text).toString('hex').slice(0,20)
41
+ await ownerRun(controlDir,runId)
42
+ const job=await startExecutorJob([text],{workspace,controlDir,binDir:bin,cli,runId,timeoutMs:5000,isResume,sessionId:'native-existing',repairEnabled:false})
43
+ const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
44
+ const captured=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
45
+ assert.equal(captured.prompt,text)
46
+ assert.deepEqual(captured.env,{run:runId,control:controlDir,repair:'false'})
47
+ if(cli==='codex')assert.equal(captured.args.at(-1),'-')
48
+ if(['codex','claude'].includes(cli))assert.ok(!captured.args.includes('--help'))
49
+ if(cli==='opencode')assert.equal(captured.args.at(-2),'--')
50
+ if(cli==='agy')assert.ok(captured.args.includes('--print='+text))
51
+ if(cli==='claude')assert.ok(!captured.args.includes('--append-system-prompt-file'))
52
+ }
53
+ for(const [runId,texts] of [['r_schedule_literal',['/goal audit list of files and give me a simple list with filenames']],['r_batch',['first\nline',' second ']]] as const) {
54
+ await ownerRun(controlDir,runId)
55
+ const job=await startExecutorJob([...texts],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000})
56
+ const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
57
+ assert.equal(JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8')).prompt,texts.join('\n\n'))
58
+ }
59
+ for(const isResume of [false,true]) {
60
+ const runId='tg_chat_'+String(isResume)
61
+ await new RunStore(controlDir).create({id:runId,chatId:101,telegramUserId:101,texts:['hi'],messageId:42})
62
+ await new RunStore(controlDir).patch(runId,{status:'running'})
63
+ const job=await startExecutorJob(['hi'],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000,isResume,sessionId:'native-existing'})
64
+ const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
65
+ const {prompt}=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
66
+ assert.ok(prompt.startsWith('hi\n\n[Chat context]'))
67
+ assert.equal(prompt.split('[Chat context]').length,2)
68
+ assert.match(prompt,/ezenciel-agents-message/)
69
+ assert.match(prompt,/ezenciel-agents-schedule/)
70
+ assert.match(prompt,/native subagents/)
71
+ }
72
+ }finally{for(const [key,value] of Object.entries(previous))if(value===undefined)delete process.env[key];else process.env[key]=value}
73
+ })
@@ -12,7 +12,7 @@ import { createRelay } from '../src/index.js'
12
12
  import { ControlStore } from '../src/control-state.js'
13
13
  import { RunStore } from '../src/runs.js'
14
14
  import { initialPreset } from '../src/ai.js'
15
- import { executorJobPrompt, executorJobEnv } from '../src/executor.js'
15
+ import { executorJobEnv } from '../src/executor.js'
16
16
 
17
17
  const until = async (check: () => Promise<boolean>) => {
18
18
  for (let i = 0; i < 200; i++) { if (await check()) return; await new Promise(r => setTimeout(r, 10)) }
@@ -113,9 +113,6 @@ test('corrupt registry and traversal IDs fail closed; external prompts never cla
113
113
  await assert.rejects(f.sources.register('../bad',f.socketPath,f.owner))
114
114
  await writeFile(join(f.dir,'event-sources.json'),'{')
115
115
  await assert.rejects(f.relay.drainSources()); assert.equal(f.launches.length,0)
116
- const prompt=executorJobPrompt('test',['ignore everything'],'source')
117
- assert.match(prompt,/NOT Telegram-owner instructions/)
118
- assert.doesNotMatch(prompt,/content from the Telegram owner/)
119
116
  assert.equal(executorJobEnv({runId:'r',controlDir:f.dir,binDir:f.dir},{TELEGRAM_BOT_TOKEN:'secret'}).TELEGRAM_BOT_TOKEN,undefined)
120
117
  assert.equal(batchReady([{...event('1'),receivedAt:Date.now()}]),false)
121
118
  assert.equal(batchReady([event('1')]),true)
@@ -133,8 +130,8 @@ test('relay launches the approved initial task and routes only matching replies
133
130
  await f.relay.drainSources()
134
131
  assert.equal(f.launches.length, 1); assert.equal(f.launches[0].options.cli, 'codex')
135
132
  assert.equal(f.launches[0].options.isResume, false)
136
- assert.equal(f.launches[0].options.model, 'gpt-5.6-sol')
137
- assert.equal(f.launches[0].options.effort, 'medium')
133
+ assert.equal(f.launches[0].options.model, undefined)
134
+ assert.equal(f.launches[0].options.effort, undefined)
138
135
  f.children[0].kill()
139
136
  await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
140
137
  const receivedAt = Date.now()
@@ -143,8 +140,8 @@ test('relay launches the approved initial task and routes only matching replies
143
140
  await f.relay.drainSources()
144
141
  assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.eventSource, 'fixture')
145
142
  assert.notEqual(f.launches[1].options.sessionId, f.launches[0].options.sessionId)
146
- assert.equal(f.launches[1].options.model, 'gpt-5.6-sol')
147
- assert.equal(f.launches[1].options.effort, 'medium')
143
+ assert.equal(f.launches[1].options.model, undefined)
144
+ assert.equal(f.launches[1].options.effort, undefined)
148
145
  f.children[1].kill()
149
146
  await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
150
147
  await f.relay.drainSources()
@@ -1,11 +1,12 @@
1
1
  import { ownerRun } from './helpers/owner-run.js'
2
+ import { RunStore } from '../src/runs.js'
2
3
  import assert from 'node:assert/strict'
3
4
  import test from 'node:test'
4
5
  import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
5
6
  import { spawn } from 'node:child_process'
6
7
  import { tmpdir } from 'node:os'
7
8
  import path from 'node:path'
8
- import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, executorJobPrompt, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, startExecutorJob, terminateJob } from '../src/executor.js'
9
+ import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, startExecutorJob, terminateJob } from '../src/executor.js'
9
10
  import { splitTelegramText } from '../src/reply.js'
10
11
  import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
11
12
 
@@ -46,16 +47,6 @@ test('cancellation stops detached tool descendants even after their parent exits
46
47
  }
47
48
  })
48
49
 
49
- test('the job prompt labels channel text as untrusted and requires ez message', () => {
50
- const prompt = executorJobPrompt('r_test', ['hello'])
51
- assert.match(prompt, /untrusted incoming channel content/)
52
- assert.match(prompt, /ezenciel-agents-message/)
53
- assert.match(prompt, /--text-file/)
54
- assert.match(prompt, /r_test/)
55
- assert.match(prompt, /hello/)
56
- assert.match(prompt, /Stdout is not sent to Telegram/)
57
- })
58
-
59
50
  test('Telegram replies are split within the configured message limit', () => {
60
51
  const text = `${'a'.repeat(9)} ${'b'.repeat(9)} ${'c'.repeat(9)}`
61
52
  const chunks = splitTelegramText(text, 10)
@@ -96,7 +87,6 @@ test('the Grok invocation is headless, workspace-scoped, and token-free', () =>
96
87
  '--output-format', 'plain',
97
88
  '--always-approve',
98
89
  '--verbatim',
99
- '--max-turns', '8',
100
90
  ])
101
91
  assert.equal(invocation.args.includes('TELEGRAM_BOT_TOKEN'), false)
102
92
  })
@@ -105,8 +95,7 @@ test('the antigravity invocation is headless, skips permissions, and uses print
105
95
  const invocation = antigravityInvocation('test prompt')
106
96
  assert.equal(invocation.command, 'agy')
107
97
  assert.deepEqual(invocation.args, [
108
- '--print', 'test prompt',
109
- '--dangerously-skip-permissions',
98
+ '--dangerously-skip-permissions', '--print=test prompt',
110
99
  ])
111
100
  })
112
101
 
@@ -118,6 +107,7 @@ test('the opencode invocation is headless, auto-approves, and sets model and wor
118
107
  '--auto',
119
108
  '--format',
120
109
  'json',
110
+ '--',
121
111
  'test prompt',
122
112
  ])
123
113
  })
@@ -179,8 +169,70 @@ test('Codex plugin access stays scoped to the explicitly bound registry', () =>
179
169
  test('Codex compaction preserves native resume and validates transported options',()=>{
180
170
  const args=EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',sessionId:'native-id',isResume:true,codexAutoCompactTokens:32000},'', 'hello')
181
171
  assert.ok(args.includes('model_auto_compact_token_limit=32000'))
182
- assert.deepEqual(args.slice(-3),['resume','native-id','hello'])
183
- assert.ok(EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent'},'','hello').includes('model_auto_compact_token_limit=64000'))
172
+ assert.deepEqual(args.slice(-3),['resume','native-id','-'])
173
+ assert.ok(!EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent'},'','hello').some(a=>a.includes('model_auto_compact_token_limit')))
184
174
  for(const value of [0,-1,NaN,1.5]) assert.throws(()=>EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',codexAutoCompactTokens:value},'','hello'),/compaction/)
185
175
  assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/agent',codexAutoCompactTokens:32000},'','hello').some(arg=>arg.includes('compact')))
186
176
  })
177
+
178
+
179
+ test('adapters do not append instruction files or impose a workflow turn budget', () => {
180
+ const opts={workspace:'/agent/work/tasks/example'}
181
+ assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs(opts,'','literal').includes('--append-system-prompt-file'))
182
+ assert.ok(!EXECUTOR_REGISTRY.grok.buildArgs(opts,'/tmp/prompt','literal').includes('--max-turns'))
183
+ })
184
+
185
+ test('Codex external isolation changes only explicit sandbox argv and never enters child environment', () => {
186
+ const args = EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent',codexSandbox:'external',sessionId:'native-id',isResume:true},'', 'hello')
187
+ assert.equal(args[args.indexOf('--sandbox')+1], 'danger-full-access')
188
+ assert.ok(args.includes('native-id'))
189
+ assert.equal(executorEnvironment({EZ_CODEX_SANDBOX:'external'}).EZ_CODEX_SANDBOX, undefined)
190
+ })
191
+
192
+ test('external Codex isolation cannot launch host runs', async t => {
193
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-external-sandbox-'))
194
+ t.after(() => rm(root,{recursive:true,force:true}))
195
+ await ownerRun(root,'r_sandbox')
196
+ const previous = {transport:process.env.EZ_EXECUTOR_TRANSPORT,telegram:process.env.EZ_TELEGRAM_ENABLED}
197
+ try {
198
+ process.env.EZ_TELEGRAM_ENABLED='false'
199
+ for (const transport of ['host','']) {
200
+ process.env.EZ_EXECUTOR_TRANSPORT=transport
201
+ await assert.rejects(startExecutorJob(['Hello'],{workspace:root,controlDir:root,binDir:root,runId:'r_sandbox',timeoutMs:0,cli:'codex',codexSandbox:'external'}), /owner-authorized native local/)
202
+ }
203
+ } finally {
204
+ for (const [key,value] of [['EZ_EXECUTOR_TRANSPORT',previous.transport],['EZ_TELEGRAM_ENABLED',previous.telegram]]) {
205
+ if (value === undefined) delete process.env[key!]; else process.env[key!]=value
206
+ }
207
+ }
208
+ })
209
+
210
+ test('external local owner chat preserves authorization and literal input', async t => {
211
+ const root=await mkdtemp(path.join(tmpdir(),'ez-external-owner-'))
212
+ t.after(()=>rm(root,{recursive:true,force:true}))
213
+ const prior={path:process.env.PATH,transport:process.env.EZ_EXECUTOR_TRANSPORT}
214
+ const bin=path.join(root,'bin');await mkdir(bin)
215
+ await writeFile(path.join(bin,'codex'),`#!/usr/bin/env node\nconst fs=require('fs');let text='';process.stdin.on('data',b=>text+=b);process.stdin.on('end',()=>fs.writeFileSync(${JSON.stringify(path.join(root,'observed.json'))},JSON.stringify({args:process.argv.slice(2),text,secret:process.env.TELEGRAM_BOT_TOKEN})));`,{mode:0o755})
216
+ await ownerRun(root,'r_owner_external')
217
+ const opts={workspace:root,controlDir:root,binDir:bin,runId:'r_owner_external',timeoutMs:0,cli:'codex',codexSandbox:'external' as const}
218
+ try {
219
+ process.env.PATH=bin+path.delimiter+prior.path;process.env.EZ_EXECUTOR_TRANSPORT='local'
220
+ const job=await startExecutorJob([' literal /goal request\n'],opts)
221
+ const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup()
222
+ assert.equal(code,0)
223
+ const observed=JSON.parse(await readFile(path.join(root,'observed.json'),'utf8'))
224
+ assert.equal(observed.text,' literal /goal request\n')
225
+ assert.equal(observed.args[observed.args.indexOf('--sandbox')+1],'danger-full-access')
226
+ assert.equal(observed.secret,undefined)
227
+ const runs=new RunStore(root)
228
+ await runs.create({id:'r_foreign',chatId:999,telegramUserId:999,texts:['no']})
229
+ await runs.patch('r_foreign',{status:'running'})
230
+ await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_foreign'}),/blocked|owner/i)
231
+ await runs.create({id:'r_restricted',chatId:101,telegramUserId:101,texts:['no'],taskId:'task_'+'a'.repeat(32)})
232
+ await runs.patch('r_restricted',{status:'running'})
233
+ await assert.rejects(startExecutorJob(['no'],{...opts,runId:'r_restricted'}),/owner-authorized native local/)
234
+ } finally {
235
+ if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
236
+ if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
237
+ }
238
+ })
@@ -198,6 +198,8 @@ for (const cleanupFails of [false,true]) test(`polling conflict preserves work u
198
198
  let finished=false
199
199
  const start=relay.start().finally(()=>{finished=true})
200
200
  await until(async()=>polls===1)
201
+ if(!cleanupFails)await new Promise(resolve=>setTimeout(resolve,5200))
202
+ assert.equal(polls,1,'permanent conflict must not restart after the old five-second retry interval')
201
203
  assert.equal(sourceStops,0,'a polling conflict must not stop the relay')
202
204
  assert.equal((await runs.get('tg_92'))?.status,'running')
203
205
  assert.ok(child && child.exitCode===null && child.signalCode===null)
@@ -280,3 +282,65 @@ test('group members can inspect failures and wake review without exposing other
280
282
  assert.equal((await runs.list()).filter(r=>r.scheduled).length,1)
281
283
  await assert.rejects(exec(process.execPath,[bin,'run','tg_2'],{env}),/Unknown owner/)
282
284
  })
285
+
286
+ for (const failure of [400,401,503,'programming'] as const) test(`setup failure ${failure} retries only transient errors`,async t=>{
287
+ const dir=await mkdtemp(join(tmpdir(),'ez-setup-failure-'))
288
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
289
+ let commands=0,polls=0
290
+ relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
291
+ relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
292
+ if(method==='setMyCommands' && ++commands===1) {
293
+ if(failure==='programming')throw Error('Synthetic permanent setup fault')
294
+ return {ok:false,error_code:failure,description:'Synthetic setup failure'} as any
295
+ }
296
+ if(method==='getUpdates'){
297
+ polls++
298
+ if(signal && !signal.aborted)await new Promise<void>(resolve=>signal.addEventListener('abort',()=>resolve(),{once:true}))
299
+ return {ok:true,result:[]} as any
300
+ }
301
+ return {ok:true,result:true} as any
302
+ })
303
+ const started=relay.start()
304
+ try {
305
+ await until(async()=>commands>0)
306
+ if(failure===503){await until(async()=>polls>0);assert.equal(commands,3)}
307
+ else {await new Promise(resolve=>setTimeout(resolve,5200));assert.equal(commands,1);assert.equal(polls,0)}
308
+ }finally{await relay.stop();await started;await rm(dir,{recursive:true,force:true})}
309
+ })
310
+
311
+ test('shutdown aborts a pending bot initialization without starting polling',async()=>{
312
+ const dir=await mkdtemp(join(tmpdir(),'ez-init-stop-'))
313
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw Error('No executor expected')})
314
+ let initSignal:Parameters<typeof relay.bot.init>[0],polls=0
315
+ relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
316
+ if(method==='getMe'){
317
+ initSignal=signal
318
+ if(signal && !signal.aborted)await new Promise<void>(resolve=>signal.addEventListener('abort',()=>resolve(),{once:true}))
319
+ throw Error('Initialization aborted')
320
+ }
321
+ if(method==='getUpdates')polls++
322
+ return {ok:true,result:true} as any
323
+ })
324
+ const started=relay.start()
325
+ try {await until(async()=>Boolean(initSignal));await relay.stop();await started;assert.equal(initSignal?.aborted,true);assert.equal(polls,0)}
326
+ finally {await relay.stop();await started;await rm(dir,{recursive:true,force:true})}
327
+ })
328
+
329
+ test('failed reviewer readback exposes its stop through show, list and resume until explicit edit',async t=>{
330
+ const dir=await mkdtemp(join(tmpdir(),'ez-review-stop-'));t.after(()=>rm(dir,{recursive:true,force:true}))
331
+ const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
332
+ await control.requestPairing(101,101);const owner=await control.approveOwner(101)
333
+ const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
334
+ const s=await scheduler.save({id:'review',name:'Review',text:'Review failures',trigger:{everySeconds:60,start:new Date(now).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
335
+ await runs.create({id:'r_original',chatId:101,telegramUserId:101,texts:['Work'],execution});await runs.patch('r_original',{status:'failed'})
336
+ await scheduler.tick(owner,runs,now)
337
+ const reviewer=(await runs.list()).find(r=>r.scheduled)!
338
+ await runs.patch(reviewer.id,{status:'failed'})
339
+ const cli=async(args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env:{...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:''}})).stdout)
340
+ for(const action of ['show','resume']){
341
+ const result=await cli([action,s.id]);assert.equal(result.nextEligibleAt,null);assert.deepEqual(result.failedReviewRunIds,[reviewer.id]);assert.match(result.recovery,/explicitly edit/)
342
+ }
343
+ const listed=await cli(['list']);assert.equal(listed[0].nextEligibleAt,null)
344
+ await scheduler.save({...s,text:'Repaired reviewer'})
345
+ const result=await cli(['show',s.id]);assert.notEqual(result.nextEligibleAt,null);assert.deepEqual(result.failedReviewRunIds,[])
346
+ })
@@ -12,6 +12,29 @@ import { EXECUTOR_REGISTRY } from '../src/executor.js'
12
12
  import { RunStore } from '../src/runs.js'
13
13
  import { packageVersion } from '../src/version.js'
14
14
  import { executionDefaults } from '../src/model-policy.js'
15
+ import { workspaceLease } from '../src/plugins/workspace-lease.mjs'
16
+
17
+ test('host restart clears dead native lease only after proving previous CLI stopped',async()=>{
18
+ const root=await mkdtemp(path.join(tmpdir(),'ez-native-recovery-'));
19
+ const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools'),directory=path.join(controlDir,'host-executor');
20
+ const abort=new AbortController();let server:Promise<void>|undefined;
21
+ try {
22
+ await mkdir(workspace);await mkdir(toolsHome);await mkdir(directory,{recursive:true});
23
+ await writeFile(path.join(toolsHome,'config.json'),JSON.stringify({schemaVersion:1,workspace:await realpath(workspace)}));
24
+ const child=spawn(process.execPath,['-e','process.exit(0)']);const deadPid=child.pid;await new Promise(r=>child.once('close',r));
25
+ await writeFile(path.join(toolsHome,'workspace-writer.lock'),JSON.stringify({pid:deadPid,kind:'native',runId:'r_old'}));
26
+ await writeFile(path.join(directory,'r_old.running.json'),'{}');
27
+ await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:process.pid}));
28
+ const installation={cli:'grok',agents:[{name:'test',workspace,controlDir,toolsHome,binDir:path.join(root,'bin')}]};
29
+ await assert.rejects(serveHostExecutor(installation,abort.signal),/Previous host CLI is still running/);
30
+ await readFile(path.join(toolsHome,'workspace-writer.lock'));
31
+ await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:deadPid}));
32
+ server=serveHostExecutor(installation,abort.signal);
33
+ for(let n=0;n<100;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
34
+ await readFile(path.join(directory,'heartbeat.json'));
35
+ await assert.rejects(readFile(path.join(toolsHome,'workspace-writer.lock')),{code:'ENOENT'});
36
+ }finally{abort.abort();await server;await rm(root,{recursive:true,force:true});}
37
+ });
15
38
 
16
39
  test('one installed CLI executes two agent bindings with separate minds and sanitized environment', async () => {
17
40
  const root=await mkdtemp(path.join(tmpdir(),'ez-host-'))
@@ -23,12 +46,12 @@ test('one installed CLI executes two agent bindings with separate minds and sani
23
46
  let server:Promise<void>|undefined
24
47
  try {
25
48
  const binary=path.join(root,'cli')
26
- await writeFile(binary,`#!${process.execPath}\nif(process.env.EZ_RUN_ID==='r_hold')setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,args:process.argv.slice(2)}));\n`,{mode:0o700})
49
+ await writeFile(binary,`#!${process.execPath}\nif(process.env.EZ_RUN_ID==='r_hold')setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,repair:process.env.EZ_REPAIR_ENABLED,args:process.argv.slice(2)}));\n`,{mode:0o700})
27
50
  await writeFile(path.join(root,'claude'),await readFile(binary),{mode:0o700})
28
51
  await writeFile(path.join(root,'codex'),await readFile(binary),{mode:0o700})
29
52
  process.env.PATH=root+path.delimiter+oldPath
30
53
  EXECUTOR_REGISTRY.grok.command=binary
31
- EXECUTOR_REGISTRY.grok.buildArgs=EXECUTOR_REGISTRY.codex.buildArgs
54
+ EXECUTOR_REGISTRY.grok.buildArgs=(opts,file,prompt)=>[...EXECUTOR_REGISTRY.codex.buildArgs(opts,file,prompt).slice(0,-1),prompt]
32
55
  process.env.TELEGRAM_BOT_TOKEN='must-not-reach-host-cli'
33
56
  const sharedAlias=path.join(root,'shared-alias')
34
57
  await symlink(root,sharedAlias)
@@ -44,8 +67,13 @@ test('one installed CLI executes two agent bindings with separate minds and sani
44
67
  const dir=path.join(agent.controlDir,'host-executor')
45
68
  for(let n=0;n<100;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
46
69
  assert.equal(JSON.parse(await readFile(path.join(dir,'heartbeat.json'),'utf8')).version,packageVersion)
70
+ const release = await workspaceLease(agent.toolsHome)
47
71
  await ownerRun(agent.controlDir, `r_${agent.name}`)
48
72
  await writeFile(path.join(dir,`r_${agent.name}.request.json`),JSON.stringify({texts:['test'],options:{workspace:'/wrong',controlDir:'/wrong',toolsHome:'/wrong',cli:'grok',timeoutMs:5000}}))
73
+ await new Promise(r=>setTimeout(r,300))
74
+ await readFile(path.join(dir,`r_${agent.name}.request.json`))
75
+ await assert.rejects(readFile(path.join(dir,`r_${agent.name}.process.json`)),{code:'ENOENT'})
76
+ await release?.()
49
77
  }
50
78
  for(const agent of agents){
51
79
  const file=path.join(agent.controlDir,'host-executor',`r_${agent.name}.events`)
@@ -56,7 +84,8 @@ test('one installed CLI executes two agent bindings with separate minds and sani
56
84
  assert.equal(result.cwd,await realpath(agent.workspace))
57
85
  assert.equal(result.control,agent.controlDir)
58
86
  assert.equal(result.token,undefined)
59
- assert.match(result.args.join(' '),/you are its repairer/)
87
+ assert.equal(result.args.at(-1),'test')
88
+ assert.equal(result.repair,'true')
60
89
  assert.ok(result.args.includes(agent.toolsHome))
61
90
  assert.ok(result.args.includes(await realpath(root)))
62
91
  assert.ok(!result.args.includes('/wrong'))
@@ -72,8 +101,8 @@ test('one installed CLI executes two agent bindings with separate minds and sani
72
101
  client.stdin.end(JSON.stringify({texts:['Telegram message'],options:{cli:'grok',timeoutMs:5000,codexAutoCompactTokens:32000,repairEnabled:false}}))
73
102
  assert.equal(await new Promise(resolve=>client.once('close',resolve)),0,stderr)
74
103
  assert.equal(JSON.parse(stdout).run,'tg_6293305')
75
- assert.match(JSON.parse(stdout).args.join(' '),/Automatic repair is disabled/)
76
- assert.doesNotMatch(JSON.parse(stdout).args.join(' '),/you are its repairer/)
104
+ assert.equal(JSON.parse(stdout).args.at(-1),'Telegram message')
105
+ assert.equal(JSON.parse(stdout).repair,'false')
77
106
  assert.ok(JSON.parse(stdout).args.includes('model_auto_compact_token_limit=32000'))
78
107
  const eventId='event_'+'a'.repeat(64)
79
108
  await ownerRun(agents[0].controlDir, eventId, {sourceId:'fixture',bindingId:'binding',eventIds:['1']})
@@ -107,8 +136,10 @@ test('one installed CLI executes two agent bindings with separate minds and sani
107
136
  assert.ok(JSON.parse(boundOutput).args.includes('agent-only-fixture'))
108
137
  await assert.rejects(serveHostExecutor({cli:'grok',agents},new AbortController().signal),/already running/)
109
138
  const directory=path.join(agents[0].controlDir,'host-executor')
110
- const submit=async(id:string)=>{await ownerRun(agents[0].controlDir,id);await writeFile(path.join(directory,id+'.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',timeoutMs:5000}}))}
111
- await submit('r_hold')
139
+ const runs=new RunStore(agents[0].controlDir)
140
+ await runs.create({id:'r_hold',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'held',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
141
+ await runs.patch('r_hold',{status:'running'})
142
+ await writeFile(path.join(directory,'r_hold.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
112
143
  for(let n=0;n<100;n++){try{await readFile(path.join(directory,'r_hold.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
113
144
  await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
114
145
  await new RunStore(agents[0].controlDir).patch('r_schedule_queued',{status:'running'})
@@ -116,17 +147,27 @@ test('one installed CLI executes two agent bindings with separate minds and sani
116
147
  const otherDirectory=path.join(agents[1].controlDir,'host-executor')
117
148
  await ownerRun(agents[1].controlDir,'r_other_shared')
118
149
  await writeFile(path.join(otherDirectory,'r_other_shared.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
119
- await new Promise(r=>setTimeout(r,350))
120
- await assert.rejects(readFile(path.join(directory,'r_schedule_queued.running.json')),{code:'ENOENT'})
121
- await assert.rejects(readFile(path.join(otherDirectory,'r_other_shared.running.json')),{code:'ENOENT'})
122
- await assert.rejects(readFile(path.join(otherDirectory,'r_other_shared.events')),{code:'ENOENT'})
150
+ await runs.create({id:'tg_42',chatId:101,telegramUserId:101,messageId:42,texts:['Chat while scheduled work runs']})
151
+ await runs.patch('tg_42',{status:'running'})
152
+ await writeFile(path.join(directory,'tg_42.request.json'),JSON.stringify({texts:['Chat while scheduled work runs'],options:{cli:'grok'}}))
153
+ const completed=async(dir:string,id:string)=>{
154
+ let output=''
155
+ for(let n=0;n<200;n++){try{output=await readFile(path.join(dir,id+'.events'),'utf8');if(output.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
156
+ assert.match(output, /"stream":"exit","code":0/)
157
+ return output.trim().split('\n').map(line=>JSON.parse(line))
158
+ }
159
+ // A running scheduled engine does not reserve either its agent or its
160
+ // shared workspace, including another binding through a filesystem alias.
161
+ const [,,chatEvents]=await Promise.all([completed(directory,'r_schedule_queued'),completed(otherDirectory,'r_other_shared'),completed(directory,'tg_42')])
162
+ const chat=JSON.parse(chatEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
163
+ assert.match(chat.args.at(-1),/^Chat while scheduled work runs/)
164
+ assert.match(chat.args.at(-1),/ezenciel-agents-message/)
165
+ assert.doesNotMatch(await readFile(path.join(directory,'r_hold.events'),'utf8'),/"stream":"exit"/)
166
+ await readFile(path.join(directory,'r_hold.running.json'))
123
167
  await writeFile(path.join(directory,'r_hold.cancel'),'')
124
- let output=''
125
- for(let n=0;n<200;n++){try{output=await readFile(path.join(directory,'r_schedule_queued.events'),'utf8');if(output.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
126
- assert.match(output, /"stream":"exit","code":0/)
127
- assert.match(await readFile(path.join(directory,'r_hold.events'),'utf8'), /"stream":"exit","code":1/)
128
- for(let n=0;n<200;n++){try{output=await readFile(path.join(otherDirectory,'r_other_shared.events'),'utf8');if(output.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
129
- assert.match(output, /"stream":"exit","code":0/)
168
+ let heldOutput=''
169
+ for(let n=0;n<200;n++){heldOutput=await readFile(path.join(directory,'r_hold.events'),'utf8');if(heldOutput.includes('"stream":"exit"'))break;await new Promise(r=>setTimeout(r,20))}
170
+ assert.match(heldOutput, /"stream":"exit","code":1/)
130
171
  } finally {
131
172
  abort.abort();await server
132
173
  EXECUTOR_REGISTRY.grok.command=old
@@ -60,7 +60,7 @@ test('packaged configure accepts token through stdin without echo or extra initi
60
60
  const result = spawnSync(process.execPath, [bin, 'configure', 'codex', '--token-stdin'], { cwd: root, input: token, encoding: 'utf8', timeout: 10000 })
61
61
  assert.equal(result.status, 0, result.stderr)
62
62
  assert.equal((result.stdout + result.stderr).includes(token), false)
63
- assert.equal(JSON.parse(result.stdout).created.length, 4)
63
+ assert.equal(JSON.parse(result.stdout).created.length, 3)
64
64
  assert.equal(parseEnv(await readFile(path.join(root, '.env'), 'utf8')).TELEGRAM_BOT_TOKEN, token)
65
65
  } finally { await rm(root, { recursive: true, force: true }) }
66
66
  })