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.
- package/README.md +22 -4
- package/package.json +4 -4
- package/src/auth/admission-scope.js +34 -0
- package/src/auth/gate.js +6 -0
- package/src/auth/http.js +4 -0
- package/src/auth/service.js +6 -0
- package/src/board/done-gate.js +155 -0
- package/src/board/evidence-resolvers.js +138 -0
- package/src/cli.js +24 -1
- package/src/commands/board.js +116 -0
- package/src/commands/session.js +1094 -279
- package/src/engram/build-state.js +183 -0
- package/src/engram/canonical.js +75 -0
- package/src/engram/document.js +190 -0
- package/src/engram/governance.js +67 -0
- package/src/engram/index.js +73 -0
- package/src/engram/ingest-document.js +107 -0
- package/src/engram/namespace.js +89 -0
- package/src/engram/observations.js +34 -0
- package/src/engram/sla.js +104 -0
- package/src/engram/store.js +120 -0
- package/src/engram/tools.js +152 -0
- package/src/engram/trust.js +105 -0
- package/src/events/schema.js +18 -0
- package/src/legacy-cli.js +6 -3
- package/src/mcp/registry.js +27 -10
- package/src/mcp/session-stdio-server.js +209 -18
- package/src/net/test-egress-guard.js +158 -0
- package/src/review/investor-dd-orchestrator.js +14 -2
- package/src/session/admission-auth.js +312 -0
- package/src/session/admission-preimage.js +90 -0
- package/src/session/admission.js +620 -0
- package/src/session/agent-registry.js +22 -63
- package/src/session/checkpoints.js +25 -1
- package/src/session/codebase-context.js +54 -0
- package/src/session/coordination-guidance.js +6 -6
- package/src/session/daemon-spawn.js +20 -2
- package/src/session/daemon.js +26 -125
- package/src/session/event-identity.js +9 -2
- package/src/session/file-lease-integrations.js +1300 -0
- package/src/session/file-locks.js +967 -464
- package/src/session/first-message.js +11 -3
- package/src/session/listener.js +246 -20
- package/src/session/listeners.js +71 -80
- package/src/session/live-source.js +47 -16
- package/src/session/message-edits.js +89 -0
- package/src/session/paths.js +2 -0
- package/src/session/recall/embedder.js +181 -0
- package/src/session/recall/entities.js +0 -0
- package/src/session/recall/index-build.js +38 -0
- package/src/session/recall/index-core.js +108 -0
- package/src/session/recall/index.js +131 -0
- package/src/session/recall/needle-eval.js +279 -0
- package/src/session/recall/observation-core.js +183 -0
- package/src/session/recall/observations.js +21 -0
- package/src/session/recall/provenance.js +153 -0
- package/src/session/recall/retrieve.js +374 -0
- package/src/session/recall/text.js +0 -0
- package/src/session/recall/token-stats.js +81 -0
- package/src/session/recap.js +54 -26
- package/src/session/remote-hydrate.js +12 -20
- package/src/session/store.js +13 -14
- package/src/session/stream.js +23 -3
- package/src/session/sync.js +789 -22
- package/src/session/tickets.js +316 -0
- package/src/session/title-sync.js +2 -1
- package/src/session/usage-report.js +41 -2
- package/src/session/wake/pump.js +8 -3
- package/src/session/wake/resolve-target.js +10 -5
|
@@ -4,16 +4,14 @@ import path from "node:path";
|
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
|
|
6
6
|
import { STUCK_THRESHOLDS } from "../agents/jules/pulse.js";
|
|
7
|
-
import { createAgentEvent } from "../events/schema.js";
|
|
8
7
|
import { inferSessionAgentIdentity } from "./agent-identity.js";
|
|
9
8
|
import { resolveSessionPaths } from "./paths.js";
|
|
10
|
-
import {
|
|
9
|
+
import { buildContextBriefing } from "./recap.js";
|
|
11
10
|
import {
|
|
12
11
|
assignFriendlyName,
|
|
13
12
|
buildSentiWelcome,
|
|
14
13
|
shouldAutoRenameInRegistry,
|
|
15
14
|
} from "./senti-naming.js";
|
|
16
|
-
import { appendToStream } from "./stream.js";
|
|
17
15
|
|
|
18
16
|
const AGENT_SNAPSHOT_SCHEMA_VERSION = "1.0.0";
|
|
19
17
|
|
|
@@ -177,28 +175,6 @@ async function writeAgentSnapshot(snapshotPath, snapshot) {
|
|
|
177
175
|
}
|
|
178
176
|
}
|
|
179
177
|
|
|
180
|
-
async function emitAgentEvent(
|
|
181
|
-
sessionId,
|
|
182
|
-
event,
|
|
183
|
-
payload,
|
|
184
|
-
{ targetPath = process.cwd(), awaitRemoteSync = false } = {}
|
|
185
|
-
) {
|
|
186
|
-
const envelope = createAgentEvent({
|
|
187
|
-
event,
|
|
188
|
-
agent: {
|
|
189
|
-
id: payload.agentId,
|
|
190
|
-
model: payload.model,
|
|
191
|
-
displayName: payload.displayName,
|
|
192
|
-
provider: payload.provider,
|
|
193
|
-
role: payload.role,
|
|
194
|
-
clientKind: payload.clientKind,
|
|
195
|
-
},
|
|
196
|
-
sessionId,
|
|
197
|
-
payload,
|
|
198
|
-
});
|
|
199
|
-
await appendToStream(sessionId, envelope, { targetPath, awaitRemoteSync });
|
|
200
|
-
}
|
|
201
|
-
|
|
202
178
|
function buildAgentSnapshotPath(paths, agentId) {
|
|
203
179
|
const normalizedAgentId = normalizeString(agentId);
|
|
204
180
|
if (!normalizedAgentId) {
|
|
@@ -288,17 +264,15 @@ function _untrackLocalAgent(sessionId, agentId) {
|
|
|
288
264
|
_localAgents.delete(_agentKey(sessionId, agentId));
|
|
289
265
|
}
|
|
290
266
|
|
|
291
|
-
async function
|
|
267
|
+
async function _leaveAllLocalAgents(reason) {
|
|
292
268
|
const entries = [..._localAgents.values()];
|
|
293
269
|
_localAgents.clear();
|
|
294
270
|
for (const entry of entries) {
|
|
295
271
|
try {
|
|
296
|
-
await
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
{ targetPath: entry.targetPath },
|
|
301
|
-
);
|
|
272
|
+
await unregisterAgent(entry.sessionId, entry.agentId, {
|
|
273
|
+
reason,
|
|
274
|
+
targetPath: entry.targetPath,
|
|
275
|
+
});
|
|
302
276
|
} catch {
|
|
303
277
|
// Best-effort: a stuck filesystem or network shouldn't block exit.
|
|
304
278
|
}
|
|
@@ -309,7 +283,7 @@ function _ensureExitHooksInstalled() {
|
|
|
309
283
|
if (_exitHooksInstalled) return;
|
|
310
284
|
_exitHooksInstalled = true;
|
|
311
285
|
const onSignal = (signal) => {
|
|
312
|
-
void
|
|
286
|
+
void _leaveAllLocalAgents("manual").finally(() => {
|
|
313
287
|
process.removeListener(signal, onSignal);
|
|
314
288
|
process.kill(process.pid, signal);
|
|
315
289
|
});
|
|
@@ -317,7 +291,7 @@ function _ensureExitHooksInstalled() {
|
|
|
317
291
|
process.on("SIGINT", onSignal);
|
|
318
292
|
process.on("SIGTERM", onSignal);
|
|
319
293
|
process.on("beforeExit", () => {
|
|
320
|
-
void
|
|
294
|
+
void _leaveAllLocalAgents("manual");
|
|
321
295
|
});
|
|
322
296
|
}
|
|
323
297
|
|
|
@@ -332,7 +306,6 @@ export async function registerAgent(
|
|
|
332
306
|
role = "observer",
|
|
333
307
|
targetPath = process.cwd(),
|
|
334
308
|
trackProcessExit = true,
|
|
335
|
-
awaitRemoteSync = false,
|
|
336
309
|
} = {}
|
|
337
310
|
) {
|
|
338
311
|
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
@@ -426,46 +399,39 @@ export async function registerAgent(
|
|
|
426
399
|
);
|
|
427
400
|
|
|
428
401
|
await writeAgentSnapshot(snapshotPath, snapshot);
|
|
429
|
-
await emitAgentEvent(paths.sessionId, "agent_join", {
|
|
430
|
-
agentId: snapshot.agentId,
|
|
431
|
-
model: snapshot.model,
|
|
432
|
-
displayName: snapshot.displayName,
|
|
433
|
-
provider: snapshot.provider,
|
|
434
|
-
clientKind: snapshot.clientKind,
|
|
435
|
-
role: snapshot.role,
|
|
436
|
-
status: snapshot.status,
|
|
437
|
-
}, { targetPath, awaitRemoteSync });
|
|
438
402
|
if (trackProcessExit) {
|
|
439
403
|
_trackLocalAgent(paths.sessionId, snapshot.agentId, targetPath);
|
|
440
404
|
}
|
|
441
405
|
|
|
406
|
+
let welcome = null;
|
|
442
407
|
if (renamedFrom) {
|
|
443
|
-
|
|
408
|
+
welcome = buildSentiWelcome({
|
|
444
409
|
agentId: snapshot.agentId,
|
|
445
410
|
model: snapshot.model,
|
|
446
411
|
role: snapshot.role,
|
|
447
412
|
wasAnonymous: true,
|
|
448
413
|
originalAgentId: renamedFrom,
|
|
449
414
|
});
|
|
450
|
-
await emitAgentEvent(paths.sessionId, "agent_identified", {
|
|
451
|
-
...welcome,
|
|
452
|
-
sessionId: paths.sessionId,
|
|
453
|
-
}, { targetPath });
|
|
454
415
|
}
|
|
455
416
|
|
|
417
|
+
let contextBriefing = null;
|
|
456
418
|
if (normalizeString(snapshot.agentId).toLowerCase() !== "senti") {
|
|
457
|
-
await
|
|
419
|
+
contextBriefing = await buildContextBriefing(paths.sessionId, {
|
|
458
420
|
forAgentId: snapshot.agentId,
|
|
459
421
|
targetPath,
|
|
460
|
-
awaitRemoteSync,
|
|
461
422
|
}).catch(() => {});
|
|
462
423
|
}
|
|
463
424
|
|
|
464
425
|
return {
|
|
465
426
|
...snapshot,
|
|
466
427
|
snapshotPath,
|
|
467
|
-
emittedJoinEvent:
|
|
468
|
-
emittedContextBriefing:
|
|
428
|
+
emittedJoinEvent: false,
|
|
429
|
+
emittedContextBriefing: false,
|
|
430
|
+
returnedContextBriefing: Boolean(contextBriefing),
|
|
431
|
+
onboarding: {
|
|
432
|
+
welcome,
|
|
433
|
+
contextBriefing,
|
|
434
|
+
},
|
|
469
435
|
refreshedExistingAgent: false,
|
|
470
436
|
};
|
|
471
437
|
}
|
|
@@ -607,16 +573,9 @@ export async function unregisterAgent(
|
|
|
607
573
|
);
|
|
608
574
|
|
|
609
575
|
await writeAgentSnapshot(snapshotPath, snapshot);
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
role: snapshot.role,
|
|
614
|
-
model: snapshot.model,
|
|
615
|
-
displayName: snapshot.displayName,
|
|
616
|
-
provider: snapshot.provider,
|
|
617
|
-
clientKind: snapshot.clientKind,
|
|
618
|
-
}, { targetPath });
|
|
619
|
-
// Already left explicitly — don't double-emit on process exit.
|
|
576
|
+
// Presence and roster state are projections, not transcript events. The
|
|
577
|
+
// snapshot is authoritative for this workspace; remote listener presence is
|
|
578
|
+
// renewed through the TTL endpoint.
|
|
620
579
|
_untrackLocalAgent(paths.sessionId, snapshot.agentId);
|
|
621
580
|
|
|
622
581
|
return {
|
|
@@ -17,6 +17,15 @@ const DEFAULT_RESTORE_MAX_EVENTS = 120;
|
|
|
17
17
|
const MAX_RESTORE_CONTEXT_EVENTS = 50;
|
|
18
18
|
const MAX_RESTORE_EVENTS = 200;
|
|
19
19
|
const DEFAULT_CREATED_BY_AGENT_ID = "senti";
|
|
20
|
+
// Checkpoint origin honesty (Carter, room 430135 lane 2): a cut is `service` ONLY when a managed
|
|
21
|
+
// infra cutter creates it; everything else — an agent's CLI, an agent-run `sl session daemon` — is
|
|
22
|
+
// `agent`. Never INFER `service`: absent/unknown/anything-but-the-literal-string collapses to `agent`,
|
|
23
|
+
// so honesty can't ride on a remembered flag. `createdBy` only defaults to the service id on `service`.
|
|
24
|
+
const CHECKPOINT_ORIGIN_SERVICE = "service";
|
|
25
|
+
const CHECKPOINT_ORIGIN_AGENT = "agent";
|
|
26
|
+
function normalizeCheckpointOrigin(origin) {
|
|
27
|
+
return normalizeString(origin) === CHECKPOINT_ORIGIN_SERVICE ? CHECKPOINT_ORIGIN_SERVICE : CHECKPOINT_ORIGIN_AGENT;
|
|
28
|
+
}
|
|
20
29
|
|
|
21
30
|
function normalizeString(value) {
|
|
22
31
|
return String(value ?? "").trim();
|
|
@@ -196,6 +205,7 @@ export function buildManualCheckpointPayload(sessionId, {
|
|
|
196
205
|
title,
|
|
197
206
|
summary,
|
|
198
207
|
createdByAgentId = "",
|
|
208
|
+
origin = "",
|
|
199
209
|
tokenStart,
|
|
200
210
|
tokenEnd,
|
|
201
211
|
} = {}) {
|
|
@@ -229,6 +239,7 @@ export function buildManualCheckpointPayload(sessionId, {
|
|
|
229
239
|
if (normalizedCreatedBy) {
|
|
230
240
|
body.createdByAgentId = normalizedCreatedBy;
|
|
231
241
|
}
|
|
242
|
+
body.origin = normalizeCheckpointOrigin(origin);
|
|
232
243
|
if (tokenRange) {
|
|
233
244
|
body.tokenRange = tokenRange;
|
|
234
245
|
}
|
|
@@ -243,6 +254,7 @@ export function buildGenerateCheckpointPayload(sessionId, {
|
|
|
243
254
|
minEvents = DEFAULT_MIN_EVENTS,
|
|
244
255
|
maxEvents = DEFAULT_MAX_EVENTS,
|
|
245
256
|
createdByAgentId = "",
|
|
257
|
+
origin = "",
|
|
246
258
|
idempotencyKey = "",
|
|
247
259
|
} = {}) {
|
|
248
260
|
const normalizedSessionId = normalizeString(sessionId);
|
|
@@ -268,6 +280,7 @@ export function buildGenerateCheckpointPayload(sessionId, {
|
|
|
268
280
|
if (normalizedCreatedBy) {
|
|
269
281
|
body.createdByAgentId = normalizedCreatedBy;
|
|
270
282
|
}
|
|
283
|
+
body.origin = normalizeCheckpointOrigin(origin);
|
|
271
284
|
return {
|
|
272
285
|
body,
|
|
273
286
|
idempotencyKey:
|
|
@@ -612,9 +625,17 @@ export async function generateSessionCheckpointBestEffort(sessionId, options = {
|
|
|
612
625
|
return buildCheckpointNoop("remote_sync_disabled_env");
|
|
613
626
|
}
|
|
614
627
|
try {
|
|
628
|
+
// ORIGIN HONESTY (lane 2): default `createdBy` to the service id ONLY for a real `service`-origin cut.
|
|
629
|
+
// An agent-run best-effort cut (the CLI, an agent's daemon) is `agent` — its createdBy is the real agent
|
|
630
|
+
// id or ABSENT, never silently "senti". Absence of a creator is not the service; the humble state is honest.
|
|
631
|
+
const origin = normalizeCheckpointOrigin(options.origin);
|
|
632
|
+
const createdByAgentId = origin === CHECKPOINT_ORIGIN_SERVICE
|
|
633
|
+
? (normalizeString(options.createdByAgentId) || DEFAULT_CREATED_BY_AGENT_ID)
|
|
634
|
+
: normalizeString(options.createdByAgentId);
|
|
615
635
|
const result = await generateSessionCheckpoint(normalizedSessionId, {
|
|
616
636
|
...options,
|
|
617
|
-
|
|
637
|
+
origin,
|
|
638
|
+
createdByAgentId,
|
|
618
639
|
});
|
|
619
640
|
return normalizeCheckpointGenerationResult(result);
|
|
620
641
|
} catch (error) {
|
|
@@ -633,6 +654,9 @@ export async function generateSessionCheckpointBestEffort(sessionId, options = {
|
|
|
633
654
|
|
|
634
655
|
export {
|
|
635
656
|
DEFAULT_CREATED_BY_AGENT_ID,
|
|
657
|
+
CHECKPOINT_ORIGIN_SERVICE,
|
|
658
|
+
CHECKPOINT_ORIGIN_AGENT,
|
|
659
|
+
normalizeCheckpointOrigin,
|
|
636
660
|
DEFAULT_BATCH_MAX_CHECKPOINTS,
|
|
637
661
|
DEFAULT_MAX_EVENTS,
|
|
638
662
|
DEFAULT_MIN_EVENTS,
|
|
@@ -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
|
+
}
|
|
@@ -2,12 +2,12 @@ export const COORDINATION_GUIDANCE_TITLE = "Multi-Agent Coordination Protocol";
|
|
|
2
2
|
|
|
3
3
|
export const COORDINATION_ETIQUETTE_ITEMS = Object.freeze([
|
|
4
4
|
"Find the recent Senti session for this codebase: run `sl session list --path .` and `sl session list --remote --path .`; join the right room with `sl session join <id> --name <your-name> --role coder`.",
|
|
5
|
-
"
|
|
6
|
-
"Before implementation, post a short plan
|
|
7
|
-
"Claim shared files before editing with `lock
|
|
8
|
-
"Run one background/secondary listener for replies with `sl session listen --session <id> --agent <your-name> --interval 60 --active-interval
|
|
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`.
|
|
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.
|
|
5
|
+
"Keep transient activity outside chat with `sl session action <id> working_on --target-sequence <n>`; post through `sl session post-agent` only for a material decision, finding, blocker, handoff, or completion so the transcript remains readable.",
|
|
6
|
+
"Before implementation, post a short semantic plan only when peers need it; keep routine ownership and progress in message actions.",
|
|
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
|
+
"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
|
+
"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 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
|
-
|
|
121
|
-
|
|
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: "" };
|
package/src/session/daemon.js
CHANGED
|
@@ -22,11 +22,6 @@ import {
|
|
|
22
22
|
registerAgent,
|
|
23
23
|
unregisterAgent,
|
|
24
24
|
} from "./agent-registry.js";
|
|
25
|
-
import {
|
|
26
|
-
DEFAULT_FILE_LOCK_TTL_SECONDS,
|
|
27
|
-
lockFile,
|
|
28
|
-
unlockFile,
|
|
29
|
-
} from "./file-locks.js";
|
|
30
25
|
import {
|
|
31
26
|
DEFAULT_MAX_EVENTS as DEFAULT_CHECKPOINT_MAX_EVENTS,
|
|
32
27
|
DEFAULT_MIN_EVENTS as DEFAULT_CHECKPOINT_MIN_EVENTS,
|
|
@@ -65,8 +60,6 @@ const DEFAULT_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
|
65
60
|
const DEFAULT_CHECKPOINT_EVENT_THRESHOLD = DEFAULT_CHECKPOINT_MIN_EVENTS;
|
|
66
61
|
const DEFAULT_CHECKPOINT_IDLE_MS = DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE;
|
|
67
62
|
const CHECKPOINT_MEANINGFUL_EVENT_NAMES = new Set([
|
|
68
|
-
"file_lock",
|
|
69
|
-
"file_unlock",
|
|
70
63
|
"finding",
|
|
71
64
|
"help_request",
|
|
72
65
|
"help_response",
|
|
@@ -84,6 +77,9 @@ const CHECKPOINT_IGNORED_EVENT_NAMES = new Set([
|
|
|
84
77
|
"agent_status",
|
|
85
78
|
"context_briefing",
|
|
86
79
|
"daemon_alert",
|
|
80
|
+
"file_lock",
|
|
81
|
+
"file_lock_expired",
|
|
82
|
+
"file_unlock",
|
|
87
83
|
"session_checkpoint",
|
|
88
84
|
"session_listen_error",
|
|
89
85
|
"session_recap",
|
|
@@ -138,14 +134,27 @@ async function emitSentiEvent(
|
|
|
138
134
|
payload = {},
|
|
139
135
|
{ targetPath = process.cwd(), nowIso = new Date().toISOString() } = {}
|
|
140
136
|
) {
|
|
137
|
+
const projectionOnly = normalizeString(event) === "daemon_alert";
|
|
141
138
|
const envelope = createAgentEvent({
|
|
142
139
|
event,
|
|
143
140
|
agentId: SENTI_IDENTITY.id,
|
|
144
141
|
agentModel: SENTI_IDENTITY.model,
|
|
145
142
|
sessionId,
|
|
146
143
|
ts: normalizeIsoTimestamp(nowIso, new Date().toISOString()),
|
|
147
|
-
payload
|
|
144
|
+
payload: projectionOnly
|
|
145
|
+
? {
|
|
146
|
+
...payload,
|
|
147
|
+
ephemeral: true,
|
|
148
|
+
projection: "session_health",
|
|
149
|
+
}
|
|
150
|
+
: payload,
|
|
148
151
|
});
|
|
152
|
+
// Daemon health, presence, conflict hints, and lifecycle notices are
|
|
153
|
+
// operational projections. They are returned to the caller/status surface,
|
|
154
|
+
// but never become durable conversation rows.
|
|
155
|
+
if (projectionOnly) {
|
|
156
|
+
return envelope;
|
|
157
|
+
}
|
|
149
158
|
await appendToStream(sessionId, envelope, {
|
|
150
159
|
targetPath,
|
|
151
160
|
});
|
|
@@ -731,126 +740,12 @@ async function runHelpWatcher(daemonState) {
|
|
|
731
740
|
}
|
|
732
741
|
}
|
|
733
742
|
|
|
734
|
-
function
|
|
735
|
-
const normalized = normalizeString(raw);
|
|
736
|
-
if (!normalized) {
|
|
737
|
-
return {
|
|
738
|
-
filePath: "",
|
|
739
|
-
intent: "",
|
|
740
|
-
};
|
|
741
|
-
}
|
|
742
|
-
const separatorMatch = /\s(?:—|–|-)\s/.exec(normalized);
|
|
743
|
-
if (!separatorMatch) {
|
|
744
|
-
return {
|
|
745
|
-
filePath: normalizeString(normalized),
|
|
746
|
-
intent: "",
|
|
747
|
-
};
|
|
748
|
-
}
|
|
749
|
-
const separatorIndex = Number(separatorMatch.index || 0);
|
|
750
|
-
return {
|
|
751
|
-
filePath: normalizeString(normalized.slice(0, separatorIndex)),
|
|
752
|
-
intent: normalizeString(normalized.slice(separatorIndex + separatorMatch[0].length)),
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
function parseSessionDirective(event = {}) {
|
|
757
|
-
if (normalizeString(event.event) !== "session_message") {
|
|
758
|
-
return null;
|
|
759
|
-
}
|
|
760
|
-
const message = normalizeString(event.payload?.message);
|
|
761
|
-
if (!message) {
|
|
762
|
-
return null;
|
|
763
|
-
}
|
|
764
|
-
const directive = /^(lock|unlock)\s*:\s*(.+)$/i.exec(message);
|
|
765
|
-
if (!directive) {
|
|
766
|
-
return null;
|
|
767
|
-
}
|
|
768
|
-
const action = normalizeString(directive[1]).toLowerCase();
|
|
769
|
-
const body = normalizeString(directive[2]);
|
|
770
|
-
const parsed = splitFileAndIntent(body);
|
|
771
|
-
if (!parsed.filePath) {
|
|
772
|
-
return null;
|
|
773
|
-
}
|
|
774
|
-
return {
|
|
775
|
-
action,
|
|
776
|
-
filePath: parsed.filePath,
|
|
777
|
-
intent: parsed.intent,
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
async function maybeHandleSessionDirective(daemonState, event) {
|
|
743
|
+
async function maybeHandleTaskDirective(daemonState, event) {
|
|
782
744
|
const agentId = normalizeString(event.agent?.id);
|
|
783
745
|
if (!agentId || agentId === SENTI_IDENTITY.id) {
|
|
784
746
|
return null;
|
|
785
747
|
}
|
|
786
748
|
const nowIso = normalizeIsoTimestamp(event.ts, new Date().toISOString());
|
|
787
|
-
const fileDirective = parseSessionDirective(event);
|
|
788
|
-
if (fileDirective) {
|
|
789
|
-
if (fileDirective.action === "lock") {
|
|
790
|
-
const result = await lockFile(
|
|
791
|
-
daemonState.sessionId,
|
|
792
|
-
agentId,
|
|
793
|
-
fileDirective.filePath,
|
|
794
|
-
{
|
|
795
|
-
intent: fileDirective.intent,
|
|
796
|
-
ttlSeconds: DEFAULT_FILE_LOCK_TTL_SECONDS,
|
|
797
|
-
targetPath: daemonState.targetPath,
|
|
798
|
-
nowIso,
|
|
799
|
-
}
|
|
800
|
-
);
|
|
801
|
-
if (!result.locked) {
|
|
802
|
-
await emitSentiEvent(
|
|
803
|
-
daemonState.sessionId,
|
|
804
|
-
"daemon_alert",
|
|
805
|
-
{
|
|
806
|
-
alert: "file_lock_denied",
|
|
807
|
-
file: result.file || fileDirective.filePath,
|
|
808
|
-
requestedBy: agentId,
|
|
809
|
-
heldBy: result.heldBy || null,
|
|
810
|
-
since: result.since || null,
|
|
811
|
-
suggestion: `${fileDirective.filePath} is locked by ${result.heldBy || "another agent"} (${result.since || "recently"}). Coordinate before editing.`,
|
|
812
|
-
},
|
|
813
|
-
{
|
|
814
|
-
targetPath: daemonState.targetPath,
|
|
815
|
-
nowIso,
|
|
816
|
-
}
|
|
817
|
-
);
|
|
818
|
-
}
|
|
819
|
-
return result;
|
|
820
|
-
}
|
|
821
|
-
if (fileDirective.action === "unlock") {
|
|
822
|
-
const result = await unlockFile(
|
|
823
|
-
daemonState.sessionId,
|
|
824
|
-
agentId,
|
|
825
|
-
fileDirective.filePath,
|
|
826
|
-
{
|
|
827
|
-
reason: "session_message_unlock",
|
|
828
|
-
targetPath: daemonState.targetPath,
|
|
829
|
-
nowIso,
|
|
830
|
-
}
|
|
831
|
-
);
|
|
832
|
-
if (!result.unlocked && result.reason === "held_by_other_agent") {
|
|
833
|
-
await emitSentiEvent(
|
|
834
|
-
daemonState.sessionId,
|
|
835
|
-
"daemon_alert",
|
|
836
|
-
{
|
|
837
|
-
alert: "file_unlock_denied",
|
|
838
|
-
file: result.file || fileDirective.filePath,
|
|
839
|
-
requestedBy: agentId,
|
|
840
|
-
heldBy: result.heldBy || null,
|
|
841
|
-
since: result.since || null,
|
|
842
|
-
suggestion: `${fileDirective.filePath} is locked by ${result.heldBy || "another agent"}. Only the lock holder can release it.`,
|
|
843
|
-
},
|
|
844
|
-
{
|
|
845
|
-
targetPath: daemonState.targetPath,
|
|
846
|
-
nowIso,
|
|
847
|
-
}
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
return result;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
|
|
854
749
|
try {
|
|
855
750
|
return await handleTaskDirective(daemonState.sessionId, event, {
|
|
856
751
|
targetPath: daemonState.targetPath,
|
|
@@ -888,10 +783,10 @@ async function runSessionDirectiveWatcher(daemonState) {
|
|
|
888
783
|
if (!daemonState.running) {
|
|
889
784
|
return;
|
|
890
785
|
}
|
|
891
|
-
if (normalizeString(event.event) !== "session_message") {
|
|
786
|
+
if (normalizeString(event.event) !== "session_message" || Number(event.messageRevision || 1) > 1) {
|
|
892
787
|
continue;
|
|
893
788
|
}
|
|
894
|
-
await
|
|
789
|
+
await maybeHandleTaskDirective(daemonState, event);
|
|
895
790
|
}
|
|
896
791
|
} catch (error) {
|
|
897
792
|
if (error && typeof error === "object" && error.name === "AbortError") {
|
|
@@ -1385,6 +1280,12 @@ async function maybeGenerateSessionCheckpoint(
|
|
|
1385
1280
|
targetPath: daemonState.targetPath,
|
|
1386
1281
|
minEvents: daemonState.checkpointMinEvents,
|
|
1387
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",
|
|
1388
1289
|
createdByAgentId: SENTI_IDENTITY.id,
|
|
1389
1290
|
nowIso: normalizedNow,
|
|
1390
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
|
|
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
|
-
|
|
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
|
|