sentinelayer-cli 0.41.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 (44) hide show
  1. package/README.md +18 -0
  2. package/package.json +3 -2
  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 +483 -47
  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/index.js +11 -2
  16. package/src/engram/ingest-document.js +107 -0
  17. package/src/engram/store.js +6 -1
  18. package/src/engram/tools.js +6 -0
  19. package/src/events/schema.js +18 -0
  20. package/src/legacy-cli.js +2 -2
  21. package/src/net/test-egress-guard.js +158 -0
  22. package/src/review/investor-dd-orchestrator.js +14 -2
  23. package/src/session/admission-auth.js +312 -0
  24. package/src/session/admission-preimage.js +90 -0
  25. package/src/session/admission.js +620 -0
  26. package/src/session/checkpoints.js +25 -1
  27. package/src/session/codebase-context.js +54 -0
  28. package/src/session/coordination-guidance.js +1 -1
  29. package/src/session/daemon-spawn.js +20 -2
  30. package/src/session/daemon.js +7 -1
  31. package/src/session/event-identity.js +9 -2
  32. package/src/session/first-message.js +11 -3
  33. package/src/session/listener.js +3 -0
  34. package/src/session/live-source.js +47 -16
  35. package/src/session/message-edits.js +89 -0
  36. package/src/session/recap.js +26 -4
  37. package/src/session/remote-hydrate.js +7 -19
  38. package/src/session/store.js +13 -14
  39. package/src/session/stream.js +23 -3
  40. package/src/session/sync.js +128 -11
  41. package/src/session/tickets.js +316 -0
  42. package/src/session/title-sync.js +2 -1
  43. package/src/session/usage-report.js +41 -2
  44. package/src/session/wake/resolve-target.js +10 -5
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads";
4
+
5
+ const CACHE_LIMIT_BYTES = 2 * 1024 * 1024;
6
+ const CACHE_TIMEOUT_MS = 750;
7
+
8
+ // Session creation is not an ingest command. Never recursively walk the cwd:
9
+ // it can be a home directory, a monorepo, or a slow network filesystem.
10
+ // The worker owns only a read; it cannot write metadata after its deadline.
11
+ if (!isMainThread && workerData?.kind === "session-context-cache") {
12
+ let context = {};
13
+ try {
14
+ const cachePath = path.join(workerData.targetPath, ".sentinelayer", "CODEBASE_INGEST.json");
15
+ const stat = await fs.stat(cachePath);
16
+ if (stat.isFile() && stat.size <= CACHE_LIMIT_BYTES) {
17
+ const raw = await fs.readFile(cachePath);
18
+ if (raw.byteLength <= CACHE_LIMIT_BYTES) {
19
+ const parsed = JSON.parse(raw.toString("utf8"));
20
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) context = parsed;
21
+ }
22
+ }
23
+ } catch {
24
+ // Missing, malformed, unreadable or oversized optional context is absent.
25
+ }
26
+ parentPort.postMessage(context);
27
+ }
28
+
29
+ export async function readSessionCodebaseContext(targetPath, { timeoutMs = CACHE_TIMEOUT_MS } = {}) {
30
+ let worker;
31
+ let timer;
32
+ try {
33
+ worker = new Worker(new URL("./codebase-context.js", import.meta.url), {
34
+ workerData: { kind: "session-context-cache", targetPath },
35
+ resourceLimits: { maxOldGenerationSizeMb: 32 },
36
+ stdout: true,
37
+ stderr: true,
38
+ });
39
+ worker.stdout.resume();
40
+ worker.stderr.resume();
41
+ return await new Promise((resolve) => {
42
+ timer = setTimeout(() => resolve({}), timeoutMs);
43
+ worker.once("message", resolve);
44
+ worker.once("error", () => resolve({}));
45
+ worker.once("exit", () => resolve({}));
46
+ });
47
+ } catch {
48
+ return {};
49
+ } finally {
50
+ clearTimeout(timer);
51
+ // Await actual worker termination, not merely a race against its promise.
52
+ if (worker) await worker.terminate();
53
+ }
54
+ }
@@ -7,7 +7,7 @@ export const COORDINATION_ETIQUETTE_ITEMS = Object.freeze([
7
7
  "Claim shared files outside chat before editing with `sl session lock <id> <file> --agent <your-agent-id> --intent \"<intent>\"`; install terminal/editor preflights once with `sl session guard-install <id> --agent <your-agent-id>`, inspect with `sl session locks <id>`, renew long work with `sl session renew`, and release with `sl session unlock`. Lease lifecycle never belongs in chat; guarded terminal/editor denials report the file, holder, intent, and expiry directly to the blocked agent.",
8
8
  "Run one background/secondary listener for replies with `sl session listen --session <id> --agent <your-name> --transport poll --interval 60 --active-interval 60 --emit ndjson --no-presence`; polling applies bounded jitter, exponential transient backoff, and `Retry-After` as a hard floor. `session listen` refuses duplicate local listeners for the same session/agent by default; use `--force` only to stop and replace an existing local owner and `--allow-duplicate` only when you intentionally need parallel wake hooks. `session listen` is only a delivery cursor, not a grounding command; join or recap before acting. For your primary interactive listener, omit `--no-presence` so it renews an ephemeral TTL outside the transcript. Unsupported or degraded presence remains unknown and never falls back to durable heartbeat events. If background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
9
9
  "For long-lived rooms, make sure exactly one visible participant owns the Senti daemon: `sl session daemon --session <id> --recap-interval 300 --checkpoint-interval 60`. Routine room health is a derived status projection, not a transcript event; run `sl session recap now <id> --remote --agent <your-name> --json` for local grounding and use an explicit checkpoint only for a durable semantic handoff.",
10
- "Use message actions for low-noise coordination before posting a new top-level message: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK is useful, `sl session action <id> working_on --target-sequence <n>` for ownership, and `sl session reply <id> <sequence> \"<message>\"` / `sl session comment <id> <sequence> \"<message>\"` for threaded responses. `sl session read <id> --remote --agent <your-name>` advances one monotonic per-agent read cursor for the displayed window; it never appends per-message view events; reserve `sl session view <id> <sequence>` for repair/backfill of that same cursor. Run `sl session actions` for the full list.",
10
+ "Use message actions for low-noise coordination before posting a new top-level message: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK is useful, `sl session action <id> working_on --target-sequence <n>` for ownership, and `sl session reply <id> <sequence> \"<message>\"` / `sl session comment <id> <sequence> \"<message>\"` for threaded responses. `sl session read <id> --remote --agent <your-name>` advances one monotonic per-agent delivery cursor for the displayed window; it never appends per-message view events or proves human viewing. Correct your own message with `sl session edit <id> <sequence-or-reply-uuid> \"<replacement>\"`; edits retain audit history and notify recipients. Run `sl session actions` for the full list.",
11
11
  "Search before asking peers to restate context: `sl session search <id> \"<topic>\" --limit 10`.",
12
12
  "Run `sl review --diff` after each finished file or PR-ready diff and post the result summary back to the session.",
13
13
  "Post findings through `sl session say <id> \"finding: [P2] <title> in <file>:<line>\"` with enough context for a peer to act.",
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
+ import { fileURLToPath } from "node:url";
5
6
 
6
7
  import { resolveSessionPaths } from "./paths.js";
7
8
  import {
@@ -116,9 +117,22 @@ export async function getDaemonStatus(sessionId, { targetPath = process.cwd() }
116
117
  };
117
118
  }
118
119
 
120
+ // The daemon is ALWAYS this package's own CLI entry, never "whatever script is running".
121
+ // It used to be process.argv[1]: under a test runner that is the TEST FILE, so the "daemon"
122
+ // re-ran the tests, and every bootstrapped session spawned more detached copies of them.
123
+ // On 2026-09-29 one such run became a detached fork bomb that took the machine down and
124
+ // created thousands of sessions on the API.
125
+ const CLI_ENTRY_PATH = fileURLToPath(new URL("../../bin/sl.js", import.meta.url));
126
+
119
127
  export function resolveCliEntryPath() {
120
- const entry = normalizeString(process.argv[1]);
121
- return entry ? path.resolve(entry) : "";
128
+ return CLI_ENTRY_PATH;
129
+ }
130
+
131
+ // Under the node test runner (it sets NODE_TEST_CONTEXT in every test process), a detached
132
+ // daemon outlives the test that started it. One is spawned only when the test names the
133
+ // entry itself (cliPath), deliberately; never as a side effect of code under test.
134
+ function underTestRunner() {
135
+ return normalizeString(process.env.NODE_TEST_CONTEXT) !== "";
122
136
  }
123
137
 
124
138
  /**
@@ -156,6 +170,10 @@ export async function spawnDetachedSentiDaemon({
156
170
  };
157
171
  }
158
172
 
173
+ if (!normalizeString(cliPath) && underTestRunner()) {
174
+ return { spawned: false, pid: null, reason: "test_runner", logPath: "" };
175
+ }
176
+
159
177
  const entryPath = normalizeString(cliPath) || resolveCliEntryPath();
160
178
  if (!entryPath) {
161
179
  return { spawned: false, pid: null, reason: "cli_entry_unresolved", logPath: "" };
@@ -783,7 +783,7 @@ async function runSessionDirectiveWatcher(daemonState) {
783
783
  if (!daemonState.running) {
784
784
  return;
785
785
  }
786
- if (normalizeString(event.event) !== "session_message") {
786
+ if (normalizeString(event.event) !== "session_message" || Number(event.messageRevision || 1) > 1) {
787
787
  continue;
788
788
  }
789
789
  await maybeHandleTaskDirective(daemonState, event);
@@ -1280,6 +1280,12 @@ async function maybeGenerateSessionCheckpoint(
1280
1280
  targetPath: daemonState.targetPath,
1281
1281
  minEvents: daemonState.checkpointMinEvents,
1282
1282
  maxEvents: daemonState.checkpointMaxEvents,
1283
+ // origin is the AUTHORITATIVE field (lane 2): this daemon is a process an agent
1284
+ // started, so its cuts are origin:'agent' — never the managed service, even though
1285
+ // #800's humble default would already resolve to agent. Declaring it explicitly means
1286
+ // the daemon's honesty does not silently ride on that default. createdBy stays the
1287
+ // descriptive "senti" persona; origin:'agent' is what a DoD/audit keys on.
1288
+ origin: "agent",
1283
1289
  createdByAgentId: SENTI_IDENTITY.id,
1284
1290
  nowIso: normalizedNow,
1285
1291
  });
@@ -25,6 +25,9 @@ export function sessionEventHasDurableCursor(event = {}) {
25
25
  }
26
26
 
27
27
  export function sessionEventUpgradesExisting(existingEvent = {}, candidateEvent = {}) {
28
+ const existingRevision = Number(existingEvent.messageRevision ?? 1);
29
+ const candidateRevision = Number(candidateEvent.messageRevision ?? 1);
30
+ if (candidateRevision !== existingRevision) return candidateRevision > existingRevision;
28
31
  const existingSequence = readSessionEventSequence(existingEvent);
29
32
  const candidateSequence = readSessionEventSequence(candidateEvent);
30
33
  if (candidateSequence > 0 && existingSequence <= 0) {
@@ -85,7 +88,10 @@ function mergeObjectPreferringPrimary(primaryValue, secondaryValue) {
85
88
  function mergeDuplicateEvent(existingEvent = {}, candidateEvent = {}) {
86
89
  const existingScore = eventDurabilityScore(existingEvent);
87
90
  const candidateScore = eventDurabilityScore(candidateEvent);
88
- const candidateIsPrimary = candidateScore > existingScore || (
91
+ const existingRevision = Number(existingEvent.messageRevision ?? 1);
92
+ const candidateRevision = Number(candidateEvent.messageRevision ?? 1);
93
+ const differentRevision = existingRevision !== candidateRevision;
94
+ const candidateIsPrimary = differentRevision ? candidateRevision > existingRevision : candidateScore > existingScore || (
89
95
  candidateScore === existingScore &&
90
96
  sessionEventUpgradesExisting(existingEvent, candidateEvent)
91
97
  ) || (
@@ -99,7 +105,8 @@ function mergeDuplicateEvent(existingEvent = {}, candidateEvent = {}) {
99
105
  ...secondaryEvent,
100
106
  ...primaryEvent,
101
107
  agent: mergeObjectPreferringPrimary(primaryEvent.agent, secondaryEvent.agent),
102
- payload: mergeObjectPreferringPrimary(primaryEvent.payload, secondaryEvent.payload),
108
+ // Revision snapshots replace, rather than merge, the body/routing fields.
109
+ payload: differentRevision ? primaryEvent.payload : mergeObjectPreferringPrimary(primaryEvent.payload, secondaryEvent.payload),
103
110
  };
104
111
  }
105
112
 
@@ -1,5 +1,6 @@
1
1
  import { createAgentEvent } from "../events/schema.js";
2
2
  import { appendToStream } from "./stream.js";
3
+ import { syncSessionEventToApi } from "./sync.js";
3
4
 
4
5
  export const FIRST_MESSAGE_AGENT = Object.freeze({
5
6
  id: "senti",
@@ -72,7 +73,7 @@ export function buildFirstSentiMessage({ sessionId } = {}) {
72
73
 
73
74
  /**
74
75
  * 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
+ * session. Best-effort with bounded remote sync — a failure never fails session
76
77
  * creation. Returns { posted, reason }.
77
78
  */
78
79
  export async function postFirstSentiMessage({ sessionId, targetPath = process.cwd() } = {}) {
@@ -91,8 +92,15 @@ export async function postFirstSentiMessage({ sessionId, targetPath = process.cw
91
92
  },
92
93
  });
93
94
  try {
94
- await appendToStream(sid, event, { targetPath, awaitRemoteSync: true });
95
- return { posted: true, reason: "posted" };
95
+ const persisted = await appendToStream(sid, event, {
96
+ targetPath, syncRemote: false, lockTimeoutMs: 500,
97
+ });
98
+ const remoteSync = await syncSessionEventToApi(sid, persisted, {
99
+ targetPath, timeoutMs: 2_000,
100
+ }).catch(() => ({ synced: false, reason: "sync_failed" }));
101
+ // Keep posted's existing local-durability meaning; remote acknowledgement
102
+ // is explicit and must never be inferred from this compatibility field.
103
+ return { posted: true, reason: "posted", remoteSynced: Boolean(remoteSync.synced), remoteSync };
96
104
  } catch (error) {
97
105
  return { posted: false, reason: normalizeString(error?.message) || "append_failed" };
98
106
  }
@@ -8,6 +8,7 @@ import {
8
8
  } from "./sync.js";
9
9
  import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
10
10
  import { isSessionListenerLifecycleEvent } from "./control-events.js";
11
+ import { sessionMessageRoutingEvent } from "./message-edits.js";
11
12
 
12
13
  const BROADCAST_RECIPIENTS = new Set([
13
14
  "*",
@@ -68,6 +69,7 @@ function addRecipientValue(values, value) {
68
69
  }
69
70
 
70
71
  export function collectSessionEventRecipients(event = {}) {
72
+ event = sessionMessageRoutingEvent(event);
71
73
  const values = [];
72
74
  if (!isPlainObject(event)) return values;
73
75
  const payload = isPlainObject(event.payload) ? event.payload : {};
@@ -90,6 +92,7 @@ export function collectSessionEventRecipients(event = {}) {
90
92
 
91
93
  export function eventMatchesAgent(event = {}, agentId = "") {
92
94
  if (!isPlainObject(event)) return false;
95
+ event = sessionMessageRoutingEvent(event);
93
96
  const normalizedAgentId = normalizeComparableId(agentId);
94
97
  if (!normalizedAgentId) return false;
95
98
 
@@ -21,10 +21,10 @@ import { setTimeout as sleep } from "node:timers/promises";
21
21
  import { resolveSessionPaths } from "./paths.js";
22
22
  import { readStream } from "./stream.js";
23
23
  import {
24
- addSessionEventIdentityKeys,
25
24
  dedupeSessionEvents,
26
- sessionEventHasKnownIdentity,
25
+ readSessionEventSequence,
27
26
  sessionEventIdentityKeys,
27
+ sessionEventUpgradesExisting,
28
28
  } from "./event-identity.js";
29
29
 
30
30
  const DEFAULT_RECONNECT_BACKOFF_MS = 2_000;
@@ -61,14 +61,21 @@ export async function* watchLocalStream({
61
61
  } = {}) {
62
62
  if (!sessionId) return;
63
63
  const paths = resolveSessionPaths(sessionId, { targetPath });
64
- let lastTs = null;
64
+ let observed = new Map();
65
+ const observe = (event, previous = observed) => {
66
+ const keys = sessionEventIdentityKeys(event);
67
+ const existing = keys.map((key) => previous.get(key)).find(Boolean);
68
+ const changed = !existing || sessionEventUpgradesExisting(existing, event);
69
+ for (const key of keys) observed.set(key, changed ? event : existing);
70
+ return changed;
71
+ };
65
72
 
66
73
  // Replay the tail first so any caller getting the iterator catches
67
74
  // up with the in-flight context before live events start arriving.
68
- const initial = dedupeSessionEvents(await _readEvents(sessionId, { targetPath, tail: initialTail }));
75
+ const baseline = dedupeSessionEvents(await _readEvents(sessionId, { targetPath, tail: 0 }));
76
+ for (const event of baseline) observe(event);
77
+ const initial = Number(initialTail) > 0 ? baseline.slice(-Math.floor(Number(initialTail))) : baseline;
69
78
  for (const event of initial) {
70
- const candidate = event.ts || event.timestamp;
71
- if (candidate) lastTs = candidate;
72
79
  yield { source: "fs", event };
73
80
  }
74
81
 
@@ -114,11 +121,15 @@ export async function* watchLocalStream({
114
121
  await pendingPromise;
115
122
  if (aborted()) break;
116
123
 
117
- const events = await _readEvents(sessionId, { targetPath, tail: 0, since: lastTs });
124
+ // Revisions retain the original timestamp. Identity + monotonic revision,
125
+ // not wall time, determines whether a local projection is new.
126
+ const events = dedupeSessionEvents(await _readEvents(sessionId, { targetPath, tail: 0 }));
127
+ // The NDJSON stream rotates at its configured event cap. Retire identities
128
+ // no longer in that file instead of accumulating a daemon-lifetime cache.
129
+ const previous = observed;
130
+ observed = new Map();
118
131
  for (const event of events) {
119
- const candidate = event.ts || event.timestamp;
120
- if (lastTs && candidate && candidate <= lastTs) continue;
121
- if (candidate) lastTs = candidate;
132
+ if (!observe(event, previous)) continue;
122
133
  queue.push({ source: "fs", event });
123
134
  }
124
135
  while (queue.length > 0) {
@@ -154,11 +165,24 @@ export async function* watchRemoteStream({
154
165
  sessionId,
155
166
  )}/stream`;
156
167
  let backoff = reconnectBackoffMs;
168
+ // Highest server sequence actually DELIVERED to the consumer. SSE is a live push with no
169
+ // backlog: reconnecting without it silently drops everything published while we were away,
170
+ // and a listener that missed messages looks exactly like a quiet room. This is what makes an
171
+ // agent "go dark".
172
+ let resumeSequence = 0;
157
173
 
158
174
  while (!signal?.aborted) {
159
175
  let response;
176
+ // Resume strictly after the last delivered event. The server filter is exclusive
177
+ // (`sequence_id > from_sequence`), so this replays nothing and skips nothing. The first
178
+ // connection sends no cursor and starts live, which is the behaviour attaching to a room
179
+ // should have -- resume only ever applies to a RECONNECT.
180
+ const url =
181
+ resumeSequence > 0
182
+ ? `${endpoint}?fromSequence=${encodeURIComponent(String(resumeSequence))}`
183
+ : endpoint;
160
184
  try {
161
- response = await _sseFetch(endpoint, {
185
+ response = await _sseFetch(url, {
162
186
  method: "GET",
163
187
  headers: {
164
188
  Authorization: `Bearer ${token}`,
@@ -201,6 +225,11 @@ export async function* watchRemoteStream({
201
225
  try {
202
226
  const event = JSON.parse(payload);
203
227
  yield { source: "sse", event };
228
+ // Advance only AFTER the consumer has taken the event. If the generator is closed
229
+ // mid-yield we resume one early and the merger dedups the repeat -- a duplicate is
230
+ // recoverable, a gap is not.
231
+ const sequence = readSessionEventSequence(event);
232
+ if (sequence > resumeSequence) resumeSequence = sequence;
204
233
  } catch {
205
234
  yield { source: "sse", raw: payload };
206
235
  }
@@ -255,7 +284,7 @@ export async function* mergeLiveSources({
255
284
  : watchRemoteStream({ apiBaseUrl, sessionId, token, signal })
256
285
  : null;
257
286
 
258
- const seen = new Set();
287
+ const seen = new Map();
259
288
  const queue = [];
260
289
  let pending = null;
261
290
 
@@ -301,14 +330,16 @@ export async function* mergeLiveSources({
301
330
  if (item.event) {
302
331
  const key = eventKey(item.event);
303
332
  if (key) {
304
- if (sessionEventHasKnownIdentity(item.event, seen) || seen.has(key)) continue;
305
- addSessionEventIdentityKeys(seen, item.event);
306
- seen.add(key);
333
+ const keys = sessionEventIdentityKeys(item.event);
334
+ const existing = keys.map((identity) => seen.get(identity)).find(Boolean) || seen.get(key);
335
+ if (existing && !sessionEventUpgradesExisting(existing, item.event)) continue;
336
+ for (const identity of keys) seen.set(identity, item.event);
337
+ seen.set(key, item.event);
307
338
  if (seen.size > 5000) {
308
339
  // bound memory — older keys roll out
309
340
  const trimmed = Array.from(seen).slice(-2500);
310
341
  seen.clear();
311
- for (const k of trimmed) seen.add(k);
342
+ for (const [identity, event] of trimmed) seen.set(identity, event);
312
343
  }
313
344
  }
314
345
  }
@@ -0,0 +1,89 @@
1
+ import { createAgentEvent } from "../events/schema.js";
2
+ import { enrichEventWithMentions } from "./mentions.js";
3
+ import { sessionEventIdentityKeys } from "./event-identity.js";
4
+
5
+ export function messageRevision(value = {}) {
6
+ const revision = Number(value.messageRevision ?? value.message_revision ?? 1);
7
+ return Number.isSafeInteger(revision) && revision > 0 ? revision : 1;
8
+ }
9
+
10
+ export function replyRevisionEvent(sessionId, reply = {}) {
11
+ if (!reply.id || (reply.actionType ?? reply.action_type) !== "reply") return null;
12
+ const sequence = reply.targetSequenceId ?? reply.target_sequence_id ?? null;
13
+ const target = sequence ? `#${sequence}` : reply.targetCursor || "target";
14
+ const note = String(reply.note ?? "");
15
+ return createAgentEvent({
16
+ event: "session_reply",
17
+ agentId: reply.actorId ?? reply.actor_id ?? "unknown",
18
+ agentModel: reply.actorKind ?? reply.actor_kind ?? "session-action",
19
+ sessionId,
20
+ eventId: `session-action-${reply.id}`,
21
+ cursor: `action:${reply.id}`,
22
+ ts: reply.createdAt ?? reply.created_at,
23
+ messageRevision: messageRevision(reply),
24
+ editedAt: reply.editedAt,
25
+ editedBy: reply.editedBy,
26
+ canEdit: reply.canEdit,
27
+ revisionEvidence: reply.revisionEvidence,
28
+ payload: {
29
+ actionId: reply.id,
30
+ actionType: "reply",
31
+ targetSequenceId: sequence,
32
+ targetCursor: reply.targetCursor || null,
33
+ targetActionId: reply.targetActionId || null,
34
+ note,
35
+ metadata: reply.metadata && typeof reply.metadata === "object" ? reply.metadata : undefined,
36
+ message: `reply ${target}: ${note}`,
37
+ source: "session_action",
38
+ },
39
+ });
40
+ }
41
+
42
+ // Edits are immutable notifications with their own sequence/identity. Only the
43
+ // nested snapshot contributes to the CURRENT body; it is never replayed as a
44
+ // new authored message or task directive.
45
+ export function sessionMessageEditSnapshot(event = {}) {
46
+ if (event.event !== "session_message_edited" || event.payload?.schema !== "session-message-edit:v1") return null;
47
+ const payload = event.payload;
48
+ if (payload.targetActionId) {
49
+ const reply = payload.reply;
50
+ if (reply?.id !== payload.targetActionId || messageRevision(reply) !== messageRevision(payload)) return null;
51
+ return replyRevisionEvent(event.sessionId, reply);
52
+ }
53
+ const snapshot = payload.event;
54
+ if (!snapshot?.id || snapshot.id !== payload.targetMessageId || messageRevision(snapshot) !== messageRevision(payload)) return null;
55
+ return snapshot;
56
+ }
57
+
58
+ export function projectSessionMessageEdits(events = []) {
59
+ const snapshots = new Map();
60
+ for (const event of events) {
61
+ const snapshot = sessionMessageEditSnapshot(event) || (["session_message", "session_reply"].includes(event.event) ? event : null);
62
+ if (!snapshot) continue;
63
+ for (const key of sessionEventIdentityKeys(snapshot)) {
64
+ if (!snapshots.has(key) || messageRevision(snapshot) > messageRevision(snapshots.get(key))) snapshots.set(key, snapshot);
65
+ }
66
+ }
67
+ return events.map((event) => {
68
+ if (event.event === "session_message_edited") return event;
69
+ const snapshot = sessionEventIdentityKeys(event).map((key) => snapshots.get(key)).filter(Boolean)
70
+ .sort((left, right) => messageRevision(right) - messageRevision(left))[0];
71
+ return snapshot && messageRevision(snapshot) > messageRevision(event) ? { ...event, ...snapshot } : event;
72
+ });
73
+ }
74
+
75
+ // Routing consumes the immutable revision snapshot, but delivery/dedupe always
76
+ // consumes the notification's own identity. Reply edits retain parent routing.
77
+ export function sessionMessageRoutingEvent(event = {}) {
78
+ const snapshot = sessionMessageEditSnapshot(event);
79
+ if (!snapshot) return event;
80
+ const parentPayload = event.payload?.event?.payload || {};
81
+ const payload = { ...parentPayload, ...snapshot.payload };
82
+ if (event.payload?.reply) payload.message = String(event.payload.reply.note ?? "");
83
+ for (const key of ["to", "recipient", "recipients", "targetAgent", "targetAgentId", "broadcast", "mentions"]) {
84
+ if (event.payload[key] !== undefined) payload[key] = event.payload[key];
85
+ }
86
+ const routed = enrichEventWithMentions({ ...snapshot, agent: event.agent || snapshot.agent, payload });
87
+ if (routed.payload?.mentions?.broadcast?.length) routed.payload.broadcast = true;
88
+ return routed;
89
+ }
@@ -923,7 +923,10 @@ function buildRecapText({
923
923
  const lockText = `${activeLocks} file lock${activeLocks === 1 ? "" : "s"} active`;
924
924
  const pendingText =
925
925
  pendingTasks > 0 ? `You have ${pendingTasks} pending task${pendingTasks === 1 ? "" : "s"}.` : "";
926
- const taskText = buildTaskLedgerText(taskLedger);
926
+ const taskText = buildTaskLedgerText(taskLedger, {
927
+ liveListenerCount: liveListeners.length,
928
+ recentActorCount: recentActors.length,
929
+ });
927
930
  const usageText = buildUsageLedgerText(usageSummary);
928
931
  const workPlanText = buildWorkPlanText(workPlan);
929
932
  const snippetText = snippets.length > 0 ? `Recent: ${snippets.join(" | ")}` : "";
@@ -933,9 +936,20 @@ function buildRecapText({
933
936
  ).trim();
934
937
  }
935
938
 
936
- function buildTaskLedgerText(taskLedger = emptyTaskLedgerSummary()) {
939
+ function buildTaskLedgerText(
940
+ taskLedger = emptyTaskLedgerSummary(),
941
+ { liveListenerCount = 0, recentActorCount = 0 } = {},
942
+ ) {
937
943
  const total = Number(taskLedger.total || 0);
938
944
  if (!total) {
945
+ const listeners = Math.max(0, Math.floor(Number(liveListenerCount || 0)));
946
+ const actors = Math.max(0, Math.floor(Number(recentActorCount || 0)));
947
+ if (listeners > 0) {
948
+ return `Session tasks: none queued (${listeners} live listener${listeners === 1 ? "" : "s"}; no Senti task assignments recorded)`;
949
+ }
950
+ if (actors > 0) {
951
+ return `Session tasks: none queued (${actors} recent actor${actors === 1 ? "" : "s"}; no Senti task assignments recorded)`;
952
+ }
939
953
  return "Session tasks: none queued";
940
954
  }
941
955
  const active = Number(taskLedger.active || 0);
@@ -1112,7 +1126,7 @@ const AGENT_JOIN_RULES = [
1112
1126
  "",
1113
1127
  "**Writing back** — You can use **markdown**: bold, italic, lists, fenced code, and `inline code`. The web dashboard renders it. Plain text also works. Keep posts terse and technical — link to the work, don't recap it.",
1114
1128
  "",
1115
- "**Actions and threading** — Use message actions instead of top-level ACK chatter: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK matters, and `sl session action <id> working_on --target-sequence <n>` for ownership. `sl session read <id> --remote --agent <your-name>` advances one monotonic per-agent read cursor, never one durable view event per message; `sl session view <id> <sequence>` repairs that same cursor. Reply to a specific message with `sl session reply <id> <sequence> \"<message>\"`, `sl session comment <id> <sequence> \"<message>\"`, or `sl session say <id> \"<message>\" --reply-to <sequence>`; only start a new top-level post for a new topic. Run `sl session actions` for the full list.",
1129
+ "**Actions and threading** — Use message actions instead of top-level ACK chatter: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK matters, and `sl session action <id> working_on --target-sequence <n>` for ownership. `sl session read <id> --remote --agent <your-name>` advances one monotonic per-agent delivery cursor, never a durable view event or proof of human viewing. Reply to a specific message with `sl session reply <id> <sequence> \"<message>\"`, `sl session comment <id> <sequence> \"<message>\"`, or `sl session say <id> \"<message>\" --reply-to <sequence>`; correct your own message with `sl session edit <id> <sequence-or-reply-uuid> \"<replacement>\"`. Edits retain audit history and notify recipients; only start a new top-level post for a new topic. Run `sl session actions` for the full list.",
1116
1130
  "",
1117
1131
  "**Search before asking** — Use `sl session search <id> \"<topic>\" --limit 10` to recover old context before asking another agent to re-paste or summarize what is already in the transcript.",
1118
1132
  "",
@@ -1146,7 +1160,10 @@ function buildPeriodicText(recap = {}) {
1146
1160
  ? "no live listeners"
1147
1161
  : "listener status unknown";
1148
1162
  const recentActorText = `${recentActors} recent actor${recentActors === 1 ? "" : "s"}`;
1149
- const taskText = buildTaskLedgerText(summary.taskLedger);
1163
+ const taskText = buildTaskLedgerText(summary.taskLedger, {
1164
+ liveListenerCount: liveListeners,
1165
+ recentActorCount: recentActors,
1166
+ });
1150
1167
  const workPlanText = buildWorkPlanText(summary.workPlan);
1151
1168
  const usageText = buildUsageLedgerText({
1152
1169
  totals: summary.usageTotals,
@@ -1216,6 +1233,7 @@ export async function buildSessionRecap(
1216
1233
  );
1217
1234
  const liveListenerIds = liveListenerRows.map((row) => row.agentId).filter(Boolean);
1218
1235
 
1236
+ const selectedSourceEvents = events.filter(isMeaningfulRecapSourceEvent);
1219
1237
  const actorEvents = visibleEvents.filter(isMeaningfulRecapSourceEvent);
1220
1238
  const recentActorSet = new Set();
1221
1239
  for (const event of actorEvents) {
@@ -1294,6 +1312,10 @@ export async function buildSessionRecap(
1294
1312
  usageTotals: usageSummary.totals,
1295
1313
  usageTopAgents: usageSummary.topAgents,
1296
1314
  snippets,
1315
+ selectedEventCount: events.length,
1316
+ selectedSourceEventCount: selectedSourceEvents.length,
1317
+ visibleSourceEventCount: actorEvents.length,
1318
+ ignoredOperationalEventCount: Math.max(0, events.length - selectedSourceEvents.length),
1297
1319
  elapsedMinutes,
1298
1320
  windowElapsedMinutes,
1299
1321
  sessionStartedAt: sessionMetadata?.createdAt
@@ -32,8 +32,10 @@ import {
32
32
  sessionEventIdentityKeys,
33
33
  sessionEventHasKnownIdentity,
34
34
  sessionEventUpgradesExisting,
35
+ dedupeSessionEvents,
35
36
  } from "./event-identity.js";
36
37
  import { isSessionControlEvent } from "./control-events.js";
38
+ import { sessionMessageEditSnapshot } from "./message-edits.js";
37
39
 
38
40
  const EVENTS_CURSOR_SUFFIX = "events";
39
41
  const DEFAULT_EVENT_PAGE_LIMIT = 200;
@@ -370,25 +372,11 @@ export async function hydrateSessionFromRemote({
370
372
 
371
373
  // Dedup across sources — both endpoints can return the same event
372
374
  // (e.g. a human relay event). Cursor values are unique per event.
373
- const seenCursors = new Set();
374
- const seenKeys = new Set();
375
- const merged = [];
376
- for (const e of humanResult?.events || []) {
377
- const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
378
- if (c && seenCursors.has(c)) continue;
379
- if (sessionEventHasKnownIdentity(e, seenKeys)) continue;
380
- if (c) seenCursors.add(c);
381
- addSessionEventIdentityKeys(seenKeys, e);
382
- merged.push(e);
383
- }
384
- for (const e of eventsResult?.events || []) {
385
- const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
386
- if (c && seenCursors.has(c)) continue;
387
- if (sessionEventHasKnownIdentity(e, seenKeys)) continue;
388
- if (c) seenCursors.add(c);
389
- addSessionEventIdentityKeys(seenKeys, e);
390
- merged.push(e);
391
- }
375
+ const sourceEvents = [...(humanResult?.events || []), ...(eventsResult?.events || [])];
376
+ const merged = dedupeSessionEvents(sourceEvents.flatMap((event) => {
377
+ const snapshot = sessionMessageEditSnapshot(event);
378
+ return snapshot ? [snapshot, event] : [event];
379
+ }));
392
380
 
393
381
  // If BOTH pollers failed, surface the human-message failure (the
394
382
  // legacy contract) so existing callers see no behavior change. If
@@ -4,10 +4,10 @@ import path from "node:path";
4
4
  import process from "node:process";
5
5
 
6
6
  import { buildArtifactLineageIndex, verifyArtifactChain } from "../daemon/artifact-lineage.js";
7
- import { collectCodebaseIngest } from "../ingest/engine.js";
7
+ import { readSessionCodebaseContext } from "./codebase-context.js";
8
8
  import { computeSessionAnalytics } from "./analytics.js";
9
9
  import { resolveSessionPaths, resolveSessionsRoot } from "./paths.js";
10
- import { appendToStream } from "./stream.js";
10
+ import { appendToStream, withSessionStreamLock } from "./stream.js";
11
11
 
12
12
  const SESSION_SCHEMA_VERSION = "1.0.0";
13
13
  const DEFAULT_TTL_SECONDS = 24 * 60 * 60;
@@ -247,13 +247,7 @@ function normalizeSessionTemplate(raw = null) {
247
247
  }
248
248
 
249
249
  async function collectSessionCodebaseContext(targetPath) {
250
- const cachedIngestPath = path.join(targetPath, ".sentinelayer", "CODEBASE_INGEST.json");
251
- const cachedIngest = await readJsonFile(cachedIngestPath, { allowMissing: true });
252
- if (cachedIngest && typeof cachedIngest === "object") {
253
- return normalizeCodebaseContext(cachedIngest);
254
- }
255
- const ingest = await collectCodebaseIngest({ rootPath: targetPath });
256
- return normalizeCodebaseContext(ingest);
250
+ return normalizeCodebaseContext(await readSessionCodebaseContext(targetPath));
257
251
  }
258
252
 
259
253
  async function buildArchiveSidecars(
@@ -507,11 +501,16 @@ export async function createSession({
507
501
  }
508
502
  );
509
503
 
510
- await fsp.mkdir(paths.agentsDir, { recursive: true });
511
- await saveMetadata(metadata, paths);
512
- await fsp.writeFile(paths.streamPath, "", { encoding: "utf-8", flag: "a" });
513
-
514
- return buildSessionPayload(metadata, paths, nowIso);
504
+ return withSessionStreamLock(sessionId, async () => {
505
+ // A concurrent materializer may have won while optional context was read.
506
+ // Never reset its title/TTL/status or race its first billing append.
507
+ const existing = await loadMetadata(sessionId, { targetPath: resolvedTargetPath });
508
+ if (existing) return buildSessionPayload(existing.metadata, paths, nowIso);
509
+ await fsp.mkdir(paths.agentsDir, { recursive: true });
510
+ await saveMetadata(metadata, paths);
511
+ await fsp.writeFile(paths.streamPath, "", { encoding: "utf-8", flag: "a" });
512
+ return buildSessionPayload(metadata, paths, nowIso);
513
+ }, { targetPath: resolvedTargetPath });
515
514
  }
516
515
 
517
516
  export async function updateSessionTitle(