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.
- package/package.json +7 -2
- package/src/agents/backend/tools/timeout-audit.js +33 -17
- package/src/agents/devtestbot/config/definition.js +100 -0
- package/src/agents/devtestbot/config/system-prompt.js +92 -0
- package/src/agents/devtestbot/index.js +9 -0
- package/src/agents/devtestbot/runner.js +775 -0
- package/src/agents/devtestbot/tool.js +707 -0
- package/src/commands/legacy-args.js +4 -0
- package/src/commands/omargate.js +4 -0
- package/src/commands/session.js +960 -159
- package/src/commands/swarm.js +11 -2
- package/src/guide/generator.js +14 -0
- package/src/legacy-cli.js +35 -18
- package/src/prompt/generator.js +4 -16
- package/src/review/ai-review.js +95 -6
- package/src/review/dd-report-email-client.js +148 -0
- package/src/review/investor-dd-devtestbot.js +599 -0
- package/src/review/investor-dd-orchestrator.js +135 -3
- package/src/review/omargate-orchestrator.js +20 -2
- package/src/review/persona-prompts.js +34 -1
- package/src/review/report.js +61 -2
- package/src/scan/generator.js +1 -1
- package/src/session/coordination-guidance.js +49 -0
- package/src/session/daemon.js +3 -2
- package/src/session/event-identity.js +139 -0
- package/src/session/listener.js +330 -0
- package/src/session/live-source.js +11 -2
- package/src/session/mentions.js +130 -0
- package/src/session/remote-hydrate.js +223 -8
- package/src/session/setup-guides.js +3 -15
- package/src/session/store.js +117 -5
- package/src/session/stream.js +17 -7
- package/src/session/sync.js +375 -26
- package/src/session/title-sync.js +107 -0
- package/src/spec/generator.js +8 -10
- package/src/swarm/registry.js +20 -0
- 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
|
-
|
|
229
|
+
let [humanResult, eventsResult] = await Promise.all([
|
|
78
230
|
_poll(sessionId, { targetPath, since: humanCursor }),
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
30
|
-
|
|
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.
|
package/src/session/store.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
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:
|
|
479
|
+
createdAt: createdIso,
|
|
423
480
|
updatedAt: nowIso,
|
|
424
|
-
expiresAt:
|
|
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:
|
|
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) {
|
package/src/session/stream.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 } :
|
|
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;
|