pactive 0.0.0-stage → 0.0.2-alpha.1

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.
@@ -0,0 +1,143 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { NOT_IMPLEMENTED } from './errors.mjs';
5
+ import { ledger } from './lineage.mjs';
6
+ import { resolvePaths } from './paths.mjs';
7
+ import { loadState } from './state.mjs';
8
+
9
+ const STALE_LOCK_MS = 30000;
10
+ const PEER_TTL_MS = 120000;
11
+ const CLOSED_EVENTS = ['outcome', 'closed', 'settled'];
12
+
13
+ /**
14
+ * Read only health report. It never reads credentials and never writes anything.
15
+ * Host probing is the caller's job: without ctx.hosts the report says so.
16
+ */
17
+ export async function doctor(ctx = {}) {
18
+ const env = ctx.env ?? process.env;
19
+ const paths = ctx.paths ?? resolvePaths(ctx.hostKind ?? 'default', env);
20
+ const state = ctx.state ?? loadState(paths);
21
+
22
+ const warnings = [];
23
+ const checks = [];
24
+
25
+ const dirMode = modeOf(paths.root);
26
+ checks.push({
27
+ id: 'state-root',
28
+ status: dirMode === null ? 'FAIL' : 'PASS',
29
+ detail: dirMode === null ? `state root is missing: ${paths.root}` : `state root present (${dirMode})`,
30
+ });
31
+
32
+ const fileMode = modeOf(paths.stateFile);
33
+ checks.push({
34
+ id: 'perm-dir',
35
+ status: dirMode === null ? 'FAIL' : dirMode === '0700' ? 'PASS' : 'FAIL',
36
+ detail: dirMode === null ? 'state root missing' : `dir mode ${dirMode} (expected 0700)`,
37
+ });
38
+ checks.push({
39
+ id: 'perm-file',
40
+ status: fileMode === null ? 'PASS' : fileMode === '0600' ? 'PASS' : 'FAIL',
41
+ detail: fileMode === null ? 'state file absent' : `file mode ${fileMode} (expected 0600)`,
42
+ });
43
+
44
+ const quarantined = fs.existsSync(paths.root)
45
+ ? fs.readdirSync(paths.root).filter((f) => f.includes('.corrupt-'))
46
+ : [];
47
+ if (quarantined.length > 0) {
48
+ warnings.push(`state corrupt artifacts found: ${quarantined.length}`);
49
+ checks.push({ id: 'state-corrupt', status: 'WARN', detail: `${quarantined.length} quarantined file(s)` });
50
+ }
51
+
52
+ const locksStale = countStaleLocks(paths.locksDir);
53
+ checks.push({
54
+ id: 'locks',
55
+ status: locksStale === 0 ? 'PASS' : 'WARN',
56
+ detail: `${locksStale} stale lock(s)`,
57
+ });
58
+
59
+ const events = ledger.tail(paths.ledgerFile, Number.MAX_SAFE_INTEGER);
60
+ const triggered = events.filter((e) => e.event === 'triggered').length;
61
+ const closed = events.filter((e) => CLOSED_EVENTS.includes(e.event)).length;
62
+ const dangling = Math.max(0, triggered - closed);
63
+ if (dangling > 0) {
64
+ warnings.push(`ledger has ${dangling} dangling trigger(s) without a closing event`);
65
+ }
66
+ checks.push({
67
+ id: 'ledger-closure',
68
+ status: dangling === 0 ? 'PASS' : 'WARN',
69
+ detail: `${triggered} triggered, ${closed} closed, ${dangling} dangling`,
70
+ });
71
+
72
+ const hosts = Array.isArray(ctx.hosts)
73
+ ? ctx.hosts.map((h) => ({
74
+ host: h.host ?? 'unknown',
75
+ present: h.present === true,
76
+ version: h.version ?? null,
77
+ mode: h.mode ?? null,
78
+ compatible: h.compatible === true,
79
+ }))
80
+ : [];
81
+ if (!Array.isArray(ctx.hosts)) {
82
+ warnings.push(`host probing is done by the caller (${NOT_IMPLEMENTED})`);
83
+ checks.push({ id: 'hosts', status: 'WARN', detail: `host probing not provided (${NOT_IMPLEMENTED})` });
84
+ } else {
85
+ for (const host of hosts) {
86
+ if (!host.present) warnings.push(`host ${host.host} is not present`);
87
+ }
88
+ checks.push({ id: 'hosts', status: 'PASS', detail: `${hosts.length} host(s) reported` });
89
+ }
90
+
91
+ const peers = state.peers && typeof state.peers === 'object' ? state.peers : {};
92
+ const now = Number.isFinite(ctx.now) ? ctx.now : Date.now();
93
+ const sessions = Object.values(peers).filter((p) => p && Number.isFinite(p.lastSeen) && now - p.lastSeen <= PEER_TTL_MS).length;
94
+
95
+ const report = {
96
+ ok: checks.every((c) => c.status !== 'FAIL'),
97
+ hosts,
98
+ stateRoot: paths.root,
99
+ perm: {
100
+ dir: dirMode ?? 'absent',
101
+ file: fileMode ?? 'absent',
102
+ },
103
+ idle: {
104
+ enabled: state.idle?.enabled === true,
105
+ source: 'core-timeline',
106
+ },
107
+ sessions,
108
+ tasks: Array.isArray(state.tasks) ? state.tasks.length : 0,
109
+ outbox: Array.isArray(state.outbox) ? state.outbox.length : 0,
110
+ locksStale,
111
+ secrets: { credentialsRead: false },
112
+ warnings,
113
+ checks,
114
+ };
115
+ return report;
116
+ }
117
+
118
+ function modeOf(target) {
119
+ try {
120
+ return (fs.statSync(target).mode & 0o777).toString(8).padStart(4, '0');
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ function countStaleLocks(locksDir) {
127
+ let names = [];
128
+ try {
129
+ names = fs.readdirSync(locksDir);
130
+ } catch {
131
+ return 0;
132
+ }
133
+ let stale = 0;
134
+ for (const name of names) {
135
+ try {
136
+ const stat = fs.statSync(path.join(locksDir, name));
137
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) stale += 1;
138
+ } catch {
139
+ // the lock vanished while we looked at it
140
+ }
141
+ }
142
+ return stale;
143
+ }
@@ -0,0 +1,8 @@
1
+ // Shared error helpers. The contract freezes one public error-code constant.
2
+ export const NOT_IMPLEMENTED = 'NOT_IMPLEMENTED';
3
+
4
+ export function pactiveError(code, message) {
5
+ const err = new Error(message);
6
+ err.code = code;
7
+ return err;
8
+ }
@@ -0,0 +1,95 @@
1
+ export const GUARD_REASONS = ['quiet', 'hourly-limit', 'min-interval', 'cooldown'];
2
+ export const HOUR_WINDOW_MS = 3600000;
3
+
4
+ /** Quiet hours are local wall clock windows, "HH:MM"; a window may wrap midnight. */
5
+ export function inQuietWindow(quietStart, quietEnd, now) {
6
+ if (!quietStart || !quietEnd) return false;
7
+ const start = toMinutes(quietStart);
8
+ const end = toMinutes(quietEnd);
9
+ if (start === null || end === null) return false;
10
+ const d = new Date(now);
11
+ const current = d.getHours() * 60 + d.getMinutes();
12
+ if (start === end) return false;
13
+ if (start < end) return current >= start && current < end;
14
+ return current >= start || current < end;
15
+ }
16
+
17
+ function toMinutes(value) {
18
+ if (typeof value !== 'string') return null;
19
+ const m = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
20
+ if (!m) return null;
21
+ const hours = Number(m[1]);
22
+ const minutes = Number(m[2]);
23
+ if (hours > 23 || minutes > 59) return null;
24
+ return hours * 60 + minutes;
25
+ }
26
+
27
+ /** The four gates: quiet window, hourly limit, minimum interval, cooldown. */
28
+ export function checkGuard(state, now = Date.now()) {
29
+ const ts = Number.isFinite(now) ? now : Date.now();
30
+ const policy = state.policy || {};
31
+ const guard = state.guard || {};
32
+
33
+ if (inQuietWindow(policy.quietStart, policy.quietEnd, ts)) {
34
+ return { pass: false, reason: 'quiet' };
35
+ }
36
+
37
+ const limit = Number.isFinite(policy.hourlyLimit) ? policy.hourlyLimit : 6;
38
+ const windowStart = Number.isFinite(guard.hourWindowStart) ? guard.hourWindowStart : 0;
39
+ const count = Number.isFinite(guard.hourCount) ? guard.hourCount : 0;
40
+ // The window is only rolled over when it really expired; a count without a
41
+ // window start still counts for the current hour.
42
+ const windowExpired = windowStart > 0 && ts - windowStart >= HOUR_WINDOW_MS;
43
+ if (!windowExpired && count >= limit) {
44
+ return { pass: false, reason: 'hourly-limit' };
45
+ }
46
+
47
+ const lastTriggerAt = Number.isFinite(guard.lastTriggerAt) ? guard.lastTriggerAt : 0;
48
+ if (lastTriggerAt > 0) {
49
+ const minIntervalMs = Number.isFinite(policy.minIntervalMs) ? policy.minIntervalMs : 600000;
50
+ if (ts - lastTriggerAt < minIntervalMs) {
51
+ return { pass: false, reason: 'min-interval' };
52
+ }
53
+ const cooldownMs = Number.isFinite(policy.cooldownMs) ? policy.cooldownMs : 1800000;
54
+ if (ts - lastTriggerAt < cooldownMs) {
55
+ return { pass: false, reason: 'cooldown' };
56
+ }
57
+ }
58
+
59
+ return { pass: true, reason: 'idle' };
60
+ }
61
+
62
+ /**
63
+ * Reserve the right to trigger once for the current activity epoch.
64
+ * Blocks are reported with a reason; the reservation is recorded in the state.
65
+ */
66
+ export function reserveTrigger(state, now = Date.now()) {
67
+ const ts = Number.isFinite(now) ? now : Date.now();
68
+ const epoch = Number.isFinite(state.epoch) ? state.epoch : 0;
69
+ if (!state.guard || typeof state.guard !== 'object') {
70
+ state.guard = { hourWindowStart: 0, hourCount: 0, lastTriggerAt: 0, lastTriggerEpoch: -1 };
71
+ }
72
+ const guard = state.guard;
73
+ if (!Number.isFinite(guard.lastTriggerEpoch)) guard.lastTriggerEpoch = -1;
74
+
75
+ const gate = checkGuard(state, ts);
76
+ if (!gate.pass) {
77
+ return { reserved: false, epoch, reason: gate.reason };
78
+ }
79
+ if (guard.lastTriggerEpoch === epoch) {
80
+ // one trigger per activity epoch; a new user or agent action opens the next one
81
+ return { reserved: false, epoch, reason: 'requires-action' };
82
+ }
83
+
84
+ const windowStart = Number.isFinite(guard.hourWindowStart) ? guard.hourWindowStart : 0;
85
+ if (windowStart > 0 && ts - windowStart >= HOUR_WINDOW_MS) {
86
+ guard.hourWindowStart = ts;
87
+ guard.hourCount = 0;
88
+ } else if (windowStart === 0) {
89
+ guard.hourWindowStart = ts;
90
+ }
91
+ guard.hourCount = (Number.isFinite(guard.hourCount) ? guard.hourCount : 0) + 1;
92
+ guard.lastTriggerAt = ts;
93
+ guard.lastTriggerEpoch = epoch;
94
+ return { reserved: true, epoch };
95
+ }
@@ -0,0 +1,35 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { pactiveError } from './errors.mjs';
6
+
7
+ /**
8
+ * Stable identity of an agent session.
9
+ * agentId = name#hostKind#sessionId
10
+ * ownerKey = name#hostKind#projectHash (project hash keeps clones isolated)
11
+ */
12
+ export function makeIdentity({ name, hostKind, sessionId, cwd } = {}) {
13
+ const n = typeof name === 'string' ? name.trim() : '';
14
+ const h = typeof hostKind === 'string' ? hostKind.trim() : '';
15
+ const s = typeof sessionId === 'string' ? sessionId.trim() : '';
16
+ if (!n || !h || !s) {
17
+ throw pactiveError('IDENTITY_INCOMPLETE', 'name, hostKind and sessionId are required');
18
+ }
19
+ const projectHash = hashProject(cwd ?? process.cwd());
20
+ return {
21
+ agentId: `${n}#${h}#${s}`,
22
+ ownerKey: `${n}#${h}#${projectHash}`,
23
+ projectHash,
24
+ };
25
+ }
26
+
27
+ export function hashProject(cwd) {
28
+ let real = path.resolve(String(cwd));
29
+ try {
30
+ real = fs.realpathSync(real);
31
+ } catch {
32
+ // a not yet existing directory still hashes deterministically
33
+ }
34
+ return crypto.createHash('sha256').update(real).digest('hex').slice(0, 12);
35
+ }
@@ -0,0 +1,53 @@
1
+ import { checkGuard } from './guard.mjs';
2
+
3
+ export const IDLE_REASONS = [
4
+ 'idle', 'disabled', 'no-control', 'busy', 'threshold',
5
+ 'quiet', 'hourly-limit', 'min-interval', 'cooldown', 'requires-action',
6
+ ];
7
+
8
+ const POLICY_KEYS = ['quietStart', 'quietEnd', 'hourlyLimit', 'minIntervalMs', 'cooldownMs'];
9
+
10
+ /**
11
+ * Four gates must all open before a proactive prompt is allowed:
12
+ * control returned, agent not busy, activity threshold reached, guard open.
13
+ */
14
+ export function evaluateIdle(state, now = Date.now(), policy = {}) {
15
+ const ts = Number.isFinite(now) ? now : Date.now();
16
+ const p = policy && typeof policy === 'object' ? policy : {};
17
+ const idleCfg = { ...(state.idle || {}), ...(p.idle || {}) };
18
+
19
+ const enabled = firstDefined(p.enabled, p.idleEnabled, idleCfg.enabled, false);
20
+ const thresholdMs = firstDefined(p.idleThresholdMs, p.thresholdMs, idleCfg.thresholdMs, 0);
21
+
22
+ const guardState = { ...state, policy: { ...(state.policy || {}) } };
23
+ for (const key of POLICY_KEYS) {
24
+ if (p[key] !== undefined) guardState.policy[key] = p[key];
25
+ }
26
+ if (p.policy && typeof p.policy === 'object') Object.assign(guardState.policy, p.policy);
27
+
28
+ const guard = checkGuard(guardState, ts);
29
+ const gates = {
30
+ control: state.controlReturned === true,
31
+ notBusy: state.busy === false,
32
+ threshold: ts - (Number.isFinite(state.lastActivityAt) ? state.lastActivityAt : 0) >= thresholdMs,
33
+ guard: guard.pass === true,
34
+ };
35
+
36
+ let reason;
37
+ if (!enabled) reason = 'disabled';
38
+ else if (!gates.control) reason = 'no-control';
39
+ else if (!gates.notBusy) reason = 'busy';
40
+ else if (!gates.threshold) reason = 'threshold';
41
+ else if (!gates.guard) reason = guard.reason;
42
+ else reason = 'idle';
43
+
44
+ return { idle: reason === 'idle', reason, gates };
45
+ }
46
+
47
+ function firstDefined(...values) {
48
+ for (const v of values) {
49
+ if (v !== undefined && v !== null) return v;
50
+ }
51
+ return undefined;
52
+ }
53
+
@@ -0,0 +1,13 @@
1
+ // Public surface of the core engine. The names below are frozen by the contract.
2
+ export { resolvePaths } from './paths.mjs';
3
+ export { loadState, saveState, mutateState, defaultState } from './state.mjs';
4
+ export { makeIdentity } from './identity.mjs';
5
+ export { markActivity, markControlReturned, getActivity } from './activity.mjs';
6
+ export { evaluateIdle } from './idle.mjs';
7
+ export { checkGuard, reserveTrigger } from './guard.mjs';
8
+ export { schedule } from './scheduler.mjs';
9
+ export { mailbox } from './mailbox.mjs';
10
+ export { ledger } from './lineage.mjs';
11
+ export { config } from './config.mjs';
12
+ export { doctor } from './doctor.mjs';
13
+ export { NOT_IMPLEMENTED } from './errors.mjs';
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { pactiveError } from './errors.mjs';
5
+
6
+ let tmpSeq = 0;
7
+
8
+ /** Append-only event ledger: one JSON object per line, private file. */
9
+ export const ledger = {
10
+ append(file, event) {
11
+ if (typeof file !== 'string' || !file) throw pactiveError('LEDGER_PATH_REQUIRED', 'ledger path is required');
12
+ const dir = path.dirname(file);
13
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
14
+ try {
15
+ fs.chmodSync(dir, 0o700);
16
+ } catch {
17
+ // reported by doctor
18
+ }
19
+ const record = { ts: Date.now(), ...event };
20
+ const fd = fs.openSync(file, 'a', 0o600);
21
+ try {
22
+ fs.writeSync(fd, `${JSON.stringify(record)}\n`);
23
+ fs.fsyncSync(fd);
24
+ } finally {
25
+ fs.closeSync(fd);
26
+ }
27
+ try {
28
+ fs.chmodSync(file, 0o600);
29
+ } catch {
30
+ // reported by doctor
31
+ }
32
+ return record;
33
+ },
34
+
35
+ tail(file, n = 20) {
36
+ const limit = Number.isFinite(n) && n > 0 ? Math.floor(n) : 20;
37
+ let raw = '';
38
+ try {
39
+ raw = fs.readFileSync(file, 'utf8');
40
+ } catch (err) {
41
+ if (err.code === 'ENOENT') return [];
42
+ throw err;
43
+ }
44
+ const lines = raw.split('\n').filter((l) => l.trim() !== '');
45
+ const parsed = [];
46
+ for (const line of lines) {
47
+ try {
48
+ parsed.push(JSON.parse(line));
49
+ } catch {
50
+ // a broken line is skipped, the ledger read must not abort
51
+ }
52
+ }
53
+ return parsed.slice(-limit);
54
+ },
55
+
56
+ lineage(file, id) {
57
+ if (id === undefined || id === null || id === '') return [];
58
+ const wanted = String(id);
59
+ return ledger.tail(file, Number.MAX_SAFE_INTEGER).filter((e) => matches(e, wanted));
60
+ },
61
+ };
62
+
63
+ function matches(event, id) {
64
+ if (!event || typeof event !== 'object') return false;
65
+ if (event.id === id) return true;
66
+ if (event.taskId === id) return true;
67
+ if (event.threadId === id) return true;
68
+ if (event.messageId === id) return true;
69
+ if (event.outboxId === id) return true;
70
+ if (event.payload && typeof event.payload === 'object' && event.payload.id === id) return true;
71
+ return false;
72
+ }
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { pactiveError } from './errors.mjs';
5
+
6
+ export const LOCK_STALE_MS = 30000;
7
+ export const LOCK_TIMEOUT_MS = 10000;
8
+
9
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
+
11
+ export function ensureDir(dir) {
12
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
13
+ try {
14
+ fs.chmodSync(dir, 0o700);
15
+ } catch {
16
+ // A directory we cannot tighten is reported by doctor, not silently ignored here.
17
+ }
18
+ return dir;
19
+ }
20
+
21
+ /**
22
+ * Cross-process mutex: an exclusive lock file plus a stale timeout.
23
+ * A stale lock is taken over (never silently dropped) and reported through onStale.
24
+ */
25
+ export async function withLock(locksDir, name, fn, opts = {}) {
26
+ const staleMs = opts.staleMs ?? LOCK_STALE_MS;
27
+ const timeoutMs = opts.timeoutMs ?? LOCK_TIMEOUT_MS;
28
+ ensureDir(locksDir);
29
+ const lockFile = path.join(locksDir, `${name}.lock`);
30
+ const deadline = Date.now() + timeoutMs;
31
+
32
+ for (;;) {
33
+ try {
34
+ const fd = fs.openSync(lockFile, 'wx', 0o600);
35
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
36
+ fs.closeSync(fd);
37
+ break;
38
+ } catch (err) {
39
+ if (err.code !== 'EEXIST') throw err;
40
+ let stat = null;
41
+ try {
42
+ stat = fs.statSync(lockFile);
43
+ } catch {
44
+ continue; // the lock vanished between the failed open and the stat
45
+ }
46
+ if (Date.now() - stat.mtimeMs > staleMs) {
47
+ try {
48
+ fs.unlinkSync(lockFile);
49
+ } catch {
50
+ // another process may have taken it over already
51
+ }
52
+ if (typeof opts.onStale === 'function') opts.onStale(lockFile);
53
+ continue;
54
+ }
55
+ if (Date.now() > deadline) {
56
+ throw pactiveError('LOCK_TIMEOUT', `could not acquire lock ${lockFile}`);
57
+ }
58
+ await sleep(20);
59
+ }
60
+ }
61
+
62
+ try {
63
+ return await fn();
64
+ } finally {
65
+ try {
66
+ fs.unlinkSync(lockFile);
67
+ } catch {
68
+ // the lock was already removed
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,162 @@
1
+ import { NOT_IMPLEMENTED } from './errors.mjs';
2
+
3
+ export const PEER_TTL_MS = 120000;
4
+ export const RECEIPT_STATES = ['accepted', 'queued', 'rejected', 'error', 'unreachable'];
5
+
6
+ let seq = 0;
7
+
8
+ /**
9
+ * Cross session mailbox: a live peer gets the message in its inbox, an absent
10
+ * peer leaves the message in the outbox. "queued" is never reported as accepted.
11
+ */
12
+ export const mailbox = {
13
+ send(state, msg, now = Date.now()) {
14
+ const ts = Number.isFinite(now) ? now : Date.now();
15
+ if (!msg || typeof msg !== 'object') {
16
+ return { ok: false, state: 'rejected', reason: 'message-required', at: ts };
17
+ }
18
+ if (!state.peers || typeof state.peers !== 'object') state.peers = {};
19
+ if (!Array.isArray(state.outbox)) state.outbox = [];
20
+
21
+ const target = resolveTarget(state, msg.to, ts);
22
+ if (!target.ok) {
23
+ return { ok: false, state: 'rejected', reason: target.reason, at: ts };
24
+ }
25
+
26
+ const id = msg.id ?? `msg-${ts}-${(seq += 1)}`;
27
+ const message = {
28
+ id,
29
+ from: msg.from ?? state.identity?.agentId ?? '',
30
+ to: target.agentId,
31
+ kind: msg.kind ?? 'text',
32
+ body: msg.body ?? '',
33
+ at: ts,
34
+ state: 'pending',
35
+ threadId: msg.threadId ?? id,
36
+ inReplyTo: msg.inReplyTo ?? null,
37
+ ackedAt: 0,
38
+ reason: null,
39
+ };
40
+
41
+ const peer = state.peers[target.agentId];
42
+ if (peer && isFresh(peer, ts)) {
43
+ message.state = 'accepted';
44
+ if (!Array.isArray(peer.inbox)) peer.inbox = [];
45
+ peer.inbox.push(message);
46
+ return { ok: true, state: 'accepted', id, at: ts };
47
+ }
48
+
49
+ message.state = 'queued';
50
+ message.reason = 'peer-offline';
51
+ state.outbox.push(message);
52
+ return { ok: true, state: 'queued', id, outboxId: id, reason: 'peer-offline', at: ts };
53
+ },
54
+
55
+ ack(state, id, now = Date.now()) {
56
+ const ts = Number.isFinite(now) ? now : Date.now();
57
+ const message = findMessage(state, id);
58
+ if (!message) return null;
59
+ message.ackedAt = ts;
60
+ return message;
61
+ },
62
+
63
+ reply(state, id, body, now = Date.now()) {
64
+ const ts = Number.isFinite(now) ? now : Date.now();
65
+ const original = findMessage(state, id);
66
+ if (!original) {
67
+ return { ok: false, state: 'rejected', reason: 'unknown-message', at: ts };
68
+ }
69
+ return mailbox.send(state, {
70
+ from: original.to,
71
+ to: original.from,
72
+ body,
73
+ kind: original.kind,
74
+ threadId: original.threadId ?? original.id,
75
+ inReplyTo: original.id,
76
+ }, ts);
77
+ },
78
+
79
+ inbox(state, agentId) {
80
+ const peer = state.peers && typeof state.peers === 'object' ? state.peers[agentId] : null;
81
+ return peer && Array.isArray(peer.inbox) ? [...peer.inbox] : [];
82
+ },
83
+
84
+ outbox(state, agentId) {
85
+ if (!Array.isArray(state.outbox)) return [];
86
+ return state.outbox.filter((m) => m.to === agentId).map((m) => ({ ...m }));
87
+ },
88
+
89
+ /** Retry the outbox. A failed attempt stays queued, it is never rewritten. */
90
+ async drain(state, now = Date.now(), deliver) {
91
+ const ts = Number.isFinite(now) ? now : Date.now();
92
+ if (!Array.isArray(state.outbox)) return [];
93
+ const pending = state.outbox.filter((m) => m.state === 'queued').sort((a, b) => a.at - b.at);
94
+ const receipts = [];
95
+ for (const message of pending) {
96
+ if (typeof deliver !== 'function') {
97
+ receipts.push({ ok: false, id: message.id, state: 'error', reason: NOT_IMPLEMENTED, at: ts });
98
+ break;
99
+ }
100
+ let receipt;
101
+ try {
102
+ receipt = await deliver(message);
103
+ } catch (err) {
104
+ receipt = { ok: false, state: 'error', reason: err && err.message ? err.message : String(err) };
105
+ }
106
+ const state5 = receipt && receipt.state ? receipt.state : 'error';
107
+ receipts.push({ ...receipt, id: message.id, at: ts });
108
+ if (state5 === 'accepted') {
109
+ message.state = 'accepted';
110
+ message.deliveredAt = ts;
111
+ state.outbox = state.outbox.filter((m) => m.id !== message.id);
112
+ const peer = state.peers && state.peers[message.to];
113
+ if (peer) {
114
+ if (!Array.isArray(peer.inbox)) peer.inbox = [];
115
+ peer.inbox.push({ ...message });
116
+ }
117
+ } else {
118
+ // still queued: a failure must never look like a delivery
119
+ message.state = 'queued';
120
+ message.lastAttempt = { state: state5, reason: receipt && receipt.reason, at: ts };
121
+ }
122
+ }
123
+ return receipts;
124
+ },
125
+ };
126
+
127
+ function isFresh(peer, now) {
128
+ const lastSeen = peer && Number.isFinite(peer.lastSeen) ? peer.lastSeen : 0;
129
+ return lastSeen > 0 && now - lastSeen <= PEER_TTL_MS;
130
+ }
131
+
132
+ function resolveTarget(state, to, now) {
133
+ const peers = state.peers || {};
134
+ const ids = Object.keys(peers);
135
+ if (typeof to === 'string' && to.trim()) {
136
+ const wanted = to.trim();
137
+ if (peers[wanted]) return { ok: true, agentId: wanted };
138
+ const matches = ids.filter((id) => id.startsWith(`${wanted}#`));
139
+ if (matches.length === 1) return { ok: true, agentId: matches[0] };
140
+ if (matches.length > 1) return { ok: false, reason: 'ambiguous-target' };
141
+ return { ok: true, agentId: wanted }; // unknown peer: the message waits in the outbox
142
+ }
143
+ const live = ids.filter((id) => isFresh(peers[id], now));
144
+ if (live.length === 1) return { ok: true, agentId: live[0] };
145
+ if (live.length > 1) return { ok: false, reason: 'ambiguous-target' };
146
+ return { ok: false, reason: 'no-target' };
147
+ }
148
+
149
+ function findMessage(state, id) {
150
+ if (!id) return null;
151
+ if (Array.isArray(state.outbox)) {
152
+ const queued = state.outbox.find((m) => m.id === id);
153
+ if (queued) return queued;
154
+ }
155
+ const peers = state.peers && typeof state.peers === 'object' ? state.peers : {};
156
+ for (const peer of Object.values(peers)) {
157
+ if (!peer || !Array.isArray(peer.inbox)) continue;
158
+ const found = peer.inbox.find((m) => m.id === id);
159
+ if (found) return found;
160
+ }
161
+ return null;
162
+ }