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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +9 -5
  4. package/bin/ez +16 -7
  5. package/docs/architecture/ai-selection.md +4 -0
  6. package/docs/docker-runtime.md +28 -2
  7. package/docs/plugin-guide.md +3 -3
  8. package/docs/plugins.md +13 -10
  9. package/docs/scheduling.md +62 -22
  10. package/docs/setup.md +2 -2
  11. package/docs/standalone-cli.md +4 -4
  12. package/docs/upgrades.md +16 -15
  13. package/package.json +4 -2
  14. package/src/agent-guidance.ts +2 -2
  15. package/src/ai-cli.ts +4 -2
  16. package/src/ai.ts +17 -5
  17. package/src/application-channel.ts +12 -8
  18. package/src/control-state.ts +10 -4
  19. package/src/delivery-socket-client.mjs +15 -1
  20. package/src/delivery-socket.ts +18 -4
  21. package/src/executor.ts +63 -10
  22. package/src/host-executor-client.ts +23 -8
  23. package/src/host-executor.ts +63 -32
  24. package/src/index.ts +157 -35
  25. package/src/menu.ts +3 -2
  26. package/src/plugins/manager.mjs +43 -40
  27. package/src/plugins/workspace-lease.mjs +1 -1
  28. package/src/runs.ts +67 -3
  29. package/src/schedule-cli.ts +24 -15
  30. package/src/scheduled-tasks.ts +53 -17
  31. package/src/scheduler.ts +7 -8
  32. package/src/session-close.ts +17 -0
  33. package/src/setup-fallback.ts +62 -25
  34. package/src/tools-line.d.mts +1 -0
  35. package/src/tools-line.mjs +3 -0
  36. package/src/updates/binding.mjs +4 -10
  37. package/src/updates/control.mjs +3 -2
  38. package/src/updates/runtime.mjs +25 -9
  39. package/src/updates/supervisor.mjs +105 -16
  40. package/src/workspace.ts +12 -1
  41. package/test/ai-cli.test.ts +23 -1
  42. package/test/ai.test.ts +72 -4
  43. package/test/application-channel.test.ts +24 -0
  44. package/test/application-controls.test.ts +4 -1
  45. package/test/delivery-transport.test.ts +41 -2
  46. package/test/executor.test.ts +32 -4
  47. package/test/host-executor.test.ts +70 -3
  48. package/test/intake-relay.test.ts +71 -3
  49. package/test/model-policy.test.ts +1 -1
  50. package/test/plugin-broker.test.mjs +48 -1
  51. package/test/plugin-manager.test.mjs +52 -38
  52. package/test/run-handoff.test.ts +136 -0
  53. package/test/schedule-cli.test.ts +24 -1
  54. package/test/scheduled-tasks.test.ts +45 -15
  55. package/test/session-close.test.ts +17 -0
  56. package/test/setup-fallback.test.ts +194 -16
  57. package/test/updates.test.mjs +55 -12
  58. package/test/workspace.test.ts +6 -0
@@ -7,7 +7,8 @@ import { fileURLToPath } from 'node:url';
7
7
  import { createHash, randomUUID, randomBytes } from 'node:crypto';
8
8
  import { spawn } from 'node:child_process';
9
9
  import { callDeliverySocket } from '../delivery-socket-client.mjs';
10
- import { readFileSync, realpathSync } from 'node:fs';
10
+ import { TOOLS_LINE } from '../tools-line.mjs';
11
+ import { readFileSync, readlinkSync, realpathSync } from 'node:fs';
11
12
 
12
13
  const reserved = new Set(['status','updates','plugins','tools','message','owner','approval','react','setup','help','version']);
13
14
  const id = value => { if(typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(value)) throw Error('Invalid identifier'); return value; };
@@ -41,39 +42,31 @@ export async function stewardOwned(file) {
41
42
  const parent = await fs.stat(path.dirname(file));
42
43
  await fs.chown(file, parent.uid, parent.gid);
43
44
  }
44
- // Native instructions keep only the registry locator and one crucial routing
45
- // hint. Inventory and full scheduler usage are generated on read.
46
- export async function toolDiscoveryBlock(home,hostConfig) {
47
- home=await fs.realpath(home);
48
- let launcher=path.join(home,'bin','ez');
49
- const config=hostConfig?{hostConfig}:await json(path.join(home,'config.json')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error;});
50
- if(config?.hostConfig) {
51
- const host=await json(config.hostConfig);
52
- if(host.isolation==='isolated') launcher='ez';
53
- }
45
+ // Retire the former `ez tools` footer once: replace the marked block with
46
+ // TOOLS_LINE and leave every other byte untouched. The name is kept because
47
+ // upgraders released before this change call it on the target package;
48
+ // remove it when no installed core predates the change.
49
+ export async function bindToolDiscovery(_home,workspace) {
50
+ workspace=await fs.realpath(workspace);
54
51
  const start='<!-- ez tools: begin -->',end='<!-- ez tools: end -->';
55
- const block=start+'\nUse `'+launcher+'`; run it with `--help` or `tools list --details` to discover installed capabilities, and read the matching skill when needed.\nKeep the channel responsive: during a foreground owner turn, prefer `ezenciel-agents-schedule create --now ...` when work can continue independently. A `[schedule ...]` turn executes its assigned work directly and must not schedule it again.\n'+end;
56
- if(Buffer.byteLength(block)>600)throw Error('Tool discovery guidance exceeds the 600-byte core budget');
57
- return {block,start,end};
58
- }
59
- export async function bindToolDiscovery(home,workspace) {
60
- home=await fs.realpath(home);workspace=await fs.realpath(workspace);
61
- const {block,start,end}=await toolDiscoveryBlock(home);
62
52
  for(const name of ['AGENTS.md','AGENTS.override.md']) {
63
53
  const file=path.join(workspace,name);
64
54
  const stat=await fs.lstat(file).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
65
- if(!stat && name!=='AGENTS.md')continue;
66
- if(stat && !stat.isFile())throw Error('Tool instructions must be a regular file');
67
- const prior=stat?await fs.readFile(file,'utf8'):'';
55
+ if(!stat)continue;
56
+ if(!stat.isFile())throw Error('Tool instructions must be a regular file');
57
+ const prior=await fs.readFile(file,'utf8');
68
58
  const from=prior.indexOf(start),to=prior.indexOf(end);
69
- if((from<0)!==(to<0)||(from>=0&&(to<from||prior.indexOf(start,from+start.length)>=0||prior.indexOf(end,to+end.length)>=0)))throw Error('Malformed tool discovery block');
70
- const next=from<0?prior+'\n'+block+'\n':prior.slice(0,from)+block+prior.slice(to+end.length);
71
- if(next===prior)continue;
59
+ if(from<0&&to<0)continue;
60
+ if((from<0)!==(to<0)||to<from||prior.indexOf(start,from+start.length)>=0||prior.indexOf(end,to+end.length)>=0)throw Error('Malformed tool discovery block');
61
+ const next=prior.slice(0,from)+(prior.includes(TOOLS_LINE)?'':TOOLS_LINE)+prior.slice(to+end.length);
72
62
  const tmp=file+'.'+randomUUID()+'.tmp';
73
63
  try {await fs.writeFile(tmp,next,{mode:0o600,flag:'wx'});await fs.rename(tmp,file);}finally{await fs.rm(tmp,{force:true});}
74
64
  }
75
65
  }
76
66
  const invocationDirectory = home => path.join(home,'command-invocations');
67
+ // A lease pid is only meaningful inside the PID namespace that wrote it. The
68
+ // isolated broker writes leases from its container, which the host cannot probe.
69
+ const pidNamespace=(()=>{try{return readlinkSync('/proc/self/ns/pid');}catch{return undefined;}})();
77
70
  async function activeInvocations(home) {
78
71
  const directory=invocationDirectory(home),files=await fs.readdir(directory).catch(error=>{if(error.code==='ENOENT')return [];throw error;});
79
72
  for(const file of files) {
@@ -81,8 +74,11 @@ async function activeInvocations(home) {
81
74
  const lease=await json(path.join(directory,file)).catch(error=>{if(error.code==='ENOENT')return null;throw error;});
82
75
  if(!lease)continue;
83
76
  if(!Number.isSafeInteger(lease.pid)||lease.pid<1||typeof lease.container!=='string'||!lease.container)throw Error('Invalid command invocation lease; inspect before recovery');
84
- try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH'&&error.code!=='EPERM')throw error;}
85
- const state=await run(['container','inspect',lease.container],{capture:true});
77
+ const foreign=typeof lease.pidNamespace==='string'&&lease.pidNamespace!==pidNamespace;
78
+ if(!foreign) {try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH'&&error.code!=='EPERM')throw error;}}
79
+ const state=await run(['container','inspect','--format','{{.State.Running}}',lease.container],{capture:true});
80
+ // Foreign leases: the call container's own state is the only liveness signal.
81
+ if(state.code===0&&foreign&&state.stdout.trim()==='true')return true;
86
82
  if(state.code===0)throw Error(`Stale command invocation lease; inspect container ${lease.container} before recovery`);
87
83
  if(!/No such (?:object|container)/i.test(state.stderr+state.stdout))throw Error('Unable to verify a stale command invocation lease; inspect Docker before recovery');
88
84
  await fs.rm(path.join(directory,file));
@@ -91,7 +87,7 @@ async function activeInvocations(home) {
91
87
  }
92
88
  async function invocationLease(home,container) {
93
89
  const directory=invocationDirectory(home);await privateDir(directory);
94
- const file=path.join(directory,randomUUID()+'.json');await fs.writeFile(file,JSON.stringify({pid:process.pid,container}),{mode:0o600,flag:'wx'});
90
+ const file=path.join(directory,randomUUID()+'.json');await fs.writeFile(file,JSON.stringify({pid:process.pid,container,...(pidNamespace?{pidNamespace}:{})}),{mode:0o600,flag:'wx'});
95
91
  await stewardOwned(file);
96
92
  return async()=>{await fs.rm(file,{force:true});};
97
93
  }
@@ -150,9 +146,11 @@ export async function snapshot(source) {
150
146
  return {source,files,manifest,deployment,sharedRevisions,revision:`sha256:${digest.digest('hex')}`};
151
147
  }
152
148
  export function validate(m,d,files) {
153
- keys(m,['schemaVersion','id','version','description','commands','skills']);
149
+ keys(m,['schemaVersion','id','version','description','commands','skills','example']);
154
150
  if(m.schemaVersion!==1 || (typeof m.version!=='string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(m.version))) throw Error('Unsupported manifest version');
155
151
  id(m.id); strings(m.skills);
152
+ // One representative call shown by `tools list --details`; usage stays in --help/skill.
153
+ if(m.example!==undefined && (typeof m.example!=='string' || m.example.length>160 || /[\0-\x1f\x7f]/.test(m.example) || !Object.hasOwn(m.commands||{},m.example.split(' ')[0]))) throw Error('Manifest example must be one line of at most 160 characters starting with a declared command');
156
154
  keys(d,['schemaVersion','services','commands','exports',...(d.schemaVersion>=2?['secrets']:[]),...(d.schemaVersion===3?['sharedServices']:[])]);
157
155
  if(![1,2,3].includes(d.schemaVersion) || !d.services || !d.commands) throw Error('Unsupported deployment descriptor');
158
156
  for(const [name,s] of Object.entries(d.services)) {
@@ -397,11 +395,13 @@ export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,
397
395
  child.once('close',async(code,childSignal)=>{process.off('SIGTERM',term);process.off('SIGINT',int);
398
396
  clearTimeout(killTimer);
399
397
  clearTimeout(timeout);signal?.removeEventListener('abort',term);
400
- if(cancelled&&container) {
398
+ // --rm only removes a container that ran. Compose stopping between
399
+ // create and start (cancelled, signalled or failed) strands it Created.
400
+ if(container&&(cancelled||code!==0)) {
401
401
  try {
402
402
  await removeCommandContainer(container);
403
403
  } catch(error) {
404
- const uncertain=error instanceof Error?error:Error('Cancelled command container cleanup failed');
404
+ const uncertain=error instanceof Error?error:Error('Command container cleanup failed');
405
405
  uncertain.containerCleanupUncertain=true;return reject(uncertain);
406
406
  }
407
407
  }
@@ -409,7 +409,7 @@ export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,
409
409
  resolve({code:cancelled?130:code??(childSignal?130:1),stdout,stderr});});
410
410
  });
411
411
  }
412
- // Compose --rm can race cancellation. Confirm disappearance instead of treating
412
+ // Compose --rm can race this cleanup. Confirm disappearance instead of treating
413
413
  // Docker's in-progress removal as either failure or completed cleanup.
414
414
  export async function removeCommandContainer(container,execute=run) {
415
415
  const cleanup=await execute(['container','rm','--force',container],{capture:true});
@@ -422,7 +422,7 @@ export async function removeCommandContainer(container,execute=run) {
422
422
  await new Promise(resolve=>setTimeout(resolve,100));
423
423
  }
424
424
  }
425
- throw Error(`Cancelled command container cleanup failed: ${cleanup.stderr||cleanup.stdout}`);
425
+ throw Error(`Command container cleanup failed: ${cleanup.stderr||cleanup.stdout}`);
426
426
  }
427
427
 
428
428
  async function checked(args) {
@@ -530,7 +530,6 @@ export async function init(home,workspace,catalogFile,hostConfig,standalone=fals
530
530
  if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
531
531
  if(typeof home!=='string'||typeof workspace!=='string'||!path.isAbsolute(home)||!path.isAbsolute(workspace)||/[\r\n\0$:,]/.test(home+workspace)) throw Error('Explicit absolute home/workspace required');
532
532
  workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);if(hostConfig)hostConfig=await fs.realpath(hostConfig);
533
- await toolDiscoveryBlock(home,hostConfig);
534
533
  if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
535
534
  catalogFile=path.resolve(catalogFile||fileURLToPath(new URL('../../default-plugins.json',import.meta.url)));
536
535
  const sources=await json(catalogFile);
@@ -563,7 +562,6 @@ export async function init(home,workspace,catalogFile,hostConfig,standalone=fals
563
562
  agent.binDir=bin;agent.toolsHome=home;await atomic(hostConfig,host);
564
563
  }
565
564
  });
566
- await bindToolDiscovery(home,workspace);
567
565
  if(hostConfig && path.basename(hostConfig)==='host-executor.json') await (await import('../updates/binding.mjs')).bindUpdates(home,hostConfig);
568
566
  return {ok:true,launcher:path.join(home,'bin','ez'),workspace};
569
567
  }
@@ -651,12 +649,17 @@ export async function main(args) {
651
649
  return emit({plugin:name,revision:record.revision,path:skill,content:await fs.readFile(file,'utf8')});
652
650
  }
653
651
  if(action==='list') {
654
- if(group==='tools' && args.length===1 && args[0]==='--details')return emit(Object.fromEntries(Object.entries(r.plugins).map(([name,p])=>[name,{
655
- description:typeof p.manifest.description==='string'?p.manifest.description.replace(/\s+/g,' ').trim().slice(0,200):'',
656
- commands:Object.keys(p.manifest.commands).map(alias=>`ez ${alias} --help`),
657
- skills:p.manifest.skills.map(skill=>path.join(p.source,skill)),
658
- skillReads:p.manifest.skills.map(skill=>['ez','tools','skill',name,skill]),
659
- }])));
652
+ // Self-sufficient index: aliases, purpose, skill and one call per plugin.
653
+ if(group==='tools' && args.length===1 && args[0]==='--details')return emit(Object.fromEntries(Object.entries(r.plugins).map(([name,p])=>{
654
+ const aliases=Object.keys(p.manifest.commands);
655
+ return [name,{
656
+ aliases,
657
+ purpose:typeof p.manifest.description==='string'?p.manifest.description.replace(/\s+/g,' ').trim().slice(0,200):'',
658
+ skills:p.manifest.skills.map(skill=>path.join(p.source,skill)),
659
+ skillReads:p.manifest.skills.map(skill=>['ez','tools','skill',name,skill]),
660
+ example:`ez ${p.manifest.example ?? `${aliases[0]} --help`}`,
661
+ }];
662
+ })));
660
663
  if(args.length)throw Error('Use tools list [--details] or plugins list');
661
664
  return emit(group==='tools'?r.commands:r.plugins);
662
665
  }
@@ -23,7 +23,7 @@ export async function recoverNativeLease(home) {
23
23
  export async function workspaceLease(home,owner={kind:'plugin'}) {
24
24
  const file=path.join(home,'workspace-writer.lock'),temp=file+'.'+randomUUID()+'.tmp';
25
25
  await fs.writeFile(temp,JSON.stringify({...owner,pid:process.pid}),{flag:'wx',mode:0o600});
26
- try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');return undefined;}
26
+ try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');if(owner.kind==='native'&&prior.owner.kind==='native')return async()=>{};return undefined;}
27
27
  finally {await fs.rm(temp);}
28
28
  return async()=>{await fs.rm(file);};
29
29
  }
package/src/runs.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { validApplicationOrigin, type ApplicationOrigin } from './application-origin.js'
2
2
  import { type FailureEvidence, type FailureReview, validFailureReview, failureStamp, failureEvidence } from './failure.js'
3
3
  import { randomBytes } from 'node:crypto'
4
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { isHostRunId } from './host-executor-protocol.js'
4
7
  import { validOrigin, type ExternalOrigin } from './event-sources.js'
5
8
  import { normalizeReactionEmoji } from './reaction.js'
6
9
  import { validScheduledOrigin, type ScheduledOrigin } from './scheduler.js'
@@ -36,6 +39,8 @@ export type RunRecord = {
36
39
  // Ordered task setups tried for this occurrence; the last one is active.
37
40
  attempts?: SetupAttempt[]
38
41
  replyOnly?: boolean
42
+ // Silent end-of-conversation turn in a replaced chat session (session-close.ts).
43
+ sessionClose?: true
39
44
  exitCode?: number | null
40
45
  failureReason?: string
41
46
  failure?: FailureEvidence
@@ -86,9 +91,10 @@ export const retainedOutbox = (controlDir: string): StoredOutboxItem[] => [...ou
86
91
  // Stateless pipe: the relay owns the ledger in process memory. Cross-process
87
92
  // producers (engine children, plugin children, host executor) reach it through
88
93
  // the delivery socket (see delivery-socket.ts), never through control/ files.
89
- // A restart drops in-flight runs and queued items by design; in-flight sends
90
- // report unknown rather than claiming success. Long-lived relays cap retained
91
- // terminal records so memory stays bounded.
94
+ // A restart drops in-flight runs and queued items by design (except handed-off
95
+ // host-transport runs, below); in-flight sends report unknown rather than
96
+ // claiming success. Long-lived relays cap retained terminal records so memory
97
+ // stays bounded.
92
98
  const MAX_TERMINAL_RUNS = 2000
93
99
  const MAX_TERMINAL_OUTBOX = 2000
94
100
 
@@ -142,6 +148,53 @@ export const activeSetup = (run: Pick<RunRecord, 'attempts' | 'execution'>): AiP
142
148
 
143
149
  export const newRunId = (): string => `r_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
144
150
 
151
+ // The one exception to memory-only runs: a graceful relay stop hands its
152
+ // running host-transport runs to the next relay, whose executors outlive it.
153
+ // The next relay adopts and watches them; it never relaunches one.
154
+ const handoffFile = (controlDir: string): string => join(controlDir, 'run-handoff.json')
155
+ const handedRun = (value: unknown): value is RunRecord => {
156
+ const run = value as RunRecord
157
+ return Boolean(run && typeof run === 'object' && (run.version === 1 || run.version === 2) && typeof run.id === 'string' && isHostRunId(run.id) &&
158
+ run.status === 'running' && Array.isArray(run.texts) && run.texts.every(text => typeof text === 'string') && Number.isFinite(Date.parse(run.createdAt)) &&
159
+ (run.execution === undefined || isExecutionChoice(run.execution)) && (run.scheduled === undefined || validScheduledOrigin(run.scheduled)) &&
160
+ (run.external === undefined || validOrigin(run.external)) && (run.application === undefined || validApplicationOrigin(run.application)))
161
+ }
162
+
163
+ export const saveRunHandoff = async (controlDir: string, records: RunRecord[]): Promise<void> => {
164
+ const file = handoffFile(controlDir), temporary = `${file}.${process.pid}.tmp`
165
+ const ids=new Set(records.map(run=>run.id))
166
+ const receipts=[...outboxFor(controlDir).values()].filter(item=>item.runId&&ids.has(item.runId)&&(item.state==='sent'||item.state==='failed'))
167
+ await writeFile(temporary, JSON.stringify({ version: 1, writtenAt: new Date().toISOString(), runs: records, receipts }), { mode: 0o600 })
168
+ await rename(temporary, file)
169
+ }
170
+
171
+ const HANDOFF_MAX_AGE_MS = 24 * 60 * 60 * 1000
172
+
173
+ // Reads and removes the record. An unreadable, corrupt or stale record adopts
174
+ // nothing: those executors finish unobserved, like a restart without handoff.
175
+ export const takeRunHandoff = async (controlDir: string, now = Date.now()): Promise<RunRecord[]> => {
176
+ try {
177
+ let raw: string
178
+ try { raw = await readFile(handoffFile(controlDir), 'utf8') }
179
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
180
+ await rm(handoffFile(controlDir))
181
+ const value = JSON.parse(raw) as { version?: unknown; writtenAt?: unknown; runs?: unknown; receipts?: unknown }
182
+ const age = now - Date.parse(typeof value.writtenAt === 'string' ? value.writtenAt : '')
183
+ if (value.version !== 1 || !(age >= -60_000 && age <= HANDOFF_MAX_AGE_MS)) throw new Error('Invalid or stale run handoff')
184
+ if (!Array.isArray(value.runs) || value.runs.length > 100) throw new Error('Invalid run handoff')
185
+ const valid = value.runs.filter(handedRun)
186
+ if (valid.length !== value.runs.length) console.error('Ignoring invalid handed-off runs', value.runs.length - valid.length)
187
+ const ids=new Set(valid.map(run=>run.id))
188
+ if(Array.isArray(value.receipts)&&value.receipts.length<=MAX_TERMINAL_OUTBOX){
189
+ for(const item of value.receipts as StoredOutboxItem[])if(item&&typeof item.id==='string'&&/^[a-zA-Z0-9_-]+$/.test(item.id)&&item.runId&&ids.has(item.runId)&&(item.state==='sent'||item.state==='failed'))outboxFor(controlDir).set(item.id,item)
190
+ }
191
+ return [...new Map(valid.map(run => [run.id, run])).values()]
192
+ } catch (error) {
193
+ console.error('Ignoring unreadable run handoff', error instanceof Error ? error.message : 'Unknown error')
194
+ return []
195
+ }
196
+ }
197
+
145
198
  export class RunStore {
146
199
  constructor(private readonly controlDir: string) {}
147
200
 
@@ -162,6 +215,7 @@ export class RunStore {
162
215
  application?: ApplicationOrigin
163
216
  delivery?: { bindingId: string; scope: string }
164
217
  script?: ScriptRunRef
218
+ sessionClose?: true
165
219
  }, exclusiveSchedule = false): Promise<RunRecord> {
166
220
  const store = runsFor(this.controlDir)
167
221
  if (input.id) {
@@ -194,6 +248,7 @@ export class RunStore {
194
248
  delivery: input.delivery,
195
249
  scheduled: input.scheduled,
196
250
  ...(input.script ? { script: input.script } : {}),
251
+ ...(input.sessionClose ? { sessionClose: true as const } : {}),
197
252
  status: 'queued',
198
253
  createdAt: new Date().toISOString(),
199
254
  }
@@ -207,6 +262,15 @@ export class RunStore {
207
262
  return runsFor(this.controlDir).get(id) ?? null
208
263
  }
209
264
 
265
+ // Re-admit a run from the previous relay's handoff; its executor is already running.
266
+ async adopt(run: RunRecord): Promise<RunRecord> {
267
+ if (!handedRun(run)) throw new Error('Invalid handed-off run')
268
+ const existing = runsFor(this.controlDir).get(run.id)
269
+ if (existing && existing.status !== 'running') throw new Error('Handed-off run already settled')
270
+ runsFor(this.controlDir).set(run.id, run)
271
+ return run
272
+ }
273
+
210
274
  async patch(
211
275
  id: string,
212
276
  change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut' | 'attempts'>>,
@@ -8,7 +8,7 @@ import { ControlStore, sameOwner } from './control-state.js'
8
8
  import { ApplicationBindings } from './application-channel.js'
9
9
  import type { RunRecord } from './runs.js'
10
10
  import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
11
- import { initialPreset, isPreset, presetLabel, MAX_FALLBACKS, type AiPreset } from './ai.js'
11
+ import { initialPreset, isPreset, presetLabel, type AiPreset } from './ai.js'
12
12
  import { executionOverrides } from './model-policy.js'
13
13
  import { holdsSchedule, Scheduler, executionType } from './scheduler.js'
14
14
  import { Scripts, fileSha256, workspaceEntry, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
@@ -17,17 +17,18 @@ import { provisionedHome } from './auth-profile.js'
17
17
  import { ownsRun } from './identity.js'
18
18
  import { type Trigger } from './schedule-time.js'
19
19
 
20
- // `cli=claude,model=sonnet,effort=medium` names one fallback setup.
20
+ // Each fallback selects its own native engine, credential home and model.
21
21
  const fallbackSetup = (spec: string, index: number): AiPreset => {
22
22
  const fields = Object.fromEntries(spec.split(',').map(part => {
23
23
  const at = part.indexOf('=')
24
- if (at < 1) throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
24
+ if (at < 1) throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID]')
25
25
  return [part.slice(0, at), part.slice(at + 1)]
26
26
  }))
27
- if (Object.keys(fields).some(key => !['cli','model','effort','provider'].includes(key)) || !fields.cli || !fields.model)
28
- throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
27
+ if (Object.keys(fields).some(key => !['cli','model','auth-profile','effort','provider'].includes(key)) || !fields.cli || !fields.model)
28
+ throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID]')
29
29
  const base = initialPreset(fields.cli)
30
- const preset = executionOverrides(base.cli, {...base, ...(fields.provider ? {provider: fields.provider} : {})}, fields.model, fields.effort)
30
+ const preset = executionOverrides(base.cli, {...base, ...(fields.provider ? {provider: fields.provider} : {}),
31
+ ...(fields['auth-profile'] !== undefined ? {authProfile: fields['auth-profile']} : {})}, fields.model, fields.effort)
31
32
  const named = {...preset, id: `fallback-${index + 1}`, name: presetLabel(preset).slice(0, 80)}
32
33
  if (!isPreset(named)) throw new Error('Invalid fallback setup')
33
34
  return named
@@ -49,7 +50,7 @@ async function main() {
49
50
  create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE | --script SCRIPT_ID [--arg=VALUE ...])
50
51
  --now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
51
52
  [--cli EXECUTOR] [--auth-profile NAME] [--model MODEL] [--effort <native-effort>]
52
- [--fallback cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID] ... | --clear-fallbacks]
53
+ [--fallback cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID] ... | --clear-fallbacks]
53
54
  [--preflight-file FILE | --clear-preflight]
54
55
  [--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
55
56
  script list | show SCRIPT_ID | remove SCRIPT_ID
@@ -71,14 +72,19 @@ Failures default to unreviewed owner runs. Review records a diagnosis; it never
71
72
  A conditional review schedule consumes no model run when there are no unreviewed failures.
72
73
  New tasks capture the selected engine settings; edit preserves existing settings unless overridden.
73
74
  Every task requires a concrete saved model. Supply --model if the selected settings have none; existing tasks never fall back to chat or client defaults.
74
- Fallbacks (at most ${MAX_FALLBACKS}, in order) run only when the previous setup's CLI is unavailable or its provider
75
- rejected the turn before any work began. Access denial, unknown errors, cancellation and failure after work began stop
76
- the chain; nothing is replayed. A quota rejection skips later setups of the same client or provider (quota scope),
77
- even with different credentials. The run records each attempt, setup, failure category and any reported reset time.
75
+ Fallbacks run in the saved order only when the previous setup's CLI is unavailable or its provider
76
+ rejected the turn before any work began, including an unavailable login. A Claude usage limit the run ended on
77
+ after work began continues on the next setup in a fresh session, told the stopped session and that its work may be
78
+ partly applied. Account/organization access denial, unknown errors, cancellation and other failures after work
79
+ began stop the chain; nothing is replayed.
80
+ A login or quota rejection skips later setups of the same native login profile
81
+ or upstream provider. Named profiles must be owner-provisioned logins for separate accounts; Ez never enables overage.
82
+ The run records each attempt, setup, failure category and any reported reset time.
78
83
  Trigger runs an existing task once with its saved instructions, AI and delivery binding in a fresh session. Reuse the request key after an uncertain result; the regular schedule is unchanged.
79
84
  Creates a scheduled task. Instructions are text, never shell commands.
80
85
  Use --now to run once. Run completion is not delivery proof.
81
- Edit replaces the full schedule. Pause/remove affect future work; cancel stops a particular run.
86
+ Edit replaces the full schedule but keeps its paused/enabled state; only pause/resume toggle it.
87
+ Pause/remove affect future work; cancel stops a particular run.
82
88
  Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/weekday OR semantics.`);return}
83
89
  const config=loadControlConfig(), control=new ControlStore(config.controlDir,config.pairingTtlMs)
84
90
  const owner=(await control.status()).owner
@@ -190,13 +196,15 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
190
196
  v.cron ? {cron:v.cron,timezone:v.timezone!,start,until:v.until} : {everySeconds:Number(v['every-seconds']),start,until:v.until}
191
197
  if(v['clear-preflight'] && v['preflight-file']) throw Error('Choose one preflight operation')
192
198
  const previousSchedule = action === 'edit' ? await scheduler.get(id!) : undefined
199
+ // Edit keeps the paused/enabled state; only pause/resume toggle it.
200
+ const enabled = previousSchedule?.enabled ?? true
193
201
  const preflight = v['clear-preflight'] ? undefined : v['preflight-file'] ? JSON.parse(await readFile(v['preflight-file'],'utf8')) : previousSchedule?.preflight
194
202
  const origin = caller?.application ?? caller?.delivery
195
203
  const delivery = previousSchedule ? previousSchedule.delivery : (origin ? {bindingId:origin.bindingId,scope:origin.scope} : undefined)
196
204
  if (!delivery && !owner.telegramChatId) throw new Error('Create the schedule from an authenticated channel turn to bind its reply destination')
197
205
  if (v.script) {
198
206
  result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || v.script,preflight,
199
- originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
207
+ originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled,owner,
200
208
  script:{id:v.script,args:v.arg ?? []}},action==='create'))
201
209
  console.log(JSON.stringify(result,null,2));return
202
210
  }
@@ -207,10 +215,11 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
207
215
  const preset = executionOverrides(base.cli, v['auth-profile'] === undefined ? base : {...base, authProfile:v['auth-profile']}, v.model, v.effort)
208
216
  if (!isPreset(preset)) throw new Error('Invalid task AI selection')
209
217
  const fallbacks = v['clear-fallbacks'] ? undefined : v.fallback ? v.fallback.map(fallbackSetup) : previous?.fallbacks
210
- if (preset.authProfile !== undefined) await provisionedHome(config.controlDir, preset.cli, preset.authProfile)
218
+ for (const setup of [preset, ...fallbacks ?? []])
219
+ if (setup.authProfile !== undefined) await provisionedHome(config.controlDir, setup.cli, setup.authProfile)
211
220
  result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
212
221
  preflight,
213
- originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
222
+ originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled,owner,
214
223
  execution:{sessionId:previous?.sessionId || randomUUID(),preset,...(fallbacks ? {fallbacks} : {})}},action==='create'))
215
224
  }else{
216
225
  if(!id)throw new Error('ID required')
@@ -1,7 +1,8 @@
1
1
  import { executionDefaults } from './model-policy.js'
2
- import { presetLabel } from './ai.js'
2
+ import { presetLabel, type AiPreset } from './ai.js'
3
+ import { AUTH_PROFILE_CLIS } from './auth-profile.js'
3
4
  import type { Owner } from './control-state.js'
4
- import type { Schedule, ActiveSchedule } from './scheduler.js'
5
+ import type { Schedule, ScheduledTaskView } from './scheduler.js'
5
6
 
6
7
  const ownsSchedule = (owner: Owner, schedule: Schedule) =>
7
8
  schedule.owner.telegramUserId === owner.telegramUserId &&
@@ -11,15 +12,45 @@ const ownsSchedule = (owner: Owner, schedule: Schedule) =>
11
12
  // Saved schedules created under an earlier model policy remain inspectable. The
12
13
  // executor will apply the same defaults (and enforce its current policy) when
13
14
  // it starts the run; an outdated saved selection must not hide every menu row.
14
- const displayedPreset = (execution: NonNullable<Schedule['execution']>) => {
15
- try { return executionDefaults(execution.preset.cli, execution.preset) }
16
- catch { return execution.preset }
15
+ const displayedPreset = (preset: AiPreset) => {
16
+ try { return executionDefaults(preset.cli, preset) }
17
+ catch { return preset }
18
+ }
19
+
20
+ const setupLabel = (preset: AiPreset) => {
21
+ const label = presetLabel(displayedPreset(preset))
22
+ return AUTH_PROFILE_CLIS.includes(preset.cli) && preset.authProfile === undefined
23
+ ? `${preset.cli} (default login)${label.slice(preset.cli.length)}` : label
17
24
  }
18
25
 
19
26
  // Script schedules have no AI preset; show what core actually invokes.
20
- const engineLabel = (schedule: ActiveSchedule) => schedule.script
27
+ const engineLabel = (schedule: ScheduledTaskView) => schedule.script
21
28
  ? `Script · ${schedule.script.id} · ${schedule.scriptRevision ? `rev ${schedule.scriptRevision.slice(0, 8)}` : 'not registered'}`
22
- : presetLabel(displayedPreset(schedule.execution!))
29
+ : [schedule.execution!.preset, ...schedule.execution!.fallbacks ?? []].map(setupLabel).join(' → ')
30
+
31
+ const every = (count: number, unit: string) => `Every ${count === 1 ? '' : `${count} `}${unit}${count === 1 ? '' : 's'}`
32
+ const frequency = (schedule: Schedule) => {
33
+ const trigger = schedule.trigger
34
+ if ('at' in trigger) return 'Once'
35
+ if ('everySeconds' in trigger) {
36
+ const seconds = trigger.everySeconds
37
+ for (const [size, unit] of [[86400,'day'],[3600,'hour'],[60,'minute']] as const)
38
+ if (seconds % size === 0) return every(seconds / size, unit)
39
+ return every(seconds, 'second')
40
+ }
41
+ const cron = trigger.cron.trim().replace(/\s+/g, ' ')
42
+ const [minute, hour, day, month, weekday] = cron.split(' ')
43
+ let label = `Cron ${cron}`
44
+ if (`${hour} ${day} ${month} ${weekday}` === '* * * *') {
45
+ const step = /^\*\/(\d+)$/.exec(minute)
46
+ if (minute === '*') label = 'Every minute'
47
+ else if (step && Number(step[1]) > 0 && 60 % Number(step[1]) === 0) label = every(Number(step[1]), 'minute')
48
+ else if (/^\d+$/.test(minute)) label = `Hourly at :${minute.padStart(2,'0')}`
49
+ } else if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && day === '*' && month === '*' && ['*','1-5'].includes(weekday)) {
50
+ label = `${weekday === '*' ? 'Daily' : 'Weekdays'} at ${hour.padStart(2,'0')}:${minute.padStart(2,'0')}`
51
+ }
52
+ return `${label} · ${trigger.timezone}`
53
+ }
23
54
 
24
55
  const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
25
56
  const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
@@ -36,41 +67,46 @@ const preview = (schedule: Schedule) => {
36
67
  return chars.length > 140 ? chars.slice(0,139).join('')+'…' : sentence
37
68
  }
38
69
 
39
- const currentState = (schedule: ActiveSchedule) =>
40
- schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' : '● Active'
70
+ const currentState = (schedule: ScheduledTaskView) =>
71
+ [schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' : undefined,
72
+ !schedule.enabled ? '⏸ Paused' : undefined].filter(Boolean).join(' · ') || '● Active'
41
73
 
42
- const lastRun = (schedule: ActiveSchedule) => {
74
+ const lastRun = (schedule: ScheduledTaskView) => {
43
75
  if (!schedule.lastRun) return '— Not run yet'
44
76
  const status = schedule.lastRun.status === 'completed' ? '✓ Completed' : schedule.lastRun.status === 'failed' ? '✕ Failed' : '⊘ Cancelled'
45
77
  return `${status} · ${time(schedule.lastRun.at)}${schedule.lastRun.currentRevision ? '' : ' · previous version'}`
46
78
  }
47
79
 
48
- export const ownedScheduledTasks = (schedules: ActiveSchedule[], owner: Owner) =>
80
+ export const ownedScheduledTasks = (schedules: ScheduledTaskView[], owner: Owner) =>
49
81
  schedules.filter((schedule) => ownsSchedule(owner, schedule))
50
82
  .sort((a, b) => a.name.localeCompare(b.name))
51
83
 
52
- export const scheduledTasksText = (schedules: ActiveSchedule[], owner: Owner) => {
84
+ export const scheduledTasksText = (schedules: ScheduledTaskView[], owner: Owner) => {
53
85
  const owned = ownedScheduledTasks(schedules, owner)
54
- if (!owned.length) return '📅 Scheduled tasks\n\nNo active scheduled tasks.'
55
- return [`📅 Scheduled tasks · ${owned.length} active`, 'All times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
56
- `${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n ${engineLabel(schedule)}\n ${preview(schedule)}`
86
+ if (!owned.length) return '📅 Scheduled tasks\n\nNo scheduled tasks.'
87
+ const paused = owned.filter(schedule => !schedule.enabled).length
88
+ return [`📅 Scheduled tasks · ${owned.length - paused} active · ${paused} paused`, 'Next/last times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
89
+ `${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Schedule · ${frequency(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n Execution · ${engineLabel(schedule)}\n ${preview(schedule)}`
57
90
  )].join('\n\n')
58
91
  }
59
92
 
60
- export const scheduledTaskDetailText = (schedule: ActiveSchedule, number: number, total?: number) => {
93
+ export const scheduledTaskDetailText = (schedule: ScheduledTaskView, number: number, total?: number) => {
61
94
  const instructions = schedule.script ? preview(schedule) : schedule.text.trim()
62
95
  const capped = instructions.length > 2000 ? instructions.slice(0, 2000) + '… (truncated; full text lives in the workspace)' : instructions
63
96
  return [
64
97
  `📅 ${number}${total === undefined ? '' : ` of ${total}`} · ${schedule.name}`,
65
98
  currentState(schedule),
66
99
  '',
100
+ 'Schedule',
101
+ frequency(schedule),
102
+ '',
67
103
  'Next run',
68
104
  schedule.nextAt === null ? '—' : time(schedule.nextAt),
69
105
  '',
70
106
  'Last run',
71
107
  lastRun(schedule),
72
108
  '',
73
- schedule.script ? 'Execution' : 'Engine',
109
+ 'Execution path',
74
110
  engineLabel(schedule),
75
111
  '',
76
112
  schedule.script ? 'Script arguments' : 'Instructions',
package/src/scheduler.ts CHANGED
@@ -29,7 +29,7 @@ export type ScheduledRunSummary = {
29
29
  at: string
30
30
  currentRevision: boolean
31
31
  }
32
- export type ActiveSchedule = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary; scriptRevision?: string }
32
+ export type ScheduledTaskView = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary; scriptRevision?: string }
33
33
  export type ScheduledOrigin = { id: string; revision: string; dueAt: string; pairedAt: string; originRunId?: string }
34
34
  export const validScheduledOrigin = (v: unknown): v is ScheduledOrigin => {
35
35
  const s = v as ScheduledOrigin
@@ -102,10 +102,9 @@ export class Scheduler {
102
102
  return nextOccurrence(s.trigger,-1)
103
103
  }
104
104
  }
105
- async listActiveReadOnly(runs: RunRecord[]): Promise<ActiveSchedule[]> {
106
- const active: ActiveSchedule[] = []
105
+ async listTasksReadOnly(runs: RunRecord[]): Promise<ScheduledTaskView[]> {
106
+ const active: ScheduledTaskView[] = []
107
107
  for (const s of await this.listReadOnly()) {
108
- if (!s.enabled) continue
109
108
  const current = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.revision === s.revision && ownsRun(s.owner,r))
110
109
  const history = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.pairedAt === s.owner.pairedAt && ownsRun(s.owner,r))
111
110
  .filter((r): r is RunRecord & { status: ScheduledRunSummary['status'] } =>
@@ -113,11 +112,11 @@ export class Scheduler {
113
112
  .sort((a,b) => (b.endedAt ?? b.startedAt ?? b.createdAt).localeCompare(a.endedAt ?? a.startedAt ?? a.createdAt))
114
113
  const last = history[0]
115
114
  const runState = current.some(r => r.status === 'running') ? 'running' : current.some(r => r.status === 'queued') ? 'queued' : undefined
116
- if (!runState && current.some(r => holdsSchedule(s,r))) continue
115
+ if (s.enabled && !runState && current.some(r => holdsSchedule(s,r))) continue
117
116
  try {
118
- const nextAt = await this.pendingOccurrence(s)
117
+ const nextAt = s.enabled ? await this.pendingOccurrence(s) : null
119
118
  const scriptRevision = s.script ? await new Scripts(this.controlDir).get(s.script.id).then(r => r.revision, () => undefined) : undefined
120
- if (runState || nextAt !== null) active.push({...s,nextAt,runState,...(scriptRevision ? {scriptRevision} : {}),...(last ? {
119
+ if (!s.enabled || runState || nextAt !== null) active.push({...s,nextAt,runState,...(scriptRevision ? {scriptRevision} : {}),...(last ? {
121
120
  lastRun: {status:last.status,at:last.endedAt ?? last.startedAt ?? last.createdAt,currentRevision:last.scheduled!.revision===s.revision},
122
121
  } : {})})
123
122
  }
@@ -144,7 +143,7 @@ export class Scheduler {
144
143
  assertScheduledModel(preset.model)
145
144
  assertEffort(preset.effort, preset.model, preset.cli)
146
145
  }
147
- const key = (p: AiPreset) => JSON.stringify([p.cli, p.provider, p.model, p.effort])
146
+ const key = (p: AiPreset) => JSON.stringify([p.cli, p.authProfile, p.provider, p.model, p.effort])
148
147
  if (new Set(setups.map(key)).size !== setups.length) throw new Error('Task setups must be distinct')
149
148
  s = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
150
149
  }
@@ -0,0 +1,17 @@
1
+ import type { SessionState, Owner } from './control-state.js'
2
+ import { ownerId, ownerEpoch } from './control-state.js'
3
+ import type { RunStore, RunRecord } from './runs.js'
4
+
5
+ // A chat conversation has no native end. Before one is replaced, its engine
6
+ // gets one silent turn in that same native session to keep durable facts in
7
+ // the workspace. Ez only transports this literal; the engine decides whether
8
+ // anything is written. No reply mechanism is offered, so nothing is delivered.
9
+ // Owner conversations only: a close turn carries owner authority, so a private
10
+ // application scope (which runs under its binding's narrower authority) gets none.
11
+ export const SESSION_CLOSE_PROMPT = '[conversation closing] This conversation is ending. Save anything a future conversation will need where AGENTS.md says to keep memory; otherwise do nothing.'
12
+
13
+ export async function queueSessionClose(runs: RunStore, owner: Owner | null, session: SessionState | null | undefined): Promise<RunRecord | undefined> {
14
+ if (!owner || !session?.hasStarted || !session.preset || session.applicationScope) return undefined
15
+ return runs.create({ ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner), texts: [SESSION_CLOSE_PROMPT],
16
+ execution: { sessionId: session.sessionId, preset: session.preset }, sessionClose: true })
17
+ }