sentinelayer-cli 0.40.0 → 0.42.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.
Files changed (69) hide show
  1. package/README.md +22 -4
  2. package/package.json +4 -4
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +1094 -279
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/governance.js +67 -0
  16. package/src/engram/index.js +73 -0
  17. package/src/engram/ingest-document.js +107 -0
  18. package/src/engram/namespace.js +89 -0
  19. package/src/engram/observations.js +34 -0
  20. package/src/engram/sla.js +104 -0
  21. package/src/engram/store.js +120 -0
  22. package/src/engram/tools.js +152 -0
  23. package/src/engram/trust.js +105 -0
  24. package/src/events/schema.js +18 -0
  25. package/src/legacy-cli.js +6 -3
  26. package/src/mcp/registry.js +27 -10
  27. package/src/mcp/session-stdio-server.js +209 -18
  28. package/src/net/test-egress-guard.js +158 -0
  29. package/src/review/investor-dd-orchestrator.js +14 -2
  30. package/src/session/admission-auth.js +312 -0
  31. package/src/session/admission-preimage.js +90 -0
  32. package/src/session/admission.js +620 -0
  33. package/src/session/agent-registry.js +22 -63
  34. package/src/session/checkpoints.js +25 -1
  35. package/src/session/codebase-context.js +54 -0
  36. package/src/session/coordination-guidance.js +6 -6
  37. package/src/session/daemon-spawn.js +20 -2
  38. package/src/session/daemon.js +26 -125
  39. package/src/session/event-identity.js +9 -2
  40. package/src/session/file-lease-integrations.js +1300 -0
  41. package/src/session/file-locks.js +967 -464
  42. package/src/session/first-message.js +11 -3
  43. package/src/session/listener.js +246 -20
  44. package/src/session/listeners.js +71 -80
  45. package/src/session/live-source.js +47 -16
  46. package/src/session/message-edits.js +89 -0
  47. package/src/session/paths.js +2 -0
  48. package/src/session/recall/embedder.js +181 -0
  49. package/src/session/recall/entities.js +0 -0
  50. package/src/session/recall/index-build.js +38 -0
  51. package/src/session/recall/index-core.js +108 -0
  52. package/src/session/recall/index.js +131 -0
  53. package/src/session/recall/needle-eval.js +279 -0
  54. package/src/session/recall/observation-core.js +183 -0
  55. package/src/session/recall/observations.js +21 -0
  56. package/src/session/recall/provenance.js +153 -0
  57. package/src/session/recall/retrieve.js +374 -0
  58. package/src/session/recall/text.js +0 -0
  59. package/src/session/recall/token-stats.js +81 -0
  60. package/src/session/recap.js +54 -26
  61. package/src/session/remote-hydrate.js +12 -20
  62. package/src/session/store.js +13 -14
  63. package/src/session/stream.js +23 -3
  64. package/src/session/sync.js +789 -22
  65. package/src/session/tickets.js +316 -0
  66. package/src/session/title-sync.js +2 -1
  67. package/src/session/usage-report.js +41 -2
  68. package/src/session/wake/pump.js +8 -3
  69. package/src/session/wake/resolve-target.js +10 -5
@@ -0,0 +1,316 @@
1
+ // tickets.js — the room's shared backlog from the CLI: list, claim, renew, release,
2
+ // submit, and the ticket event feed (API "Ticket Lease Contract (T2)").
3
+ //
4
+ // Credentials: every call resolves auth with resolveActiveAuthSession. Inside an
5
+ // admitted agent's command (the runCli choke point), that is the agent's admission
6
+ // credential and never the human token, so a ticket is claimed AS the agent.
7
+ //
8
+ // Lease state: a claim's { leaseId, fence, version, expiresAt } is kept per
9
+ // (session, ticket, identity) in a local file. Only a MISSING file means "no lease
10
+ // held here"; an unreadable one refuses, never reads as absent.
11
+ //
12
+ // Lost responses: a write's Idempotency-Key is stored BEFORE the request is sent. If
13
+ // the response is lost, re-running the same command reuses that key, and the server
14
+ // replays the original result instead of performing the operation again.
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import fsp from "node:fs/promises";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import process from "node:process";
21
+
22
+ import { requestJson, requestJsonMutation } from "../auth/http.js";
23
+ import { resolveActiveAuthSession } from "../auth/service.js";
24
+
25
+ function normalizeString(value) {
26
+ return String(value ?? "").trim();
27
+ }
28
+
29
+ function segment(value, label) {
30
+ const text = normalizeString(value).toLowerCase();
31
+ if (!/^[a-z0-9][a-z0-9._:-]{0,63}$/.test(text)) throw new Error(`${label} is not a valid identifier.`);
32
+ return text.replace(/:/g, "%3a");
33
+ }
34
+
35
+ export function ticketLeasePath(sessionId, ticketId, identity, { homeDir } = {}) {
36
+ return path.join(
37
+ homeDir || os.homedir(),
38
+ ".sentinelayer",
39
+ "agents",
40
+ segment(identity, "identity"),
41
+ "tickets",
42
+ segment(sessionId, "sessionId"),
43
+ `${segment(ticketId, "ticketId")}.json`
44
+ );
45
+ }
46
+
47
+ async function readState(filePath) {
48
+ let raw;
49
+ try {
50
+ raw = await fsp.readFile(filePath, "utf8");
51
+ } catch (error) {
52
+ if (error && error.code === "ENOENT") return null;
53
+ throw new Error(`The local ticket lease state at ${filePath} is unreadable; refusing to guess.`);
54
+ }
55
+ try {
56
+ const parsed = JSON.parse(raw);
57
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
58
+ return parsed;
59
+ } catch {
60
+ throw new Error(`The local ticket lease state at ${filePath} is unreadable; refusing to guess.`);
61
+ }
62
+ }
63
+
64
+ async function writeState(filePath, state) {
65
+ await fsp.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
66
+ const tmp = `${filePath}.${randomUUID()}.tmp`;
67
+ await fsp.writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: "wx" });
68
+ await fsp.rename(tmp, filePath);
69
+ }
70
+
71
+ async function auth(targetPath, resolveAuthSession) {
72
+ const session = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
73
+ if (!session?.token || !session?.apiUrl) throw new Error("Not authenticated for this session.");
74
+ return { token: session.token, apiUrl: normalizeString(session.apiUrl).replace(/\/+$/, "") };
75
+ }
76
+
77
+ function ticketsUrl(apiUrl, sessionId, suffix = "") {
78
+ return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/tickets${suffix}`;
79
+ }
80
+
81
+ /** The Idempotency-Key for this logical write: reused until its response is stored. */
82
+ async function operationKey(statePath, operation, fingerprint) {
83
+ const state = (await readState(statePath)) || {};
84
+ const pending = state.pending;
85
+ if (pending && pending.operation === operation && pending.fingerprint === fingerprint && pending.key) {
86
+ return { key: pending.key, state };
87
+ }
88
+ const key = `${operation}-${randomUUID()}`;
89
+ const next = { ...state, pending: { operation, fingerprint, key } };
90
+ await writeState(statePath, next);
91
+ return { key, state: next };
92
+ }
93
+
94
+ function leaseState(response) {
95
+ return {
96
+ version: 1,
97
+ leaseId: response.lease.leaseId,
98
+ fence: response.lease.fence,
99
+ ticketVersion: response.version,
100
+ expiresAt: response.lease.expiresAt,
101
+ };
102
+ }
103
+
104
+ const SNAPSHOT_PAGE = 200;
105
+ const SNAPSHOT_MAX_PAGES = 50;
106
+
107
+ function malformedSnapshot(reason) {
108
+ return new Error(`The ticket snapshot is malformed (${reason}); refusing a partial list.`);
109
+ }
110
+
111
+ /**
112
+ * One snapshot page, validated before anything in it is believed. The snapshot is keyset by
113
+ * ticket id: ids on a page are STRICTLY ascending and above the previous continuation (so
114
+ * none repeats), and the continuation is exactly the LAST id returned -- a token that jumps
115
+ * ahead would silently skip every ticket in between. Anything else is not a complete list.
116
+ */
117
+ function validatedSnapshotPage(body, { first, afterId }) {
118
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw malformedSnapshot("a page is not an object");
119
+ if (!Array.isArray(body.items)) throw malformedSnapshot("a page has no items list");
120
+ if (typeof body.hasMore !== "boolean") throw malformedSnapshot("a page does not say whether there is more");
121
+ if (first && !(Number.isSafeInteger(body.cursor) && body.cursor >= 0)) {
122
+ throw malformedSnapshot("the first page has no room cursor to follow events from");
123
+ }
124
+ let previous = afterId;
125
+ for (const item of body.items) {
126
+ const id = item && typeof item === "object" ? item.id : null;
127
+ if (typeof id !== "string" || !id) throw malformedSnapshot("a ticket has no id");
128
+ if (previous && !(id > previous)) throw malformedSnapshot("ticket ids are not in ascending order");
129
+ previous = id;
130
+ }
131
+ if (body.hasMore) {
132
+ if (!body.items.length) throw malformedSnapshot("an empty page claims there is more");
133
+ const next = body.nextAfterId;
134
+ if (typeof next !== "string" || !next) throw malformedSnapshot("there is more but no way to continue");
135
+ if (next !== previous) throw malformedSnapshot("the continuation is not the last ticket returned");
136
+ }
137
+ return body;
138
+ }
139
+
140
+ /**
141
+ * The room's COMPLETE backlog, from GET /tickets/snapshot paged to the end. The board read
142
+ * (GET /tickets) is a bounded, newest-first page: filtering it for "available" could miss
143
+ * claimable work beyond the page. `cursor` is the FIRST page's room cursor, taken with that
144
+ * page; `sl session ticket events --after <cursor>` continues from exactly there. A snapshot
145
+ * that cannot be completed is refused, never returned partial.
146
+ */
147
+ export async function listTickets(
148
+ sessionId,
149
+ {
150
+ targetPath = process.cwd(),
151
+ available = false,
152
+ resolveAuthSession = resolveActiveAuthSession,
153
+ requestRead = requestJson,
154
+ pageSize = SNAPSHOT_PAGE,
155
+ maxPages = SNAPSHOT_MAX_PAGES,
156
+ } = {}
157
+ ) {
158
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
159
+ const items = [];
160
+ let cursor = null;
161
+ let afterId = null;
162
+ let complete = false;
163
+ for (let page = 0; page < maxPages && !complete; page += 1) {
164
+ const query = new URLSearchParams({ limit: String(pageSize) });
165
+ if (afterId) query.set("afterId", afterId);
166
+ const body = validatedSnapshotPage(
167
+ await requestRead(`${ticketsUrl(apiUrl, sessionId, "/snapshot")}?${query}`, {
168
+ method: "GET",
169
+ headers: { Authorization: `Bearer ${token}` },
170
+ }),
171
+ { first: page === 0, afterId },
172
+ );
173
+ if (page === 0) cursor = body.cursor; // ONLY the first page's cursor is the baseline
174
+ items.push(...body.items);
175
+ if (!body.hasMore) complete = true;
176
+ else afterId = body.nextAfterId;
177
+ }
178
+ if (!complete) {
179
+ throw new Error(`The room has more than ${maxPages * pageSize} tickets; refusing a partial list.`);
180
+ }
181
+ // Newest first for people; the snapshot itself is in id order.
182
+ items.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")) || String(a.id).localeCompare(String(b.id)));
183
+ // Available = claimable by someone now: open, or working with an expired lease.
184
+ // Never blocked work: the MVP has no dependencies, and blocked is never available.
185
+ const filtered = available
186
+ ? items.filter((t) => t.status === "open" || (t.status === "working" && t.lease && !t.lease.live))
187
+ : items;
188
+ return { items: filtered, cursor, count: filtered.length, complete: true };
189
+ }
190
+
191
+ export async function ticketEvents(
192
+ sessionId,
193
+ { after = 0, targetPath = process.cwd(), resolveAuthSession = resolveActiveAuthSession, requestRead = requestJson } = {}
194
+ ) {
195
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
196
+ return requestRead(`${ticketsUrl(apiUrl, sessionId, "/events")}?after=${encodeURIComponent(String(after))}`, {
197
+ method: "GET",
198
+ headers: { Authorization: `Bearer ${token}` },
199
+ });
200
+ }
201
+
202
+ async function mutate(url, { token, key, operation, body, requestMutation }) {
203
+ return requestMutation(url, {
204
+ method: "POST",
205
+ operationName: `session.ticket_${operation}`,
206
+ idempotencyKey: key,
207
+ headers: { Authorization: `Bearer ${token}`, "Idempotency-Key": key },
208
+ body,
209
+ });
210
+ }
211
+
212
+ export async function claimTicket(
213
+ sessionId,
214
+ ticketId,
215
+ {
216
+ identity,
217
+ expectedVersion = null,
218
+ targetPath = process.cwd(),
219
+ homeDir,
220
+ resolveAuthSession = resolveActiveAuthSession,
221
+ requestMutation = requestJsonMutation,
222
+ } = {}
223
+ ) {
224
+ const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
225
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
226
+ const { key } = await operationKey(statePath, "claim", String(expectedVersion ?? "any"));
227
+ const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/claim`), {
228
+ token, key, operation: "claim", requestMutation,
229
+ body: expectedVersion == null ? {} : { expectedVersion: Number(expectedVersion) },
230
+ });
231
+ // The lease is recorded here only if the server says it is held by the identity that
232
+ // asked: an agent by its own id, a person as a human. Anything else is not this lease.
233
+ const holder = response?.ticket?.lease;
234
+ const heldByIdentity =
235
+ identity === "human" ? holder?.holderKind === "human" : holder?.holderKind === "agent" && holder?.holder === identity;
236
+ if (!heldByIdentity) {
237
+ throw new Error(
238
+ `The server recorded this lease for ${holder?.holderKind || "nobody"} "${holder?.holder || ""}", not for "${identity}". ` +
239
+ "Nothing was recorded locally; release it from the web if it is not yours."
240
+ );
241
+ }
242
+ await writeState(statePath, leaseState(response));
243
+ return response;
244
+ }
245
+
246
+ async function heldLease(statePath) {
247
+ const state = await readState(statePath);
248
+ if (!state || !state.leaseId) {
249
+ throw new Error("This identity holds no lease on that ticket here. Claim it first.");
250
+ }
251
+ return state;
252
+ }
253
+
254
+ export async function renewTicketLease(
255
+ sessionId,
256
+ ticketId,
257
+ { identity, targetPath = process.cwd(), homeDir, resolveAuthSession = resolveActiveAuthSession, requestMutation = requestJsonMutation } = {}
258
+ ) {
259
+ const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
260
+ const held = await heldLease(statePath);
261
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
262
+ const { key } = await operationKey(statePath, "renew", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
263
+ const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/lease/renew`), {
264
+ token, key, operation: "renew", requestMutation, body: { leaseId: held.leaseId, fence: held.fence },
265
+ });
266
+ await writeState(statePath, leaseState(response));
267
+ return response;
268
+ }
269
+
270
+ export async function releaseTicketLease(
271
+ sessionId,
272
+ ticketId,
273
+ { identity, reason = null, targetPath = process.cwd(), homeDir, resolveAuthSession = resolveActiveAuthSession, requestMutation = requestJsonMutation } = {}
274
+ ) {
275
+ const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
276
+ const held = await heldLease(statePath);
277
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
278
+ const { key } = await operationKey(statePath, "release", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
279
+ const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/lease/release`), {
280
+ token, key, operation: "release", requestMutation,
281
+ body: { leaseId: held.leaseId, fence: held.fence, expectedVersion: held.ticketVersion, ...(reason ? { reason } : {}) },
282
+ });
283
+ await fsp.rm(statePath, { force: true });
284
+ return response;
285
+ }
286
+
287
+ export async function submitTicket(
288
+ sessionId,
289
+ ticketId,
290
+ {
291
+ identity,
292
+ implementationSha = null,
293
+ evidence = [],
294
+ targetPath = process.cwd(),
295
+ homeDir,
296
+ resolveAuthSession = resolveActiveAuthSession,
297
+ requestMutation = requestJsonMutation,
298
+ } = {}
299
+ ) {
300
+ const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
301
+ const held = await heldLease(statePath);
302
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
303
+ const { key } = await operationKey(statePath, "submit", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
304
+ const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/submit`), {
305
+ token, key, operation: "submit", requestMutation,
306
+ body: {
307
+ leaseId: held.leaseId,
308
+ fence: held.fence,
309
+ expectedVersion: held.ticketVersion,
310
+ ...(implementationSha ? { implementationSha } : {}),
311
+ evidence,
312
+ },
313
+ });
314
+ await fsp.rm(statePath, { force: true });
315
+ return response;
316
+ }
@@ -38,6 +38,7 @@ export async function pushSessionTitleToApi(
38
38
  timeoutMs = DEFAULT_TITLE_SYNC_TIMEOUT_MS,
39
39
  maxRetries = 1,
40
40
  retryDelayMs = DEFAULT_TITLE_SYNC_RETRY_DELAY_MS,
41
+ autoRotate = true,
41
42
  resolveAuthSession = resolveActiveAuthSession,
42
43
  requestMutation = requestJsonMutation,
43
44
  recordRemoteTitleSync = recordSessionRemoteTitleSync,
@@ -63,7 +64,7 @@ export async function pushSessionTitleToApi(
63
64
  const session = await resolveAuthSession({
64
65
  cwd: targetPath,
65
66
  env,
66
- autoRotate: true,
67
+ autoRotate,
67
68
  });
68
69
  if (!session?.token || !session?.apiUrl) {
69
70
  await recordRemoteTitleSync(normalizedSessionId, {
@@ -64,6 +64,45 @@ function optionalUsdCell(value, hasValue) {
64
64
  return hasValue ? usdCell(value) : "-";
65
65
  }
66
66
 
67
+ function booleanFlag(value) {
68
+ if (typeof value === "boolean") return value;
69
+ if (typeof value === "number" && Number.isFinite(value)) return value !== 0;
70
+ const text = normalize(value).toLowerCase();
71
+ if (!text) return null;
72
+ if (["1", "true", "t", "yes", "y"].includes(text)) return true;
73
+ if (["0", "false", "f", "no", "n"].includes(text)) return false;
74
+ return null;
75
+ }
76
+
77
+ function hasHostedCustomerCost(bag) {
78
+ const camelFlag = booleanFlag(bag.hasCustomerCost);
79
+ if (camelFlag != null) return camelFlag;
80
+ const snakeFlag = booleanFlag(bag.has_customer_cost);
81
+ if (snakeFlag != null) return snakeFlag;
82
+ return [
83
+ bag.customerCostUsd,
84
+ bag.customer_cost_usd,
85
+ bag.billableCostUsd,
86
+ bag.billable_cost_usd,
87
+ ].some((value) => value != null && value !== "");
88
+ }
89
+
90
+ function rollupCostSummary(rollup = {}) {
91
+ const parts = [`provider ${usdCell(rollup.providerCostUsd)}`];
92
+ if (rollup.customerCostUsd != null) {
93
+ parts.push(`customer ${usdCell(rollup.customerCostUsd)}`);
94
+ }
95
+ const unpriced = nonNegativeInt(rollup.unpriced);
96
+ if (unpriced > 0) {
97
+ parts.push(`unpriced ${intCell(unpriced)}`);
98
+ }
99
+ const estimatedEntries = nonNegativeInt(rollup.estimatedEntries);
100
+ if (estimatedEntries > 0) {
101
+ parts.push(`estimated ${intCell(estimatedEntries)}`);
102
+ }
103
+ return parts.join(", ");
104
+ }
105
+
67
106
  function timestampOnly(iso) {
68
107
  if (!iso) return "";
69
108
  const date = new Date(iso);
@@ -122,7 +161,7 @@ function rollupToJson(rollup) {
122
161
 
123
162
  function hostedRollupToJson(rollup, fallbackLabel = "") {
124
163
  const bag = object(rollup);
125
- const hasCustomerCost = Boolean(bag.hasCustomerCost ?? bag.has_customer_cost ?? bag.customerCostUsd != null ?? bag.customer_cost_usd != null);
164
+ const hasCustomerCost = hasHostedCustomerCost(bag);
126
165
  return {
127
166
  label: normalize(bag.label ?? bag.agentId ?? bag.agent_id ?? bag.action ?? fallbackLabel) || "unknown",
128
167
  entries: nonNegativeInt(bag.entries ?? bag.entryCount ?? bag.entry_count ?? bag.count ?? bag.interactions),
@@ -412,7 +451,7 @@ export function renderSessionUsageSummary(report = {}) {
412
451
  lines.push("Per agent:");
413
452
  for (const agent of agents) {
414
453
  lines.push(
415
- `- ${agent.label}: ${intCell(agent.totalTokens)} tokens, ${intCell(agent.entries)} entries, provider ${usdCell(agent.providerCostUsd)}`,
454
+ `- ${agent.label}: ${intCell(agent.totalTokens)} tokens, ${intCell(agent.entries)} entries, ${rollupCostSummary(agent)}`,
416
455
  );
417
456
  }
418
457
  }
@@ -10,13 +10,17 @@
10
10
  // daemon and make this untestable. The thin sentid runtime entrypoint adapts
11
11
  // the real pollSessionEvents to the contract below; tests inject a fake.
12
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
13
+ // At-least-once end to end: when semantic events are present, the fetch cursor
14
+ // advances only to the cursor of the last COMMITTED event (one whose seq the
15
+ // dispatcher actually advanced past). An empty page may advance across a range
16
+ // the server proved contains no visible semantic events; this transport-only
17
+ // progress never advances the persisted committed-sequence cursor. A
15
18
  // retryable/uncommitted failure stops the batch, the fetch cursor stays behind
16
19
  // it, and the next poll re-delivers that event — so a transient wake failure is
17
20
  // retried, never silently skipped.
18
21
 
19
22
  import { readWakeCursor, writeWakeCursor } from "./cursor-store.js";
23
+ import { cursorAdvances } from "../sync-cursor.js";
20
24
 
21
25
  function seqOf(event) {
22
26
  const raw = event?.sequenceId ?? event?.seq ?? event?.payload?.sequenceId;
@@ -98,7 +102,8 @@ export function createWakePump({
98
102
  await ensureSeeded();
99
103
  const { events = [], cursor: latestCursor = null } = (await pollEvents(sessionId, { afterCursor: fetchCursor })) || {};
100
104
  if (!Array.isArray(events) || events.length === 0) {
101
- return { fetchCursor, results: [], idle: true };
105
+ const next = cursorAdvances(latestCursor, fetchCursor) ? latestCursor : fetchCursor;
106
+ return { fetchCursor: next, results: [], idle: true };
102
107
  }
103
108
  const results = await dispatcher.dispatchBatch(events, deps);
104
109
  const committedSeq = dispatcher.getCursor();
@@ -16,8 +16,10 @@
16
16
  // transitive graph (auth/sync/`open`/...) into the wake daemon. The matcher
17
17
  // here is small enough to own.
18
18
 
19
+ import { messageRevision, sessionMessageEditSnapshot, sessionMessageRoutingEvent } from "../message-edits.js";
20
+
19
21
  const BROADCAST_RECIPIENTS = new Set(["*", "all", "broadcast", "everyone", "anyone", "agents", "all-agents"]);
20
- const DEFAULT_WAKE_EVENT_TYPES = new Set(["session_message", "help_request"]);
22
+ const DEFAULT_WAKE_EVENT_TYPES = new Set(["session_message", "help_request", "session_message_edited"]);
21
23
  const MAX_WAKE_MESSAGE_CHARS = 16_000;
22
24
 
23
25
  function requireNonEmptyString(value, label) {
@@ -98,7 +100,7 @@ function defaultFormatMessage(event, agentId) {
98
100
  const author = agentIdOf(event) || "unknown";
99
101
  const text = event?.payload?.message ?? event?.message ?? "";
100
102
  const body = typeof text === "string" ? text : "";
101
- const head = `Senti wake for ${agentId}: new message from ${author}.`;
103
+ const head = `Senti wake for ${agentId}: ${event.event === "session_message_edited" ? "edited message" : "new message"} from ${author}.`;
102
104
  const combined = body ? `${head}\n\n${body}` : head;
103
105
  return combined.length > MAX_WAKE_MESSAGE_CHARS ? combined.slice(0, MAX_WAKE_MESSAGE_CHARS) : combined;
104
106
  }
@@ -128,18 +130,21 @@ export function createResolveTarget({
128
130
  // Only wake on real message events; acks/reactions/views/system are skips.
129
131
  const type = eventTypeOf(event);
130
132
  if (!type || !wakeEventTypes.has(type)) return null;
133
+ if (type === "session_message" && messageRevision(event) > 1) return null;
134
+ if (type === "session_message_edited" && !sessionMessageEditSnapshot(event)) return null;
135
+ const routed = sessionMessageRoutingEvent(event);
131
136
 
132
137
  // No self-wake: never wake the agent on its own message. Normalize both
133
138
  // sides (handles @-prefix / casing / punctuation) so the loop-guard can't
134
139
  // be slipped by a non-canonical author id.
135
- const author = agentIdOf(event);
140
+ const author = agentIdOf(routed);
136
141
  if (author && normalizeComparableId(author) === selfLower) return null;
137
142
 
138
143
  // Routing: directed-to-me / broadcast / untargeted-room wakes us; a message
139
144
  // aimed at a different specific agent is intentionally unroutable.
140
- if (!matchesAgent(event, selfLower)) return null;
145
+ if (!matchesAgent(routed, selfLower)) return null;
141
146
 
142
- return { host: hostName, sessionId: resumeId, message: formatMessage(event, selfId) };
147
+ return { host: hostName, sessionId: resumeId, message: formatMessage({ ...routed, event: type }, selfId) };
143
148
  };
144
149
  }
145
150