@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.
- package/.dockerignore +1 -0
- package/.env.example +9 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +92 -0
- package/Dockerfile +11 -9
- package/README.md +32 -27
- package/THIRD_PARTY_NOTICES.md +4 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +42 -5
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +32 -7
- package/docs/application-channel.md +38 -10
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +106 -21
- package/docs/host-service.md +17 -6
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +63 -2
- package/docs/releasing.md +13 -0
- package/docs/repair.md +1 -1
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +96 -16
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +12 -4
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +44 -9
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +88 -17
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/codex-session.ts +5 -3
- package/src/config.ts +18 -28
- package/src/control-state.ts +187 -9
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +110 -31
- package/src/files.ts +38 -11
- package/src/host-executor.ts +50 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +96 -52
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +100 -23
- package/src/isolation.ts +25 -0
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +16 -8
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +178 -26
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +11 -7
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +6 -3
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +29 -9
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +31 -18
- package/src/version.ts +20 -0
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +24 -20
- package/templates/agent-purpose.md +1 -0
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +18 -88
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +84 -0
- package/test/application-channel.test.ts +66 -15
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +16 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +63 -3
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +47 -37
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +12 -8
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +47 -11
- package/test/failure.test.ts +18 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +80 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +18 -6
- package/test/install-tools.test.mjs +80 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +143 -5
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/scheduler.test.ts +2 -2
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +102 -13
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +27 -11
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -44
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -15
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
- package/templates/agent-guidance.md +0 -114
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
package/src/schedule-cli.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
35
|
-
Use --now to
|
|
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
|
|
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:
|
|
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.
|
|
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=
|
|
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')
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -16,17 +16,60 @@ const displayedPreset = (schedule: Schedule) => {
|
|
|
16
16
|
catch { return schedule.execution.preset }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
127
|
-
if (!
|
|
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
|
|
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>\
|
|
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
|
-
|
|
128
|
-
|
|
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 (
|
|
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
|
|
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,
|
package/src/software-status.ts
CHANGED
|
@@ -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 {
|
|
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 ·
|
|
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 {
|
|
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
|
+
}
|
package/src/task-executor.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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})
|
package/src/task-workspace.ts
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
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('
|
|
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
|
+
}
|