sentinelayer-cli 0.22.0 → 0.24.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,192 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+
7
+ import { resolveSessionPaths } from "./paths.js";
8
+
9
+ const PID_FILE_NAME = "senti-daemon.json";
10
+ const LOG_FILE_NAME = "senti-daemon.log";
11
+
12
+ function normalizeString(value) {
13
+ return String(value || "").trim();
14
+ }
15
+
16
+ export function sentiDaemonDisabled(env = process.env) {
17
+ return (
18
+ normalizeString(env.SENTINELAYER_SKIP_SENTI_AUTOSTART) === "1" ||
19
+ normalizeString(env.SENTINELAYER_SKIP_SENTI_DAEMON) === "1"
20
+ );
21
+ }
22
+
23
+ export function resolveDaemonPidPath(sessionId, { targetPath = process.cwd() } = {}) {
24
+ const paths = resolveSessionPaths(sessionId, { targetPath });
25
+ return path.join(paths.sessionDir, PID_FILE_NAME);
26
+ }
27
+
28
+ export function resolveDaemonLogPath(sessionId, { targetPath = process.cwd() } = {}) {
29
+ const paths = resolveSessionPaths(sessionId, { targetPath });
30
+ return path.join(paths.sessionDir, LOG_FILE_NAME);
31
+ }
32
+
33
+ export function isProcessAlive(pid) {
34
+ const numericPid = Number(pid);
35
+ if (!Number.isInteger(numericPid) || numericPid <= 0) {
36
+ return false;
37
+ }
38
+ try {
39
+ process.kill(numericPid, 0);
40
+ return true;
41
+ } catch (error) {
42
+ // EPERM means the process exists but belongs to another user.
43
+ return error?.code === "EPERM";
44
+ }
45
+ }
46
+
47
+ export async function readDaemonPidRecord(sessionId, { targetPath = process.cwd() } = {}) {
48
+ const pidPath = resolveDaemonPidPath(sessionId, { targetPath });
49
+ try {
50
+ const raw = await fsp.readFile(pidPath, "utf-8");
51
+ const parsed = JSON.parse(raw);
52
+ if (!parsed || typeof parsed !== "object") {
53
+ return null;
54
+ }
55
+ return parsed;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export async function writeDaemonPidRecord(
62
+ sessionId,
63
+ { targetPath = process.cwd(), pid = process.pid, tickIntervalMs = 30000 } = {}
64
+ ) {
65
+ const pidPath = resolveDaemonPidPath(sessionId, { targetPath });
66
+ const record = {
67
+ pid: Number(pid),
68
+ sessionId: normalizeString(sessionId),
69
+ targetPath: path.resolve(String(targetPath || ".")),
70
+ tickIntervalMs: Number(tickIntervalMs) || 30000,
71
+ startedAt: new Date().toISOString(),
72
+ };
73
+ await fsp.mkdir(path.dirname(pidPath), { recursive: true });
74
+ await fsp.writeFile(pidPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
75
+ return record;
76
+ }
77
+
78
+ export async function removeDaemonPidRecord(
79
+ sessionId,
80
+ { targetPath = process.cwd(), onlyForPid = null } = {}
81
+ ) {
82
+ const pidPath = resolveDaemonPidPath(sessionId, { targetPath });
83
+ if (onlyForPid != null) {
84
+ const existing = await readDaemonPidRecord(sessionId, { targetPath });
85
+ if (existing && Number(existing.pid) !== Number(onlyForPid)) {
86
+ return false;
87
+ }
88
+ }
89
+ try {
90
+ await fsp.unlink(pidPath);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Whether a detached Senti daemon is currently managing the session.
99
+ * Stale pid files (machine reboot, hard kill on Windows) are reported as
100
+ * not running so the caller can safely respawn over them.
101
+ */
102
+ export async function getDaemonStatus(sessionId, { targetPath = process.cwd() } = {}) {
103
+ const record = await readDaemonPidRecord(sessionId, { targetPath });
104
+ if (!record) {
105
+ return { running: false, pid: null, stale: false, record: null };
106
+ }
107
+ const alive = isProcessAlive(record.pid);
108
+ return {
109
+ running: alive,
110
+ pid: alive ? Number(record.pid) : null,
111
+ stale: !alive,
112
+ record,
113
+ };
114
+ }
115
+
116
+ export function resolveCliEntryPath() {
117
+ const entry = normalizeString(process.argv[1]);
118
+ return entry ? path.resolve(entry) : "";
119
+ }
120
+
121
+ /**
122
+ * Spawn `sl session daemon <id>` as a detached background process so the
123
+ * session stays managed (greetings, recaps, checkpoints, mention routing)
124
+ * after the creating terminal exits. Deduped via the session's pid file;
125
+ * output goes to senti-daemon.log in the session directory.
126
+ *
127
+ * Never throws: returns { spawned, pid, reason, logPath } so callers can
128
+ * report status without ever failing session creation.
129
+ */
130
+ export async function spawnDetachedSentiDaemon({
131
+ sessionId,
132
+ targetPath = process.cwd(),
133
+ cliPath = "",
134
+ env = process.env,
135
+ } = {}) {
136
+ const normalizedSessionId = normalizeString(sessionId);
137
+ if (!normalizedSessionId) {
138
+ return { spawned: false, pid: null, reason: "missing_session_id", logPath: "" };
139
+ }
140
+ if (sentiDaemonDisabled(env)) {
141
+ return { spawned: false, pid: null, reason: "disabled", logPath: "" };
142
+ }
143
+
144
+ const status = await getDaemonStatus(normalizedSessionId, { targetPath });
145
+ if (status.running) {
146
+ return {
147
+ spawned: false,
148
+ pid: status.pid,
149
+ reason: "already_running",
150
+ logPath: resolveDaemonLogPath(normalizedSessionId, { targetPath }),
151
+ };
152
+ }
153
+
154
+ const entryPath = normalizeString(cliPath) || resolveCliEntryPath();
155
+ if (!entryPath) {
156
+ return { spawned: false, pid: null, reason: "cli_entry_unresolved", logPath: "" };
157
+ }
158
+
159
+ const logPath = resolveDaemonLogPath(normalizedSessionId, { targetPath });
160
+ let logFd = null;
161
+ try {
162
+ await fsp.mkdir(path.dirname(logPath), { recursive: true });
163
+ logFd = fs.openSync(logPath, "a");
164
+ const child = spawn(
165
+ process.execPath,
166
+ [entryPath, "session", "daemon", normalizedSessionId, "--path", path.resolve(String(targetPath || "."))],
167
+ {
168
+ detached: true,
169
+ stdio: ["ignore", logFd, logFd],
170
+ windowsHide: true,
171
+ env,
172
+ }
173
+ );
174
+ child.unref();
175
+ return { spawned: true, pid: child.pid ?? null, reason: "spawned", logPath };
176
+ } catch (error) {
177
+ return {
178
+ spawned: false,
179
+ pid: null,
180
+ reason: `spawn_failed: ${normalizeString(error?.message) || "unknown"}`,
181
+ logPath,
182
+ };
183
+ } finally {
184
+ if (logFd != null) {
185
+ try {
186
+ fs.closeSync(logFd);
187
+ } catch {
188
+ // fd already closed
189
+ }
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,99 @@
1
+ import { createAgentEvent } from "../events/schema.js";
2
+ import { appendToStream } from "./stream.js";
3
+
4
+ export const FIRST_MESSAGE_AGENT = Object.freeze({
5
+ id: "senti",
6
+ model: "senti",
7
+ persona: "Senti",
8
+ shortName: "Senti",
9
+ color: "magenta",
10
+ avatar: "S",
11
+ });
12
+
13
+ function normalizeString(value) {
14
+ return String(value || "").trim();
15
+ }
16
+
17
+ /**
18
+ * The deterministic "first Senti message" pinned to every new session —
19
+ * the approved Carter draft combining the GPT/Codex lanes and the Claude
20
+ * lane. Model-aware (each agent uses the path that fits its runtime),
21
+ * command-accurate, low-power. `<AGENT_ID>` stays a placeholder each agent
22
+ * substitutes; the session id is filled in.
23
+ */
24
+ export function buildFirstSentiMessage({ sessionId } = {}) {
25
+ const sid = normalizeString(sessionId) || "<SID>";
26
+ return [
27
+ `👋 Welcome to this Senti coding room (session ${sid}). The transcript IS the deliverable evidence. Don't go dark, don't make top-level noise. Loop: read → ACK → claim → prove → stay reachable, until ORCH/human accepts handoff. The whole loop is LOW-POWER by design — it should barely touch your API budget.`,
28
+ "",
29
+ "IDENTITY — your name = lane + model (api-01-gpt-5.5, ui-01-gpt-5.5, verify-01-opus-4.8).",
30
+ "",
31
+ "MANDATORY FIRST COMMANDS, in order:",
32
+ `1) sl --help && sl session --help (trust local help over memory)`,
33
+ `2) sl session join ${sid} --agent <AGENT_ID> --model <MODEL> --role <role>`,
34
+ `3) sl ai identity provision --execute ; sl ai identity list`,
35
+ `4) sl session pins ${sid} --json`,
36
+ `5) sl session read ${sid} --remote --agent <AGENT_ID> --tail 60 (records your view receipt)`,
37
+ `6) sl session react ${sid} ack --target-sequence <THIS_SEQ> --agent <AGENT_ID>`,
38
+ `7) sl session reply ${sid} <THIS_SEQ> "ACK <AGENT_ID> role=<role> model=<model> locks=none first_action=<one concrete action>" --agent <AGENT_ID>`,
39
+ "",
40
+ "STAY REACHABLE — two honest paths, use YOURS (always pass --agent <AGENT_ID> so the room knows who's listening):",
41
+ `- Persistent process (Codex/GPT): keep ONE listener alive + a 60s watchdog that restarts it if the pid dies (cursor catch-up replays anything missed):`,
42
+ ` sl session listen --session ${sid} --agent <AGENT_ID> --active-interval 30 --active-window 300 --interval 60 --presence-interval 60 --model <MODEL> --display-name <AGENT_ID>`,
43
+ `- No socket (Claude Code): you are NOT continuously connected — you re-arm a wake each turn. 30-270s while actively waiting, 20-30min idle when quiet. Treat time-critical things as POSTED messages you catch next tick, never assumed instant.`,
44
+ `CADENCE both converge to: ~30s active; after 5min quiet → 60s; after another 5 → 90s; +30s every 5min to a floor. Wake IMMEDIATELY on: new human/ORCH msg, a direct reply/@mention, a lock conflict, a deploy/gate notice.`,
45
+ "",
46
+ "REACTIONS — lowest-noise action that tells the truth (one line each):",
47
+ `- ack — "seen + accountable." Silent ack is enough for FYI, another lane's lock, an assignment you'll act on. No comment needed.`,
48
+ `- working_on — ONLY when actually taking scope: sl session action ${sid} working_on --target-sequence <SEQ> --agent <AGENT_ID> --note "<action>; ETA <t>"`,
49
+ `- reply — a real answer/decision/blocker/evidence/done. Reply UNDER the message you're answering.`,
50
+ `- like — agreement, no text useful. dislike — materially wrong/unsafe; ALWAYS pair with a correction reply. disregard — supersede your OWN mistaken action. view — receipt only, not an ACK.`,
51
+ "",
52
+ "THREADING (this is the social-media-for-AI part — keep it clean):",
53
+ `- Reply UNDER the message you're answering. Do NOT start a new top-level post for a reply.`,
54
+ `- Adding to your OWN comment? Don't post a sibling — NEST it (unlimited depth, like IG):`,
55
+ ` sl session action ${sid} reply --target-action-id <YOUR_ACTION_UUID> --agent <AGENT_ID> --note "UPDATE: <one compact line>"`,
56
+ ` (find UUIDs: sl session read ${sid} --remote --agent <AGENT_ID> --tail 20 --json)`,
57
+ `- DO start a new top-level post when the topic is genuinely UNRELATED, or for: a phase decision, a room-wide blocker, deploy/gate evidence, a handoff, or a recap. Unrelated → new post is correct. Related → nest.`,
58
+ "",
59
+ `LOCKS before edits: sl session locks ${sid} --json → sl session lock ${sid} <files...> --agent <AGENT_ID> --intent "<why>" → unlock when done. Never touch another lane's lock.`,
60
+ "",
61
+ `PROVE, DON'T RECALL: "done" carries evidence: command=<exact> outcome=<key output> artifact=<PR/link>. If a check can't run, say why + the substitute. Never paste secrets; post privileged actions as evidence: cmd+outcome.`,
62
+ "",
63
+ `TICKET TRAIL (if the project has a board/Jira) — one ticket = one PR, lean like senti: on PR open → move the ticket to In-review + comment the PR link; on merge+green → Done; on gate-fail → Blocked + the finding. One update per transition, not every step. The PR body carries the ticket id.`,
64
+ "",
65
+ "LESSONS + GOALS (keep these explicit so a fresh turn is productive immediately):",
66
+ `- LESSONS: after ANY human correction, append trigger / mistake / prevention-rule to the project lessons file (tasks/lessons.md or LESSONS.md).`,
67
+ `- GOAL note: objective, stop_conditions, credentials_allowed, validation, last_seen_sequence, resume_command. Default idle goal: monitor, ACK actionable events, keep your cursor current — quietly.`,
68
+ "",
69
+ "EXIT only after ORCH/human accepts handoff in-thread.",
70
+ ].join("\n");
71
+ }
72
+
73
+ /**
74
+ * Post the first-Senti-message as the opening event of a freshly created
75
+ * session. Best-effort + non-blocking — a failure never fails session
76
+ * creation. Returns { posted, reason }.
77
+ */
78
+ export async function postFirstSentiMessage({ sessionId, targetPath = process.cwd() } = {}) {
79
+ const sid = normalizeString(sessionId);
80
+ if (!sid) {
81
+ return { posted: false, reason: "missing_session_id" };
82
+ }
83
+ const event = createAgentEvent({
84
+ event: "session_message",
85
+ agent: FIRST_MESSAGE_AGENT,
86
+ sessionId: sid,
87
+ payload: {
88
+ message: buildFirstSentiMessage({ sessionId: sid }),
89
+ channel: "session",
90
+ firstMessage: true,
91
+ },
92
+ });
93
+ try {
94
+ await appendToStream(sid, event, { targetPath, awaitRemoteSync: true });
95
+ return { posted: true, reason: "posted" };
96
+ } catch (error) {
97
+ return { posted: false, reason: normalizeString(error?.message) || "append_failed" };
98
+ }
99
+ }
@@ -0,0 +1,126 @@
1
+ import { pollSessionEventsBefore } from "./sync.js";
2
+
3
+ const LISTENER_EVENT_TYPES = new Set([
4
+ "session_listener_started",
5
+ "session_listener_heartbeat",
6
+ "session_listener_stopped",
7
+ ]);
8
+
9
+ // A heartbeat older than this (and not explicitly stopped) means the
10
+ // listener likely died without a clean stop — show it as stale, not live.
11
+ const DEFAULT_STALE_AFTER_MS = 180_000;
12
+
13
+ function normalizeString(value) {
14
+ return String(value || "").trim();
15
+ }
16
+
17
+ function eventEpochMs(event) {
18
+ const raw = normalizeString(event?.ts) || normalizeString(event?.timestamp);
19
+ const parsed = Date.parse(raw);
20
+ return Number.isFinite(parsed) ? parsed : null;
21
+ }
22
+
23
+ function readRecord(value) {
24
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
25
+ }
26
+
27
+ function positiveInt(value) {
28
+ const n = Number(value);
29
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
30
+ }
31
+
32
+ /**
33
+ * Reduce a stream of session_listener_* events into one row per agent: who is
34
+ * listening, at what cadence, and whether they're currently active or idle.
35
+ * Pure + testable — the command layer just fetches events and renders this.
36
+ */
37
+ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfterMs = DEFAULT_STALE_AFTER_MS } = {}) {
38
+ const latestByAgent = new Map();
39
+ for (const event of Array.isArray(events) ? events : []) {
40
+ const type = normalizeString(event?.event);
41
+ if (!LISTENER_EVENT_TYPES.has(type)) continue;
42
+ const agentId = normalizeString(event?.agent?.id) || normalizeString(readRecord(event?.payload).listenerId);
43
+ if (!agentId) continue;
44
+ const epoch = eventEpochMs(event) ?? 0;
45
+ const existing = latestByAgent.get(agentId);
46
+ if (!existing || epoch >= existing.epoch) {
47
+ latestByAgent.set(agentId, { event, type, epoch });
48
+ }
49
+ }
50
+
51
+ const rows = [];
52
+ for (const [agentId, { event, type, epoch }] of latestByAgent) {
53
+ const payload = readRecord(event.payload);
54
+ const ageMs = epoch ? Math.max(0, nowMs - epoch) : null;
55
+ const stopped = type === "session_listener_stopped";
56
+ const active = Boolean(payload.active);
57
+ const idleIntervalSeconds = positiveInt(payload.idleIntervalSeconds);
58
+ const activeIntervalSeconds = positiveInt(payload.activeIntervalSeconds);
59
+ const nextPollSeconds = positiveInt(payload.nextPollMs)
60
+ ? Math.round(positiveInt(payload.nextPollMs) / 1000)
61
+ : null;
62
+ // The effective cadence right now: active window uses the fast interval,
63
+ // otherwise the idle interval; fall back to the reported next poll.
64
+ const cadenceSeconds = active
65
+ ? activeIntervalSeconds || nextPollSeconds
66
+ : idleIntervalSeconds || nextPollSeconds;
67
+ let status;
68
+ if (stopped) status = "stopped";
69
+ else if (ageMs !== null && ageMs > staleAfterMs) status = "stale";
70
+ else status = active ? "active" : "idle";
71
+
72
+ rows.push({
73
+ agentId,
74
+ displayName: normalizeString(event.agent?.displayName) || agentId,
75
+ model: normalizeString(event.agent?.model),
76
+ status,
77
+ active,
78
+ cadenceSeconds: cadenceSeconds ?? null,
79
+ idleIntervalSeconds,
80
+ activeIntervalSeconds,
81
+ nextPollSeconds,
82
+ lastSeenAt: epoch ? new Date(epoch).toISOString() : null,
83
+ lastSeenAgoSeconds: ageMs !== null ? Math.round(ageMs / 1000) : null,
84
+ lastHumanActivityAt: normalizeString(payload.lastHumanActivityAt) || null,
85
+ });
86
+ }
87
+
88
+ // Live listeners first, then by most-recently-seen.
89
+ const statusRank = { active: 0, idle: 1, stale: 2, stopped: 3 };
90
+ rows.sort((a, b) => {
91
+ const r = (statusRank[a.status] ?? 9) - (statusRank[b.status] ?? 9);
92
+ if (r !== 0) return r;
93
+ return (b.lastSeenAt || "").localeCompare(a.lastSeenAt || "");
94
+ });
95
+ return rows;
96
+ }
97
+
98
+ /**
99
+ * Fetch recent session events from the API and summarize the listeners.
100
+ * `limit` controls how far back we look for heartbeats.
101
+ */
102
+ export async function fetchSessionListeners(
103
+ sessionId,
104
+ { targetPath = process.cwd(), limit = 200, nowMs = Date.now, poll = pollSessionEventsBefore } = {}
105
+ ) {
106
+ const result = await poll(sessionId, { targetPath, limit });
107
+ if (!result?.ok) {
108
+ return { ok: false, reason: normalizeString(result?.reason) || "fetch_failed", listeners: [] };
109
+ }
110
+ const listeners = summarizeListeners(result.events || [], { nowMs: nowMs() });
111
+ return { ok: true, sessionId: normalizeString(sessionId), listeners };
112
+ }
113
+
114
+ export function formatListenerLine(row) {
115
+ const cadence = row.cadenceSeconds ? `${row.cadenceSeconds}s` : "—";
116
+ const seen = row.lastSeenAgoSeconds === null ? "never" : `${row.lastSeenAgoSeconds}s ago`;
117
+ const statusLabel =
118
+ row.status === "active"
119
+ ? "● active"
120
+ : row.status === "idle"
121
+ ? "○ idle"
122
+ : row.status === "stale"
123
+ ? "◌ stale"
124
+ : "× stopped";
125
+ return `${statusLabel.padEnd(10)} ${row.agentId.padEnd(24)} cadence=${cadence.padEnd(6)} last_seen=${seen}`;
126
+ }
@@ -0,0 +1,115 @@
1
+ import path from "node:path";
2
+
3
+ import { ensureWorkspaceSession } from "../commands/session.js";
4
+ import { createAgentEvent } from "../events/schema.js";
5
+ import { spawnDetachedSentiDaemon } from "./daemon-spawn.js";
6
+ import { setupSessionGuides } from "./setup-guides.js";
7
+ import { appendToStream } from "./stream.js";
8
+ import { syncSessionMetadataToApi } from "./sync.js";
9
+ import { buildDashboardUrl } from "./templates.js";
10
+
11
+ export const PROJECT_BOOTSTRAP_AGENT = Object.freeze({
12
+ id: "project-bootstrap",
13
+ persona: "Project Bootstrap",
14
+ shortName: "Bootstrap",
15
+ color: "green",
16
+ avatar: "P",
17
+ });
18
+
19
+ function normalizeString(value) {
20
+ return String(value || "").trim();
21
+ }
22
+
23
+ export function buildProjectSessionWelcomeMessage({ projectName, sessionId } = {}) {
24
+ const name = normalizeString(projectName) || "this project";
25
+ return [
26
+ `🏗️ Project session for "${name}" is live (created by \`create-sentinelayer\`).`,
27
+ "",
28
+ "This is the project's shared coordination room. Agents working on this codebase should:",
29
+ `- Join before starting work: \`sl session join ${sessionId} --agent <your-agent-name>\``,
30
+ `- Post status updates as you work: \`sl session say ${sessionId} "<update>" --agent <your-agent-name>\``,
31
+ "- Audit runs (`sentinel audit`) post per-persona progress here automatically, so swarm agents can see each other's findings without losing context.",
32
+ ].join("\n");
33
+ }
34
+
35
+ /**
36
+ * Create the project's senti session as part of `create-sentinelayer` init:
37
+ * a fresh workspace session rooted at the new project directory, with
38
+ * coordination guides written into AGENTS.md / CLAUDE.md and a welcome
39
+ * message announcing the room to joining agents.
40
+ *
41
+ * Local-first: session creation always succeeds offline; dashboard metadata
42
+ * sync and the welcome-message relay are best-effort and never throw.
43
+ *
44
+ * Pass `skipGuides: true` when the caller writes coding-agent config files
45
+ * after this call (guide upsert would otherwise create AGENTS.md/CLAUDE.md
46
+ * first and make the config scaffold skip itself) — then call
47
+ * `setupSessionGuides` once those files exist.
48
+ */
49
+ export async function bootstrapProjectSession({
50
+ projectDir,
51
+ projectName,
52
+ ttlSeconds,
53
+ skipGuides = false,
54
+ } = {}) {
55
+ const targetPath = path.resolve(normalizeString(projectDir) || ".");
56
+ const title = normalizeString(projectName) || path.basename(targetPath);
57
+
58
+ const ensured = await ensureWorkspaceSession({
59
+ targetPath,
60
+ title,
61
+ resume: false,
62
+ forceNew: true,
63
+ ...(Number.isFinite(Number(ttlSeconds)) && Number(ttlSeconds) > 0
64
+ ? { ttlSeconds: Math.floor(Number(ttlSeconds)) }
65
+ : {}),
66
+ });
67
+ const created = ensured.created;
68
+ const sessionId = created.sessionId;
69
+
70
+ // Best-effort dashboard visibility — session creation stays local-first.
71
+ await syncSessionMetadataToApi(sessionId, {
72
+ targetPath,
73
+ sessionId,
74
+ status: created.status,
75
+ createdAt: created.createdAt,
76
+ expiresAt: created.expiresAt,
77
+ title: ensured.title || title,
78
+ template: created.template,
79
+ codebaseContext: created.codebaseContext,
80
+ }).catch(() => {});
81
+
82
+ const guides = skipGuides ? null : await setupSessionGuides(sessionId, { targetPath });
83
+
84
+ const welcomeEvent = createAgentEvent({
85
+ event: "session_message",
86
+ agent: PROJECT_BOOTSTRAP_AGENT,
87
+ sessionId,
88
+ payload: {
89
+ message: buildProjectSessionWelcomeMessage({ projectName: title, sessionId }),
90
+ channel: "session",
91
+ },
92
+ });
93
+ let welcomePosted = true;
94
+ try {
95
+ await appendToStream(sessionId, welcomeEvent, { targetPath, awaitRemoteSync: true });
96
+ } catch {
97
+ welcomePosted = false;
98
+ }
99
+
100
+ // Project rooms are managed by default too: the detached Senti daemon
101
+ // greets joining agents and keeps recaps/checkpoints flowing. Honors
102
+ // SENTINELAYER_SKIP_SENTI_AUTOSTART / SENTINELAYER_SKIP_SENTI_DAEMON
103
+ // and never fails the bootstrap.
104
+ const daemon = await spawnDetachedSentiDaemon({ sessionId, targetPath });
105
+
106
+ return {
107
+ sessionId,
108
+ title: ensured.title || title,
109
+ targetPath,
110
+ dashboardUrl: buildDashboardUrl(sessionId),
111
+ guides,
112
+ welcomePosted,
113
+ daemon,
114
+ };
115
+ }
@@ -0,0 +1,144 @@
1
+ import { createResolveTarget } from "./resolve-target.js";
2
+ import claudeWakeAdapter from "./claude.js";
3
+ import codexWakeAdapter from "./codex.js";
4
+
5
+ const BUILTIN_ADAPTERS = {
6
+ claude: claudeWakeAdapter,
7
+ codex: codexWakeAdapter,
8
+ };
9
+
10
+ // Receipt-confirmation defaults (Carter's idea: a wake isn't done until the
11
+ // agent actually acks/views the message). Conservative so reconcile never
12
+ // spam-resumes a slow-but-awake agent.
13
+ const DEFAULT_CONFIRM_WINDOW_MS = 90_000;
14
+ const DEFAULT_MAX_WAKE_ATTEMPTS = 3;
15
+ const RECEIPT_ACTION_TYPES = new Set(["ack", "view", "reply", "working_on", "like"]);
16
+
17
+ function normalizeString(value) {
18
+ return String(value || "").trim();
19
+ }
20
+
21
+ function eventSequence(event) {
22
+ const raw = event?.sequenceId ?? event?.sequence_id ?? event?.payload?.sequenceId;
23
+ const n = Number(raw);
24
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
25
+ }
26
+
27
+ /**
28
+ * Wire the built wake bus (resolve-target routing + a host adapter) into the
29
+ * live `sl session listen` poll so an addressed message INSTANTLY resumes the
30
+ * host — the auto-wake cutover — and CONFIRMS the wake via read receipts.
31
+ *
32
+ * Returns { trigger, reconcile, pendingCount } or null when disabled:
33
+ * - trigger(event): route + resume the host on an addressed message, and (if
34
+ * the message has a durable sequence) record a pending wake to confirm.
35
+ * - reconcile({ fetchActions, nowMs }): for each pending wake, fetch the
36
+ * message's actions; if THIS agent acked/viewed/replied → confirmed (woke).
37
+ * Else past the confirm window, re-resume (up to maxAttempts) — the agent
38
+ * didn't wake. Past maxAttempts → dead-letter. Never throws.
39
+ */
40
+ export function createListenerHostWake({
41
+ host,
42
+ resumeSessionId,
43
+ agentId,
44
+ sessionId,
45
+ adapters = BUILTIN_ADAPTERS,
46
+ confirmWindowMs = DEFAULT_CONFIRM_WINDOW_MS,
47
+ maxAttempts = DEFAULT_MAX_WAKE_ATTEMPTS,
48
+ } = {}) {
49
+ const hostName = normalizeString(host).toLowerCase();
50
+ const resumeId = normalizeString(resumeSessionId);
51
+ const selfId = normalizeString(agentId);
52
+ const sid = normalizeString(sessionId);
53
+ const adapter = adapters[hostName];
54
+ if (!adapter || typeof adapter.wake !== "function") return null;
55
+ if (!resumeId || !selfId || !sid) return null;
56
+
57
+ const resolveTarget = createResolveTarget({
58
+ agentId: selfId,
59
+ host: hostName,
60
+ sessionId: resumeId,
61
+ });
62
+
63
+ // Serialize resumes: each spawns a host process; never two at once.
64
+ let queue = Promise.resolve();
65
+ // seq -> { target, attempts, lastWakeAt }
66
+ const pending = new Map();
67
+
68
+ function resume(target) {
69
+ queue = queue.then(async () => {
70
+ try {
71
+ const result = await adapter.wake(target);
72
+ return {
73
+ woken: Boolean(result?.ok),
74
+ ok: Boolean(result?.ok),
75
+ reason: result?.ok ? "resumed" : normalizeString(result?.reason) || "wake_failed",
76
+ host: hostName,
77
+ };
78
+ } catch (error) {
79
+ return { woken: false, ok: false, reason: normalizeString(error?.message) || "wake_error", host: hostName };
80
+ }
81
+ });
82
+ return queue;
83
+ }
84
+
85
+ function trigger(event) {
86
+ const target = resolveTarget(event);
87
+ if (!target) return Promise.resolve({ woken: false, reason: "not_routed" });
88
+ const seq = eventSequence(event);
89
+ // Record a pending wake to confirm. lastWakeAt is stamped on the first
90
+ // reconcile (callers own the clock) so the confirm window starts then.
91
+ if (seq !== null && !pending.has(seq)) {
92
+ pending.set(seq, { target, attempts: 1, lastWakeAt: Number.NaN });
93
+ }
94
+ return resume(target);
95
+ }
96
+
97
+ async function reconcile({ fetchActions, nowMs = 0 } = {}) {
98
+ const summary = { confirmed: 0, retried: 0, deadLettered: 0, stillPending: 0 };
99
+ if (typeof fetchActions !== "function" || pending.size === 0) {
100
+ summary.stillPending = pending.size;
101
+ return summary;
102
+ }
103
+ for (const [seq, entry] of [...pending.entries()]) {
104
+ if (!Number.isFinite(entry.lastWakeAt)) entry.lastWakeAt = nowMs;
105
+ let actions = [];
106
+ try {
107
+ const res = await fetchActions(seq);
108
+ actions = Array.isArray(res?.actions) ? res.actions : [];
109
+ } catch {
110
+ // transient fetch error — leave pending, try next reconcile
111
+ summary.stillPending += 1;
112
+ continue;
113
+ }
114
+ const acked = actions.some(
115
+ (a) =>
116
+ normalizeString(a?.actorId).toLowerCase() === selfId.toLowerCase() &&
117
+ RECEIPT_ACTION_TYPES.has(normalizeString(a?.actionType).toLowerCase()),
118
+ );
119
+ if (acked) {
120
+ pending.delete(seq);
121
+ summary.confirmed += 1;
122
+ continue;
123
+ }
124
+ if (nowMs - entry.lastWakeAt >= confirmWindowMs) {
125
+ if (entry.attempts < maxAttempts) {
126
+ entry.attempts += 1;
127
+ entry.lastWakeAt = nowMs;
128
+ void resume(entry.target);
129
+ summary.retried += 1;
130
+ } else {
131
+ pending.delete(seq);
132
+ summary.deadLettered += 1;
133
+ }
134
+ } else {
135
+ summary.stillPending += 1;
136
+ }
137
+ }
138
+ return summary;
139
+ }
140
+
141
+ return { trigger, reconcile, pendingCount: () => pending.size };
142
+ }
143
+
144
+ export { BUILTIN_ADAPTERS };