sentinelayer-cli 0.19.0 → 0.21.0

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,184 @@
1
+ // Wake dispatcher — the decision core of the `sentid` daemon (Wake-Up Bus L2).
2
+ //
3
+ // Given session events (from the L0 stream/listener), it decides which, if any,
4
+ // agent to wake and routes the wake through the adapter registry. It is kept
5
+ // pure and dependency-injected so it can be unit-tested with a fake adapter and
6
+ // a fake target resolver, independent of the live stream or any host CLI.
7
+ //
8
+ // Delivery contract (the wake-bus invariant Carter cares about = NO SILENT
9
+ // MISSED MESSAGES). The RESUME cursor is the high-water mark of *committed*
10
+ // progress, NOT merely of attempts:
11
+ // - successful wake .................... advance cursor
12
+ // - resolver returns null (no target / self-wake / intentionally unroutable)
13
+ // ................................... advance cursor (legitimately nothing to do)
14
+ // - failed wake (adapter ok:false or throw) ... DO NOT advance; return
15
+ // retryable=true so the daemon re-polls from the cursor and retries (with
16
+ // backoff at the daemon-loop level). After `maxAttempts` for that seq, write
17
+ // a durable dead-letter record and THEN advance, to escape a poison-event
18
+ // wedge. If no dead-letter sink is wired, DO NOT advance (wedge-loud beats
19
+ // silent-loss).
20
+ // - unknown host (resolver routed to an UNREGISTERED host) ... CONFIG failure:
21
+ // fail loud (dead-letter + error log), never silently advance.
22
+ //
23
+ // Monotonic-seq RESUME: setCursor() seeds the cursor from the last-acked seq
24
+ // persisted across restarts, so a reconnect replays the backlog exactly once.
25
+
26
+ function seqOf(event) {
27
+ const raw = event?.sequenceId ?? event?.seq ?? event?.payload?.sequenceId;
28
+ const n = Number(raw);
29
+ return Number.isFinite(n) ? n : null;
30
+ }
31
+
32
+ /**
33
+ * @param {object} opts
34
+ * @param {{ resolve: Function, has: Function }} opts.registry wake-adapter registry
35
+ * @param {(event:object) => ({host:string, sessionId:string, message:string}|null)} opts.resolveTarget
36
+ * Maps an event to the agent to wake, or null to skip (no target / self-wake /
37
+ * intentionally unroutable — the caller owns that policy).
38
+ * @param {number} [opts.maxAttempts=5] retries per seq before dead-lettering.
39
+ * @param {(record:object) => (void|Promise<void>)} [opts.deadLetter] durable DLQ sink.
40
+ * @param {(result:object, event:object) => void} [opts.onResult]
41
+ * @param {(level:string, msg:string, meta?:object) => void} [opts.logger]
42
+ */
43
+ export function createWakeDispatcher({
44
+ registry,
45
+ resolveTarget,
46
+ maxAttempts = 5,
47
+ deadLetter,
48
+ onResult,
49
+ logger,
50
+ } = {}) {
51
+ if (!registry || typeof registry.resolve !== "function" || typeof registry.has !== "function") {
52
+ throw new TypeError("wake dispatcher: registry with resolve() and has() is required");
53
+ }
54
+ if (typeof resolveTarget !== "function") {
55
+ throw new TypeError("wake dispatcher: resolveTarget(event) function is required");
56
+ }
57
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
58
+ throw new TypeError("wake dispatcher: maxAttempts must be a positive integer");
59
+ }
60
+ const log = typeof logger === "function" ? logger : () => {};
61
+ const hasDeadLetter = typeof deadLetter === "function";
62
+ let lastSeq = 0;
63
+ // Per-seq attempt counts for in-flight (not yet committed) failed wakes.
64
+ const attempts = new Map();
65
+
66
+ function advance(seq) {
67
+ if (seq !== null) {
68
+ lastSeq = Math.max(lastSeq, seq);
69
+ attempts.delete(seq);
70
+ }
71
+ }
72
+
73
+ async function toDeadLetter(record) {
74
+ if (!hasDeadLetter) return false;
75
+ try {
76
+ await deadLetter(record);
77
+ return true;
78
+ } catch (error) {
79
+ log("error", "dead-letter sink threw", { seq: record.seq, reason: error?.message });
80
+ return false;
81
+ }
82
+ }
83
+
84
+ function finalize(result, event, { advanceSeq, seq } = {}) {
85
+ if (advanceSeq) advance(seq);
86
+ if (typeof onResult === "function") onResult(result, event);
87
+ return result;
88
+ }
89
+
90
+ async function dispatchEvent(event, deps = {}) {
91
+ const seq = seqOf(event);
92
+ if (seq !== null && seq <= lastSeq) {
93
+ return { skipped: true, reason: "already_seen", seq };
94
+ }
95
+
96
+ const target = resolveTarget(event);
97
+ if (!target) {
98
+ advance(seq);
99
+ return { skipped: true, reason: "no_target", seq };
100
+ }
101
+
102
+ const { host, sessionId, message } = target;
103
+
104
+ // Unknown host = config failure: fail loud, never a silent skip.
105
+ if (!registry.has(host)) {
106
+ log("error", "wake dispatch: unknown host (config failure)", { host, sessionId, seq });
107
+ const wrote = await toDeadLetter({ kind: "config_error", reason: "unknown_host", host, sessionId, seq });
108
+ // Advance only if the failure is durably recorded; otherwise wedge loudly.
109
+ return finalize(
110
+ { ok: false, skipped: false, hostName: host ?? null, sessionId: sessionId ?? null, seq, reason: "unknown_host", retryable: false, deadLettered: wrote },
111
+ event,
112
+ { advanceSeq: wrote, seq }
113
+ );
114
+ }
115
+
116
+ let result;
117
+ try {
118
+ result = await registry.resolve(host).wake({ sessionId, message }, deps);
119
+ } catch (error) {
120
+ result = { ok: false, hostName: host, sessionId, code: null, reason: error?.message || "wake_threw" };
121
+ }
122
+
123
+ if (result?.ok) {
124
+ return finalize({ ...result, skipped: false, seq, retryable: false }, event, { advanceSeq: true, seq });
125
+ }
126
+
127
+ // Failed wake: retry until maxAttempts, then dead-letter to escape wedge.
128
+ const n = (seq !== null ? attempts.get(seq) || 0 : 0) + 1;
129
+ if (seq !== null) attempts.set(seq, n);
130
+
131
+ if (n < maxAttempts) {
132
+ log("warn", "wake failed; will retry", { host, sessionId, seq, attempt: n, reason: result?.reason });
133
+ // DO NOT advance -> daemon re-polls from cursor and retries.
134
+ return finalize({ ...result, ok: false, skipped: false, seq, retryable: true, attempt: n }, event, { advanceSeq: false, seq });
135
+ }
136
+
137
+ log("error", "wake failed; attempts exhausted", { host, sessionId, seq, attempts: n, reason: result?.reason });
138
+ const wrote = await toDeadLetter({ kind: "wake_failure", host, sessionId, seq, reason: result?.reason, attempts: n });
139
+ // Advance past a poison event only if durably dead-lettered; else wedge loud.
140
+ return finalize(
141
+ { ...result, ok: false, skipped: false, seq, retryable: !wrote, attempts: n, deadLettered: wrote },
142
+ event,
143
+ { advanceSeq: wrote, seq }
144
+ );
145
+ }
146
+
147
+ async function dispatchBatch(events = [], deps = {}) {
148
+ if (!Array.isArray(events)) throw new TypeError("wake dispatcher: events must be an array");
149
+ // Process in monotonic seq order so the cursor advances correctly even if the
150
+ // source delivers out of order. A retryable failure stops the batch so the
151
+ // backlog stays in order (the daemon will re-poll from the cursor).
152
+ const ordered = [...events].sort((a, b) => {
153
+ const sa = seqOf(a);
154
+ const sb = seqOf(b);
155
+ if (sa === null || sb === null) return 0;
156
+ return sa - sb;
157
+ });
158
+ const results = [];
159
+ for (const event of ordered) {
160
+ const seq = seqOf(event);
161
+ const beforeCursor = lastSeq;
162
+ const r = await dispatchEvent(event, deps);
163
+ results.push(r);
164
+ const uncommittedSeq = seq !== null && seq > beforeCursor && lastSeq < seq;
165
+ const uncommittedUnsequencedFailure = seq === null && r?.ok === false && !r?.skipped;
166
+ if (r.retryable || uncommittedSeq || uncommittedUnsequencedFailure) break; // do not skip ahead of unacked work
167
+ }
168
+ return results;
169
+ }
170
+
171
+ return {
172
+ dispatchEvent,
173
+ dispatchBatch,
174
+ getCursor: () => lastSeq,
175
+ /** Seed the RESUME cursor from a persisted last-acked seq on daemon startup. */
176
+ setCursor: (n) => {
177
+ const v = Number(n);
178
+ if (Number.isFinite(v) && v >= 0) lastSeq = v;
179
+ return lastSeq;
180
+ },
181
+ };
182
+ }
183
+
184
+ export default createWakeDispatcher;
@@ -0,0 +1,135 @@
1
+ // Wake pump — the live event source for the sentid daemon (Wake-Up Bus L2).
2
+ //
3
+ // Connects a session's event stream to the dispatcher: fetch new events, run
4
+ // them through dispatcher.dispatchBatch (which applies resolveTarget routing,
5
+ // the at-least-once retry/DLQ contract, and the in-memory RESUME cursor), then
6
+ // persist that cursor for restart RESUME.
7
+ //
8
+ // The event source `pollEvents` is INJECTED, not imported: importing
9
+ // sync.js/listener.js would drag the heavy auth/`open` module graph into the
10
+ // daemon and make this untestable. The thin sentid runtime entrypoint adapts
11
+ // the real pollSessionEvents to the contract below; tests inject a fake.
12
+ //
13
+ // At-least-once end to end: the fetch cursor advances only to the cursor of the
14
+ // last COMMITTED event (one whose seq the dispatcher actually advanced past). A
15
+ // retryable/uncommitted failure stops the batch, the fetch cursor stays behind
16
+ // it, and the next poll re-delivers that event — so a transient wake failure is
17
+ // retried, never silently skipped.
18
+
19
+ import { readWakeCursor, writeWakeCursor } from "./cursor-store.js";
20
+
21
+ function seqOf(event) {
22
+ const raw = event?.sequenceId ?? event?.seq ?? event?.payload?.sequenceId;
23
+ const n = Number(raw);
24
+ return Number.isFinite(n) ? n : null;
25
+ }
26
+
27
+ function cursorOf(event) {
28
+ const c = event?.cursor;
29
+ return typeof c === "string" && c ? c : null;
30
+ }
31
+
32
+ const defaultSleep = (ms, { signal } = {}) =>
33
+ new Promise((resolve) => {
34
+ const t = setTimeout(resolve, ms);
35
+ if (typeof t.unref === "function") t.unref();
36
+ if (signal) signal.addEventListener("abort", () => { clearTimeout(t); resolve(); }, { once: true });
37
+ });
38
+
39
+ /**
40
+ * @param {object} opts
41
+ * @param {string} opts.sessionId
42
+ * @param {{ dispatchBatch:Function, getCursor:Function, setCursor:Function }} opts.dispatcher
43
+ * @param {(sessionId:string, o:{afterCursor:string|null}) => Promise<{events:object[], cursor?:string|null}>} opts.pollEvents
44
+ * @param {{ targetPath?:string, idleMs?:number, readCursor?:Function, writeCursor?:Function, sleep?:Function, logger?:Function }} [opts]
45
+ */
46
+ export function createWakePump({
47
+ sessionId,
48
+ dispatcher,
49
+ pollEvents,
50
+ targetPath,
51
+ idleMs = 1500,
52
+ readCursor = readWakeCursor,
53
+ writeCursor = writeWakeCursor,
54
+ sleep = defaultSleep,
55
+ logger,
56
+ } = {}) {
57
+ if (typeof sessionId !== "string" || sessionId.trim() === "") {
58
+ throw new TypeError("wake pump: sessionId must be a non-empty string");
59
+ }
60
+ if (!dispatcher || typeof dispatcher.dispatchBatch !== "function" || typeof dispatcher.getCursor !== "function") {
61
+ throw new TypeError("wake pump: dispatcher with dispatchBatch()/getCursor() is required");
62
+ }
63
+ if (typeof pollEvents !== "function") {
64
+ throw new TypeError("wake pump: pollEvents function is required");
65
+ }
66
+ const log = typeof logger === "function" ? logger : () => {};
67
+ let seeded = false;
68
+
69
+ // Seed the dispatcher's RESUME cursor from disk once, on first use.
70
+ async function ensureSeeded() {
71
+ if (seeded) return;
72
+ seeded = true;
73
+ if (typeof dispatcher.setCursor === "function") {
74
+ dispatcher.setCursor(await readCursor(sessionId, { targetPath }));
75
+ }
76
+ }
77
+
78
+ // The fetch cursor advances only to the last COMMITTED event's cursor.
79
+ function nextFetchCursor(events, committedSeq, fallback) {
80
+ let best = fallback;
81
+ let bestSeq = -Infinity;
82
+ for (const event of events) {
83
+ const seq = seqOf(event);
84
+ const cursor = cursorOf(event);
85
+ if (cursor !== null && seq !== null && seq <= committedSeq && seq > bestSeq) {
86
+ bestSeq = seq;
87
+ best = cursor;
88
+ }
89
+ }
90
+ return best;
91
+ }
92
+
93
+ /**
94
+ * One fetch -> dispatch -> persist cycle. Returns the next fetch cursor and the
95
+ * dispatch results so the caller (or a test) can observe progress.
96
+ */
97
+ async function tickOnce({ fetchCursor = null, deps = {} } = {}) {
98
+ await ensureSeeded();
99
+ const { events = [], cursor: latestCursor = null } = (await pollEvents(sessionId, { afterCursor: fetchCursor })) || {};
100
+ if (!Array.isArray(events) || events.length === 0) {
101
+ return { fetchCursor, results: [], idle: true };
102
+ }
103
+ const results = await dispatcher.dispatchBatch(events, deps);
104
+ const committedSeq = dispatcher.getCursor();
105
+ // Only adopt the poller's latest cursor if the WHOLE batch committed.
106
+ const allCommitted = results.length === events.length && results.every((r) => !r.retryable);
107
+ const advanced = nextFetchCursor(events, committedSeq, fetchCursor);
108
+ const next = allCommitted && typeof latestCursor === "string" && latestCursor ? latestCursor : advanced;
109
+ await writeCursor(sessionId, committedSeq, { targetPath });
110
+ log("debug", "wake pump tick", { committedSeq, fetched: events.length, advanced: next !== fetchCursor });
111
+ return { fetchCursor: next, results, idle: false };
112
+ }
113
+
114
+ /** Run the fetch loop until `signal` aborts. */
115
+ async function start({ signal, fetchCursor = null, deps = {} } = {}) {
116
+ let cursor = fetchCursor;
117
+ while (!signal?.aborted) {
118
+ let idle = true;
119
+ try {
120
+ const tick = await tickOnce({ fetchCursor: cursor, deps });
121
+ cursor = tick.fetchCursor;
122
+ idle = tick.idle;
123
+ } catch (error) {
124
+ if (signal?.aborted || error?.name === "AbortError") break;
125
+ log("error", "wake pump tick failed; backing off", { reason: error?.message });
126
+ }
127
+ if (!signal?.aborted) await sleep(idle ? idleMs : 0, { signal });
128
+ }
129
+ return { fetchCursor: cursor };
130
+ }
131
+
132
+ return { tickOnce, start, ensureSeeded };
133
+ }
134
+
135
+ export default createWakePump;
@@ -0,0 +1,80 @@
1
+ // Wake adapter registry for the Senti Wake-Up & Notification Bus (L2).
2
+ //
3
+ // Decouples the `sentid` daemon from individual host adapters: the daemon
4
+ // depends only on the LOCKED adapter interface --
5
+ // { hostName: string, wake(target[, deps]) -> Promise<{ok, hostName, ...}> }
6
+ // (optionally installWakeHook(opts))
7
+ // -- and concrete adapters (wake/claude.js, wake/codex.js, ...) are registered
8
+ // at wiring time. This means the daemon can be built and unit-tested against a
9
+ // fake adapter with no dependency on which adapter PRs have merged.
10
+
11
+ function assertValidAdapter(adapter) {
12
+ if (!adapter || typeof adapter !== "object") {
13
+ throw new TypeError("wake registry: adapter must be an object");
14
+ }
15
+ if (typeof adapter.hostName !== "string" || adapter.hostName.trim() === "") {
16
+ throw new TypeError("wake registry: adapter.hostName must be a non-empty string");
17
+ }
18
+ if (typeof adapter.wake !== "function") {
19
+ throw new TypeError(`wake registry: adapter "${adapter.hostName}" must expose a wake() function`);
20
+ }
21
+ return adapter;
22
+ }
23
+
24
+ /**
25
+ * Create a wake-adapter registry, optionally seeded with adapters.
26
+ *
27
+ * @param {Array<{hostName: string, wake: Function}>} [adapters]
28
+ */
29
+ export function createWakeRegistry(adapters = []) {
30
+ const map = new Map();
31
+
32
+ function hosts() {
33
+ return [...map.keys()];
34
+ }
35
+
36
+ function register(adapter) {
37
+ assertValidAdapter(adapter);
38
+ if (map.has(adapter.hostName)) {
39
+ throw new Error(`wake registry: adapter for host "${adapter.hostName}" is already registered`);
40
+ }
41
+ map.set(adapter.hostName, adapter);
42
+ return adapter;
43
+ }
44
+
45
+ function resolve(hostName) {
46
+ const adapter = map.get(hostName);
47
+ if (!adapter) {
48
+ throw new Error(
49
+ `wake registry: no adapter registered for host "${hostName}" (known: ${hosts().join(", ") || "none"})`
50
+ );
51
+ }
52
+ return adapter;
53
+ }
54
+
55
+ function has(hostName) {
56
+ return map.has(hostName);
57
+ }
58
+
59
+ if (!Array.isArray(adapters)) {
60
+ throw new TypeError("wake registry: adapters must be an array");
61
+ }
62
+ adapters.forEach(register);
63
+
64
+ return { register, resolve, has, hosts };
65
+ }
66
+
67
+ /**
68
+ * Wake an agent through the registry in one call. Forwards `deps` (e.g. an
69
+ * injected execFileImpl) to the resolved adapter's wake().
70
+ *
71
+ * @param {{resolve: Function}} registry
72
+ * @param {{ host: string, sessionId: string, message: string }} target
73
+ * @param {object} [deps]
74
+ */
75
+ export async function wakeVia(registry, { host, sessionId, message } = {}, deps = {}) {
76
+ const adapter = registry.resolve(host);
77
+ return adapter.wake({ sessionId, message }, deps);
78
+ }
79
+
80
+ export default createWakeRegistry;
@@ -0,0 +1,146 @@
1
+ // Wake target resolver for the sentid daemon (Wake-Up Bus L2 live-wiring).
2
+ //
3
+ // A sentid instance serves one local agent. This factory builds the
4
+ // resolveTarget(event) the dispatcher (dispatcher.js) calls to decide whether a
5
+ // session event should wake THAT agent, and if so with what host/sessionId.
6
+ //
7
+ // Targeting policy (returning null = "intentionally unroutable", which the
8
+ // dispatcher treats as a clean skip that advances the cursor):
9
+ // - only wake event TYPES count (a session_message), not acks/reactions/views;
10
+ // - never wake the agent on its OWN events (no self-wake loop);
11
+ // - wake when the message is directed to this agent, is a broadcast, or is an
12
+ // untargeted room message; a message directed to a DIFFERENT agent is null.
13
+ //
14
+ // Routing mirrors listener.js's eventMatchesAgent vocabulary, but is kept
15
+ // self-contained on purpose: importing listener.js would drag its whole
16
+ // transitive graph (auth/sync/`open`/...) into the wake daemon. The matcher
17
+ // here is small enough to own.
18
+
19
+ const BROADCAST_RECIPIENTS = new Set(["*", "all", "broadcast", "everyone", "anyone", "agents", "all-agents"]);
20
+ const DEFAULT_WAKE_EVENT_TYPES = new Set(["session_message", "help_request"]);
21
+ const MAX_WAKE_MESSAGE_CHARS = 16_000;
22
+
23
+ function requireNonEmptyString(value, label) {
24
+ if (typeof value !== "string" || value.trim() === "") {
25
+ throw new TypeError(`resolve-target: ${label} must be a non-empty string`);
26
+ }
27
+ return value.trim();
28
+ }
29
+
30
+ function agentIdOf(event) {
31
+ const payload = event && typeof event.payload === "object" && event.payload ? event.payload : {};
32
+ const a = event?.agentId ?? event?.agent_id ?? event?.agent ?? payload.agentId ?? payload.agent_id;
33
+ if (a && typeof a === "object") return typeof a.id === "string" ? a.id : null;
34
+ return typeof a === "string" ? a : null;
35
+ }
36
+
37
+ function eventTypeOf(event) {
38
+ return event?.event || event?.type || event?.payload?.event || null;
39
+ }
40
+
41
+ function normalizeComparableId(value) {
42
+ return String(value || "")
43
+ .trim()
44
+ .toLowerCase()
45
+ .replace(/^@+/, "")
46
+ .replace(/[^a-z0-9._-]+/g, "-")
47
+ .replace(/^-+|-+$/g, "");
48
+ }
49
+
50
+ function addRecipientValue(out, value) {
51
+ if (value === undefined || value === null) return;
52
+ if (Array.isArray(value)) {
53
+ for (const item of value) addRecipientValue(out, item);
54
+ return;
55
+ }
56
+ if (value && typeof value === "object") {
57
+ addRecipientValue(out, value.id ?? value.agentId ?? value.agent_id ?? value.name);
58
+ return;
59
+ }
60
+ for (const token of String(value).split(/[\s,;]+/g)) {
61
+ const normalized = normalizeComparableId(token);
62
+ if (normalized) out.push(normalized);
63
+ }
64
+ }
65
+
66
+ // Collect recipient tokens from the shapes the session stream uses.
67
+ function recipientsOf(event) {
68
+ const payload = event && typeof event.payload === "object" && event.payload ? event.payload : {};
69
+ const out = [];
70
+ for (const src of [
71
+ event?.to,
72
+ event?.recipient,
73
+ event?.recipients,
74
+ event?.targetAgent,
75
+ event?.targetAgentId,
76
+ payload.to,
77
+ payload.recipient,
78
+ payload.recipients,
79
+ payload.targetAgent,
80
+ payload.targetAgentId,
81
+ ]) {
82
+ addRecipientValue(out, src);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ // Mirrors listener.js eventMatchesAgent: broadcast flag, broadcast tokens,
88
+ // untargeted (no recipients) => match, or a directed match.
89
+ function matchesAgent(event, selfLower) {
90
+ const payload = event && typeof event.payload === "object" && event.payload ? event.payload : {};
91
+ if (event?.broadcast === true || payload.broadcast === true) return true;
92
+ const recipients = recipientsOf(event);
93
+ if (recipients.length === 0) return true;
94
+ return recipients.some((r) => BROADCAST_RECIPIENTS.has(r) || r === selfLower);
95
+ }
96
+
97
+ function defaultFormatMessage(event, agentId) {
98
+ const author = agentIdOf(event) || "unknown";
99
+ const text = event?.payload?.message ?? event?.message ?? "";
100
+ const body = typeof text === "string" ? text : "";
101
+ const head = `Senti wake for ${agentId}: new message from ${author}.`;
102
+ const combined = body ? `${head}\n\n${body}` : head;
103
+ return combined.length > MAX_WAKE_MESSAGE_CHARS ? combined.slice(0, MAX_WAKE_MESSAGE_CHARS) : combined;
104
+ }
105
+
106
+ /**
107
+ * @param {object} opts
108
+ * @param {string} opts.agentId the local agent this daemon wakes (e.g. "claude-mythos")
109
+ * @param {string} opts.host host adapter name (e.g. "claude")
110
+ * @param {string} opts.sessionId resume session id passed to the host adapter
111
+ * @param {Set<string>} [opts.wakeEventTypes]
112
+ * @param {(event:object, agentId:string) => string} [opts.formatMessage]
113
+ * @returns {(event:object) => ({host:string, sessionId:string, message:string}|null)}
114
+ */
115
+ export function createResolveTarget({
116
+ agentId,
117
+ host,
118
+ sessionId,
119
+ wakeEventTypes = DEFAULT_WAKE_EVENT_TYPES,
120
+ formatMessage = defaultFormatMessage,
121
+ } = {}) {
122
+ const selfId = requireNonEmptyString(agentId, "agentId");
123
+ const hostName = requireNonEmptyString(host, "host");
124
+ const resumeId = requireNonEmptyString(sessionId, "sessionId");
125
+ const selfLower = normalizeComparableId(selfId);
126
+
127
+ return function resolveTarget(event) {
128
+ // Only wake on real message events; acks/reactions/views/system are skips.
129
+ const type = eventTypeOf(event);
130
+ if (!type || !wakeEventTypes.has(type)) return null;
131
+
132
+ // No self-wake: never wake the agent on its own message. Normalize both
133
+ // sides (handles @-prefix / casing / punctuation) so the loop-guard can't
134
+ // be slipped by a non-canonical author id.
135
+ const author = agentIdOf(event);
136
+ if (author && normalizeComparableId(author) === selfLower) return null;
137
+
138
+ // Routing: directed-to-me / broadcast / untargeted-room wakes us; a message
139
+ // aimed at a different specific agent is intentionally unroutable.
140
+ if (!matchesAgent(event, selfLower)) return null;
141
+
142
+ return { host: hostName, sessionId: resumeId, message: formatMessage(event, selfId) };
143
+ };
144
+ }
145
+
146
+ export default createResolveTarget;
@@ -0,0 +1,103 @@
1
+ // sentid runtime entrypoint — assembles the Wake-Up Bus (L2) into a daemon.
2
+ //
3
+ // Wires the merged pieces into one local-agent waker:
4
+ // registry(host adapters) + resolveTarget(routing) + dispatcher(at-least-once
5
+ // + DLQ + RESUME cursor) + pump(live fetch -> dispatch -> persist).
6
+ // A sentid instance serves ONE local agent (agentId) and wakes it (host +
7
+ // resumeSessionId) when the session stream has a message for it.
8
+ //
9
+ // The real event source is pollSessionEvents (sync.js), but it is LAZY-imported
10
+ // inside the default poll adapter so this module stays unit-testable: importing
11
+ // sync.js at module load would drag the heavy auth/`open` graph in. Tests inject
12
+ // `pollImpl` (and fake adapters) and the lazy import never fires.
13
+
14
+ import { createWakeRegistry } from "./registry.js";
15
+ import { createResolveTarget } from "./resolve-target.js";
16
+ import { createWakeDispatcher } from "./dispatcher.js";
17
+ import { createWakePump } from "./pump.js";
18
+ import claudeWakeAdapter from "./claude.js";
19
+ import codexWakeAdapter from "./codex.js";
20
+
21
+ function requireNonEmptyString(value, label) {
22
+ if (typeof value !== "string" || value.trim() === "") {
23
+ throw new TypeError(`sentid: ${label} must be a non-empty string`);
24
+ }
25
+ return value.trim();
26
+ }
27
+
28
+ // Adapt pollSessionEvents(sessionId, {since, targetPath}) -> the pump's
29
+ // pollEvents(sessionId, {afterCursor}) -> { events, cursor } contract. A failed
30
+ // poll (ok:false: circuit open / auth / network) yields no events, which the
31
+ // pump treats as idle and retries on the next tick — never a thrown crash.
32
+ function makePollEvents(pollImpl, targetPath) {
33
+ let poll = pollImpl;
34
+ return async (sessionId, { afterCursor = null } = {}) => {
35
+ if (!poll) {
36
+ ({ pollSessionEvents: poll } = await import("../sync.js"));
37
+ }
38
+ const result = (await poll(sessionId, { since: afterCursor, targetPath })) || {};
39
+ return {
40
+ events: Array.isArray(result.events) ? result.events : [],
41
+ cursor: typeof result.cursor === "string" ? result.cursor : afterCursor,
42
+ };
43
+ };
44
+ }
45
+
46
+ /**
47
+ * @param {object} opts
48
+ * @param {string} opts.sessionId Senti session to watch
49
+ * @param {string} opts.agentId local agent this daemon wakes (e.g. "claude-mythos")
50
+ * @param {string} opts.host host adapter name (e.g. "claude")
51
+ * @param {string} opts.resumeSessionId host session id to resume on wake
52
+ * @param {Array} [opts.adapters] defaults to [claudeWakeAdapter, codexWakeAdapter]
53
+ * @param {string} [opts.targetPath]
54
+ * @param {number} [opts.maxAttempts] dispatcher retry budget before DLQ
55
+ * @param {number} [opts.idleMs] pump idle backoff
56
+ * @param {Function} [opts.deadLetter] durable DLQ sink
57
+ * @param {Function} [opts.logger]
58
+ * @param {Function} [opts.pollImpl] injected poller (default: lazy pollSessionEvents)
59
+ * @param {object} [opts.wakeDeps] deps forwarded to adapter.wake() (e.g. execFileImpl)
60
+ */
61
+ export function createSentid({
62
+ sessionId,
63
+ agentId,
64
+ host,
65
+ resumeSessionId,
66
+ adapters = [claudeWakeAdapter, codexWakeAdapter],
67
+ targetPath,
68
+ maxAttempts,
69
+ idleMs,
70
+ deadLetter,
71
+ logger,
72
+ pollImpl,
73
+ wakeDeps = {},
74
+ } = {}) {
75
+ const sid = requireNonEmptyString(sessionId, "sessionId");
76
+ const localAgent = requireNonEmptyString(agentId, "agentId");
77
+ const hostName = requireNonEmptyString(host, "host");
78
+ const resumeId = requireNonEmptyString(resumeSessionId, "resumeSessionId");
79
+
80
+ const registry = createWakeRegistry(adapters);
81
+ if (!registry.has(hostName)) {
82
+ throw new Error(`sentid: no adapter registered for host "${hostName}" (have: ${registry.hosts().join(", ") || "none"})`);
83
+ }
84
+
85
+ const resolveTarget = createResolveTarget({ agentId: localAgent, host: hostName, sessionId: resumeId });
86
+ const dispatcher = createWakeDispatcher({ registry, resolveTarget, maxAttempts, deadLetter, logger });
87
+ const pollEvents = makePollEvents(pollImpl, targetPath);
88
+ const pump = createWakePump({ sessionId: sid, dispatcher, pollEvents, targetPath, idleMs, logger });
89
+
90
+ return {
91
+ sessionId: sid,
92
+ agentId: localAgent,
93
+ host: hostName,
94
+ registry,
95
+ dispatcher,
96
+ pump,
97
+ start: (o = {}) => pump.start({ ...o, deps: wakeDeps }),
98
+ tickOnce: (o = {}) => pump.tickOnce({ ...o, deps: wakeDeps }),
99
+ getCursor: () => dispatcher.getCursor(),
100
+ };
101
+ }
102
+
103
+ export default createSentid;