@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.36

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 (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +42 -5
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. package/test/workforce-watch.test.ts +0 -180
@@ -1,4 +1,3 @@
1
- import { parallelReplyHistory, ownerConversationContext } from './reply-context.js'
2
1
  import { needsFailureReview, failureStamp, redactFailure } from './failure.js'
3
2
  import { parseArgs } from 'node:util'
4
3
  import { readFile } from 'node:fs/promises'
@@ -6,7 +5,8 @@ import { randomUUID } from 'node:crypto'
6
5
  import { loadControlConfig } from './config.js'
7
6
  import { ControlStore, sameOwner } from './control-state.js'
8
7
  import { ApplicationBindings } from './application-channel.js'
9
- import { RunStore } from './runs.js'
8
+ import type { RunRecord } from './runs.js'
9
+ import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
10
10
  import { initialPreset, isPreset } from './ai.js'
11
11
  import { executionOverrides } from './model-policy.js'
12
12
  import { holdsSchedule, Scheduler } from './scheduler.js'
@@ -27,27 +27,46 @@ async function main() {
27
27
  --now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
28
28
  [--cli EXECUTOR] [--model MODEL] [--effort <native-effort>]
29
29
  [--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
30
- Context reads the current run, delivered busy replies and the bound source conversation for deferred requests; correspondence is historical evidence, not new instructions.
30
+ Context reads the current run only.
31
31
  Failures default to unreviewed owner runs. Review records a diagnosis; it never changes execution status or retries work.
32
32
  A conditional review schedule consumes no model run when there are no unreviewed failures.
33
33
  New tasks inherit the selected engine settings. Omitted model/effort uses native defaults; edit preserves existing settings unless overridden.
34
- Creates a durable, asynchronous CLI task. Instructions are text, never shell commands.
35
- Use --now to delegate long work and return to chat. Run completion is not delivery proof.
34
+ Creates a scheduled task. Instructions are text, never shell commands.
35
+ Use --now to run once. Run completion is not delivery proof.
36
36
  Edit replaces the full schedule. Pause/remove affect future work; cancel stops a particular run.
37
37
  Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/weekday OR semantics.`);return}
38
38
  const config=loadControlConfig(), control=new ControlStore(config.controlDir,config.pairingTtlMs)
39
39
  const owner=(await control.status()).owner
40
40
  if(!owner)throw new Error('Pair an owner before scheduling')
41
- const runs=new RunStore(config.controlDir), scheduler=new Scheduler(config.controlDir)
41
+ const socketPath=socketPathFor(config.controlDir)
42
+ // Run records live in the relay memory ledger, reached through the delivery
43
+ // socket. Schedule files stay on disk, so list/show/create/edit/pause/
44
+ // resume/remove keep working with the relay stopped; run-inspecting actions
45
+ // (context/failures/run/review/runs/cancel) require the running relay.
46
+ const socketUnavailable = (error: unknown): boolean =>
47
+ error instanceof Error && /Delivery relay unavailable/.test(error.message)
48
+ const runs={
49
+ get: (runId: string): Promise<RunRecord | null> =>
50
+ callDeliverySocket(socketPath, { op: 'get', payload: { runId } }).then(run => run as RunRecord).catch(error => {
51
+ if (error instanceof Error && /Unknown run/.test(error.message)) return null
52
+ throw error
53
+ }),
54
+ list: (): Promise<RunRecord[]> => callDeliverySocket(socketPath, { op: 'list' }).then(result => result as RunRecord[]),
55
+ listBestEffort: (): Promise<RunRecord[]> => callDeliverySocket(socketPath, { op: 'list' })
56
+ .then(result => result as RunRecord[]).catch(error => { if (socketUnavailable(error)) return []; throw error }),
57
+ patch: (runId: string, change: unknown): Promise<RunRecord> =>
58
+ callDeliverySocket(socketPath, { op: 'patch', payload: { runId, change: change as Record<string, unknown> } }).then(run => run as RunRecord),
59
+ }
60
+ const scheduler=new Scheduler(config.controlDir)
42
61
  const caller=process.env.EZ_RUN_ID ? await runs.get(process.env.EZ_RUN_ID) : null
43
62
  if (caller?.application || caller?.delivery) await new ApplicationBindings(config.controlDir).authorize(caller)
44
63
  if(process.env.EZ_RUN_ID && (!caller || caller.status!=='running' || caller.external || caller.taskId || caller.replyOnly ||
45
64
  !ownsRun(owner, caller) ||
46
65
  (caller.scheduled && caller.scheduled.pairedAt!==owner.pairedAt)))throw new Error('Scheduling requires an active owner-authorized run')
47
66
  const owned=(s:{owner:typeof owner})=>sameOwner(s.owner,owner)
48
- const ownsFailureRun=(r:Awaited<ReturnType<RunStore['get']>>)=>r && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt)
67
+ const ownsFailureRun=(r:RunRecord | null)=>r && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt)
49
68
  const show=async(s:Awaited<ReturnType<Scheduler['get']>>)=>{
50
- const held=(await runs.list()).filter(r=>holdsSchedule(s,r))
69
+ const held=(await runs.listBestEffort()).filter(r=>holdsSchedule(s,r))
51
70
  const interruptedRunIds=held.filter(r=>r.interrupted).map(r=>r.id)
52
71
  const failedReviewRunIds=held.filter(r=>!r.interrupted).map(r=>r.id)
53
72
  const next=s.enabled && !held.length ? nextOccurrence(s.trigger,Date.now()) : null
@@ -59,7 +78,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
59
78
  if(!caller)throw new Error('Context requires an active owner run')
60
79
  const origin=caller.scheduled?.originRunId ? await runs.get(caller.scheduled.originRunId) : null
61
80
  if(origin && !ownsFailureRun(origin))throw new Error('Source context is outside this owner binding')
62
- result=caller.application || caller.delivery ? {run:caller,...(origin ? {origin} : {})} : {run:caller,busyReplies:await parallelReplyHistory(config.controlDir,caller),...(origin?{origin:await ownerConversationContext(config.controlDir,origin)}:{})}
81
+ result={run:caller,...(origin ? {origin} : {})}
63
82
  }else if(action==='failures'){
64
83
  const limit=Number(v.limit || 20)
65
84
  if(!Number.isSafeInteger(limit) || limit<1 || limit>100)throw new Error('Limit must be 1..100')
@@ -102,7 +121,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
102
121
  if(action==='cancel'){
103
122
  const run=await runs.get(id)
104
123
  if(!run?.scheduled || run.scheduled.pairedAt!==owner.pairedAt || !ownsRun(owner,run))throw new Error('Unknown background run')
105
- await scheduler.cancel(id);result={cancelRequested:id}
124
+ await scheduler.cancel(id, run);result={cancelRequested:id}
106
125
  }else{
107
126
  const s=await scheduler.get(id)
108
127
  if(!owned(s))throw new Error('Schedule ownership mismatch')
@@ -16,17 +16,60 @@ const displayedPreset = (schedule: Schedule) => {
16
16
  catch { return schedule.execution.preset }
17
17
  }
18
18
 
19
- export const scheduledTasksText = (schedules: ActiveSchedule[], owner: Owner) => {
20
- const owned = schedules.filter((schedule) => ownsSchedule(owner, schedule))
19
+ const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
20
+ const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
21
+ const time = (value: number | string) => {
22
+ const date = new Date(value)
23
+ if (!Number.isFinite(date.getTime())) return '—'
24
+ return `${weekdays[date.getUTCDay()]}, ${months[date.getUTCMonth()]} ${date.getUTCDate()} · ${String(date.getUTCHours()).padStart(2,'0')}:${String(date.getUTCMinutes()).padStart(2,'0')} UTC`
25
+ }
26
+
27
+ const preview = (schedule: Schedule) => {
28
+ const sentence = schedule.text.trim().replace(/\s+/gu,' ').split(/(?<=[.!?])\s/u)[0]
29
+ const chars = Array.from(sentence)
30
+ return chars.length > 140 ? chars.slice(0,139).join('')+'…' : sentence
31
+ }
32
+
33
+ const currentState = (schedule: ActiveSchedule) =>
34
+ schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' : '● Active'
35
+
36
+ const lastRun = (schedule: ActiveSchedule) => {
37
+ if (!schedule.lastRun) return '— Not run yet'
38
+ const status = schedule.lastRun.status === 'completed' ? '✓ Completed' : schedule.lastRun.status === 'failed' ? '✕ Failed' : '⊘ Cancelled'
39
+ return `${status} · ${time(schedule.lastRun.at)}${schedule.lastRun.currentRevision ? '' : ' · previous version'}`
40
+ }
41
+
42
+ export const ownedScheduledTasks = (schedules: ActiveSchedule[], owner: Owner) =>
43
+ schedules.filter((schedule) => ownsSchedule(owner, schedule))
21
44
  .sort((a, b) => a.name.localeCompare(b.name))
22
- if (!owned.length) return 'Active scheduled tasks\n\nNo active scheduled tasks for this owner.'
23
- return ['Active scheduled tasks', ...owned.map((schedule) => {
45
+
46
+ export const scheduledTasksText = (schedules: ActiveSchedule[], owner: Owner) => {
47
+ const owned = ownedScheduledTasks(schedules, owner)
48
+ if (!owned.length) return '📅 Scheduled tasks\n\nNo active scheduled tasks.'
49
+ return [`📅 Scheduled tasks · ${owned.length} active`, 'All times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) => {
24
50
  const preset = displayedPreset(schedule)
25
- const sentence = schedule.text.trim().replace(/\s+/gu,' ').split(/(?<=[.!?])\s/u)[0]
26
- const chars = Array.from(sentence)
27
- const preview = chars.length > 140 ? chars.slice(0,139).join('')+'…' : sentence
28
- const next = schedule.nextAt === null ? '' : `Next: ${new Date(schedule.nextAt).toISOString().replace('T',' ').replace('.000Z',' UTC')}`
29
- const timing = [schedule.runState === 'running' ? 'Running' : schedule.runState === 'queued' ? 'Queued' : '',next].filter(Boolean).join(' · ')
30
- return `• ${schedule.name}\n ${presetLabel(preset)}\n ${timing}\n ${preview}`
51
+ return `${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n ${presetLabel(preset)}\n ${preview(schedule)}`
31
52
  })].join('\n\n')
32
53
  }
54
+
55
+ export const scheduledTaskDetailText = (schedule: ActiveSchedule, number: number, total?: number) => {
56
+ const preset = displayedPreset(schedule)
57
+ const instructions = schedule.text.trim()
58
+ const capped = instructions.length > 2000 ? instructions.slice(0, 2000) + '… (truncated; full text lives in the workspace)' : instructions
59
+ return [
60
+ `📅 ${number}${total === undefined ? '' : ` of ${total}`} · ${schedule.name}`,
61
+ currentState(schedule),
62
+ '',
63
+ 'Next run',
64
+ schedule.nextAt === null ? '—' : time(schedule.nextAt),
65
+ '',
66
+ 'Last run',
67
+ lastRun(schedule),
68
+ '',
69
+ 'Engine',
70
+ presetLabel(preset),
71
+ '',
72
+ 'Instructions',
73
+ capped,
74
+ ].join('\n')
75
+ }
package/src/scheduler.ts CHANGED
@@ -18,7 +18,12 @@ export type Schedule = {
18
18
  owner: Owner; execution: ExecutionChoice
19
19
  delivery?: { bindingId: string; scope: string }
20
20
  }
21
- export type ActiveSchedule = Schedule & { nextAt: number | null; runState?: 'queued' | 'running' }
21
+ export type ScheduledRunSummary = {
22
+ status: 'completed' | 'failed' | 'cancelled'
23
+ at: string
24
+ currentRevision: boolean
25
+ }
26
+ export type ActiveSchedule = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary }
22
27
  export type ScheduledOrigin = { id: string; revision: string; dueAt: string; pairedAt: string; originRunId?: string }
23
28
  export const validScheduledOrigin = (v: unknown): v is ScheduledOrigin => {
24
29
  const s = v as ScheduledOrigin
@@ -85,11 +90,18 @@ export class Scheduler {
85
90
  for (const s of await this.listReadOnly()) {
86
91
  if (!s.enabled) continue
87
92
  const current = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.revision === s.revision && ownsRun(s.owner,r))
93
+ const history = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.pairedAt === s.owner.pairedAt && ownsRun(s.owner,r))
94
+ .filter((r): r is RunRecord & { status: ScheduledRunSummary['status'] } =>
95
+ r.status === 'completed' || r.status === 'failed' || r.status === 'cancelled')
96
+ .sort((a,b) => (b.endedAt ?? b.startedAt ?? b.createdAt).localeCompare(a.endedAt ?? a.startedAt ?? a.createdAt))
97
+ const last = history[0]
88
98
  const runState = current.some(r => r.status === 'running') ? 'running' : current.some(r => r.status === 'queued') ? 'queued' : undefined
89
99
  if (!runState && current.some(r => holdsSchedule(s,r))) continue
90
100
  try {
91
101
  const nextAt = await this.pendingOccurrence(s)
92
- if (runState || nextAt !== null) active.push({...s,nextAt,runState})
102
+ if (runState || nextAt !== null) active.push({...s,nextAt,runState,...(last ? {
103
+ lastRun: {status:last.status,at:last.endedAt ?? last.startedAt ?? last.createdAt,currentRevision:last.scheduled!.revision===s.revision},
104
+ } : {})})
93
105
  }
94
106
  catch { console.error('Unreadable schedule cursor',s.id) }
95
107
  }
@@ -121,10 +133,10 @@ export class Scheduler {
121
133
  (!!s.delivery || ((s.owner.telegramLinkedAt ?? s.owner.pairedAt) === (owner.telegramLinkedAt ?? owner.pairedAt) && s.owner.telegramChatId === owner.telegramChatId))
122
134
  } catch { return false }
123
135
  }
124
- async cancel(runId: string) {
136
+ async cancel(runId: string, run?: RunRecord | null) {
125
137
  assertId(runId); await this.ensure()
126
- const run = await new RunStore(this.controlDir).get(runId)
127
- if (!run?.scheduled) throw new Error('Unknown background run')
138
+ const record = run ?? await new RunStore(this.controlDir).get(runId)
139
+ if (!record?.scheduled) throw new Error('Unknown background run')
128
140
  await atomic(join(this.dir,runId+'.cancel'),{})
129
141
  }
130
142
  async cancelled(runId: string): Promise<boolean> {
@@ -160,11 +172,12 @@ export class Scheduler {
160
172
  if (s.when === 'unreviewed-failures' && !(await runs.list()).some(r => needsFailureReview(r) && ownsRun(owner, r) && (!r.scheduled || r.scheduled.pairedAt === owner.pairedAt))) {
161
173
  await atomic(cursor,{next:future}); continue
162
174
  }
175
+ const dueAt = new Date(next).toISOString()
163
176
  await runs.create({id:scheduledRunId(s,next),
164
177
  ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
165
178
  ...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
166
- texts:[s.text],execution:s.execution,
167
- scheduled:{id:s.id,revision:s.revision,dueAt:new Date(next).toISOString(),pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}})
179
+ texts:[`[schedule ${s.id} due ${dueAt}]`, s.text],execution:s.execution,
180
+ scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}})
168
181
  // A restart between run creation and this cursor write sees the same occurrence ID.
169
182
  await atomic(cursor,{next:future})
170
183
  } catch { console.error('Schedule dispatch failed',s.id) }
package/src/service.ts CHANGED
@@ -8,21 +8,21 @@ import { parseEnv } from 'node:util'
8
8
  const run = promisify(execFile)
9
9
  const packageRoot = fileURLToPath(new URL('../', import.meta.url))
10
10
 
11
- export const serviceDefinition = (directory: string) => {
11
+ export const serviceDefinition = (directory: string, includePluginBroker = false) => {
12
12
  const root = path.resolve(directory)
13
13
  if (/[\r\n\0]/.test(root)) throw new Error('Service paths must be single-line values.')
14
14
  return { command: 'docker', args: ['compose', '--project-directory', packageRoot,
15
- '--env-file', path.join(root, 'docker.env'), 'up', '-d', '--wait', 'relay'] }
15
+ '--env-file', path.join(root, 'docker.env'), 'up', '-d', '--wait', ...(includePluginBroker ? ['relay', 'plugin-broker'] : ['relay'])] }
16
16
  }
17
17
 
18
18
  // Docker owns lifecycle. No host-service fallback or provider onboarding here.
19
19
  export const installService = async (directory: string) => {
20
- const definition = serviceDefinition(directory)
21
20
  const file = path.join(path.resolve(directory), 'docker.env')
22
21
  if (!(await lstat(file)).isFile()) throw new Error('docker.env must be a regular file')
23
22
  const values = parseEnv(await readFile(file, 'utf8'))
24
23
  if (!values.COMPOSE_PROJECT_NAME || !values.EZ_RELAY_ENV_FILE)
25
24
  throw new Error('docker.env must bind COMPOSE_PROJECT_NAME and EZ_RELAY_ENV_FILE; see docs/docker-runtime.md')
25
+ const definition = serviceDefinition(directory, values.EZ_EXECUTOR_TRANSPORT === 'local')
26
26
  await run(definition.command, definition.args)
27
27
  return { service: 'relay', runtime: 'docker', project: values.COMPOSE_PROJECT_NAME, active: true }
28
28
  }
package/src/setup.ts CHANGED
@@ -113,7 +113,7 @@ export const runCli = async (): Promise<void> => {
113
113
  const envFilePath = path.resolve(process.cwd(), '.env')
114
114
 
115
115
  if (args[0] === '--help' || args[0] === '-h') {
116
- console.log('Usage: ezenciel-agents-setup configure <executor> [--token-stdin] | service | init | status | <executor>\nconfigure writes private .env paths and seeds missing mind files; it does not start or pair the bot.\n--token-stdin accepts the BotFather token without putting it in command arguments or output.\nservice starts only the Docker Compose relay bound by docker.env in the current directory. It does not install the host-executor service; follow docs/host-service.md. It does not pair the owner.\nFollow docs/setup.md through a real Telegram reply; package installation alone is incomplete.')
116
+ console.log('Usage: ezenciel-agents-setup configure <executor> [--purpose-file PATH] [--token-stdin] | service | init [--purpose-file PATH] | status | <executor>\nFresh workspace setup requires a concise purpose file written from the owner request. configure writes private .env paths and seeds missing mind files; it does not start or pair the bot.\n--token-stdin accepts the BotFather token without putting it in command arguments or output.\nservice starts only the Docker Compose relay bound by docker.env in the current directory. It does not install the host-executor service; follow docs/host-service.md. It does not pair the owner.\nFollow docs/setup.md through a real Telegram reply; package installation alone is incomplete.')
117
117
  return
118
118
  }
119
119
 
@@ -124,10 +124,13 @@ export const runCli = async (): Promise<void> => {
124
124
  }
125
125
 
126
126
  if (args[0] === 'configure') {
127
- if (!args[1] || args.length > 3 || (args[2] && args[2] !== '--token-stdin'))
128
- throw new Error('Usage: ezenciel-agents-setup configure <executor> [--token-stdin]')
127
+ const tail = args.slice(2), purposeAt = tail.indexOf('--purpose-file'), tokenAt = tail.indexOf('--token-stdin')
128
+ const purposeFile = purposeAt >= 0 ? tail[purposeAt + 1] : undefined
129
+ const consumed = new Set([...(purposeAt >= 0 ? [purposeAt, purposeAt + 1] : []), ...(tokenAt >= 0 ? [tokenAt] : [])])
130
+ if (!args[1] || (purposeAt >= 0 && !purposeFile) || tail.some((_, index) => !consumed.has(index)))
131
+ throw new Error('Usage: ezenciel-agents-setup configure <executor> [--purpose-file PATH] [--token-stdin]')
129
132
  let token: string | undefined
130
- if (args[2]) {
133
+ if (tokenAt >= 0) {
131
134
  if (process.stdin.isTTY) throw new Error('Supply the token through stdin, not a command argument.')
132
135
  let input = ''
133
136
  for await (const chunk of process.stdin) {
@@ -136,14 +139,15 @@ export const runCli = async (): Promise<void> => {
136
139
  }
137
140
  token = input.trim()
138
141
  }
139
- console.log(JSON.stringify(await configureInstallation(process.cwd(), args[1], token)))
142
+ console.log(JSON.stringify(await configureInstallation(process.cwd(), args[1], token, purposeFile && path.resolve(purposeFile))))
140
143
  return
141
144
  }
142
145
 
143
146
  if (args[0] === 'init') {
144
- if (args.length !== 1) throw new Error('Usage: ezenciel-agents-setup init (uses EZ_AGENT_WORKSPACE or ./agent)')
147
+ if (args.length !== 1 && !(args.length === 3 && args[1] === '--purpose-file'))
148
+ throw new Error('Usage: ezenciel-agents-setup init [--purpose-file PATH] (uses EZ_AGENT_WORKSPACE or ./agent)')
145
149
  const workspace = path.resolve(process.env.EZ_AGENT_WORKSPACE?.trim() || './agent')
146
- const created = await initializeWorkspace(workspace)
150
+ const created = await initializeWorkspace(workspace, args[2] && path.resolve(args[2]))
147
151
  const config = loadControlConfig()
148
152
  await new ControlStore(config.controlDir, config.pairingTtlMs).syncClientPresets(
149
153
  chatPreset(await readActiveExecutor(envFilePath)), await discoverDefaults(workspace,
@@ -1,6 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises'
2
+ import { arch as relayArch, platform as relayPlatform } from 'node:os'
2
3
  import path from 'node:path'
3
- import { packageVersion } from './version.js'
4
+ import { isolationTransport } from './isolation.js'
5
+ import { relayVersionLabel } from './version.js'
4
6
 
5
7
  export const installedPluginVersions = async (toolsHome?: string) => {
6
8
  if (!toolsHome) return null
@@ -10,14 +12,44 @@ export const installedPluginVersions = async (toolsHome?: string) => {
10
12
  } catch { return null }
11
13
  }
12
14
 
13
- export const softwareStatus = async (controlDir: string): Promise<string[]> => {
14
- const lines = [`Relay: running · v${packageVersion}`]
15
+ export const softwareStatus = async (controlDir: string, isolation?: string): Promise<string[]> => {
16
+ const lines = [`Relay: running · ${relayVersionLabel()}`]
17
+ let host: { version?: unknown; plugins?: unknown; platform?: unknown; arch?: unknown } | null = null
15
18
  try {
16
19
  const h = JSON.parse(await readFile(path.join(controlDir, 'host-executor/heartbeat.json'), 'utf8'))
17
20
  if (!Number.isFinite(h.at) || h.at > Date.now() + 5000 || Date.now() - h.at >= 15000) throw Error('Stale host')
21
+ host = h
22
+ lines.push(`Env: ${envLabel(host, isolation)}`)
18
23
  lines.push(`Host transport: running · ${typeof h.version === 'string' ? `v${h.version}` : 'version unknown'}`)
19
24
  if (!Array.isArray(h.plugins)) lines.push('Plugins: unknown')
20
25
  else lines.push(`Plugins: ${h.plugins.length ? h.plugins.map((p: {id: string; version: string}) => `${p.id} ${p.version}`).join(', ') : 'none installed'}`)
21
- } catch { lines.push('Host transport: unavailable', 'Plugins: unknown') }
26
+ } catch {
27
+ lines.push(`Env: ${envLabel(host, isolation)}`)
28
+ if (isolation === 'isolated') {
29
+ lines.push('Host transport: n/a · isolated execution')
30
+ lines.push((await brokerPlugins(controlDir)) ?? 'Plugins: isolated broker · versions via `ez tools list`')
31
+ } else lines.push('Host transport: unavailable', 'Plugins: unknown')
32
+ }
22
33
  return lines
23
34
  }
35
+
36
+ const envLabel = (host: { platform?: unknown; arch?: unknown } | null, isolation?: string): string => {
37
+ const rawPlatform = typeof host?.platform === 'string' && host.platform ? host.platform : relayPlatform()
38
+ const rawArch = typeof host?.arch === 'string' && host.arch ? host.arch : relayArch()
39
+ const where = rawPlatform === 'darwin' ? `mac (${rawPlatform}/${rawArch})` : `${rawPlatform}/${rawArch}`
40
+ const mode = isolation ?? 'unknown'
41
+ const transport = isolation === 'isolated' || isolation === 'host-capable' ? isolationTransport(isolation) : 'unknown'
42
+ return `${where} · ${mode} · ${transport}`
43
+ }
44
+
45
+ const brokerPlugins = async (controlDir: string): Promise<string | null> => {
46
+ try {
47
+ const status = JSON.parse(await readFile(path.join(controlDir, 'plugin-broker-plugins.json'), 'utf8')) as {
48
+ plugins?: { id: unknown; version: unknown }[]
49
+ }
50
+ if (!Array.isArray(status.plugins)) return null
51
+ const entries = status.plugins.filter(p => typeof p?.id === 'string' && typeof p?.version === 'string')
52
+ if (entries.length !== status.plugins.length) return null
53
+ return `Plugins: ${entries.length ? entries.map(p => `${p.id} ${p.version}`).join(', ') : 'none installed'}`
54
+ } catch { return null }
55
+ }
@@ -5,7 +5,7 @@ import { join } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { spawn, execFile } from 'node:child_process'
7
7
  import { promisify } from 'node:util'
8
- import { RunStore } from './runs.js'
8
+ import { readRun } from './delivery-socket.js'
9
9
  import { Tasks } from './tasks.js'
10
10
  import { executorEnvironment, terminateJob, type ExecutorOptions } from './executor.js'
11
11
 
@@ -38,7 +38,7 @@ export function taskArguments(directory: string, broker: string[], prompt: strin
38
38
  '-'] // Literal input travels on stdin, including slash commands and leading options.
39
39
  }
40
40
  export async function startTaskExecutor(options: ExecutorOptions) {
41
- const run = await new RunStore(options.controlDir).get(options.runId)
41
+ const run = await readRun(options.controlDir, options.runId)
42
42
  if (!run || run.status !== 'running') throw new Error('No active task run')
43
43
  const task = await new Tasks(options.controlDir).authorize(run, false)
44
44
  const capabilityNames = (task.capabilities ?? []).map(item => `capability_${item.id}`)
@@ -53,7 +53,10 @@ export async function startTaskExecutor(options: ExecutorOptions) {
53
53
  await mkdir(directory, { mode: 0o700 }); await mkdir(home, { mode: 0o700 })
54
54
  const catalog = await promisify(execFile)('codex', ['debug', 'models', '--bundled'], { env: environment, maxBuffer: 4 * 1024 * 1024 })
55
55
  await writeFile(join(temporary, 'models.json'), JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))), { mode: 0o600 })
56
- await symlink(join(homedir(), '.codex', 'auth.json'), join(home, 'auth.json'))
56
+ const auth = process.env.EZ_ISOLATION === 'isolated'
57
+ ? join(options.controlDir, 'cli', 'codex', 'auth.json')
58
+ : join(homedir(), '.codex', 'auth.json')
59
+ await symlink(auth, join(home, 'auth.json'))
57
60
  const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
58
61
  fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? [])]
59
62
  const prompt = JSON.stringify({event: run.external ? 'correspondence_received' : 'task_activated', taskId: run.taskId})
@@ -2,9 +2,10 @@ import { mkdir, lstat } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { assertId } from './identity.js'
4
4
 
5
- // Fixed, agent-bound path, never a caller-provided cwd. No shared mutable task files.
6
- export async function taskWorkspace(workspace: string, id: string): Promise<string> {
7
- const dirs=[join(workspace,'work'),join(workspace,'work','tasks'),join(workspace,'work','tasks',assertId(id))]
5
+ // Fixed, agent-bound path under the control directory, never the workspace
6
+ // mind folder and never a caller-provided cwd. No shared mutable task files.
7
+ export async function taskWorkspace(baseDir: string, id: string): Promise<string> {
8
+ const dirs=[join(baseDir,'work'),join(baseDir,'work','tasks'),join(baseDir,'work','tasks',assertId(id))]
8
9
  for(const dir of dirs){
9
10
  await mkdir(dir,{recursive:true,mode:0o700})
10
11
  if(!(await lstat(dir)).isDirectory())throw new Error('Task workspace must not be a symlink')
package/src/tasks.ts CHANGED
@@ -161,7 +161,7 @@ export class Tasks {
161
161
  async ownerCall(runId: string, command: string, args: Record<string, unknown>) {
162
162
  return this.serial(async () => {
163
163
  const run = await requireOwnerExecution(this.controlDir, runId)
164
- if (run.scheduled || run.id.startsWith('r_update_') || run.id.startsWith('r_schedule_')) throw new Error('Task changes require a current owner message')
164
+ if (run.scheduled || run.id.startsWith('r_schedule_')) throw new Error('Task changes require a current owner message')
165
165
  if (command === 'list') return this.list()
166
166
  if (command === 'revoke') {
167
167
  const task = await this.get(String(args.taskId))
@@ -0,0 +1,22 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import path from 'node:path'
3
+ import { provisionTelegramBot } from './telegram-provisioning.js'
4
+
5
+ const help = 'Usage: ezenciel-agents-provision-telegram --config PRIVATE_CONFIG_FILE < BOTFATHER_TOKEN\nConfigures one existing Ez deployment using its installed relay image and version from the deployment\'s docker.env; the config never selects an image. The token is accepted only through stdin, written only to that deployment\'s private relay secret, and is never printed.'
6
+
7
+ export const runTelegramProvisioningCli = async (args = process.argv.slice(2), input = process.stdin): Promise<void> => {
8
+ if (args.includes('--help') || args.includes('-h')) { console.log(help); return }
9
+ if (args.length !== 2 || args[0] !== '--config' || !args[1]) throw new Error(help)
10
+ if ((input as NodeJS.ReadStream).isTTY) throw new Error('Supply the BotFather token through stdin, not a command argument.')
11
+ let token = ''
12
+ for await (const chunk of input) {
13
+ token += chunk.toString()
14
+ if (token.length > 512) { (input as NodeJS.ReadStream).destroy?.(); throw new Error('Token input is too long.') }
15
+ }
16
+ await provisionTelegramBot(args[1], token.trim())
17
+ console.log(JSON.stringify({ configured: true }))
18
+ }
19
+
20
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
21
+ void runTelegramProvisioningCli().catch(error => { console.error((error as Error).message); process.exitCode = 1 })
22
+ }