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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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 = "",