@jc_stack/ez-agents 0.1.0-beta.18 → 0.1.0-beta.19

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 (53) hide show
  1. package/.env.example +5 -0
  2. package/AGENTS.md +6 -0
  3. package/CHANGELOG.md +14 -0
  4. package/CONTRIBUTING.md +29 -3
  5. package/Dockerfile +6 -0
  6. package/README.md +8 -2
  7. package/bin/ezenciel-agents-watch.mjs +8 -0
  8. package/compose.workforce-watch.yaml +33 -0
  9. package/docs/architecture/ai-selection.md +14 -7
  10. package/docs/plugin-catalog.md +1 -0
  11. package/docs/plugins.md +34 -0
  12. package/docs/responsive-channels.md +57 -0
  13. package/docs/scheduling.md +6 -4
  14. package/docs/setup.md +10 -6
  15. package/docs/workforce-watch.md +101 -0
  16. package/package.json +4 -2
  17. package/src/agent-guidance.ts +4 -0
  18. package/src/ai.ts +14 -6
  19. package/src/control-state.ts +5 -3
  20. package/src/desktop-bridge.ts +4 -2
  21. package/src/executor.ts +4 -2
  22. package/src/host-executor-client.ts +7 -1
  23. package/src/index.ts +58 -18
  24. package/src/menu.ts +4 -4
  25. package/src/model-policy.ts +8 -5
  26. package/src/plugins/manager.mjs +70 -2
  27. package/src/reply-context.ts +7 -3
  28. package/src/reply-executor.ts +2 -1
  29. package/src/reply-mcp.ts +1 -1
  30. package/src/runs.ts +0 -13
  31. package/src/schedule-cli.ts +1 -1
  32. package/src/scheduled-tasks.ts +33 -0
  33. package/src/scheduler.ts +11 -2
  34. package/src/setup.ts +2 -2
  35. package/src/task-executor.ts +2 -1
  36. package/src/updates/runtime.mjs +2 -1
  37. package/src/workforce-watch-cli.ts +14 -0
  38. package/src/workforce-watch.ts +155 -0
  39. package/templates/agent-guidance.md +11 -0
  40. package/templates/chat-guidance.md +23 -0
  41. package/test/agent-guidance.test.ts +15 -0
  42. package/test/ai.test.ts +40 -1
  43. package/test/event-sources.test.ts +4 -0
  44. package/test/failure.test.ts +13 -7
  45. package/test/host-executor.test.ts +16 -0
  46. package/test/intake-relay.test.ts +4 -0
  47. package/test/model-policy.test.ts +9 -1
  48. package/test/plugin-manager.test.mjs +49 -0
  49. package/test/reply.test.ts +22 -0
  50. package/test/runs.test.ts +7 -0
  51. package/test/schedule-cli.test.ts +2 -0
  52. package/test/scheduled-tasks.test.ts +43 -0
  53. package/test/workforce-watch.test.ts +180 -0
@@ -0,0 +1,14 @@
1
+ import { WorkforceWatch, WorkforceWatchServer } from './workforce-watch.js'
2
+ import { readFileSync } from 'node:fs'
3
+ const positive=(value:string|undefined,name:string,fallback:number):number=>{if(!value)return fallback;const parsed=Number(value);if(!Number.isSafeInteger(parsed)||parsed<=0)throw new Error(`${name} must be a positive integer`);return parsed}
4
+ const pagerDutyRoutingKey=()=>{const secretFile='/run/secrets/workforce_watch_pagerduty';try{const value=readFileSync(secretFile,'utf8').trim();if(!value||/[\r\n]/.test(value))throw new Error('Workforce PagerDuty secret must contain exactly one routing key');return value}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return undefined;throw error}}
5
+ const enrollmentToken=process.env.EZ_WATCH_ENROLL_TOKEN?.trim();if(!enrollmentToken)throw new Error('EZ_WATCH_ENROLL_TOKEN is required')
6
+ const telegramToken=process.env.TELEGRAM_BOT_TOKEN?.trim(),telegramChatId=process.env.EZ_WATCH_TELEGRAM_CHAT_ID?.trim();if(Boolean(telegramToken)!==Boolean(telegramChatId))throw new Error('Set both TELEGRAM_BOT_TOKEN and EZ_WATCH_TELEGRAM_CHAT_ID, or neither')
7
+ const routingKey=pagerDutyRoutingKey()
8
+ const notify=telegramToken&&telegramChatId?async(text:string)=>{const response=await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({chat_id:telegramChatId,text,disable_web_page_preview:true}),redirect:'error',signal:AbortSignal.timeout(10000)});if(!response.ok)throw new Error(`Telegram delivery returned HTTP ${response.status}`)}:undefined
9
+ const page=routingKey?async(event:import('./workforce-watch.js').WorkforcePage)=>{const response=await fetch('https://events.pagerduty.com/v2/enqueue',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({routing_key:routingKey,event_action:event.action,dedup_key:event.dedupKey,payload:{summary:event.summary,source:event.source,severity:event.severity,component:event.dedupKey.slice('ez:workforce:'.length),custom_details:event.customDetails}}),redirect:'error',signal:AbortSignal.timeout(10000)});if(!response.ok)throw new Error(`PagerDuty delivery returned HTTP ${response.status}`)}:undefined
10
+ if(!page)throw new Error('Mount the workforce PagerDuty secret at /run/secrets/workforce_watch_pagerduty')
11
+ const watch=new WorkforceWatch({stateDir:process.env.EZ_WATCH_STATE_DIR?.trim()||'/state',enrollmentToken,recoveryThreshold:positive(process.env.EZ_WATCH_RECOVERY_CHECKS,'EZ_WATCH_RECOVERY_CHECKS',2),notify,page,log:message=>console.error(message)})
12
+ const port=positive(process.env.EZ_WATCH_PORT,'EZ_WATCH_PORT',8080),host=process.env.EZ_WATCH_HOST?.trim()||'0.0.0.0',evaluateMs=positive(process.env.EZ_WATCH_EVALUATE_SECONDS,'EZ_WATCH_EVALUATE_SECONDS',30)*1000,server=new WorkforceWatchServer(watch,enrollmentToken)
13
+ await server.listen(port,host);watch.start(evaluateMs);console.log(`Workforce Watch listening on ${host}:${port}`)
14
+ for(const signal of ['SIGINT','SIGTERM'] as const)process.once(signal,()=>{watch.stop();void server.close().finally(()=>process.exit(0))})
@@ -0,0 +1,155 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
3
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+
6
+ export type WatchStatus = 'ok' | 'failed'
7
+ export type WatchSeverity = 'warning' | 'critical'
8
+ export type WatchEvent = { at: string; status: WatchStatus; activity?: string; error?: string; runId?: string; logsHint?: string; terminal?: boolean }
9
+ export type WorkforcePage = { action: 'trigger' | 'resolve'; dedupKey: string; summary: string; severity: WatchSeverity; source: string; customDetails: Record<string, string> }
10
+ type Incident = { openedAt: number; reason: 'missed-check-in' | 'terminal-failure'; notifiedAt?: number; pagerDutyTriggeredAt?: number; recoveredAt?: number; telegramRecoveredAt?: number; pagerDutyResolvedAt?: number }
11
+ type Worker = { id: string; tokenHash: string; checkInMs: number; graceMs: number; severity: WatchSeverity; runbookUrl?: string; createdAt: number; lastSeenAt: number; recoveryChecks: number; history: WatchEvent[]; incident?: Incident }
12
+ type State = { version: 1; workers: Record<string, Worker> }
13
+ export type EnrollRequest = { workerId: string; checkInSeconds: number; graceSeconds: number; severity?: WatchSeverity; runbookUrl?: string }
14
+ export type CheckInRequest = { status: WatchStatus; activity?: string; error?: string; runId?: string; logsHint?: string; terminal?: boolean }
15
+ export type WorkforceWatchOptions = { stateDir: string; enrollmentToken: string; recoveryThreshold?: number; notify?: (message: string) => Promise<void>; page?: (event: WorkforcePage) => Promise<void>; now?: () => number; log?: (message: string) => void }
16
+
17
+ const WORKER_ID = /^[a-z][a-z0-9-]{0,63}$/
18
+ const MAX_HISTORY = 5, MAX_BODY_BYTES = 8192
19
+ const hash = (value: string): string => createHash('sha256').update(value).digest('hex')
20
+ const sameSecret = (candidate: string, expectedHash: string): boolean => {
21
+ const a = Buffer.from(hash(candidate), 'hex'), b = Buffer.from(expectedHash, 'hex')
22
+ return a.length === b.length && timingSafeEqual(a, b)
23
+ }
24
+ const positive = (value: unknown, field: string, minimum: number, maximum: number): number => {
25
+ if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) throw new Error(`${field} must be an integer from ${minimum} to ${maximum}`)
26
+ return value as number
27
+ }
28
+ const text = (value: unknown, field: string, maximum: number): string | undefined => {
29
+ if (value === undefined) return undefined
30
+ if (typeof value !== 'string' || !value.trim() || value.length > maximum) throw new Error(`Invalid ${field}`)
31
+ return value.trim()
32
+ }
33
+ const validateEnroll = (value: unknown): EnrollRequest => {
34
+ if (!value || typeof value !== 'object') throw new Error('Invalid enrollment')
35
+ const input = value as Record<string, unknown>, severity = input.severity === undefined ? 'critical' : input.severity
36
+ if (typeof input.workerId !== 'string' || !WORKER_ID.test(input.workerId)) throw new Error('Invalid workerId')
37
+ if (severity !== 'warning' && severity !== 'critical') throw new Error('Invalid severity')
38
+ const runbookUrl = text(input.runbookUrl, 'runbookUrl', 500)
39
+ if (runbookUrl) { let url: URL; try { url = new URL(runbookUrl) } catch { throw new Error('Invalid runbookUrl') }; if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('Invalid runbookUrl') }
40
+ return { workerId: input.workerId, checkInSeconds: positive(input.checkInSeconds, 'checkInSeconds', 10, 86400), graceSeconds: positive(input.graceSeconds, 'graceSeconds', 0, 86400), severity, runbookUrl }
41
+ }
42
+ const validateCheckIn = (value: unknown): CheckInRequest => {
43
+ if (!value || typeof value !== 'object') throw new Error('Invalid check-in')
44
+ const input = value as Record<string, unknown>
45
+ if (input.status !== 'ok' && input.status !== 'failed') throw new Error('Invalid status')
46
+ if (input.terminal !== undefined && typeof input.terminal !== 'boolean') throw new Error('Invalid terminal')
47
+ return { status: input.status, activity: text(input.activity, 'activity', 300), error: text(input.error, 'error', 500), runId: text(input.runId, 'runId', 120), logsHint: text(input.logsHint, 'logsHint', 500), terminal: input.terminal }
48
+ }
49
+ const errorText = (error: unknown): string => error instanceof Error ? error.message : 'unknown error'
50
+
51
+ export class WorkforceWatch {
52
+ private readonly recoveryThreshold: number
53
+ private readonly now: () => number
54
+ private serial: Promise<unknown> = Promise.resolve()
55
+ private timer?: ReturnType<typeof setInterval>
56
+ constructor(private readonly options: WorkforceWatchOptions) {
57
+ if (!options.enrollmentToken.trim()) throw new Error('EZ_WATCH_ENROLL_TOKEN is required')
58
+ this.recoveryThreshold = positive(options.recoveryThreshold ?? 2, 'recoveryThreshold', 1, 10); this.now = options.now ?? Date.now
59
+ }
60
+ private get stateFile(): string { return join(this.options.stateDir, 'workforce-watch.json') }
61
+ private async state(): Promise<State> {
62
+ await mkdir(this.options.stateDir, { recursive: true, mode: 0o700 })
63
+ await chmod(this.options.stateDir, 0o700)
64
+ try { const parsed: unknown = JSON.parse(await readFile(this.stateFile, 'utf8')); if (!parsed || typeof parsed !== 'object' || (parsed as {version?:unknown}).version !== 1 || typeof (parsed as {workers?:unknown}).workers !== 'object') throw new Error('Invalid workforce watch state'); return parsed as State }
65
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, workers: {} }; throw error }
66
+ }
67
+ private async save(state: State): Promise<void> { const temporary = `${this.stateFile}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; await writeFile(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 }); await rename(temporary, this.stateFile) }
68
+ private enqueue<T>(work: () => Promise<T>): Promise<T> { const result = this.serial.then(work, work); this.serial = result.then(() => undefined, () => undefined); return result }
69
+ private latest(worker: Worker): WatchEvent | undefined { return worker.history.at(-1) }
70
+ private format(worker: Worker, action: 'opened' | 'recovered'): string {
71
+ const latest = this.latest(worker), lines = [`Workforce Watch ${action === 'opened' ? 'alert' : 'recovered'}: ${worker.id}`, action === 'opened' ? `Reason: ${worker.incident?.reason === 'terminal-failure' ? 'terminal failure' : 'missed check-in'}` : 'Recovery: required clean check-ins received', `Severity: ${worker.severity}`]
72
+ if (latest?.activity) lines.push(`Last activity: ${latest.activity}`); if (latest?.error) lines.push(`Last error: ${latest.error}`); if (latest?.runId) lines.push(`Run: ${latest.runId}`); if (latest) lines.push(`Last seen: ${latest.at}`); if (latest?.logsHint) lines.push(`Next: ${latest.logsHint}`); if (worker.runbookUrl) lines.push(`Runbook: ${worker.runbookUrl}`)
73
+ return lines.join('\n')
74
+ }
75
+ private page(worker: Worker, action: 'trigger' | 'resolve'): WorkforcePage {
76
+ const latest = this.latest(worker), incident = worker.incident!
77
+ const customDetails: Record<string, string> = { reason: incident.reason, lastSeen: latest?.at ?? new Date(worker.lastSeenAt).toISOString() }
78
+ if (latest?.activity) customDetails.activity = latest.activity
79
+ if (latest?.error) customDetails.error = latest.error
80
+ if (latest?.runId) customDetails.runId = latest.runId
81
+ if (latest?.logsHint) customDetails.logsHint = latest.logsHint
82
+ if (worker.runbookUrl) customDetails.runbookUrl = worker.runbookUrl
83
+ return { action, dedupKey: `ez:workforce:${worker.id}`, summary: `Workforce Watch ${action}: ${worker.id}`, severity: worker.severity, source: 'ez-workforce-watch', customDetails }
84
+ }
85
+ private reopen(worker: Worker, now: number, reason: Incident['reason']): void {
86
+ const incident = worker.incident
87
+ if (!incident) { worker.incident = { openedAt: now, reason }; return }
88
+ if (incident.recoveredAt === undefined) return
89
+ if (incident.pagerDutyResolvedAt !== undefined) delete incident.pagerDutyTriggeredAt
90
+ if (incident.telegramRecoveredAt !== undefined) delete incident.notifiedAt
91
+ delete incident.pagerDutyResolvedAt
92
+ delete incident.telegramRecoveredAt
93
+ delete incident.recoveredAt
94
+ incident.openedAt = now
95
+ incident.reason = reason
96
+ worker.recoveryChecks = 0
97
+ }
98
+ private async notifyOpen(state: State, worker: Worker): Promise<void> {
99
+ const incident = worker.incident; if (!incident) return
100
+ let failure: unknown, changed = false
101
+ if (incident.pagerDutyTriggeredAt === undefined && this.options.page) try { await this.options.page(this.page(worker, 'trigger')); incident.pagerDutyTriggeredAt = this.now(); changed = true } catch (error) { failure ??= error }
102
+ if (incident.notifiedAt === undefined && this.options.notify) try { await this.options.notify(this.format(worker, 'opened')); incident.notifiedAt = this.now(); changed = true } catch (error) { failure ??= error }
103
+ if (changed) await this.save(state)
104
+ if (failure) throw failure
105
+ }
106
+ private async notifyRecovery(state: State, worker: Worker): Promise<void> {
107
+ const incident = worker.incident; if (incident?.recoveredAt === undefined) return
108
+ let failure: unknown, changed = false
109
+ if (incident.pagerDutyTriggeredAt !== undefined && incident.pagerDutyResolvedAt === undefined && this.options.page) try { await this.options.page(this.page(worker, 'resolve')); incident.pagerDutyResolvedAt = this.now(); changed = true } catch (error) { failure ??= error }
110
+ if (incident.notifiedAt !== undefined && incident.telegramRecoveredAt === undefined && this.options.notify) try { await this.options.notify(this.format(worker, 'recovered')); incident.telegramRecoveredAt = this.now(); changed = true } catch (error) { failure ??= error }
111
+ const pagerDutyComplete = incident.pagerDutyTriggeredAt === undefined || incident.pagerDutyResolvedAt !== undefined
112
+ const telegramComplete = incident.notifiedAt === undefined || incident.telegramRecoveredAt !== undefined
113
+ if (changed && !(pagerDutyComplete && telegramComplete)) await this.save(state)
114
+ if (failure) throw failure
115
+ if (!(pagerDutyComplete && telegramComplete)) return
116
+ worker.incident = undefined
117
+ worker.recoveryChecks = 0
118
+ await this.save(state)
119
+ }
120
+ async enroll(token: string, input: unknown): Promise<{workerId:string;workerToken:string}> {
121
+ if (!sameSecret(token, hash(this.options.enrollmentToken))) throw new Error('Unauthorized')
122
+ const request = validateEnroll(input)
123
+ return this.enqueue(async () => { const state = await this.state(); if (state.workers[request.workerId]) throw new Error('Worker already enrolled'); const workerToken = randomBytes(32).toString('base64url'), now = this.now(); state.workers[request.workerId] = { id:request.workerId, tokenHash:hash(workerToken), checkInMs:request.checkInSeconds*1000, graceMs:request.graceSeconds*1000, severity:request.severity ?? 'critical', runbookUrl:request.runbookUrl, createdAt:now, lastSeenAt:now, recoveryChecks:0, history:[] }; await this.save(state); return {workerId:request.workerId,workerToken} })
124
+ }
125
+ async checkIn(workerId: string, token: string, input: unknown): Promise<{incident:boolean}> {
126
+ if (!WORKER_ID.test(workerId)) throw new Error('Unauthorized'); const request = validateCheckIn(input)
127
+ return this.enqueue(async () => { const state = await this.state(), worker = state.workers[workerId]; if (!worker || !sameSecret(token,worker.tokenHash)) throw new Error('Unauthorized'); const now = this.now(), event:WatchEvent={at:new Date(now).toISOString(),...request}; worker.lastSeenAt=now; worker.history=[...worker.history,event].slice(-MAX_HISTORY)
128
+ if (request.terminal) { worker.recoveryChecks=0; this.reopen(worker,now,'terminal-failure') }
129
+ else if (worker.incident?.recoveredAt !== undefined) { if(request.status==='failed') this.reopen(worker,now,worker.incident.reason) }
130
+ else if (worker.incident && request.status==='ok') { worker.recoveryChecks += 1; if (worker.recoveryChecks >= this.recoveryThreshold) { worker.incident.recoveredAt=now; await this.save(state); await this.notifyRecovery(state,worker); return {incident:Boolean(worker.incident)} } }
131
+ else if (request.status==='failed') worker.recoveryChecks=0
132
+ await this.save(state); await this.notifyOpen(state,worker); return {incident:Boolean(worker.incident)} })
133
+ }
134
+ async rotate(token: string, workerId: string): Promise<{workerId:string;workerToken:string}> {
135
+ if (!sameSecret(token, hash(this.options.enrollmentToken)) || !WORKER_ID.test(workerId)) throw new Error('Unauthorized')
136
+ return this.enqueue(async () => { const state=await this.state(), worker=state.workers[workerId]; if(!worker) throw new Error('Not found'); const workerToken=randomBytes(32).toString('base64url'); worker.tokenHash=hash(workerToken); worker.lastSeenAt=this.now(); worker.recoveryChecks=0; await this.save(state); return {workerId,workerToken} })
137
+ }
138
+ async evaluate(): Promise<void> { await this.enqueue(async () => { const state=await this.state(), now=this.now(); let changed=false; for (const worker of Object.values(state.workers)) if ((!worker.incident || worker.incident.recoveredAt !== undefined) && now > worker.lastSeenAt+worker.checkInMs+worker.graceMs) { this.reopen(worker,now,'missed-check-in'); worker.recoveryChecks=0; changed=true }; if(changed) await this.save(state); for(const worker of Object.values(state.workers)) { if(worker.incident?.recoveredAt !== undefined) await this.notifyRecovery(state,worker); else await this.notifyOpen(state,worker) } }) }
139
+ async inspect(workerId?: string): Promise<unknown> { return this.enqueue(async () => { const state=await this.state(); const at=(value:number|undefined):string|undefined=>value===undefined?undefined:new Date(value).toISOString(), redact=(worker:Worker) => ({id:worker.id,checkInSeconds:worker.checkInMs/1000,graceSeconds:worker.graceMs/1000,severity:worker.severity,runbookUrl:worker.runbookUrl,createdAt:new Date(worker.createdAt).toISOString(),lastSeenAt:new Date(worker.lastSeenAt).toISOString(),incident:worker.incident&&{openedAt:at(worker.incident.openedAt),reason:worker.incident.reason,notifiedAt:at(worker.incident.notifiedAt),pagerDutyTriggeredAt:at(worker.incident.pagerDutyTriggeredAt),recoveredAt:at(worker.incident.recoveredAt),telegramRecoveredAt:at(worker.incident.telegramRecoveredAt),pagerDutyResolvedAt:at(worker.incident.pagerDutyResolvedAt)},history:worker.history}); if(workerId){const worker=state.workers[workerId];if(!worker)throw new Error('Not found');return redact(worker)} return Object.values(state.workers).map(redact) }) }
140
+ start(evaluateMs: number): void { if(this.timer)return; void this.evaluate().catch(e=>this.options.log?.(`Initial evaluation failed: ${errorText(e)}`)); this.timer=setInterval(()=>void this.evaluate().catch(e=>this.options.log?.(`Evaluation failed: ${errorText(e)}`)),evaluateMs);this.timer.unref() }
141
+ stop(): void { if(this.timer)clearInterval(this.timer);this.timer=undefined }
142
+ }
143
+
144
+ const bearer = (request:IncomingMessage): string|undefined => request.headers.authorization?.startsWith('Bearer ') ? request.headers.authorization.slice(7) : undefined
145
+ const json = (response:ServerResponse,status:number,value:unknown):void => { response.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});response.end(JSON.stringify(value)) }
146
+ const requestJson = async (request:IncomingMessage):Promise<unknown> => { let body='';for await(const chunk of request){body+=chunk;if(Buffer.byteLength(body)>MAX_BODY_BYTES)throw new Error('Request too large')}try{return JSON.parse(body)}catch{throw new Error('Invalid JSON')} }
147
+ export class WorkforceWatchServer {
148
+ private server?:Server
149
+ constructor(private readonly watch:WorkforceWatch,private readonly enrollmentToken:string,private readonly log:(message:string)=>void=console.error) {}
150
+ async listen(port:number,host:string):Promise<void> { if(this.server)throw new Error('Server already listening');this.server=createServer((request,response)=>void this.route(request,response));await new Promise<void>((resolve,reject)=>{this.server!.once('error',reject);this.server!.listen(port,host,resolve)}) }
151
+ async close():Promise<void> { if(!this.server)return;await new Promise<void>((resolve,reject)=>this.server!.close(error=>error?reject(error):resolve()));this.server=undefined }
152
+ port():number { const address=this.server?.address(); if (!address || typeof address === 'string') throw new Error('Server is not listening'); return address.port }
153
+ private authorized(token:string|undefined):boolean { return Boolean(token)&&sameSecret(token!,hash(this.enrollmentToken)) }
154
+ private async route(request:IncomingMessage,response:ServerResponse):Promise<void> { try { const url=new URL(request.url??'/','http://localhost');if(request.method==='GET'&&url.pathname==='/healthz')return json(response,200,{status:'ok'});if(request.method==='POST'&&url.pathname==='/v1/enroll'){const token=bearer(request);if(!this.authorized(token))return json(response,401,{error:'unauthorized'});return json(response,201,await this.watch.enroll(token!,await requestJson(request)))}if(request.method==='GET'&&(url.pathname==='/v1/workers'||/^\/v1\/workers\/[a-z][a-z0-9-]{0,63}$/.test(url.pathname))){if(!this.authorized(bearer(request)))return json(response,401,{error:'unauthorized'});return json(response,200,await this.watch.inspect(url.pathname==='/v1/workers'?undefined:url.pathname.slice(12)))}const rotate=request.method==='POST'&&url.pathname.match(/^\/v1\/workers\/([a-z][a-z0-9-]{0,63})\/rotate$/);if(rotate){const token=bearer(request);if(!this.authorized(token))return json(response,401,{error:'unauthorized'});return json(response,200,await this.watch.rotate(token!,rotate[1]))}const match=request.method==='POST'&&url.pathname.match(/^\/v1\/workers\/([a-z][a-z0-9-]{0,63})\/check-in$/);if(match){try{return json(response,200,await this.watch.checkIn(match[1],bearer(request)??'',await requestJson(request)))}catch(error){if(errorText(error)==='Unauthorized')return json(response,401,{error:'unauthorized'});throw error}}return json(response,404,{error:'not found'}) }catch(error){this.log(`Workforce Watch request failed: ${errorText(error)}`);return json(response,400,{error:'invalid request'})} }
155
+ }
@@ -11,3 +11,14 @@ brief, relevant files, acceptance criteria, and a stopping point. Choose
11
11
  delegation, model, and effort from the task—not a fixed routing rule. Keep one
12
12
  writer per workspace; the primary agent owns integration, verification, and
13
13
  external actions.
14
+
15
+ ## Telegram replies
16
+
17
+ Use the messaging CLI for the current run's source chat, normally the paired
18
+ owner/admin Telegram chat. It cannot choose another recipient; never put a chat
19
+ ID in a message command. Format the payload as Telegram text: use actual newline
20
+ characters for paragraphs and lists. The literal strings `\n`, `\\n`, or `/n` are
21
+ visible text, not line breaks. For multiline replies, prefer
22
+ `ezenciel-agents-message --text-file ./work/reply.md` and put the real line
23
+ breaks in that file. Keep replies concise and use ordinary Markdown where it
24
+ improves readability.
@@ -0,0 +1,23 @@
1
+ # Responsive conversation
2
+
3
+ Treat a chat channel as a conversation with the person, whether Telegram,
4
+ WhatsApp, or another connected channel. Keep the turn focused and respond
5
+ concisely using the current conversation and verified receipts. Read more
6
+ context only when the answer or action requires it; do not reload history,
7
+ explore files, or narrate a plan for a simple reply.
8
+
9
+ Complete small authorized actions directly and check their receipts. For
10
+ substantial work, use an available, authorized durable handoff tool, then end
11
+ the conversational turn after it returns a task ID. Do not wait or poll here
12
+ for the worker. Never claim work was delegated before that receipt exists.
13
+ If this session lacks a delegation capability, use its available reporting
14
+ path to explain the limitation; do not invent a tool or expand permissions.
15
+
16
+ Choose the worker's model and effort for the difficulty and consequences of
17
+ the job, independently of the conversational choice. Include the objective,
18
+ relevant context and paths, constraints, authorized actions, acceptance checks,
19
+ and where to deliver the result. Use native subagents within the worker when
20
+ useful. Preserve one writer per workspace and coordinate shared resources.
21
+ The worker owns completing and verifying the job and delivering the result;
22
+ a quick conversational reply is not completion. If the person asks for status,
23
+ check actual task evidence and distinguish queued, running, and verified results.
@@ -8,6 +8,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
8
8
  import { promisify } from 'node:util'
9
9
  import test from 'node:test'
10
10
  import { desktopJobPrompt } from '../src/desktop-bridge.js'
11
+ import { chatGuidance } from '../src/agent-guidance.js'
11
12
  import { executorJobPrompt } from '../src/executor.js'
12
13
  import { taskArguments } from '../src/task-executor.js'
13
14
  import { initializeWorkspace } from '../src/workspace.js'
@@ -27,7 +28,21 @@ test('CLI and desktop prompt builders use current package guidance', async () =>
27
28
  ['desktop', desktopJobPrompt('tg_owner_gui', ['owner request'], undefined, '/tmp/bin', '/tmp/control')],
28
29
  ] as const
29
30
  for (const [kind, prompt] of prompts)
31
+ {
30
32
  assert.ok(prompt.includes(shared), `${kind} prompt is missing the current package guidance`)
33
+ assert.ok(prompt.includes(chatGuidance()), `${kind} prompt is missing channel guidance`)
34
+ }
35
+ assert.ok(!executorJobPrompt('r_schedule_job', ['work']).includes(chatGuidance()))
36
+ })
37
+
38
+ test('shared guidance teaches source-chat delivery and real Telegram line breaks', async () => {
39
+ const shared = await readFile(sharedGuidancePath, 'utf8')
40
+ assert.ok(shared.includes("current run's source chat"))
41
+ assert.match(shared, /actual newline\s+characters/)
42
+ assert.ok(shared.includes('`\\n`'))
43
+ assert.ok(shared.includes('`\\\\n`'))
44
+ assert.ok(shared.includes('`/n`'))
45
+ assert.ok(shared.includes('ezenciel-agents-message --text-file ./work/reply.md'))
31
46
  })
32
47
 
33
48
  test('package guidance resolution ignores a workspace shadow file', async () => {
package/test/ai.test.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { ControlStore } from '../src/control-state.js'
7
- import { initialPreset, readModels, isPreset } from '../src/ai.js'
7
+ import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
8
8
  import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
9
9
  import { InboxStore } from '../src/inbox.js'
10
10
  import type { Update } from 'grammy/types'
@@ -80,10 +80,13 @@ test('model catalog projects native metadata only, excluding hidden entries and
80
80
  { slug: 'fixture-model', display_name: 'Fixture', visibility: 'list',
81
81
  supported_reasoning_levels: [{ effort: 'medium' }, { effort: 'bad value' }],
82
82
  model_messages: 'Untrusted instructions must not be imported', api_key: 'fixture-secret' },
83
+ { slug: 'gpt-5.6-luna', display_name: 'Luna', visibility: 'list',
84
+ supported_reasoning_levels: [{ effort: 'high' }, { effort: 'xhigh' }, { effort: 'max' }] },
83
85
  { slug: 'hidden-model', visibility: 'hide' },
84
86
  ] }))
85
87
  assert.deepEqual(await readModels(home, async (cli) => cli === 'codex'), [
86
88
  { cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
89
+ { cli: 'codex', model: 'gpt-5.6-luna', name: 'Luna', efforts: ['high', 'xhigh'] },
87
90
  ])
88
91
  assert.equal(isPreset({ id: 'x', name: 'x', cli: 'grok', model: '--shell escape' }), false)
89
92
  } finally { await rm(home, { recursive: true, force: true }) }
@@ -144,3 +147,39 @@ for (const cli of ['codex', 'codex-gui']) {
144
147
  } finally { await rm(dir, { recursive: true, force: true }) }
145
148
  })
146
149
  }
150
+
151
+ for (const cli of ['codex', 'codex-gui']) {
152
+ test(`${cli} separates responsive chat from worker defaults and preserves upgrade choices`, async () => {
153
+ const dir = await mkdtemp(join(tmpdir(), 'ez-chat-default-'))
154
+ try {
155
+ const store = new ControlStore(dir, 1000)
156
+ await store.syncClientPresets(chatPreset(cli), [])
157
+ const chat = await store.captureChoice(chatPreset(cli))
158
+ assert.equal(chat.preset.model, 'gpt-5.6-sol')
159
+ assert.equal(chat.preset.effort, 'medium')
160
+ assert.equal(initialPreset(cli).model, 'gpt-5.6-terra')
161
+ assert.equal(initialPreset(cli).effort, 'high')
162
+ const old = initialPreset(cli)
163
+ await store.savePreset(old)
164
+ await store.defaultPreset(old.id)
165
+ await store.resetSession()
166
+ const captured = await store.captureChoice(old)
167
+ await store.syncClientPresets(chatPreset(cli), [])
168
+ assert.deepEqual(await store.captureChoice(chatPreset(cli)), captured)
169
+ assert.equal((await store.aiState(chatPreset(cli))).presets.filter(p => p.id === 'chat-default').length, 1)
170
+ } finally { await rm(dir, { recursive: true, force: true }) }
171
+ })
172
+ }
173
+
174
+ test('upgrades expose responsive chat without replacing an existing default or queued snapshot', async () => {
175
+ const dir = await mkdtemp(join(tmpdir(), 'ez-chat-upgrade-'))
176
+ try {
177
+ const store = new ControlStore(dir, 1000), old = initialPreset('codex')
178
+ const captured = await store.captureChoice(old)
179
+ await store.syncClientPresets(chatPreset('codex'), [])
180
+ assert.deepEqual(await store.captureChoice(chatPreset('codex')), captured)
181
+ const state = await store.aiState(chatPreset('codex'))
182
+ assert.equal(state.defaultId, old.id)
183
+ assert.ok(state.presets.some(p => p.id === 'chat-default'))
184
+ } finally { await rm(dir, { recursive: true, force: true }) }
185
+ })
@@ -133,6 +133,8 @@ test('relay launches the approved initial task and routes only matching replies
133
133
  await f.relay.drainSources()
134
134
  assert.equal(f.launches.length, 1); assert.equal(f.launches[0].options.cli, 'codex')
135
135
  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')
136
138
  f.children[0].kill()
137
139
  await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
138
140
  const receivedAt = Date.now()
@@ -141,6 +143,8 @@ test('relay launches the approved initial task and routes only matching replies
141
143
  await f.relay.drainSources()
142
144
  assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.eventSource, 'fixture')
143
145
  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')
144
148
  f.children[1].kill()
145
149
  await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
146
150
  await f.relay.drainSources()
@@ -137,7 +137,7 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
137
137
  } finally {release();await relay.stop();await rm(dir,{recursive:true,force:true})}
138
138
  })
139
139
 
140
- for (const cleanupFails of [false,true]) test(`fatal polling conflict waits for shared shutdown without retrying (cleanup fails: ${cleanupFails})`, async t => {
140
+ for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
141
141
  const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
142
142
  let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
143
143
  const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
@@ -170,20 +170,26 @@ for (const cleanupFails of [false,true]) test(`fatal polling conflict waits for
170
170
  const delivery=relay.drainOutbox()
171
171
  await deliveryStarted
172
172
  let finished=false
173
- const start=assert.rejects(relay.start(),/409.*Conflict/).then(()=>{finished=true})
174
- await cleaning
173
+ const start=relay.start().finally(()=>{finished=true})
174
+ await until(async()=>polls===1)
175
+ assert.equal(sourceStops,0,'a polling conflict must not stop the relay')
176
+ assert.equal((await runs.get('tg_92'))?.status,'running')
177
+ assert.ok(child && child.exitCode===null && child.signalCode===null)
175
178
  const stopping=relay.stop()
176
179
  assert.equal(relay.stop(),stopping,'concurrent stop calls share one promise')
177
180
  const stopped=cleanupFails?assert.rejects(stopping,/Synthetic shutdown failure/):stopping
178
- assert.equal(finished,false,'polling failure must wait for executor cleanup')
181
+ assert.equal(finished,false,'relay start must wait for the explicit shutdown')
182
+ await cleaning
179
183
  release()
180
184
  await until(async()=>(await runs.get('tg_92'))?.status!=='running')
181
- assert.equal(finished,false,'polling failure must wait for the in-flight delivery receipt')
182
- releaseDelivery();await delivery;await start;await stopped
185
+ assert.equal(finished,false,'shutdown must wait for the in-flight delivery receipt')
186
+ releaseDelivery();await delivery
187
+ if(cleanupFails) await assert.rejects(start,/Synthetic shutdown failure/);else await start
188
+ await stopped
183
189
  assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
184
190
  assert.equal(sourceStops,1)
185
191
  assert.deepEqual(JSON.parse(await readFile(join(dir,'outbox',`${item.id}.sent.json`),'utf8')).receipt.messageIds,[1])
186
- assert.equal(polls,1,'a conflict must not start another polling loop')
192
+ assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
187
193
  assert.equal(relay.bot.isRunning(),false)
188
194
  assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
189
195
  assert.notEqual((await runs.get('tg_92'))?.status,'running')
@@ -160,6 +160,22 @@ test('client tolerates missing heartbeat and consumes completion before checking
160
160
  } finally {client.kill();await closed;await rm(root,{recursive:true,force:true})}
161
161
  })
162
162
 
163
+ test('client records a relay interruption before exiting 130',async()=>{
164
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-interrupt-'))
165
+ const directory=path.join(root,'host-executor');await mkdir(directory)
166
+ const client=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),root,'tg_97'],{stdio:['pipe','pipe','pipe']})
167
+ let stderr='';client.stderr.on('data',chunk=>stderr+=chunk);client.stdout.resume()
168
+ client.stdin.end(JSON.stringify({texts:['test'],options:{}}))
169
+ try {
170
+ for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_97.request.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
171
+ const closed=new Promise<number|null>(resolve=>client.once('close',resolve))
172
+ client.kill('SIGTERM')
173
+ assert.equal(await closed,130)
174
+ assert.equal(await readFile(path.join(directory,'tg_97.cancel'),'utf8'),'')
175
+ assert.match(stderr,/Host executor client interrupted by SIGTERM/)
176
+ } finally {if(client.exitCode===null && client.signalCode===null)client.kill();await rm(root,{recursive:true,force:true})}
177
+ })
178
+
163
179
  test('client cancels on stale or invalid heartbeat instead of waiting indefinitely',async()=>{
164
180
  for(const heartbeat of [{at:Date.now()-60000},{at:'invalid'}]) {
165
181
  const root=await mkdtemp(path.join(tmpdir(),'ez-heartbeat-invalid-'))
@@ -225,6 +225,10 @@ test('four-item menu is owner-only; saved AI buttons work and forged/stale butto
225
225
  assert.match(f.replies.at(-1)!, /Menu expired/)
226
226
  await f.relay.bot.handleUpdate(message(7, '/settings'))
227
227
  assert.match(f.replies.at(-1)!, /Default for new conversations/)
228
+ await f.relay.bot.handleUpdate(message(8, '/status'))
229
+ assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
230
+ await f.relay.bot.handleUpdate(callback(9, 'menu:scheduled-tasks'))
231
+ assert.match(f.replies.at(-1)!, /No scheduled tasks for this owner/)
228
232
  assert.equal(f.launched.length, 0)
229
233
  } finally { await f.close() }
230
234
  })
@@ -11,7 +11,7 @@ import { taskArguments } from '../src/task-executor.js'
11
11
  import { runCodexSession } from '../src/codex-session.js'
12
12
  import { runDesktopTurn } from '../src/desktop-bridge.js'
13
13
 
14
- test('all model selections and launches reject effort above high before spawning', async () => {
14
+ test('all non-Luna model selections and launches reject effort above high before spawning', async () => {
15
15
  for (const cli of ['codex', 'codex-gui', 'grok', 'claude', 'opencode', 'agy']) {
16
16
  for (const effort of ['xhigh', 'max', 'ultra', 'unknown']) {
17
17
  const preset = { id:'blocked', name:'Blocked', cli, model:'any-model', effort }
@@ -23,6 +23,14 @@ test('all model selections and launches reject effort above high before spawning
23
23
  await assert.rejects(runDesktopTurn({workspace:'/unused',controlDir:'/unused',binDir:'/unused',runId:'unused',prompt:'',effort:'ultra'}), /capped at high/)
24
24
  })
25
25
 
26
+ test('Codex Luna accepts xhigh while every other model and CLI remains capped', async () => {
27
+ const luna = { id:'luna', name:'Luna', cli:'codex', model:'gpt-5.6-luna', effort:'xhigh' }
28
+ await validateSelection(luna, [{ cli:'codex', model:'gpt-5.6-luna', name:'Luna', efforts:['high','xhigh'] }], async () => true)
29
+ assert.deepEqual(executionDefaults('codex', { model:'gpt-5.6-luna', effort:'xhigh' }), { model:'gpt-5.6-luna', effort:'xhigh' })
30
+ assert.throws(() => executionDefaults('grok', { model:'gpt-5.6-luna', effort:'xhigh' }), /capped at high/)
31
+ assert.throws(() => executionDefaults('codex', { model:'gpt-5.6-terra', effort:'xhigh' }), /capped at high/)
32
+ })
33
+
26
34
  test('restricted tasks pin Terra high, preserve explicit choices and reject higher effort', () => {
27
35
  const args = taskArguments('/unused', ['broker'], 'prompt')
28
36
  assert.equal(args[args.indexOf('--model')+1], 'gpt-5.6-terra')
@@ -225,3 +225,52 @@ test('standalone rejects relay binding and preserves literal plugin arguments ac
225
225
  await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify({schemaVersion:1,workspace:f.workspace,catalog:{},deploymentDir:'/missing'}));
226
226
  await assert.rejects(exec(launcher,['status']),/deployment-bound/); // never hide a broken relay binding
227
227
  });
228
+
229
+ test('existing folders are read-only, persistent and fail closed when missing', async t => {
230
+ const f=await fixture(t);await init(f.home,f.workspace);
231
+ const inspected=JSON.parse((await f.call('plugins','inspect','sample','--source',f.source)).stdout);
232
+ await f.call('plugins','install','sample','--source',f.source,'--revision',inspected.revision);
233
+ const source=await fs.realpath(f.workspace);
234
+ await f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data/files');
235
+ const config=JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'));
236
+ assert.deepEqual(config.folders.sample,[{service:'sample',source,target:'/data/files'}]);
237
+ const c=JSON.parse(await fs.readFile(path.join(f.home,'packages/sample/compose.json'),'utf8'));
238
+ assert.deepEqual(c.services.sample.volumes.find(v=>v.target==='/data/files'),{type:'bind',source,target:'/data/files',read_only:true,bind:{create_host_path:false}});
239
+ await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data'),/child of a declared volume|Overlapping/);
240
+ await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data/files/child'),/Overlapping/);
241
+ await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.home),'--target','/data/private'),/private plugin state/);
242
+ await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source','/','--target','/data/root'),/private plugin state/);
243
+ await f.call('plugins','start','sample');
244
+ await fs.rename(f.workspace,f.workspace+'-moved');
245
+ await assert.rejects(f.call('plugins','start','sample'),/ENOENT/);
246
+ await assert.rejects(f.call('sample','read'),/ENOENT/);
247
+ await f.call('plugins','folder-unbind','sample','--service','sample','--target','/data/files');
248
+ assert.deepEqual(JSON.parse((await f.call('plugins','folders','sample')).stdout),[]);
249
+ });
250
+
251
+ test('folder bindings survive compatible descriptors and reject incompatible updates',async t=>{
252
+ const f=await fixture(t);const p=await snapshot(f.source);
253
+ const record={...p,project:'ezp-test-sample'};
254
+ const config={workspace:f.workspace,folders:{sample:[{service:'sample',source:f.workspace,target:'/data/files'}]}};
255
+ assert.equal(compose(config,record).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
256
+ const next=structuredClone(record);next.deployment.services.sample.volumes={data:'/new-state'};
257
+ assert.throws(()=>compose(config,next),/child of a declared volume/);
258
+ });
259
+
260
+ test('folder rebind rejects every live project container including one-shots',async t=>{
261
+ const f=await fixture(t);await init(f.home,f.workspace);const p=await snapshot(f.source);
262
+ await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
263
+ await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nif(process.argv[2]==='ps')console.log('paused-or-restarting-or-one-shot');\n`,{mode:0o700});
264
+ await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.workspace),'--target','/data/files'),/Stop the plugin/);
265
+ });
266
+
267
+ test('registered calls honor registry lock and regenerate stale Compose from current folders',async t=>{
268
+ const f=await fixture(t);await init(f.home,f.workspace);const p=await snapshot(f.source);
269
+ await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
270
+ await f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.workspace),'--target','/data/files');
271
+ const file=path.join(f.home,'packages/sample/compose.json');await fs.writeFile(file,'{}');
272
+ await f.call('sample','read');
273
+ assert.equal(JSON.parse(await fs.readFile(file,'utf8')).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
274
+ await fs.writeFile(path.join(f.home,'registry.lock'),'test');
275
+ await assert.rejects(f.call('sample','read'),/busy|EEXIST|locked/i);
276
+ });
@@ -129,3 +129,25 @@ test('a parallel reply delivered during a normal turn is retained for the follow
129
129
  assert.deepEqual(await parallelReplyHistory(root,current),[])
130
130
  }finally{await rm(root,{recursive:true,force:true})}
131
131
  })
132
+
133
+
134
+ test('reply handoff accepts independent worker choices and rejects invalid or unauthorized overrides', async () => {
135
+ const root=await mkdtemp(join(tmpdir(),'ez-reply-worker-')), runs=new RunStore(root)
136
+ try {
137
+ await ownerRun(root,'owner')
138
+ await runs.create({id:'tg_10',chatId:101,telegramUserId:101,texts:['Analyze the report'],execution:{sessionId:'c5dd1edc-be24-47b8-a579-0bc70f44cf43',preset:{id:'chat',name:'Chat',cli:'codex',model:'gpt-5.6-sol',effort:'medium'}}})
139
+ await runs.patch('tg_10',{status:'running',replyOnly:true})
140
+ for (const args of [{model:42}, {model:'bad model'}, {effort:'ultra'}, {effort:'invalid'}, {cli:'claude'}])
141
+ await assert.rejects(replyCall(root,'tg_10',root,'defer',{text:'Analyze and verify the result',...args}))
142
+ await assert.rejects(replyCall(root,'tg_10',root,'send',{text:'Hello',model:'gpt-6-astra'}),/Unexpected/)
143
+ await replyCall(root,'tg_10',root,'defer',{text:'Analyze and verify the result',model:'gpt-6-astra',effort:'high'})
144
+ const file=join(root,'schedules','s_reply_tg_10.json')
145
+ const saved=JSON.parse(await readFile(file,'utf8'))
146
+ assert.equal(saved.execution.preset.model,'gpt-6-astra')
147
+ assert.equal(saved.execution.preset.effort,'high')
148
+ await replyCall(root,'tg_10',root,'defer',{text:'retry',model:'gpt-5.6-sol',effort:'low'})
149
+ assert.deepEqual(JSON.parse(await readFile(file,'utf8')),saved)
150
+ await new ControlStore(root,900000).revokeOwner()
151
+ await assert.rejects(replyCall(root,'tg_10',root,'defer',{text:'after revocation',model:'gpt-6-astra'}),/owner-mismatch/)
152
+ } finally { await rm(root,{recursive:true,force:true}) }
153
+ })
package/test/runs.test.ts CHANGED
@@ -22,6 +22,13 @@ test('creates a queued run and binds chat id outside the workspace', async () =>
22
22
  assert.equal((await store.get(created.id))?.chatId, 101)
23
23
  }))
24
24
 
25
+ test('running does not reap a process from another executor namespace', async () => fixture(async (store) => {
26
+ const created = await store.create({ chatId: 101, telegramUserId: 101, texts: ['host-backed'] })
27
+ await store.patch(created.id, { status: 'running', pid: 999_999_999 })
28
+ assert.equal((await store.running())?.id, created.id)
29
+ assert.equal((await store.get(created.id))?.status, 'running')
30
+ }))
31
+
25
32
  test('ez message writes an outbox item for the bound run, not a telegram send', async () => fixture(async (store) => {
26
33
  const created = await store.create({ chatId: 9, telegramUserId: 9, texts: ['hello'] })
27
34
  await store.patch(created.id, { status: 'running' })
@@ -24,6 +24,8 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
24
24
  assert.equal(saved.text,args.at(-1));assert.equal(saved.execution.preset.cli,'codex')
25
25
  assert.equal(saved.execution.preset.model,'gpt-5.6-terra');assert.equal(saved.execution.preset.effort,'high')
26
26
  await assert.rejects(exec(process.execPath,[bin,'create','blocked','--at','2027-09-09T09:00:00+04:00','--text','test','--effort','xhigh'],{env}),/capped at high/)
27
+ const luna=JSON.parse((await exec(process.execPath,[bin,'create','luna','--at','2027-09-10T09:00:00+04:00','--text','Luna xhigh task','--model','gpt-5.6-luna','--effort','xhigh'],{env})).stdout)
28
+ assert.equal(luna.execution.preset.model,'gpt-5.6-luna');assert.equal(luna.execution.preset.effort,'xhigh')
27
29
  assert.equal(saved.nextEligibleAt,'2027-09-09T05:00:00.000Z')
28
30
  await assert.rejects(exec(process.execPath,[bin,...args],{env}),/exists/)
29
31
  assert.equal(JSON.parse((await exec(process.execPath,[bin,'pause','test'],{env})).stdout).enabled,false)
@@ -0,0 +1,43 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { access, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+ import { Scheduler } from '../src/scheduler.js'
8
+ import { scheduledTasksText } from '../src/scheduled-tasks.js'
9
+
10
+ const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
11
+ const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex' } }
12
+
13
+ test('scheduled task view is read-only, owner-bound, and shows stored task contents', async (t) => {
14
+ const dir = await mkdtemp(join(tmpdir(), 'ez-scheduled-tasks-'))
15
+ t.after(() => rm(dir, { recursive: true, force: true }))
16
+ const scheduler = new Scheduler(dir)
17
+
18
+ assert.deepEqual(await scheduler.listReadOnly(), [])
19
+ await assert.rejects(access(join(dir, 'schedules')), /ENOENT/)
20
+
21
+ await scheduler.save({
22
+ id: 'owner-task', name: 'Daily report', text: 'Read the ledger and send the owner a concise report.', owner, execution, enabled: true,
23
+ trigger: { cron: '0 9 * * 1-5', timezone: 'Asia/Dubai', start: '2026-01-01T00:00:00.000Z' },
24
+ })
25
+ await scheduler.save({
26
+ id: 'other-task', name: 'Other owner task', text: 'This must never be visible.',
27
+ owner: { ...owner, telegramUserId: 202, telegramChatId: 202 }, execution, enabled: true,
28
+ trigger: { at: '2027-01-01T00:00:00.000Z' },
29
+ })
30
+ const scheduleDir = join(dir, 'schedules')
31
+ const before = await readFile(join(scheduleDir, 'owner-task.json'), 'utf8')
32
+ const entries = await readdir(scheduleDir)
33
+ const text = scheduledTasksText(await scheduler.listReadOnly(), owner, Date.parse('2026-09-11T00:00:00.000Z'))
34
+
35
+ assert.match(text, /Title: Daily report/)
36
+ assert.match(text, /Instructions:\nRead the ledger and send the owner a concise report\./)
37
+ assert.match(text, /Timing: Cron 0 9 \* \* 1-5 · Asia\/Dubai/)
38
+ assert.match(text, /State: Scheduled/)
39
+ assert.match(text, /Next run: 2026-09-11T05:00:00.000Z/)
40
+ assert.doesNotMatch(text, /Other owner task|This must never be visible/)
41
+ assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
42
+ assert.deepEqual(await readdir(scheduleDir), entries)
43
+ })