sentinelayer-cli 0.8.12 → 0.9.2

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 (37) hide show
  1. package/package.json +7 -2
  2. package/src/agents/backend/tools/timeout-audit.js +33 -17
  3. package/src/agents/devtestbot/config/definition.js +100 -0
  4. package/src/agents/devtestbot/config/system-prompt.js +92 -0
  5. package/src/agents/devtestbot/index.js +9 -0
  6. package/src/agents/devtestbot/runner.js +775 -0
  7. package/src/agents/devtestbot/tool.js +707 -0
  8. package/src/commands/legacy-args.js +4 -0
  9. package/src/commands/omargate.js +4 -0
  10. package/src/commands/session.js +960 -159
  11. package/src/commands/swarm.js +11 -2
  12. package/src/guide/generator.js +14 -0
  13. package/src/legacy-cli.js +35 -18
  14. package/src/prompt/generator.js +4 -16
  15. package/src/review/ai-review.js +95 -6
  16. package/src/review/dd-report-email-client.js +148 -0
  17. package/src/review/investor-dd-devtestbot.js +599 -0
  18. package/src/review/investor-dd-orchestrator.js +135 -3
  19. package/src/review/omargate-orchestrator.js +20 -2
  20. package/src/review/persona-prompts.js +34 -1
  21. package/src/review/report.js +61 -2
  22. package/src/scan/generator.js +1 -1
  23. package/src/session/coordination-guidance.js +49 -0
  24. package/src/session/daemon.js +3 -2
  25. package/src/session/event-identity.js +139 -0
  26. package/src/session/listener.js +330 -0
  27. package/src/session/live-source.js +11 -2
  28. package/src/session/mentions.js +130 -0
  29. package/src/session/remote-hydrate.js +223 -8
  30. package/src/session/setup-guides.js +3 -15
  31. package/src/session/store.js +117 -5
  32. package/src/session/stream.js +17 -7
  33. package/src/session/sync.js +375 -26
  34. package/src/session/title-sync.js +107 -0
  35. package/src/spec/generator.js +8 -10
  36. package/src/swarm/registry.js +20 -0
  37. package/src/swarm/runtime.js +139 -1
@@ -18,11 +18,159 @@
18
18
  * sync, and vice-versa.
19
19
  */
20
20
 
21
- import { pollHumanMessages, pollSessionEvents } from "./sync.js";
22
- import { appendToStream } from "./stream.js";
21
+ import { listSessionsFromApi, pollHumanMessages, pollSessionEvents } from "./sync.js";
22
+ import { appendToStream, readStream } from "./stream.js";
23
+ import { createSession, getSession } from "./store.js";
23
24
  import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
25
+ import {
26
+ addSessionEventIdentityKeys,
27
+ sessionEventHasKnownIdentity,
28
+ } from "./event-identity.js";
24
29
 
25
30
  const EVENTS_CURSOR_SUFFIX = "events";
31
+ const DEFAULT_EVENT_PAGE_LIMIT = 200;
32
+ const DEFAULT_MAX_EVENT_PAGES = 25;
33
+
34
+ async function readExistingRelayKeys(sessionId, { targetPath = process.cwd() } = {}) {
35
+ const knownKeys = new Set();
36
+ const events = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
37
+ for (const event of events) {
38
+ addSessionEventIdentityKeys(knownKeys, event);
39
+ }
40
+ return knownKeys;
41
+ }
42
+
43
+ async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
44
+ const existing = await getSession(sessionId, { targetPath });
45
+ if (existing) {
46
+ return { materialized: false, session: existing };
47
+ }
48
+ let remoteStatus = "";
49
+ const remoteList = await listSessionsFromApi({
50
+ targetPath,
51
+ includeArchived: true,
52
+ limit: 200,
53
+ }).catch(() => null);
54
+ if (remoteList?.ok) {
55
+ const match = (remoteList.sessions || []).find((entry) => entry?.sessionId === sessionId);
56
+ remoteStatus = String(match?.archiveStatus || match?.status || "").trim().toLowerCase();
57
+ }
58
+ const created = await createSession({
59
+ targetPath,
60
+ sessionId,
61
+ title: `remote-${String(sessionId).slice(0, 8)}`,
62
+ });
63
+ return { materialized: true, session: created, remoteStatus };
64
+ }
65
+
66
+ function sourceFullyRelayed(events = [], successfulKeys = new Set()) {
67
+ const relayedEvents = Array.isArray(events) ? events : [];
68
+ if (relayedEvents.length === 0) return true;
69
+ return relayedEvents.every((event) => sessionEventHasKnownIdentity(event, successfulKeys));
70
+ }
71
+
72
+ function markPostKillEvent(event = {}) {
73
+ if (!event || typeof event !== "object" || Array.isArray(event)) return event;
74
+ const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
75
+ ? event.payload
76
+ : {};
77
+ return {
78
+ ...event,
79
+ _post_kill: true,
80
+ payload: {
81
+ ...payload,
82
+ _post_kill: true,
83
+ },
84
+ };
85
+ }
86
+
87
+ function normalizePositiveInteger(value, fallbackValue) {
88
+ if (value === undefined || value === null || String(value).trim() === "") {
89
+ return fallbackValue;
90
+ }
91
+ const normalized = Number(value);
92
+ if (!Number.isFinite(normalized) || normalized <= 0) {
93
+ return fallbackValue;
94
+ }
95
+ return Math.floor(normalized);
96
+ }
97
+
98
+ async function pollSessionEventPages({
99
+ sessionId,
100
+ targetPath,
101
+ since,
102
+ _pollEvents,
103
+ limit,
104
+ maxPages,
105
+ forceCircuitProbe = false,
106
+ }) {
107
+ const normalizedLimit = Math.max(
108
+ 1,
109
+ Math.min(DEFAULT_EVENT_PAGE_LIMIT, normalizePositiveInteger(limit, DEFAULT_EVENT_PAGE_LIMIT)),
110
+ );
111
+ const normalizedMaxPages = Math.max(
112
+ 1,
113
+ Math.min(100, normalizePositiveInteger(maxPages, DEFAULT_MAX_EVENT_PAGES)),
114
+ );
115
+ const events = [];
116
+ let cursor = typeof since === "string" && since.trim() ? since.trim() : null;
117
+ let reason = "";
118
+ let pageCount = 0;
119
+
120
+ for (let page = 0; page < normalizedMaxPages; page += 1) {
121
+ const result = await _pollEvents(sessionId, {
122
+ targetPath,
123
+ since: cursor,
124
+ limit: normalizedLimit,
125
+ forceCircuitProbe,
126
+ });
127
+ pageCount += 1;
128
+ if (!result?.ok) {
129
+ return {
130
+ ok: events.length > 0,
131
+ reason: result?.reason || "poll_failed",
132
+ events,
133
+ cursor,
134
+ pageCount,
135
+ complete: false,
136
+ truncated: events.length > 0,
137
+ };
138
+ }
139
+
140
+ const pageEvents = Array.isArray(result.events) ? result.events : [];
141
+ events.push(...pageEvents);
142
+ const nextCursor =
143
+ typeof result.cursor === "string" && result.cursor.trim() ? result.cursor.trim() : cursor;
144
+ const progressed = nextCursor && nextCursor !== cursor;
145
+ cursor = nextCursor || cursor;
146
+
147
+ if (pageEvents.length < normalizedLimit) {
148
+ return {
149
+ ok: true,
150
+ reason: "",
151
+ events,
152
+ cursor,
153
+ pageCount,
154
+ complete: true,
155
+ truncated: false,
156
+ };
157
+ }
158
+ if (!progressed) {
159
+ reason = "cursor_not_advanced";
160
+ break;
161
+ }
162
+ }
163
+
164
+ return {
165
+ ok: events.length > 0,
166
+ reason: reason || "max_event_pages_reached",
167
+ events,
168
+ cursor,
169
+ pageCount,
170
+ complete: false,
171
+ truncated: true,
172
+ };
173
+ }
26
174
 
27
175
  /**
28
176
  * Fetch new human messages for a session, append them to the local
@@ -46,6 +194,10 @@ export async function hydrateSessionFromRemote({
46
194
  _poll = pollHumanMessages,
47
195
  _pollEvents = pollSessionEvents,
48
196
  _append = appendToStream,
197
+ _ensureLocalSession = ensureLocalSessionShell,
198
+ probeOpenCircuit = true,
199
+ eventPageLimit = DEFAULT_EVENT_PAGE_LIMIT,
200
+ maxEventPages = DEFAULT_MAX_EVENT_PAGES,
49
201
  } = {}) {
50
202
  if (!sessionId || typeof sessionId !== "string") {
51
203
  return {
@@ -74,25 +226,56 @@ export async function hydrateSessionFromRemote({
74
226
  // Run both pollers in parallel — they hit different endpoints and
75
227
  // are independent. A human-only poll stays fast even when the
76
228
  // events poll is heavy.
77
- const [humanResult, eventsResult] = await Promise.all([
229
+ let [humanResult, eventsResult] = await Promise.all([
78
230
  _poll(sessionId, { targetPath, since: humanCursor }),
79
- _pollEvents(sessionId, { targetPath, since: eventsCursor }),
231
+ pollSessionEventPages({
232
+ sessionId,
233
+ targetPath,
234
+ since: eventsCursor,
235
+ _pollEvents,
236
+ limit: eventPageLimit,
237
+ maxPages: maxEventPages,
238
+ }),
80
239
  ]);
81
240
 
241
+ if (
242
+ probeOpenCircuit &&
243
+ humanResult?.reason === "circuit_breaker_open" &&
244
+ eventsResult?.reason === "circuit_breaker_open"
245
+ ) {
246
+ [humanResult, eventsResult] = await Promise.all([
247
+ _poll(sessionId, { targetPath, since: humanCursor, forceCircuitProbe: true }),
248
+ pollSessionEventPages({
249
+ sessionId,
250
+ targetPath,
251
+ since: eventsCursor,
252
+ _pollEvents,
253
+ limit: eventPageLimit,
254
+ maxPages: maxEventPages,
255
+ forceCircuitProbe: true,
256
+ }),
257
+ ]);
258
+ }
259
+
82
260
  // Dedup across sources — both endpoints can return the same event
83
261
  // (e.g. a human relay event). Cursor values are unique per event.
84
262
  const seenCursors = new Set();
263
+ const seenKeys = new Set();
85
264
  const merged = [];
86
265
  for (const e of humanResult?.events || []) {
87
266
  const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
88
267
  if (c && seenCursors.has(c)) continue;
268
+ if (sessionEventHasKnownIdentity(e, seenKeys)) continue;
89
269
  if (c) seenCursors.add(c);
270
+ addSessionEventIdentityKeys(seenKeys, e);
90
271
  merged.push(e);
91
272
  }
92
273
  for (const e of eventsResult?.events || []) {
93
274
  const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
94
275
  if (c && seenCursors.has(c)) continue;
276
+ if (sessionEventHasKnownIdentity(e, seenKeys)) continue;
95
277
  if (c) seenCursors.add(c);
278
+ addSessionEventIdentityKeys(seenKeys, e);
96
279
  merged.push(e);
97
280
  }
98
281
 
@@ -112,10 +295,33 @@ export async function hydrateSessionFromRemote({
112
295
  }
113
296
 
114
297
  let relayed = 0;
115
- for (const event of merged) {
298
+ let materializedLocalSession = false;
299
+ let remoteStatus = "";
300
+ const successfulRelayKeys =
301
+ merged.length > 0 ? await readExistingRelayKeys(sessionId, { targetPath }) : new Set();
302
+ const newEvents = successfulRelayKeys.size > 0
303
+ ? merged.filter((event) => !sessionEventHasKnownIdentity(event, successfulRelayKeys))
304
+ : merged;
305
+ if (newEvents.length > 0) {
306
+ try {
307
+ const localSession = await _ensureLocalSession(sessionId, { targetPath });
308
+ materializedLocalSession = Boolean(localSession?.materialized);
309
+ remoteStatus = String(localSession?.remoteStatus || "").trim().toLowerCase();
310
+ } catch {
311
+ // Keep the old degraded behavior: append attempts below will
312
+ // fail visibly in the returned counters, but remote polling still
313
+ // returns a structured result.
314
+ }
315
+ }
316
+ const appendEvents =
317
+ remoteStatus && !["active", "pending"].includes(remoteStatus)
318
+ ? newEvents.map((event) => markPostKillEvent(event))
319
+ : newEvents;
320
+ for (const event of appendEvents) {
116
321
  try {
117
- await _append(sessionId, event, { targetPath });
322
+ await _append(sessionId, event, { targetPath, syncRemote: false });
118
323
  relayed += 1;
324
+ addSessionEventIdentityKeys(successfulRelayKeys, event);
119
325
  } catch {
120
326
  // Append errors are observable via the stream but should not
121
327
  // abort the rest of the batch — partial relay is still progress.
@@ -123,11 +329,13 @@ export async function hydrateSessionFromRemote({
123
329
  }
124
330
 
125
331
  let persistedCursor = false;
126
- if (typeof humanResult?.cursor === "string" && humanResult.cursor.trim()) {
332
+ const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys);
333
+ const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys);
334
+ if (humanCursorSafe && typeof humanResult?.cursor === "string" && humanResult.cursor.trim()) {
127
335
  const result = await writeSyncCursor(sessionId, humanResult.cursor, { targetPath }).catch(() => null);
128
336
  persistedCursor = Boolean(result && result.written);
129
337
  }
130
- if (typeof eventsResult?.cursor === "string" && eventsResult.cursor.trim()) {
338
+ if (eventsCursorSafe && typeof eventsResult?.cursor === "string" && eventsResult.cursor.trim()) {
131
339
  await writeSyncCursor(sessionId, eventsResult.cursor, {
132
340
  targetPath,
133
341
  suffix: EVENTS_CURSOR_SUFFIX,
@@ -145,5 +353,12 @@ export async function hydrateSessionFromRemote({
145
353
  eventsRelayed: (eventsResult?.events || []).length,
146
354
  eventsCursor:
147
355
  typeof eventsResult?.cursor === "string" ? eventsResult.cursor : eventsCursor || null,
356
+ eventsPageCount: Number(eventsResult?.pageCount || 0),
357
+ eventsBackfillComplete: Boolean(eventsResult?.complete !== false),
358
+ eventsBackfillTruncated: Boolean(eventsResult?.truncated),
359
+ eventsBackfillReason: eventsResult?.complete === false ? eventsResult?.reason || "" : "",
360
+ materializedLocalSession,
361
+ localAppendComplete: humanCursorSafe && eventsCursorSafe,
362
+ remoteStatus: remoteStatus || null,
148
363
  };
149
364
  }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
 
5
5
  import { buildAgentsSessionGuideContent } from "../legacy-cli.js";
6
+ import { renderCoordinationBulletList } from "./coordination-guidance.js";
6
7
  import { getSession } from "./store.js";
7
8
 
8
9
  const SECTION_START_MARKER = "<!-- SENTINELAYER_SESSION_COORDINATION:START -->";
@@ -26,21 +27,8 @@ function buildSessionCoordinationSection() {
26
27
  return `${SECTION_START_MARKER}
27
28
  ${SECTION_HEADING}
28
29
 
29
- ### Before You Start
30
- - Check for active sessions: \`sl session list\`
31
- - Join if one exists: \`sl session join <id> --name <your-name>\`
32
- - Read recent context: \`sl session read <id> --tail 20\`
33
-
34
- ### While Working
35
- - Emit status every 5 min: \`sl session say <id> "status: <what you're doing>"\`
36
- - Lock files before editing: \`sl session say <id> "lock: <file> - <intent>"\`
37
- - Unlock when done: \`sl session say <id> "unlock: <file> - done"\`
38
- - Post findings: \`sl session say <id> "finding: [P2] <title> in <file>:<line>"\`
39
-
40
- ### On Problems
41
- - Unexpected file change? Ask: \`sl session say <id> "help: unexpected change in <file>"\`
42
- - Need another agent's work? Request: \`sl session say <id> "handoff: @<agent> <description>"\`
43
- - Found issues for others? Assign: \`sl session say <id> "assign: @<agent> <task>"\`
30
+ ### Required Etiquette
31
+ ${renderCoordinationBulletList()}
44
32
 
45
33
  ### What Not To Do
46
34
  - Do not break your autonomous loop on unexpected file changes; ask in session first.
@@ -43,6 +43,21 @@ function normalizeNonNegativeInteger(value, fallbackValue = 0) {
43
43
  return Math.floor(normalized);
44
44
  }
45
45
 
46
+ function normalizeCreateSessionId(value) {
47
+ const normalized = normalizeString(value);
48
+ if (!normalized) return randomUUID();
49
+ if (
50
+ normalized === "." ||
51
+ normalized === ".." ||
52
+ normalized.includes("/") ||
53
+ normalized.includes("\\") ||
54
+ normalized.includes("..")
55
+ ) {
56
+ throw new Error("sessionId must not contain path traversal segments.");
57
+ }
58
+ return normalized;
59
+ }
60
+
46
61
  function normalizeIsoTimestamp(value, fallbackIso = new Date().toISOString()) {
47
62
  const normalized = normalizeString(value);
48
63
  if (!normalized) {
@@ -148,7 +163,7 @@ function toRelativePosix(baseDir, absolutePath) {
148
163
 
149
164
  function normalizeDateKeyFromCloseoutPath(closeoutPath = "", fallbackIso = new Date().toISOString()) {
150
165
  const normalized = toPosixPath(closeoutPath);
151
- const match = /\/observability\/(\d{4}-\d{2}-\d{2})\//.exec(`/${normalized}`);
166
+ const match = /\/observability\/(\d{4}-\d{2}-\d{2})\//.exec("/" + normalized);
152
167
  if (match) {
153
168
  return match[1];
154
169
  }
@@ -168,6 +183,31 @@ function normalizeSharedResources(raw = {}, { nowIso = new Date().toISOString()
168
183
  };
169
184
  }
170
185
 
186
+ function normalizeRemoteTitleSync(raw = {}, { nowIso = new Date().toISOString() } = {}) {
187
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
188
+ return null;
189
+ }
190
+ const title = normalizeString(raw.title);
191
+ const lastAttemptAt = raw.lastAttemptAt
192
+ ? normalizeIsoTimestamp(raw.lastAttemptAt, nowIso)
193
+ : null;
194
+ const lastSyncedAt = raw.lastSyncedAt
195
+ ? normalizeIsoTimestamp(raw.lastSyncedAt, nowIso)
196
+ : null;
197
+ const failureReason = normalizeString(raw.failureReason) || null;
198
+ const pending = Boolean(raw.pending);
199
+ if (!pending && !title && !lastAttemptAt && !lastSyncedAt && !failureReason) {
200
+ return null;
201
+ }
202
+ return {
203
+ pending,
204
+ title: title || null,
205
+ lastAttemptAt,
206
+ lastSyncedAt,
207
+ failureReason,
208
+ };
209
+ }
210
+
171
211
  function normalizeTemplateAgent(raw = {}) {
172
212
  const source = raw && typeof raw === "object" ? raw : {};
173
213
  return {
@@ -330,6 +370,7 @@ function normalizeMetadata(raw = {}, { sessionId, targetPath, nowIso } = {}) {
330
370
  createdAt,
331
371
  updatedAt: normalizeIsoTimestamp(raw.updatedAt, nowIso),
332
372
  expiresAt,
373
+ title: normalizeString(raw.title) || null,
333
374
  ttlSeconds,
334
375
  renewalCount: Math.max(0, Number(raw.renewalCount || 0)),
335
376
  maxLifetimeSeconds: normalizePositiveInteger(raw.maxLifetimeSeconds, MAX_SESSION_LIFETIME_SECONDS),
@@ -341,6 +382,7 @@ function normalizeMetadata(raw = {}, { sessionId, targetPath, nowIso } = {}) {
341
382
  archiveStatus: normalizeString(raw.archiveStatus) || "pending",
342
383
  codebaseContext: normalizeCodebaseContext(raw.codebaseContext || {}),
343
384
  sharedResources: normalizeSharedResources(raw.sharedResources || {}, { nowIso }),
385
+ remoteTitleSync: normalizeRemoteTitleSync(raw.remoteTitleSync || null, { nowIso }),
344
386
  template: normalizeSessionTemplate(raw.template || null),
345
387
  };
346
388
  }
@@ -364,7 +406,10 @@ function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString())
364
406
  metadataPath: paths.metadataPath,
365
407
  streamPath: paths.streamPath,
366
408
  createdAt: metadata.createdAt,
409
+ updatedAt: metadata.updatedAt,
367
410
  expiresAt: metadata.expiresAt,
411
+ lastInteractionAt: metadata.lastInteractionAt,
412
+ title: metadata.title,
368
413
  elapsedTimer: buildElapsedTimer(metadata.createdAt, nowIso),
369
414
  renewalCount: metadata.renewalCount,
370
415
  status: metadata.status,
@@ -372,6 +417,7 @@ function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString())
372
417
  s3Path: metadata.s3Path,
373
418
  codebaseContext: metadata.codebaseContext,
374
419
  sharedResources: metadata.sharedResources,
420
+ remoteTitleSync: metadata.remoteTitleSync,
375
421
  template: metadata.template,
376
422
  };
377
423
  }
@@ -406,11 +452,22 @@ export async function createSession({
406
452
  targetPath = process.cwd(),
407
453
  ttlSeconds = DEFAULT_TTL_SECONDS,
408
454
  template = null,
455
+ sessionId: requestedSessionId = "",
456
+ title = "",
457
+ createdAt = "",
458
+ expiresAt = "",
459
+ lastInteractionAt = "",
409
460
  } = {}) {
410
461
  const resolvedTargetPath = path.resolve(String(targetPath || "."));
411
462
  const normalizedTtlSeconds = normalizePositiveInteger(ttlSeconds, DEFAULT_TTL_SECONDS);
412
- const sessionId = randomUUID();
463
+ const sessionId = normalizeCreateSessionId(requestedSessionId);
413
464
  const nowIso = new Date().toISOString();
465
+ const createdIso = normalizeIsoTimestamp(createdAt, nowIso);
466
+ const expiresIso = normalizeIsoTimestamp(
467
+ expiresAt,
468
+ toIsoAfterSeconds(createdIso, normalizedTtlSeconds)
469
+ );
470
+ const interactionIso = normalizeIsoTimestamp(lastInteractionAt, createdIso);
414
471
  const paths = resolveSessionPaths(sessionId, { targetPath: resolvedTargetPath });
415
472
  const codebaseContext = await collectSessionCodebaseContext(resolvedTargetPath);
416
473
 
@@ -419,20 +476,22 @@ export async function createSession({
419
476
  schemaVersion: SESSION_SCHEMA_VERSION,
420
477
  sessionId,
421
478
  targetPath: resolvedTargetPath,
422
- createdAt: nowIso,
479
+ createdAt: createdIso,
423
480
  updatedAt: nowIso,
424
- expiresAt: toIsoAfterSeconds(nowIso, normalizedTtlSeconds),
481
+ expiresAt: expiresIso,
482
+ title: normalizeString(title) || null,
425
483
  ttlSeconds: normalizedTtlSeconds,
426
484
  renewalCount: 0,
427
485
  maxLifetimeSeconds: MAX_SESSION_LIFETIME_SECONDS,
428
486
  status: SESSION_STATUS_ACTIVE,
429
- lastInteractionAt: nowIso,
487
+ lastInteractionAt: interactionIso,
430
488
  expiredAt: null,
431
489
  archivedAt: null,
432
490
  s3Path: null,
433
491
  archiveStatus: "pending",
434
492
  codebaseContext,
435
493
  sharedResources: normalizeSharedResources({}, { nowIso }),
494
+ remoteTitleSync: null,
436
495
  template: normalizeSessionTemplate(template),
437
496
  },
438
497
  {
@@ -449,6 +508,59 @@ export async function createSession({
449
508
  return buildSessionPayload(metadata, paths, nowIso);
450
509
  }
451
510
 
511
+ export async function updateSessionTitle(
512
+ sessionId,
513
+ { targetPath = process.cwd(), title = "" } = {}
514
+ ) {
515
+ const loaded = await loadMetadata(sessionId, { targetPath });
516
+ if (!loaded) {
517
+ return null;
518
+ }
519
+ const nowIso = new Date().toISOString();
520
+ const metadata = {
521
+ ...loaded.metadata,
522
+ title: normalizeString(title) || null,
523
+ updatedAt: nowIso,
524
+ };
525
+ const saved = await saveMetadata(metadata, loaded.paths);
526
+ return buildSessionPayload(saved, loaded.paths, nowIso);
527
+ }
528
+
529
+ export async function recordSessionRemoteTitleSync(
530
+ sessionId,
531
+ {
532
+ targetPath = process.cwd(),
533
+ title = "",
534
+ pending = false,
535
+ failureReason = "",
536
+ lastAttemptAt = "",
537
+ lastSyncedAt = "",
538
+ } = {}
539
+ ) {
540
+ const loaded = await loadMetadata(sessionId, { targetPath });
541
+ if (!loaded) {
542
+ return null;
543
+ }
544
+ const nowIso = new Date().toISOString();
545
+ const metadata = {
546
+ ...loaded.metadata,
547
+ remoteTitleSync: normalizeRemoteTitleSync(
548
+ {
549
+ pending: Boolean(pending),
550
+ title: normalizeString(title) || normalizeString(loaded.metadata.title) || null,
551
+ lastAttemptAt: lastAttemptAt || nowIso,
552
+ lastSyncedAt:
553
+ !pending && !normalizeString(failureReason) ? lastSyncedAt || nowIso : lastSyncedAt || null,
554
+ failureReason: normalizeString(failureReason) || null,
555
+ },
556
+ { nowIso },
557
+ ),
558
+ updatedAt: nowIso,
559
+ };
560
+ const saved = await saveMetadata(metadata, loaded.paths);
561
+ return buildSessionPayload(saved, loaded.paths, nowIso);
562
+ }
563
+
452
564
  export async function getSession(sessionId, { targetPath = process.cwd() } = {}) {
453
565
  const loaded = await loadMetadata(sessionId, { targetPath });
454
566
  if (!loaded) {
@@ -3,6 +3,7 @@ import process from "node:process";
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
4
 
5
5
  import { createAgentEvent, normalizeAgentEvent } from "../events/schema.js";
6
+ import { enrichEventWithMentions } from "./mentions.js";
6
7
  import { resolveSessionPaths } from "./paths.js";
7
8
  import { redactEventPayload } from "./redact.js";
8
9
  import { syncSessionEventToApi } from "./sync.js";
@@ -223,7 +224,7 @@ function filterBySince(events = [], since) {
223
224
  export async function appendToStream(
224
225
  sessionId,
225
226
  event,
226
- { targetPath = process.cwd(), maxEvents = DEFAULT_MAX_STREAM_EVENTS } = {}
227
+ { targetPath = process.cwd(), maxEvents = DEFAULT_MAX_STREAM_EVENTS, syncRemote = true } = {}
227
228
  ) {
228
229
  const paths = resolveSessionPaths(sessionId, { targetPath });
229
230
  const metadata = await readSessionMetadata(paths);
@@ -235,7 +236,14 @@ export async function appendToStream(
235
236
  }
236
237
 
237
238
  const rawEvent = materializeCanonicalEvent(paths.sessionId, event);
238
- const canonicalEvent = redactEventPayload(rawEvent);
239
+ // Parse `@<name>` mentions out of the message text and surface them
240
+ // as payload.to + payload.mentions so the listener (sl session listen)
241
+ // and Senti daemon can route to/notify the addressed agent. Pure +
242
+ // idempotent — if the caller already supplied an explicit `to/recipient`
243
+ // we leave it alone. Runs before redaction so the redactor sees a
244
+ // stable shape.
245
+ const enrichedEvent = enrichEventWithMentions(rawEvent);
246
+ const canonicalEvent = redactEventPayload(enrichedEvent);
239
247
  const nowIso = new Date().toISOString();
240
248
  const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_MAX_STREAM_EVENTS);
241
249
 
@@ -252,10 +260,12 @@ export async function appendToStream(
252
260
  await releaseLock(paths.lockPath);
253
261
  }
254
262
 
255
- // Best-effort dashboard sync. Never block local stream durability on API state.
256
- void syncSessionEventToApi(paths.sessionId, canonicalEvent, {
257
- targetPath,
258
- }).catch(() => {});
263
+ if (syncRemote) {
264
+ // Best-effort dashboard sync. Never block local stream durability on API state.
265
+ void syncSessionEventToApi(paths.sessionId, canonicalEvent, {
266
+ targetPath,
267
+ }).catch(() => {});
268
+ }
259
269
 
260
270
  return canonicalEvent;
261
271
  }
@@ -320,7 +330,7 @@ export async function* tailStream(
320
330
  }
321
331
 
322
332
  try {
323
- await sleep(normalizedPollMs, null, signal ? { signal } : undefined);
333
+ await sleep(normalizedPollMs, null, signal ? { signal, ref: false } : { ref: false });
324
334
  } catch (error) {
325
335
  if (error && typeof error === "object" && error.name === "AbortError") {
326
336
  return;