@tealbrick/avm 0.1.2 → 0.2.5

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/README.md CHANGED
@@ -163,3 +163,22 @@ Deployment authority correction (2026-09-15): live AVMM/Eve/Teal Brick packages
163
163
  belong exclusively on Nucbox, the Fedora N150. Neuu attempts above are historical
164
164
  and were aimed at the wrong host. Nucbox is reachable and reports AVMM
165
165
  0.13.0-alpha.2; its resident Aura still runs Eve 0.49.0. No live upgrade is implied.
166
+
167
+ ### Optional routine wake adapter
168
+
169
+ `@tealbrick/avm/wake` exports `createAvmmWakeAdapter` for the Portal management
170
+ channel's optional host-wake interface. It requires a fixed trusted transport,
171
+ worker, stable ownerKey, explicit `allowed: true`, and verified Eve `timezone:
172
+ 'UTC'`. Wake-only assignments preserve Eve as the execution owner. See the Portal
173
+ package's `MANAGEMENT.md` for supported timings, security boundary and acceptance
174
+ requirements. It is not a generic cron dispatcher or an exe.dev adapter.
175
+
176
+ ### Shared host routine registry
177
+
178
+ `@tealbrick/avm/routines`, `/routines-client` and `/routines-eve` provide the
179
+ host-owned routine store, bounded RPC client and explicitly mounted Eve tools
180
+ and dispatcher. Desktop and authenticated agent turns edit the same revisioned
181
+ records; optional AVMM wake schedules are derived from those records. See
182
+ [ROUTINES.md](./ROUTINES.md) for mounting, migration, authorization and execution
183
+ semantics. This extends the operator-owned schedule boundary described above;
184
+ it does not grant general host access or provide an exe.dev wake adapter.
package/ROUTINES.md ADDED
@@ -0,0 +1,85 @@
1
+ # Shared host routines (Eve 0.58.1)
2
+
3
+ The AVM package owns the host store, typed client and opt-in Eve routine tool.
4
+ The host store is authoritative. Neither the desktop nor Eve edits a second
5
+ schedule definition. AVMM wake assignments are derived projections of enabled
6
+ rows with `wake: true`; Eve runs one permanent minute dispatcher. Plain VPS and
7
+ exe.dev wake adapters are not supplied by this implementation.
8
+
9
+ Exports:
10
+ - `@tealbrick/avm/routines`: Node 24 host-only SQLite store and UTC cron validation.
11
+ - `@tealbrick/avm/routines-client`: fixed endpoint client; resolves its scoped
12
+ bearer at request time, refuses redirects and bounds request duration.
13
+ - `@tealbrick/avm/routines-eve`: `routineTool` and `routineDispatcher` factories.
14
+
15
+ Explicitly mount a `routineTool({bridge,scope})` as an authored tool and
16
+ `routineDispatcher({bridge,delivery})` as a root authored schedule. No package
17
+ import installs either. The delivery channel must implement proactive receive
18
+ and report completion using the occurrence ID. The Portal management channel
19
+ supports `routines:{...bridge,owner}` for authenticated desktop CRUD and durable
20
+ chat delivery. Keep build/restart configuration for explicit lifecycle actions.
21
+
22
+ ## Scope and authority
23
+
24
+ A store is configured by the host operator for exactly one organization,
25
+ workspace, logical agent and owning user. Opening it with another scope fails.
26
+ The example host RPC binds an assigned worker IP plus a scoped bearer to that
27
+ store. Portal authenticates the human/agent; this host enrollment grants access
28
+ only to this store and its wake projection. No arbitrary host paths, commands,
29
+ VM selection or model-supplied identity is accepted. Keep the service token
30
+ inside the trusted guest runtime, never model inputs or browser storage.
31
+
32
+ The Eve tool requires the authenticated managing user in the current turn,
33
+ matching its configured owner and organization. Runtime/app and agent-to-agent
34
+ principals cannot create further routines. An agent can author and edit in
35
+ response to its authenticated user's conversation. Unattended self-modification
36
+ requires a separate deliberate permission design; it is not silently enabled.
37
+ Current deployment uses a fixed owner enrollment, not multi-owner delegation.
38
+ Persisted routines remain authorized host jobs until paused/removed or host
39
+ access is revoked; live Portal membership revocation is not revalidated at each
40
+ cron occurrence in this version.
41
+
42
+ ## Records and operations
43
+
44
+ Routine: name, five-field UTC cron, instruction, enabled, wake, revision,
45
+ nextRunAt, updatedAt, tombstone. Scope belongs to the store, never request data.
46
+ `apply` supports upsert (create-only), update, delete (tombstone), and wake.
47
+ All changes require a stable requestId; updates require the observed revision.
48
+ Desktop and model retries cannot overwrite newer revisions. Names cannot be
49
+ changed; create a new routine if a new identity is wanted.
50
+
51
+ Changes commit transactionally before wake reconciliation. A failed wake update
52
+ is visibly pending and retried by the dispatcher. Existing wake registrations
53
+ may remain until reconciliation succeeds, but disabled/deleted rows cannot be
54
+ claimed. The legacy `/reconcile` RPC is disabled once shared storage is enabled.
55
+
56
+ `claim` atomically advances due rows and records stable occurrence IDs. Concurrent
57
+ claims return an occurrence once. A run can be claimed, dispatched, completed,
58
+ failed, missed or uncertain. Ambiguous dispatch is not replayed automatically:
59
+ this trades missed work for avoiding duplicate consequential actions. A tick more
60
+ than two minutes late is recorded missed; no catch-up burst. Unresolved claims
61
+ become uncertain after ten minutes. Already-running work is unaffected by edits.
62
+ The last 100 runs are returned; disk retention and retry controls remain operator
63
+ work. Completion means a final message was received, not verification of every
64
+ external side effect.
65
+
66
+ The store's in-process queue serializes async wake projections, and SQLite
67
+ transactions protect records. Run one host service per store (systemd); do not
68
+ run multiple independent wake reconcilers against the same file. Back up the
69
+ SQLite database using its backup facilities or with the service stopped,
70
+ including WAL files when applicable.
71
+
72
+ ## Consumer layout
73
+
74
+ ```
75
+ lib/routines.ts fixed client + operator scope
76
+ agent/tools/tealbrick_routines.ts routineTool factory
77
+ agent/schedules/tealbrick-routine-dispatcher.ts
78
+ agent/channels/management.ts Portal management + delivery adapter
79
+ ```
80
+
81
+ For migration, preserve existing source and workflow state, seed the host store,
82
+ remove the corresponding old static schedule from discovery, then build/restart
83
+ once. Never leave both static and dynamic execution enabled for the same job.
84
+ Subsequent edits need no rebuild or restart. Desktop reads dynamic rows from
85
+ management discovery and hides the dispatcher implementation from its list.
@@ -0,0 +1,6 @@
1
+ import type { RoutineBridge } from './routines.js';
2
+ /** Fixed operator-configured host endpoint. Secret is resolved at request time. */
3
+ export declare function createRoutineClient(options: {
4
+ url: string;
5
+ token: () => string | Promise<string>;
6
+ }): RoutineBridge;
@@ -0,0 +1,8 @@
1
+ /** Fixed operator-configured host endpoint. Secret is resolved at request time. */
2
+ export function createRoutineClient(options) {
3
+ const url = new URL(options.url);
4
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
5
+ throw Error('invalid_host_endpoint');
6
+ return { async call(action, input = {}) { const response = await fetch(url, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'content-type': 'application/json', authorization: `Bearer ${await options.token()}` }, body: JSON.stringify({ action, input }) }); const body = await response.json(); if (!response.ok)
7
+ throw Error(typeof body.error === 'string' ? body.error : 'routine_host_unavailable'); return body; } };
8
+ }
@@ -0,0 +1,32 @@
1
+ import type { RoutineBridge, RoutineScope } from './routines.js';
2
+ /** Explicitly mounted tool; no model-selected owner, host URL or shell command. */
3
+ export declare function routineTool(options: {
4
+ bridge: RoutineBridge;
5
+ scope: RoutineScope;
6
+ }): import("eve/tools").ToolDefinition<{
7
+ operation: "list" | "create" | "update" | "pause" | "resume" | "remove";
8
+ name?: string | undefined;
9
+ cron?: string | undefined;
10
+ instruction?: string | undefined;
11
+ wake?: boolean | undefined;
12
+ revision?: number | undefined;
13
+ requestId?: string | undefined;
14
+ }, any> & {
15
+ execute(input: {
16
+ operation: "list" | "create" | "update" | "pause" | "resume" | "remove";
17
+ name?: string | undefined;
18
+ cron?: string | undefined;
19
+ instruction?: string | undefined;
20
+ wake?: boolean | undefined;
21
+ revision?: number | undefined;
22
+ requestId?: string | undefined;
23
+ }, ctx: import("eve/tools").ToolContext): Promise<any>;
24
+ };
25
+ /** Install once; all recurrence definitions are read from the host at execution time. */
26
+ export declare function routineDispatcher(options: {
27
+ bridge: RoutineBridge;
28
+ delivery: any;
29
+ }): {
30
+ cron: string;
31
+ run({ to, waitUntil, appAuth }: import("eve/schedules").ScheduleHandlerArgs): void;
32
+ };
@@ -0,0 +1,30 @@
1
+ import { defineTool } from 'eve/tools';
2
+ import { defineSchedule } from 'eve/schedules';
3
+ import { z } from 'zod';
4
+ const input = z.object({ operation: z.enum(['list', 'create', 'update', 'pause', 'resume', 'remove']), name: z.string().optional(), cron: z.string().optional(), instruction: z.string().optional(), wake: z.boolean().optional(), revision: z.number().int().positive().optional(), requestId: z.string().min(16).optional() }).strict();
5
+ /** Explicitly mounted tool; no model-selected owner, host URL or shell command. */
6
+ export function routineTool(options) {
7
+ return defineTool({ description: 'Manage your host-persisted routines shared with desktop. List first to get current revisions. Create needs a unique lowercase hyphenated name, five-field UTC cron and instruction. Update/pause/resume/remove require the revision returned by list. Wake is optional and off by default. Runs deliver into the owner’s desktop chat. requestId must be a stable UUID for each intended change; reuse it only for an identical retry. Do not claim execution from a saved routine.', inputSchema: input, async execute(args, ctx) {
8
+ const auth = ctx.session.auth.current;
9
+ if (auth?.authenticator !== 'tealbrick' || auth.principalType !== 'user' || auth.principalId !== options.scope.owner || auth.attributes.org !== options.scope.org || auth.attributes.agentGrantLevel !== 'manage')
10
+ throw Error('routine_manage_permission_required');
11
+ if (args.operation === 'list')
12
+ return options.bridge.call('status');
13
+ if (!args.requestId)
14
+ throw Error('request_id_required');
15
+ const { operation, ...fields } = args;
16
+ return options.bridge.call('apply', { ...fields, operation: operation === 'create' ? 'upsert' : operation === 'remove' ? 'delete' : 'update', ...(operation === 'pause' ? { enabled: false } : operation === 'resume' ? { enabled: true } : {}) });
17
+ } });
18
+ }
19
+ /** Install once; all recurrence definitions are read from the host at execution time. */
20
+ export function routineDispatcher(options) {
21
+ return defineSchedule({ cron: '* * * * *', run({ to, waitUntil, appAuth }) { waitUntil((async () => { const { jobs } = await options.bridge.call('claim'); for (const job of jobs) {
22
+ try {
23
+ await to(options.delivery, { owner: job.owner, name: job.name, occurrence: job.occurrence }).send(job.instruction, { auth: appAuth });
24
+ await options.bridge.call('accepted', { id: job.occurrence });
25
+ }
26
+ catch {
27
+ await options.bridge.call('failed', { id: job.occurrence });
28
+ }
29
+ } })()); } });
30
+ }
@@ -0,0 +1,54 @@
1
+ export interface RoutineScope {
2
+ org: string;
3
+ workspace: string;
4
+ agent: string;
5
+ owner: string;
6
+ }
7
+ export interface Routine {
8
+ name: string;
9
+ cron: string;
10
+ instruction: string;
11
+ enabled: boolean;
12
+ wake: boolean;
13
+ revision: number;
14
+ nextRunAt: number;
15
+ deleted?: boolean;
16
+ updatedAt: number;
17
+ }
18
+ export interface RoutineRun {
19
+ id: string;
20
+ name: string;
21
+ revision: number;
22
+ dueAt: number;
23
+ status: string;
24
+ updatedAt: number;
25
+ error?: string;
26
+ }
27
+ export interface RoutineSnapshot {
28
+ scope: RoutineScope;
29
+ routines: Routine[];
30
+ runs: RoutineRun[];
31
+ wake: Record<string, unknown>;
32
+ dynamic: true;
33
+ }
34
+ export interface RoutineBridge {
35
+ call(action: string, input?: Record<string, unknown>): Promise<any>;
36
+ }
37
+ export declare function nextRoutineTime(cron: string, after: number): number;
38
+ /** One trusted, fixed agent/workspace/owner scope per store. SQLite is host-side only. */
39
+ export declare function createRoutineStore(options: {
40
+ path: string;
41
+ scope: RoutineScope;
42
+ now?: () => number;
43
+ wake?: {
44
+ status(): Promise<any>;
45
+ validate(rows: {
46
+ name: string;
47
+ cron: string;
48
+ }[]): void;
49
+ reconcile(rows: {
50
+ name: string;
51
+ cron: string;
52
+ }[]): Promise<void>;
53
+ };
54
+ }): RoutineBridge;
@@ -0,0 +1,136 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { mkdirSync, chmodSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { CronExpressionParser } from 'cron-parser';
6
+ import { z } from 'zod';
7
+ const name = z.string().regex(/^[a-z0-9][a-z0-9-]{0,47}$/);
8
+ const change = z.object({ operation: z.enum(['upsert', 'update', 'delete', 'wake']), name, requestId: z.string().min(16).max(80).regex(/^[a-zA-Z0-9-]+$/), revision: z.number().int().positive().optional(), cron: z.string().max(120).optional(), instruction: z.string().trim().min(1).max(12000).optional(), enabled: z.boolean().optional(), wake: z.boolean().optional() }).strict();
9
+ export function nextRoutineTime(cron, after) {
10
+ if (cron.trim().split(/\s+/).length !== 5)
11
+ throw Error('invalid_cron');
12
+ return CronExpressionParser.parse(cron, { currentDate: new Date(after), tz: 'UTC' }).next().getTime();
13
+ }
14
+ /** One trusted, fixed agent/workspace/owner scope per store. SQLite is host-side only. */
15
+ export function createRoutineStore(options) {
16
+ for (const value of Object.values(options.scope))
17
+ if (!value)
18
+ throw Error('invalid_scope');
19
+ mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
20
+ const db = new DatabaseSync(options.path);
21
+ chmodSync(options.path, 0o600);
22
+ db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY CHECK(id=1), json TEXT NOT NULL); CREATE TABLE IF NOT EXISTS receipts (id TEXT PRIMARY KEY,digest TEXT NOT NULL,json TEXT NOT NULL); CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY,json TEXT NOT NULL);');
23
+ db.prepare('INSERT OR IGNORE INTO state VALUES (1,?)').run(JSON.stringify({ scope: options.scope, routines: [], dirty: false }));
24
+ const read = () => JSON.parse(db.prepare('SELECT json FROM state WHERE id=1').get().json);
25
+ if (JSON.stringify(read().scope) !== JSON.stringify(options.scope))
26
+ throw Error('scope_mismatch');
27
+ const save = (s) => db.prepare('UPDATE state SET json=? WHERE id=1').run(JSON.stringify(s));
28
+ const now = options.now ?? Date.now;
29
+ const tx = (fn) => { db.exec('BEGIN IMMEDIATE'); try {
30
+ const value = fn();
31
+ db.exec('COMMIT');
32
+ return value;
33
+ }
34
+ catch (e) {
35
+ db.exec('ROLLBACK');
36
+ throw e;
37
+ } };
38
+ const runs = () => db.prepare('SELECT json FROM runs ORDER BY rowid DESC LIMIT 100').all().map((r) => JSON.parse(r.json));
39
+ const writeRun = (r) => db.prepare('INSERT OR REPLACE INTO runs VALUES (?,?)').run(r.id, JSON.stringify(r));
40
+ const reconcile = async () => { const s = read(); if (!s.dirty)
41
+ return; if (!options.wake)
42
+ throw Error('wake_unavailable'); await options.wake.reconcile(s.routines.filter(r => !r.deleted && r.enabled && r.wake)); tx(() => { const current = read(); current.dirty = false; save(current); }); };
43
+ let queue = Promise.resolve();
44
+ const call = async (action, input = {}) => {
45
+ if (action === 'status')
46
+ return { scope: options.scope, dynamic: true, routines: read().routines.filter(r => !r.deleted), runs: runs(), wake: { ...(await options.wake?.status() ?? { supported: false }), pending: read().dirty, managed: read().routines.filter(r => !r.deleted).map(r => r.name), routines: read().routines.filter(r => !r.deleted && r.wake).map(r => r.name) } };
47
+ if (action === 'apply') {
48
+ const patch = change.parse(input), digest = createHash('sha256').update(JSON.stringify(patch)).digest('hex');
49
+ const old = db.prepare('SELECT digest,json FROM receipts WHERE id=?').get(patch.requestId);
50
+ if (old) {
51
+ if (old.digest !== digest)
52
+ throw Error('request_id_conflict');
53
+ return JSON.parse(old.json);
54
+ }
55
+ const before = read(), prior = before.routines.find(r => r.name === patch.name);
56
+ if (patch.operation === 'upsert' && prior && !prior.deleted)
57
+ throw Error('schedule_exists');
58
+ if (patch.operation !== 'upsert' && (!prior || prior.deleted))
59
+ throw Error('schedule_not_found');
60
+ if (patch.operation !== 'upsert' && patch.revision !== prior.revision)
61
+ throw Error('revision_conflict');
62
+ const row = { name: patch.name, cron: patch.cron ?? prior?.cron ?? '', instruction: patch.instruction ?? prior?.instruction ?? '', enabled: patch.enabled ?? prior?.enabled ?? true, wake: patch.wake ?? prior?.wake ?? false, revision: (prior?.revision ?? 0) + 1, nextRunAt: 0, updatedAt: now(), deleted: patch.operation === 'delete' };
63
+ if (!row.instruction)
64
+ throw Error('instruction_required');
65
+ const next = nextRoutineTime(row.cron, now());
66
+ row.nextRunAt = prior && patch.cron === undefined && !(patch.enabled === true && !prior.enabled) ? prior.nextRunAt : next;
67
+ if (row.wake && row.enabled && !row.deleted) {
68
+ if (!options.wake || (await options.wake.status()).supported !== true)
69
+ throw Error('wake_unavailable');
70
+ options.wake.validate([row]);
71
+ }
72
+ tx(() => { const state = read(); const current = state.routines.find(r => r.name === row.name); if (current?.revision !== prior?.revision)
73
+ throw Error('revision_conflict'); state.routines = state.routines.filter(r => r.name !== row.name); state.routines.push(row); state.dirty ||= Boolean(row.wake || prior?.wake); save(state); db.prepare('INSERT INTO receipts VALUES (?,?,?)').run(patch.requestId, digest, JSON.stringify({ ok: true, dynamic: true, saved: true, routine: row })); });
74
+ try {
75
+ await reconcile();
76
+ }
77
+ catch {
78
+ return { ok: true, dynamic: true, saved: true, routine: row, wakePending: true };
79
+ }
80
+ return { ok: true, dynamic: true, saved: true, routine: row };
81
+ }
82
+ if (action === 'claim') {
83
+ try {
84
+ await reconcile();
85
+ }
86
+ catch { /* Persisted pending intent remains observable. */ }
87
+ return tx(() => {
88
+ const s = read(), jobs = [];
89
+ const at = now();
90
+ // Ambiguous dispatch is not automatically repeated: no duplicate model side effects.
91
+ for (const run of runs())
92
+ if (['claimed', 'dispatched'].includes(run.status) && at - run.updatedAt > 600000) {
93
+ run.status = 'uncertain';
94
+ run.updatedAt = at;
95
+ writeRun(run);
96
+ }
97
+ for (const row of s.routines) {
98
+ if (row.deleted || !row.enabled || row.nextRunAt > at)
99
+ continue;
100
+ const due = row.nextRunAt;
101
+ row.nextRunAt = nextRoutineTime(row.cron, at);
102
+ const id = createHash('sha256').update(JSON.stringify([s.scope, row.name, row.revision, due])).digest('hex');
103
+ const run = { id, name: row.name, revision: row.revision, dueAt: due, status: at - due > 120000 ? 'missed' : 'claimed', updatedAt: at };
104
+ if (!db.prepare('SELECT id FROM runs WHERE id=?').get(id)) {
105
+ writeRun(run);
106
+ if (run.status === 'claimed')
107
+ jobs.push({ ...row, occurrence: id, owner: s.scope.owner });
108
+ }
109
+ }
110
+ save(s);
111
+ return { jobs };
112
+ });
113
+ }
114
+ if (['accepted', 'complete', 'failed'].includes(action))
115
+ return tx(() => {
116
+ if (typeof input.id !== 'string')
117
+ throw Error('invalid_occurrence');
118
+ const raw = db.prepare('SELECT json FROM runs WHERE id=?').get(input.id);
119
+ if (!raw)
120
+ throw Error('unknown_occurrence');
121
+ const run = JSON.parse(raw.json);
122
+ if (run.status === 'completed')
123
+ return { ok: true };
124
+ if (action === 'accepted' && run.status !== 'claimed')
125
+ return { ok: true };
126
+ run.status = action === 'complete' ? 'completed' : action === 'failed' ? 'failed' : 'dispatched';
127
+ run.updatedAt = now();
128
+ if (action === 'failed')
129
+ run.error = 'dispatch_failed';
130
+ writeRun(run);
131
+ return { ok: true };
132
+ });
133
+ throw Error('unsupported_operation');
134
+ };
135
+ return { call(action, input) { const pending = queue.then(() => call(action, input)); queue = pending.catch(() => { }); return pending; } };
136
+ }
@@ -0,0 +1,34 @@
1
+ import type { Transport } from './index.js';
2
+ export interface WakeRoutine {
3
+ name: string;
4
+ cron: string;
5
+ }
6
+ /** Fixed-interval AVMM assignments, UTC only. Never invoke the routine payload. */
7
+ export declare function planWake(cron: string, now?: number, leadSeconds?: number): {
8
+ next: string;
9
+ every: number;
10
+ }[];
11
+ export declare function createAvmmWakeAdapter(options: {
12
+ transport: Transport;
13
+ worker: string;
14
+ ownerKey: string;
15
+ allowed: boolean; /** Must match Eve's production cron timezone. */
16
+ timezone: string;
17
+ credentialsRefreshOnRequest?: boolean;
18
+ harness?: string;
19
+ leadSeconds?: number;
20
+ }): {
21
+ status(): Promise<{
22
+ supported: boolean;
23
+ reason: string;
24
+ provider?: undefined;
25
+ timezone?: undefined;
26
+ } | {
27
+ supported: boolean;
28
+ provider: string;
29
+ timezone: string;
30
+ reason: string;
31
+ }>;
32
+ validate(routines: WakeRoutine[]): void;
33
+ reconcile(routines: WakeRoutine[]): Promise<void>;
34
+ };
@@ -0,0 +1,97 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** Fixed-interval AVMM assignments, UTC only. Never invoke the routine payload. */
3
+ export function planWake(cron, now = Date.now(), leadSeconds = 60) {
4
+ const [m, h, d, mo, w, ...rest] = cron.trim().split(/\s+/);
5
+ if (rest.length || !w || d !== '*' || mo !== '*')
6
+ throw Error('unsupported_wake_cron');
7
+ const number = (s, max) => /^\d+$/.test(s) && Number(s) <= max ? Number(s) : null;
8
+ const minute = number(m, 59), hour = number(h, 23);
9
+ const after = now + leadSeconds * 1000;
10
+ const next = (anchor, every) => ({ next: new Date(anchor + Math.max(0, Math.floor((after - anchor) / (every * 1000)) + 1) * every * 1000 - leadSeconds * 1000).toISOString(), every });
11
+ if (w === '*' && h === '*' && (m === '*' || /^\*\/\d+$/.test(m))) {
12
+ const n = m === '*' ? 1 : Number(m.slice(2));
13
+ if (n < 1 || n > 60 || 60 % n)
14
+ throw Error('unsupported_wake_cron');
15
+ return [next(0, n * 60)];
16
+ }
17
+ if (w === '*' && minute !== null && (h === '*' || /^\*\/\d+$/.test(h))) {
18
+ const n = h === '*' ? 1 : Number(h.slice(2));
19
+ if (n < 1 || n > 24 || 24 % n)
20
+ throw Error('unsupported_wake_cron');
21
+ return [next(minute * 60000, n * 3600)];
22
+ }
23
+ if (minute === null || hour === null)
24
+ throw Error('unsupported_wake_cron');
25
+ const time = (hour * 60 + minute) * 60000;
26
+ if (w === '*')
27
+ return [next(time, 86400)];
28
+ const days = w === '1-5' ? [1, 2, 3, 4, 5] : number(w, 6) !== null ? [Number(w)] : null;
29
+ if (!days)
30
+ throw Error('unsupported_wake_cron');
31
+ // 1970-01-04 is a Sunday.
32
+ return days.map(day => next((3 + day) * 86400000 + time, 604800));
33
+ }
34
+ export function createAvmmWakeAdapter(options) {
35
+ if (!/^[a-zA-Z0-9_.-]+$/.test(options.worker) || !options.ownerKey)
36
+ throw Error('invalid_wake_scope');
37
+ const prefix = 'tealwake-' + createHash('sha256').update(options.ownerKey + '\0' + options.worker).digest('hex').slice(0, 20) + '-';
38
+ const lead = options.leadSeconds ?? 60;
39
+ if (!Number.isInteger(lead) || lead < 30 || lead > 300)
40
+ throw Error('invalid_wake_lead');
41
+ const run = async (argv) => {
42
+ const r = await options.transport.run(['--json', ...argv], { signal: AbortSignal.timeout(30000) });
43
+ if (r.exitCode !== 0)
44
+ throw Error('host_wake_denied_or_unavailable');
45
+ const body = JSON.parse(r.stdout);
46
+ if (body.api_version !== 'agentvm/v1' || body.error)
47
+ throw Error('host_wake_protocol');
48
+ return body.result;
49
+ };
50
+ return {
51
+ async status() {
52
+ if (options.timezone !== 'UTC')
53
+ return { supported: false, reason: 'Host wake requires Eve schedules configured in UTC.' };
54
+ if (!options.allowed)
55
+ return { supported: false, reason: 'Host wake permission has not been granted.' };
56
+ try {
57
+ const rows = await run(['schedule', 'ls']);
58
+ if (!Array.isArray(rows))
59
+ throw Error();
60
+ const worker = await run(['worker', 'status', options.worker]);
61
+ if (worker.mode !== 'durable' || !Number.isFinite(worker.idle_timeout_seconds) || worker.idle_timeout_seconds <= lead)
62
+ throw Error();
63
+ if (worker.auth?.codex === 'shared' && (!options.credentialsRefreshOnRequest || !worker.capabilities?.includes('model:codex-subscription')))
64
+ throw Error();
65
+ if (!Array.isArray(worker.capabilities) || !worker.capabilities.includes(`harness:${options.harness ?? 'eve'}`))
66
+ throw Error();
67
+ return { supported: true, provider: 'avmm', timezone: 'UTC', reason: 'Minute/hour intervals, daily, weekdays and weekly routines. Host wakes only; Eve executes.' };
68
+ }
69
+ catch {
70
+ return { supported: false, reason: 'Host scheduler, worker capability or idle timeout could not be verified.' };
71
+ }
72
+ },
73
+ validate(routines) { for (const r of routines)
74
+ planWake(r.cron, Date.now(), lead); },
75
+ async reconcile(routines) {
76
+ if (!(await this.status()).supported)
77
+ throw Error('host_wake_unavailable');
78
+ this.validate(routines);
79
+ const desired = new Set();
80
+ const existing = await run(['schedule', 'ls']);
81
+ const worker = await run(['worker', 'status', options.worker]);
82
+ const authCaps = worker.auth?.codex === 'shared' ? ['--cap', 'model:codex-subscription'] : [];
83
+ for (const routine of routines) {
84
+ const key = createHash('sha256').update(routine.name).digest('hex').slice(0, 16);
85
+ const plans = planWake(routine.cron, Date.now(), lead);
86
+ for (const [i, p] of plans.entries()) {
87
+ const id = prefix + key + '-' + i;
88
+ desired.add(id);
89
+ await run(['schedule', 'put', id, '--agent', options.worker, '--harness', options.harness ?? 'eve', '--next-wake-at', p.next, '--timezone', 'UTC', '--misfire-policy', 'skip', '--target', 'worker', '--worker', options.worker, '--every-seconds', String(p.every), '--keep-running', ...authCaps, '--', '/usr/bin/true']);
90
+ }
91
+ }
92
+ for (const row of existing)
93
+ if (typeof row.schedule_id === 'string' && row.schedule_id.startsWith(prefix) && !desired.has(row.schedule_id))
94
+ await run(['schedule', 'remove', row.schedule_id]);
95
+ }
96
+ };
97
+ }
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "kind": "eve-extension",
3
3
  "formatVersion": 2,
4
- "builtWithEve": "0.56.0",
4
+ "builtWithEve": "0.64.1",
5
5
  "requires": {
6
6
  "extension": 1,
7
- "tool": 43,
8
- "dynamicTool": 40,
7
+ "tool": 54,
8
+ "dynamicTool": 52,
9
9
  "config": 1
10
10
  }
11
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tealbrick/avm",
3
- "version": "0.1.2",
3
+ "version": "0.2.5",
4
4
  "description": "Typed host-owned AgentVM transport and opt-in Eve capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,7 +8,8 @@
8
8
  "dist",
9
9
  "README.md",
10
10
  "PROVENANCE.md",
11
- "LICENSE"
11
+ "LICENSE",
12
+ "ROUTINES.md"
12
13
  ],
13
14
  "engines": {
14
15
  "node": ">=24"
@@ -29,6 +30,22 @@
29
30
  "./eve": {
30
31
  "types": "./dist/index.d.ts",
31
32
  "default": "./dist/index.mjs"
33
+ },
34
+ "./wake": {
35
+ "types": "./dist/client/wake.d.ts",
36
+ "import": "./dist/client/wake.js"
37
+ },
38
+ "./routines": {
39
+ "types": "./dist/client/routines.d.ts",
40
+ "import": "./dist/client/routines.js"
41
+ },
42
+ "./routines-client": {
43
+ "types": "./dist/client/routines-client.d.ts",
44
+ "import": "./dist/client/routines-client.js"
45
+ },
46
+ "./routines-eve": {
47
+ "types": "./dist/client/routines-eve.d.ts",
48
+ "import": "./dist/client/routines-eve.js"
32
49
  }
33
50
  },
34
51
  "eve": {
@@ -43,15 +60,16 @@
43
60
  "typecheck": "tsc --noEmit"
44
61
  },
45
62
  "dependencies": {
63
+ "cron-parser": "^5.4.0",
46
64
  "zod": "^4.0.0"
47
65
  },
48
66
  "devDependencies": {
49
- "eve": "0.56.0",
50
- "typescript": "^5.9.3",
51
- "@types/node": "^24.0.0"
67
+ "@types/node": "^24.0.0",
68
+ "eve": "0.64.1",
69
+ "typescript": "^5.9.3"
52
70
  },
53
71
  "peerDependencies": {
54
- "eve": ">=0.56.0 <0.57.0"
72
+ "eve": ">=0.64.1 <0.65.0"
55
73
  },
56
74
  "peerDependenciesMeta": {
57
75
  "eve": {