sentinelayer-cli 0.21.0 → 0.22.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/package.json +1 -1
- package/src/commands/session.js +233 -0
- package/src/legacy-cli.js +3 -0
- package/src/session/coordination-guidance.js +17 -0
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
78
78
|
import { mergeLiveSources } from "../session/live-source.js";
|
|
79
79
|
import { listenSessionEvents } from "../session/listener.js";
|
|
80
|
+
import { SESSION_LIVE_SUCCESS_TIPS } from "../session/coordination-guidance.js";
|
|
80
81
|
import { buildSessionRecap } from "../session/recap.js";
|
|
81
82
|
import { computeTranscriptStats } from "../session/transcript.js";
|
|
82
83
|
import { deriveSessionTitle } from "../session/senti-naming.js";
|
|
@@ -1405,6 +1406,41 @@ function formatListenerCatchupNotice(catchup = {}) {
|
|
|
1405
1406
|
].join(" ");
|
|
1406
1407
|
}
|
|
1407
1408
|
|
|
1409
|
+
// Periodic in-session coaching reminder surfaced by `session listen`. Keeps
|
|
1410
|
+
// agents continually nudged toward good coordination (ack, claim work, reply
|
|
1411
|
+
// in-thread, surface findings). `tick` makes each emission idempotent so the
|
|
1412
|
+
// same reminder is not deduped across the run.
|
|
1413
|
+
export function buildSessionCoachingEvent({
|
|
1414
|
+
sessionId,
|
|
1415
|
+
agentId,
|
|
1416
|
+
agentModel = "cli",
|
|
1417
|
+
displayName = "",
|
|
1418
|
+
listenerId = "",
|
|
1419
|
+
tick = 0,
|
|
1420
|
+
tips = SESSION_LIVE_SUCCESS_TIPS,
|
|
1421
|
+
} = {}) {
|
|
1422
|
+
const tipList = Array.isArray(tips) && tips.length ? tips : SESSION_LIVE_SUCCESS_TIPS;
|
|
1423
|
+
return createAgentEvent({
|
|
1424
|
+
event: "session_coaching",
|
|
1425
|
+
sessionId,
|
|
1426
|
+
agent: {
|
|
1427
|
+
id: agentId,
|
|
1428
|
+
model: normalizeString(agentModel) || "cli",
|
|
1429
|
+
role: "listener",
|
|
1430
|
+
displayName: normalizeString(displayName) || agentId,
|
|
1431
|
+
clientKind: "cli",
|
|
1432
|
+
},
|
|
1433
|
+
eventId: `session-coaching-${listenerId || agentId}-${tick}`,
|
|
1434
|
+
idempotencyToken: `session-coaching:${listenerId || agentId}:${tick}`,
|
|
1435
|
+
payload: compactPayload({
|
|
1436
|
+
source: "session_listen",
|
|
1437
|
+
kind: "coaching",
|
|
1438
|
+
message: "Session success reminders:",
|
|
1439
|
+
tips: [...tipList],
|
|
1440
|
+
}),
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1408
1444
|
function buildListenerCatchupEvent({
|
|
1409
1445
|
sessionId,
|
|
1410
1446
|
agentId,
|
|
@@ -1708,6 +1744,41 @@ async function resolveSessionAgentEnvelope(
|
|
|
1708
1744
|
return Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined));
|
|
1709
1745
|
}
|
|
1710
1746
|
|
|
1747
|
+
// Builds the lock/unlock say-convention directive the session daemon parses
|
|
1748
|
+
// into the authoritative file-lock registry. Kept pure + exported for testing.
|
|
1749
|
+
export function buildSessionLockDirective(verb, file, intent = "") {
|
|
1750
|
+
const normalizedFile = normalizeString(file);
|
|
1751
|
+
const normalizedIntent = normalizeString(intent);
|
|
1752
|
+
if (verb === "unlock") {
|
|
1753
|
+
return `unlock: ${normalizedFile} - ${normalizedIntent || "done"}`;
|
|
1754
|
+
}
|
|
1755
|
+
return normalizedIntent ? `lock: ${normalizedFile} - ${normalizedIntent}` : `lock: ${normalizedFile}`;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// Posts a coordination directive (e.g. "lock: <file> - <intent>") as a session
|
|
1759
|
+
// message so the session daemon processes it into the authoritative file-lock
|
|
1760
|
+
// registry. Used by `session lock`/`unlock` as ergonomic sugar over the
|
|
1761
|
+
// say-convention; locks are advisory + daemon-enforced with TTL auto-release,
|
|
1762
|
+
// so this is a best-effort post.
|
|
1763
|
+
async function postSessionDirectiveMessage(sessionId, message, {
|
|
1764
|
+
agentId,
|
|
1765
|
+
targetPath = process.cwd(),
|
|
1766
|
+
} = {}) {
|
|
1767
|
+
const clientMessageId = `cli-${randomUUID()}`;
|
|
1768
|
+
const agent = await resolveSessionAgentEnvelope(sessionId, agentId, { targetPath });
|
|
1769
|
+
await ensureSessionSayAgentRegistered(sessionId, agent, { targetPath });
|
|
1770
|
+
const event = createAgentEvent({
|
|
1771
|
+
event: "session_message",
|
|
1772
|
+
agent,
|
|
1773
|
+
sessionId,
|
|
1774
|
+
payload: { message, channel: "session", clientMessageId },
|
|
1775
|
+
});
|
|
1776
|
+
event.eventId = clientMessageId;
|
|
1777
|
+
event.idempotencyToken = clientMessageId;
|
|
1778
|
+
const result = await syncSessionEventToApi(sessionId, event, { targetPath });
|
|
1779
|
+
return { event, result };
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1711
1782
|
async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
|
|
1712
1783
|
const normalizedItems = Array.isArray(items) ? items : [];
|
|
1713
1784
|
const normalizedConcurrency = Math.max(
|
|
@@ -3225,6 +3296,121 @@ export function registerSessionCommand(program) {
|
|
|
3225
3296
|
return payload;
|
|
3226
3297
|
});
|
|
3227
3298
|
|
|
3299
|
+
const runLockDirectiveCommand = async (verb, sessionId, files, options, command) => {
|
|
3300
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
3301
|
+
if (!normalizedSessionId) {
|
|
3302
|
+
throw new Error("session id is required.");
|
|
3303
|
+
}
|
|
3304
|
+
const fileList = (Array.isArray(files) ? files : [files])
|
|
3305
|
+
.map((file) => normalizeString(file))
|
|
3306
|
+
.filter(Boolean);
|
|
3307
|
+
if (fileList.length === 0) {
|
|
3308
|
+
throw new Error(`session ${verb} requires at least one file path.`);
|
|
3309
|
+
}
|
|
3310
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
3311
|
+
await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
|
|
3312
|
+
const identity = await resolveMessageActionIdentity({
|
|
3313
|
+
sessionId: normalizedSessionId,
|
|
3314
|
+
optionAgent: options.agent,
|
|
3315
|
+
targetPath,
|
|
3316
|
+
env: process.env,
|
|
3317
|
+
});
|
|
3318
|
+
if (shouldBlockImplicitCliUserSessionSay(identity)) {
|
|
3319
|
+
throw new Error(
|
|
3320
|
+
identity.identityWarning ||
|
|
3321
|
+
`session ${verb} requires an agent identity; pass --agent <id>, set SENTINELAYER_AGENT_ID, or run session join --agent <id> first.`,
|
|
3322
|
+
);
|
|
3323
|
+
}
|
|
3324
|
+
const intent = normalizeString(options.intent);
|
|
3325
|
+
const processed = [];
|
|
3326
|
+
for (const file of fileList) {
|
|
3327
|
+
const directive = buildSessionLockDirective(verb, file, intent);
|
|
3328
|
+
await postSessionDirectiveMessage(normalizedSessionId, directive, {
|
|
3329
|
+
agentId: identity.agentId,
|
|
3330
|
+
targetPath,
|
|
3331
|
+
});
|
|
3332
|
+
processed.push(file);
|
|
3333
|
+
}
|
|
3334
|
+
const payload = {
|
|
3335
|
+
command: `session ${verb}`,
|
|
3336
|
+
sessionId: normalizedSessionId,
|
|
3337
|
+
agentId: identity.agentId,
|
|
3338
|
+
files: processed,
|
|
3339
|
+
};
|
|
3340
|
+
if (shouldEmitJson(options, command)) {
|
|
3341
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3342
|
+
return payload;
|
|
3343
|
+
}
|
|
3344
|
+
const action = verb === "lock" ? "Requested lock on" : "Released";
|
|
3345
|
+
console.log(pc.green(`${action} ${processed.length} file(s) as ${identity.agentId}: ${processed.join(", ")}`));
|
|
3346
|
+
if (verb === "lock") {
|
|
3347
|
+
console.log(
|
|
3348
|
+
pc.gray("Senti enforces fail-closed; locks auto-release on TTL. Release with `sl session unlock`."),
|
|
3349
|
+
);
|
|
3350
|
+
}
|
|
3351
|
+
return payload;
|
|
3352
|
+
};
|
|
3353
|
+
|
|
3354
|
+
session
|
|
3355
|
+
.command("lock <sessionId> <files...>")
|
|
3356
|
+
.description("Claim exclusive file locks via Senti (fail-closed, TTL auto-release)")
|
|
3357
|
+
.option("--intent <text>", "Why you're locking these files (shown to peers)")
|
|
3358
|
+
.option("--agent <id>", "Agent id claiming the lock (defaults to the joined session agent)")
|
|
3359
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
3360
|
+
.option("--json", "Emit machine-readable output")
|
|
3361
|
+
.action(async (sessionId, files, options, command) => {
|
|
3362
|
+
await runLockDirectiveCommand("lock", sessionId, files, options, command);
|
|
3363
|
+
});
|
|
3364
|
+
|
|
3365
|
+
session
|
|
3366
|
+
.command("unlock <sessionId> <files...>")
|
|
3367
|
+
.description("Release file locks you hold (Senti only lets the holder release)")
|
|
3368
|
+
.option("--intent <text>", "Optional note on the release")
|
|
3369
|
+
.option("--agent <id>", "Agent id releasing the lock (defaults to the joined session agent)")
|
|
3370
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
3371
|
+
.option("--json", "Emit machine-readable output")
|
|
3372
|
+
.action(async (sessionId, files, options, command) => {
|
|
3373
|
+
await runLockDirectiveCommand("unlock", sessionId, files, options, command);
|
|
3374
|
+
});
|
|
3375
|
+
|
|
3376
|
+
session
|
|
3377
|
+
.command("locks <sessionId>")
|
|
3378
|
+
.description("List active file locks for the session (who holds what, and when they expire)")
|
|
3379
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
3380
|
+
.option("--json", "Emit machine-readable output")
|
|
3381
|
+
.action(async (sessionId, options, command) => {
|
|
3382
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
3383
|
+
if (!normalizedSessionId) {
|
|
3384
|
+
throw new Error("session id is required.");
|
|
3385
|
+
}
|
|
3386
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
3387
|
+
await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
|
|
3388
|
+
const locks = await listFileLocks(normalizedSessionId, { targetPath });
|
|
3389
|
+
const lockList = Array.isArray(locks) ? locks : [];
|
|
3390
|
+
const payload = {
|
|
3391
|
+
command: "session locks",
|
|
3392
|
+
sessionId: normalizedSessionId,
|
|
3393
|
+
count: lockList.length,
|
|
3394
|
+
locks: lockList,
|
|
3395
|
+
};
|
|
3396
|
+
if (shouldEmitJson(options, command)) {
|
|
3397
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3398
|
+
return payload;
|
|
3399
|
+
}
|
|
3400
|
+
if (lockList.length === 0) {
|
|
3401
|
+
console.log(pc.gray("No active file locks."));
|
|
3402
|
+
return payload;
|
|
3403
|
+
}
|
|
3404
|
+
console.log(pc.bold(`Active file locks (${lockList.length})`));
|
|
3405
|
+
for (const lock of lockList) {
|
|
3406
|
+
const file = normalizeString(lock.file || lock.filePath) || "(unknown file)";
|
|
3407
|
+
const holder = normalizeString(lock.agentId) || "unknown";
|
|
3408
|
+
const expires = normalizeString(lock.expiresAt);
|
|
3409
|
+
console.log(pc.cyan(` ${file}`) + pc.gray(` held by ${holder}${expires ? ` · expires ${expires}` : ""}`));
|
|
3410
|
+
}
|
|
3411
|
+
return payload;
|
|
3412
|
+
});
|
|
3413
|
+
|
|
3228
3414
|
session
|
|
3229
3415
|
.command("listen")
|
|
3230
3416
|
.description("Background-poll a session for events addressed to this agent or broadcast")
|
|
@@ -3272,6 +3458,12 @@ export function registerSessionCommand(program) {
|
|
|
3272
3458
|
"--wake <command>",
|
|
3273
3459
|
"Wake hook: run this shell command on each matched event (notify->resume bridge). Event JSON is piped to stdin; SL_WAKE_* env vars are set.",
|
|
3274
3460
|
)
|
|
3461
|
+
.option(
|
|
3462
|
+
"--coaching-interval <seconds>",
|
|
3463
|
+
"Seconds between in-session success reminders (ack, claim work, reply in-thread). Default 900; 0 disables.",
|
|
3464
|
+
"900",
|
|
3465
|
+
)
|
|
3466
|
+
.option("--no-coaching", "Do not emit periodic in-session success reminders")
|
|
3275
3467
|
.action(async (options) => {
|
|
3276
3468
|
const normalizedSessionId = resolveSessionIdOption(options);
|
|
3277
3469
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
@@ -3345,6 +3537,44 @@ export function registerSessionCommand(program) {
|
|
|
3345
3537
|
const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
|
|
3346
3538
|
let lastPresenceHeartbeatMs = 0;
|
|
3347
3539
|
|
|
3540
|
+
// Periodic in-session success reminders (ack, claim work, reply
|
|
3541
|
+
// in-thread). Long-running interactive listeners only — skipped under
|
|
3542
|
+
// --max-polls (smoke/test) and when --no-coaching is set.
|
|
3543
|
+
const coachingIntervalSeconds =
|
|
3544
|
+
options.coaching === false
|
|
3545
|
+
? 0
|
|
3546
|
+
: parsePositiveInteger(options.coachingInterval, "coaching-interval", 900);
|
|
3547
|
+
let coachingTick = 0;
|
|
3548
|
+
const emitCoaching = () => {
|
|
3549
|
+
if (emitFormat === "ndjson") {
|
|
3550
|
+
console.log(
|
|
3551
|
+
JSON.stringify(
|
|
3552
|
+
buildSessionCoachingEvent({
|
|
3553
|
+
sessionId: normalizedSessionId,
|
|
3554
|
+
agentId,
|
|
3555
|
+
agentModel,
|
|
3556
|
+
displayName,
|
|
3557
|
+
listenerId,
|
|
3558
|
+
tick: coachingTick++,
|
|
3559
|
+
}),
|
|
3560
|
+
),
|
|
3561
|
+
);
|
|
3562
|
+
} else {
|
|
3563
|
+
console.log(pc.cyan("Session success reminders:"));
|
|
3564
|
+
for (const tip of SESSION_LIVE_SUCCESS_TIPS) {
|
|
3565
|
+
console.log(pc.gray(` - ${tip}`));
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
};
|
|
3569
|
+
let coachingTimer = null;
|
|
3570
|
+
if (coachingIntervalSeconds > 0 && maxPolls === null) {
|
|
3571
|
+
emitCoaching();
|
|
3572
|
+
coachingTimer = setInterval(emitCoaching, coachingIntervalSeconds * 1000);
|
|
3573
|
+
if (coachingTimer && typeof coachingTimer.unref === "function") {
|
|
3574
|
+
coachingTimer.unref();
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3348
3578
|
if (emitFormat === "text") {
|
|
3349
3579
|
console.log(
|
|
3350
3580
|
pc.gray(
|
|
@@ -3455,6 +3685,9 @@ export function registerSessionCommand(program) {
|
|
|
3455
3685
|
},
|
|
3456
3686
|
});
|
|
3457
3687
|
} finally {
|
|
3688
|
+
if (coachingTimer) {
|
|
3689
|
+
clearInterval(coachingTimer);
|
|
3690
|
+
}
|
|
3458
3691
|
process.removeListener("SIGINT", onSigint);
|
|
3459
3692
|
}
|
|
3460
3693
|
});
|
package/src/legacy-cli.js
CHANGED
|
@@ -227,6 +227,9 @@ function printUsage() {
|
|
|
227
227
|
console.log(" sl session read <id> --remote --agent <id> Read stream events and auto-record views");
|
|
228
228
|
console.log(" sl session view <id> <seq> Manually backfill a read receipt");
|
|
229
229
|
console.log(" sl session pins <id> --json List pinned messages with content (readable by agents)");
|
|
230
|
+
console.log(" sl session lock <id> <files...> --intent <why> Claim file locks (fail-closed, TTL auto-release)");
|
|
231
|
+
console.log(" sl session unlock <id> <files...> Release file locks you hold");
|
|
232
|
+
console.log(" sl session locks <id> --json List active file locks (holder + expiry)");
|
|
230
233
|
console.log(" sl session listen --session <id> --agent <id> Background-poll a session for new events");
|
|
231
234
|
console.log(" sl session recap now <id> --remote --json Summarize current owners, locks, and work");
|
|
232
235
|
console.log(" sl session daemon --session <id> Run Senti recaps/checkpoints for long rooms");
|
|
@@ -21,6 +21,23 @@ export function getCoordinationEtiquetteItems() {
|
|
|
21
21
|
return [...COORDINATION_ETIQUETTE_ITEMS];
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Short, punchy success reminders surfaced periodically by `session listen` so
|
|
25
|
+
// agents are continually nudged to coordinate well (Carter: "keep reminding
|
|
26
|
+
// agents how to be successful... always ack and say if you're working on
|
|
27
|
+
// something"). Kept tight on purpose — this fires on a timer, so it must stay
|
|
28
|
+
// low-noise.
|
|
29
|
+
export const SESSION_LIVE_SUCCESS_TIPS = Object.freeze([
|
|
30
|
+
"Ack messages you've read: `sl session react <id> ack --target-sequence <n>` — don't go silent.",
|
|
31
|
+
"Say what you're doing: claim work with `sl session action <id> working_on --target-sequence <n>`.",
|
|
32
|
+
'Reply in-thread with `sl session reply <id> <seq> "..."`; start a new top-level post only when needed.',
|
|
33
|
+
"Post findings and blockers in-session, and ask for help instead of stalling.",
|
|
34
|
+
"Prefer low-noise actions over new top-level messages; run `sl session actions` for the full list.",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export function getSessionLiveSuccessTips() {
|
|
38
|
+
return [...SESSION_LIVE_SUCCESS_TIPS];
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
export function renderCoordinationNumberedList({
|
|
25
42
|
items = COORDINATION_ETIQUETTE_ITEMS,
|
|
26
43
|
indent = "",
|