sentinelayer-cli 0.23.0 → 0.25.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.
- package/package.json +1 -1
- package/src/commands/guide.js +52 -0
- package/src/commands/session.js +285 -9
- package/src/coord/events-log.js +1 -1
- package/src/guide/enrich.js +173 -0
- package/src/guide/generator.js +167 -10
- package/src/legacy-cli.js +17 -0
- package/src/session/first-message.js +99 -0
- package/src/session/invitations.js +241 -0
- package/src/session/listeners.js +126 -0
- package/src/session/stream.js +31 -2
- package/src/session/wake/listen-wake.js +144 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
|
|
6
|
+
import { requestJsonMutation } from "../auth/http.js";
|
|
7
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
8
|
+
import { renderCoordinationBulletList } from "./coordination-guidance.js";
|
|
9
|
+
import { resolveSessionPaths } from "./paths.js";
|
|
10
|
+
|
|
11
|
+
export const SESSION_INVITATION_ACCEPT_ROUTE_ID =
|
|
12
|
+
"POST /api/v1/sessions/{session_id}/invitations/accept";
|
|
13
|
+
export const SESSION_MUTATION_ORIGIN = "https://sentinelayer.com";
|
|
14
|
+
|
|
15
|
+
function normalizeString(value) {
|
|
16
|
+
return String(value || "").trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeFileToken(value, fallback = "onboarding") {
|
|
20
|
+
const normalized = normalizeString(value)
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
23
|
+
.replace(/^-+|-+$/g, "");
|
|
24
|
+
return (normalized || fallback).slice(0, 80);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function canonicalSessionRef(value) {
|
|
28
|
+
const raw = normalizeString(value);
|
|
29
|
+
if (!raw) return "unavailable";
|
|
30
|
+
return raw.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createSessionMutationCsrfToken({
|
|
34
|
+
bearerToken,
|
|
35
|
+
sessionId,
|
|
36
|
+
routeId,
|
|
37
|
+
idempotencyKey,
|
|
38
|
+
} = {}) {
|
|
39
|
+
const secret = normalizeString(bearerToken);
|
|
40
|
+
if (!secret) return "";
|
|
41
|
+
const message = [
|
|
42
|
+
"session-mutation-csrf:v1",
|
|
43
|
+
canonicalSessionRef(sessionId),
|
|
44
|
+
normalizeString(routeId),
|
|
45
|
+
normalizeString(idempotencyKey),
|
|
46
|
+
].join("\0");
|
|
47
|
+
return createHmac("sha256", secret).update(message, "utf-8").digest("hex");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createSessionMutationHeaders({
|
|
51
|
+
bearerToken,
|
|
52
|
+
sessionId,
|
|
53
|
+
routeId,
|
|
54
|
+
idempotencyKey,
|
|
55
|
+
origin = SESSION_MUTATION_ORIGIN,
|
|
56
|
+
} = {}) {
|
|
57
|
+
const token = normalizeString(bearerToken);
|
|
58
|
+
const normalizedOrigin = normalizeString(origin) || SESSION_MUTATION_ORIGIN;
|
|
59
|
+
return {
|
|
60
|
+
Authorization: `Bearer ${token}`,
|
|
61
|
+
Origin: normalizedOrigin,
|
|
62
|
+
"Sec-Fetch-Site": "same-site",
|
|
63
|
+
"X-Sentinelayer-Session-Mutation": "session-mutation",
|
|
64
|
+
"X-CSRF-Token": createSessionMutationCsrfToken({
|
|
65
|
+
bearerToken: token,
|
|
66
|
+
sessionId,
|
|
67
|
+
routeId,
|
|
68
|
+
idempotencyKey,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createSessionMutationIdempotencyKey(operationName = "invitation-accept") {
|
|
74
|
+
const operation = normalizeString(operationName)
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
77
|
+
.replace(/^-+|-+$/g, "")
|
|
78
|
+
.slice(0, 32) || "session-mutation";
|
|
79
|
+
return `sl-cli-${operation}-${randomUUID()}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function acceptSessionInvitation(
|
|
83
|
+
sessionId,
|
|
84
|
+
{
|
|
85
|
+
invitationToken,
|
|
86
|
+
seatKey = "",
|
|
87
|
+
agentId = "",
|
|
88
|
+
targetPath = process.cwd(),
|
|
89
|
+
idempotencyKey = "",
|
|
90
|
+
origin = SESSION_MUTATION_ORIGIN,
|
|
91
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
92
|
+
requestMutation = requestJsonMutation,
|
|
93
|
+
} = {},
|
|
94
|
+
) {
|
|
95
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
96
|
+
const normalizedInvitationToken = normalizeString(invitationToken);
|
|
97
|
+
if (!normalizedSessionId) {
|
|
98
|
+
throw new Error("session id is required.");
|
|
99
|
+
}
|
|
100
|
+
if (!normalizedInvitationToken) {
|
|
101
|
+
throw new Error("invite token is required.");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const auth = await resolveAuthSession({
|
|
105
|
+
cwd: targetPath,
|
|
106
|
+
env: process.env,
|
|
107
|
+
autoRotate: false,
|
|
108
|
+
});
|
|
109
|
+
if (!auth?.token || !auth?.apiUrl) {
|
|
110
|
+
throw new Error("Not authenticated. Run `sl auth login` first.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const resolvedIdempotencyKey =
|
|
114
|
+
normalizeString(idempotencyKey) || createSessionMutationIdempotencyKey("session-invite-accept");
|
|
115
|
+
const apiUrl = normalizeString(auth.apiUrl).replace(/\/+$/, "");
|
|
116
|
+
const body = {
|
|
117
|
+
token: normalizedInvitationToken,
|
|
118
|
+
};
|
|
119
|
+
const normalizedSeatKey = normalizeString(seatKey);
|
|
120
|
+
const normalizedAgentId = normalizeString(agentId);
|
|
121
|
+
if (normalizedSeatKey) body.seatKey = normalizedSeatKey;
|
|
122
|
+
if (normalizedAgentId) body.agentId = normalizedAgentId;
|
|
123
|
+
|
|
124
|
+
const result = await requestMutation(
|
|
125
|
+
`${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/invitations/accept`,
|
|
126
|
+
{
|
|
127
|
+
method: "POST",
|
|
128
|
+
operationName: "session.invitation_accept",
|
|
129
|
+
idempotencyKey: resolvedIdempotencyKey,
|
|
130
|
+
headers: createSessionMutationHeaders({
|
|
131
|
+
bearerToken: auth.token,
|
|
132
|
+
sessionId: normalizedSessionId,
|
|
133
|
+
routeId: SESSION_INVITATION_ACCEPT_ROUTE_ID,
|
|
134
|
+
idempotencyKey: resolvedIdempotencyKey,
|
|
135
|
+
origin,
|
|
136
|
+
}),
|
|
137
|
+
body,
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
idempotencyKey: resolvedIdempotencyKey,
|
|
143
|
+
result,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function normalizeSessionOnboarding(onboarding = {}, claimedSeat = null) {
|
|
148
|
+
const onboardingSource =
|
|
149
|
+
onboarding && typeof onboarding === "object" && !Array.isArray(onboarding) ? onboarding : {};
|
|
150
|
+
const seatSource =
|
|
151
|
+
claimedSeat && typeof claimedSeat === "object" && !Array.isArray(claimedSeat) ? claimedSeat : {};
|
|
152
|
+
const normalized = {
|
|
153
|
+
seatKey: normalizeString(onboardingSource.seatKey) || normalizeString(seatSource.seatKey) || null,
|
|
154
|
+
agentId:
|
|
155
|
+
normalizeString(onboardingSource.agentId) ||
|
|
156
|
+
normalizeString(seatSource.claimedAgentId) ||
|
|
157
|
+
normalizeString(seatSource.agentId) ||
|
|
158
|
+
null,
|
|
159
|
+
displayName:
|
|
160
|
+
normalizeString(onboardingSource.displayName) || normalizeString(seatSource.displayName) || null,
|
|
161
|
+
role: normalizeString(onboardingSource.role) || normalizeString(seatSource.role) || null,
|
|
162
|
+
instructions:
|
|
163
|
+
normalizeString(onboardingSource.instructions) || normalizeString(seatSource.instructions) || null,
|
|
164
|
+
};
|
|
165
|
+
return Object.values(normalized).some(Boolean) ? normalized : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderOnboardingMarkdown({ sessionId, onboarding, claimedSeat, agentId }) {
|
|
169
|
+
const lines = [
|
|
170
|
+
"# Senti Session Onboarding",
|
|
171
|
+
"",
|
|
172
|
+
`Session: ${sessionId}`,
|
|
173
|
+
`Agent: ${normalizeString(onboarding.agentId) || normalizeString(agentId) || "unknown"}`,
|
|
174
|
+
];
|
|
175
|
+
if (onboarding.displayName) lines.push(`Display name: ${onboarding.displayName}`);
|
|
176
|
+
if (onboarding.role) lines.push(`Role: ${onboarding.role}`);
|
|
177
|
+
if (onboarding.seatKey) lines.push(`Seat key: ${onboarding.seatKey}`);
|
|
178
|
+
if (claimedSeat?.seatType) lines.push(`Seat type: ${normalizeString(claimedSeat.seatType)}`);
|
|
179
|
+
lines.push("", "## SOUL Instructions", "");
|
|
180
|
+
lines.push(onboarding.instructions || "No seat-specific instructions were provided.");
|
|
181
|
+
lines.push("", "## Session Etiquette", "", renderCoordinationBulletList());
|
|
182
|
+
lines.push(
|
|
183
|
+
"",
|
|
184
|
+
"## First Actions",
|
|
185
|
+
"",
|
|
186
|
+
`- Read recent room context: \`sl session read ${sessionId} --remote --tail 50 --json\``,
|
|
187
|
+
`- Start a quiet listener: \`sl session listen --session ${sessionId} --agent <your-name> --interval 60 --active-interval 5 --emit ndjson --no-presence\``,
|
|
188
|
+
`- Post a short plan before editing: \`sl session say ${sessionId} \"plan: <scope>; files: <paths>\"\``,
|
|
189
|
+
"- Review `AGENTS.md` / `CLAUDE.md`, then keep `tasks/todo.md` current for work status.",
|
|
190
|
+
"- Add any Carter correction pattern to `tasks/lessons.md` before continuing the next slice.",
|
|
191
|
+
);
|
|
192
|
+
return `${lines.join("\n")}\n`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function writeSessionOnboardingBrief(
|
|
196
|
+
sessionId,
|
|
197
|
+
{
|
|
198
|
+
onboarding,
|
|
199
|
+
claimedSeat = null,
|
|
200
|
+
agentId = "",
|
|
201
|
+
targetPath = process.cwd(),
|
|
202
|
+
} = {},
|
|
203
|
+
) {
|
|
204
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
205
|
+
const normalizedOnboarding = normalizeSessionOnboarding(onboarding, claimedSeat);
|
|
206
|
+
if (!normalizedSessionId || !normalizedOnboarding) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const paths = resolveSessionPaths(normalizedSessionId, { targetPath });
|
|
210
|
+
const owner = safeFileToken(normalizedOnboarding.agentId || agentId || normalizedOnboarding.seatKey);
|
|
211
|
+
const dir = path.join(paths.sentiDir, "onboarding");
|
|
212
|
+
const jsonPath = path.join(dir, `${owner}.json`);
|
|
213
|
+
const markdownPath = path.join(dir, `${owner}.md`);
|
|
214
|
+
const payload = {
|
|
215
|
+
schemaVersion: "1.0.0",
|
|
216
|
+
sessionId: normalizedSessionId,
|
|
217
|
+
agentId: normalizeString(agentId) || normalizedOnboarding.agentId || null,
|
|
218
|
+
onboarding: normalizedOnboarding,
|
|
219
|
+
claimedSeat: claimedSeat && typeof claimedSeat === "object" ? claimedSeat : null,
|
|
220
|
+
writtenAt: new Date().toISOString(),
|
|
221
|
+
};
|
|
222
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
223
|
+
await Promise.all([
|
|
224
|
+
fsp.writeFile(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8"),
|
|
225
|
+
fsp.writeFile(
|
|
226
|
+
markdownPath,
|
|
227
|
+
renderOnboardingMarkdown({
|
|
228
|
+
sessionId: normalizedSessionId,
|
|
229
|
+
onboarding: normalizedOnboarding,
|
|
230
|
+
claimedSeat,
|
|
231
|
+
agentId,
|
|
232
|
+
}),
|
|
233
|
+
"utf-8",
|
|
234
|
+
),
|
|
235
|
+
]);
|
|
236
|
+
return {
|
|
237
|
+
jsonPath,
|
|
238
|
+
markdownPath,
|
|
239
|
+
onboarding: normalizedOnboarding,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -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
|
+
}
|
package/src/session/stream.js
CHANGED
|
@@ -15,6 +15,10 @@ const DEFAULT_LOCK_POLL_MS = 25;
|
|
|
15
15
|
const DEFAULT_MAX_STREAM_EVENTS = 10_000;
|
|
16
16
|
const DEFAULT_REMOTE_REFRESH_TTL_SECONDS = 24 * 60 * 60;
|
|
17
17
|
const REMOTE_REFRESH_TIMEOUT_MS = 2_500;
|
|
18
|
+
const METADATA_WRITE_RETRY_ATTEMPTS = 5;
|
|
19
|
+
const METADATA_WRITE_RETRY_DELAY_MS = 50;
|
|
20
|
+
|
|
21
|
+
let metadataWriteCounter = 0;
|
|
18
22
|
|
|
19
23
|
function normalizeString(value) {
|
|
20
24
|
return String(value || "").trim();
|
|
@@ -68,11 +72,36 @@ async function readSessionMetadata(paths) {
|
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
function isRetryableMetadataWriteError(error) {
|
|
76
|
+
const code = error && typeof error === "object" ? error.code : "";
|
|
77
|
+
return code === "EPERM" || code === "EACCES" || code === "EBUSY";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function replaceMetadataFile(tmpPath, metadataPath) {
|
|
81
|
+
for (let attempt = 1; attempt <= METADATA_WRITE_RETRY_ATTEMPTS; attempt += 1) {
|
|
82
|
+
try {
|
|
83
|
+
await fsp.rename(tmpPath, metadataPath);
|
|
84
|
+
return;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (!isRetryableMetadataWriteError(error) || attempt >= METADATA_WRITE_RETRY_ATTEMPTS) {
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
await sleep(METADATA_WRITE_RETRY_DELAY_MS * attempt);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
async function writeSessionMetadata(paths, metadata = {}) {
|
|
72
95
|
await fsp.mkdir(paths.sessionDir, { recursive: true });
|
|
73
|
-
|
|
96
|
+
metadataWriteCounter = (metadataWriteCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
97
|
+
const tmpPath = `${paths.metadataPath}.${process.pid}.${Date.now()}.${metadataWriteCounter}.tmp`;
|
|
74
98
|
await fsp.writeFile(tmpPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf-8");
|
|
75
|
-
|
|
99
|
+
try {
|
|
100
|
+
await replaceMetadataFile(tmpPath, paths.metadataPath);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
await fsp.rm(tmpPath, { force: true }).catch(() => {});
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
function remoteStatusAllowsLocalRefresh(remoteSession = {}) {
|
|
@@ -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 };
|