sentinelayer-cli 0.20.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.20.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": {
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { setTimeout as sleep } from "node:timers/promises";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
+ import { spawn as defaultSpawn } from "node:child_process";
6
7
 
7
8
  import pc from "picocolors";
8
9
 
@@ -63,6 +64,7 @@ import {
63
64
  import { readSessionPreview } from "../session/preview.js";
64
65
  import {
65
66
  createSessionMessageAction,
67
+ fetchSessionPinnedMessages,
66
68
  listSessionMessageActions,
67
69
  listSessionsFromApi,
68
70
  probeSessionAccess,
@@ -75,6 +77,7 @@ import {
75
77
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
76
78
  import { mergeLiveSources } from "../session/live-source.js";
77
79
  import { listenSessionEvents } from "../session/listener.js";
80
+ import { SESSION_LIVE_SUCCESS_TIPS } from "../session/coordination-guidance.js";
78
81
  import { buildSessionRecap } from "../session/recap.js";
79
82
  import { computeTranscriptStats } from "../session/transcript.js";
80
83
  import { deriveSessionTitle } from "../session/senti-naming.js";
@@ -1403,6 +1406,41 @@ function formatListenerCatchupNotice(catchup = {}) {
1403
1406
  ].join(" ");
1404
1407
  }
1405
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
+
1406
1444
  function buildListenerCatchupEvent({
1407
1445
  sessionId,
1408
1446
  agentId,
@@ -1537,6 +1575,102 @@ export function shouldBlockImplicitCliUserSessionSay(identity = {}) {
1537
1575
  return identity?.source === "fallback" && normalizeString(identity?.agentId) === "cli-user";
1538
1576
  }
1539
1577
 
1578
+ /**
1579
+ * Wake hook for `session listen --wake "<command>"`. This is the reusable
1580
+ * notify->resume bridge: when the listener emits an event addressed to this
1581
+ * agent (or broadcast — including low-noise actions like ack/like), it runs a
1582
+ * host command so the host can resume/wake its agent. The event JSON is piped
1583
+ * to the command's stdin and key fields are exposed as SL_WAKE_* env vars.
1584
+ *
1585
+ * Bursts are coalesced: if a wake is already running, the latest event is
1586
+ * queued and fired once when the current one finishes, so a flood of activity
1587
+ * triggers one trailing wake instead of a storm of processes.
1588
+ */
1589
+ export function createSessionWakeRunner({
1590
+ command,
1591
+ sessionId,
1592
+ agentId,
1593
+ emit = () => {},
1594
+ spawnImpl = defaultSpawn,
1595
+ } = {}) {
1596
+ const wakeCommand = normalizeString(command);
1597
+ let busy = false;
1598
+ let pending = null;
1599
+
1600
+ const run = (event) => {
1601
+ if (!wakeCommand) return;
1602
+ if (busy) {
1603
+ pending = event ?? {};
1604
+ return;
1605
+ }
1606
+ busy = true;
1607
+ const env = {
1608
+ ...process.env,
1609
+ SL_WAKE_SESSION_ID: normalizeString(sessionId),
1610
+ SL_WAKE_AGENT_ID: normalizeString(agentId),
1611
+ SL_WAKE_EVENT_TYPE: normalizeString(event?.event),
1612
+ SL_WAKE_EVENT_CURSOR: normalizeString(event?.cursor),
1613
+ SL_WAKE_EVENT_SEQUENCE: String(event?.sequenceId ?? event?.sequence_id ?? ""),
1614
+ SL_WAKE_ACTOR_ID: normalizeString(event?.agent?.id || event?.agentId),
1615
+ };
1616
+ let child;
1617
+ try {
1618
+ child = spawnImpl(wakeCommand, { shell: true, env, stdio: ["pipe", "ignore", "ignore"] });
1619
+ } catch (error) {
1620
+ busy = false;
1621
+ emit({ status: "error", reason: normalizeString(error?.message) || "spawn_failed" });
1622
+ return;
1623
+ }
1624
+ emit({
1625
+ status: "fired",
1626
+ eventType: env.SL_WAKE_EVENT_TYPE,
1627
+ cursor: env.SL_WAKE_EVENT_CURSOR,
1628
+ actorId: env.SL_WAKE_ACTOR_ID,
1629
+ });
1630
+ try {
1631
+ if (child && child.stdin) {
1632
+ child.stdin.write(JSON.stringify(event ?? {}));
1633
+ child.stdin.end();
1634
+ }
1635
+ } catch {
1636
+ // Broken pipe (command ignored stdin) is non-fatal for a wake hook.
1637
+ }
1638
+ const finish = (reason) => {
1639
+ busy = false;
1640
+ if (reason) emit({ status: "error", reason });
1641
+ const next = pending;
1642
+ pending = null;
1643
+ if (next !== null) run(next);
1644
+ };
1645
+ if (child && typeof child.on === "function") {
1646
+ child.on("error", (error) => finish(normalizeString(error?.message) || "wake_failed"));
1647
+ child.on("exit", (code) => finish(code && code !== 0 ? `exit_${code}` : ""));
1648
+ } else {
1649
+ finish("");
1650
+ }
1651
+ };
1652
+
1653
+ return { trigger: run, hasCommand: Boolean(wakeCommand) };
1654
+ }
1655
+
1656
+ // Message actions (ack/like/dislike/reply/view/working_on) must be authored by
1657
+ // a concrete agent identity. The CLI's bare `cli-user` default is a reserved
1658
+ // label the API rejects (api_422), so treat it as "unset" and resolve the real
1659
+ // agent the same way `session say` does (explicit --agent > SENTINELAYER_AGENT_ID
1660
+ // > the single joined agent). Returns the resolved identity; callers should use
1661
+ // shouldBlockImplicitCliUserSessionSay() to refuse the implicit cli-user
1662
+ // fallback before sending a request that is guaranteed to fail.
1663
+ export async function resolveMessageActionIdentity({
1664
+ sessionId,
1665
+ optionAgent = "",
1666
+ targetPath = process.cwd(),
1667
+ env = process.env,
1668
+ } = {}) {
1669
+ const explicit = normalizeString(optionAgent);
1670
+ const agentSeed = explicit && explicit.toLowerCase() !== "cli-user" ? explicit : "";
1671
+ return resolveSessionSayIdentity({ sessionId, agentId: agentSeed, targetPath, env });
1672
+ }
1673
+
1540
1674
  async function ensureSessionSayAgentRegistered(
1541
1675
  sessionId,
1542
1676
  agent = {},
@@ -1610,6 +1744,41 @@ async function resolveSessionAgentEnvelope(
1610
1744
  return Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined));
1611
1745
  }
1612
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
+
1613
1782
  async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
1614
1783
  const normalizedItems = Array.isArray(items) ? items : [];
1615
1784
  const normalizedConcurrency = Math.max(
@@ -2879,7 +3048,23 @@ export function registerSessionCommand(program) {
2879
3048
  }
2880
3049
  await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
2881
3050
  const note = normalizeString(noteOverride) || normalizeString(options.note);
2882
- const agentId = await defaultAgentId(options.agent, targetPath);
3051
+ // Resolve the authoring agent. The bare `cli-user` default is rejected by
3052
+ // the API (api_422); resolveMessageActionIdentity treats it as unset and
3053
+ // falls back to the joined agent. If no concrete identity resolves, fail
3054
+ // with actionable guidance instead of firing a request guaranteed to 422.
3055
+ const identity = await resolveMessageActionIdentity({
3056
+ sessionId: normalizedSessionId,
3057
+ optionAgent: options.agent,
3058
+ targetPath,
3059
+ env: process.env,
3060
+ });
3061
+ if (shouldBlockImplicitCliUserSessionSay(identity)) {
3062
+ throw new Error(
3063
+ identity.identityWarning ||
3064
+ `${commandName} requires an agent identity; pass --agent <id>, set SENTINELAYER_AGENT_ID, or run session join --agent <id> first.`,
3065
+ );
3066
+ }
3067
+ const agentId = identity.agentId;
2883
3068
  const idempotencyKey =
2884
3069
  normalizeString(options.idempotencyKey) ||
2885
3070
  defaultActionIdempotencyKey({
@@ -2970,7 +3155,7 @@ export function registerSessionCommand(program) {
2970
3155
  .option("--target-cursor <cursor>", "Target event cursor")
2971
3156
  .option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
2972
3157
  .option("--note <text>", "Optional action note or reply body")
2973
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3158
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
2974
3159
  .option("--idempotency-key <key>", "Explicit idempotency key")
2975
3160
  .option("--path <path>", "Workspace path for the session", ".")
2976
3161
  .option("--json", "Emit machine-readable output")
@@ -2984,7 +3169,7 @@ export function registerSessionCommand(program) {
2984
3169
  .option("--target-sequence <n>", "Target event sequence id")
2985
3170
  .option("--target-cursor <cursor>", "Target event cursor")
2986
3171
  .option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
2987
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3172
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
2988
3173
  .option("--idempotency-key <key>", "Explicit idempotency key")
2989
3174
  .option("--path <path>", "Workspace path for the session", ".")
2990
3175
  .option("--json", "Emit machine-readable output")
@@ -3005,7 +3190,7 @@ export function registerSessionCommand(program) {
3005
3190
  session
3006
3191
  .command("reply <sessionId> <targetSequenceId> <message...>")
3007
3192
  .description("Reply to a target session event using the message-action channel")
3008
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3193
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
3009
3194
  .option("--idempotency-key <key>", "Explicit idempotency key")
3010
3195
  .option("--path <path>", "Workspace path for the session", ".")
3011
3196
  .option("--json", "Emit machine-readable output")
@@ -3025,7 +3210,7 @@ export function registerSessionCommand(program) {
3025
3210
  session
3026
3211
  .command("comment <sessionId> <targetSequenceId> <message...>")
3027
3212
  .description("Alias for `session reply`; add a threaded comment to a target event")
3028
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3213
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
3029
3214
  .option("--idempotency-key <key>", "Explicit idempotency key")
3030
3215
  .option("--path <path>", "Workspace path for the session", ".")
3031
3216
  .option("--json", "Emit machine-readable output")
@@ -3045,7 +3230,7 @@ export function registerSessionCommand(program) {
3045
3230
  session
3046
3231
  .command("view <sessionId> <targetSequenceId>")
3047
3232
  .description("Manually backfill a read receipt for a target session event")
3048
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3233
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
3049
3234
  .option("--idempotency-key <key>", "Explicit idempotency key")
3050
3235
  .option("--path <path>", "Workspace path for the session", ".")
3051
3236
  .option("--json", "Emit machine-readable output")
@@ -3060,6 +3245,172 @@ export function registerSessionCommand(program) {
3060
3245
  });
3061
3246
  });
3062
3247
 
3248
+ session
3249
+ .command("pins <sessionId>")
3250
+ .description("List the session's pinned messages with their content so agents can read them")
3251
+ .option("--path <path>", "Workspace path for the session", ".")
3252
+ .option("--json", "Emit machine-readable output")
3253
+ .action(async (sessionId, options, command) => {
3254
+ const normalizedSessionId = normalizeString(sessionId);
3255
+ if (!normalizedSessionId) {
3256
+ throw new Error("session id is required.");
3257
+ }
3258
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3259
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3260
+ const result = await fetchSessionPinnedMessages(normalizedSessionId, { targetPath });
3261
+ if (!result.ok) {
3262
+ throw new Error(`Could not load pinned messages (${result.reason || "unknown"}).`);
3263
+ }
3264
+ const pinLimit = result.pinLimit || 10;
3265
+ const payload = {
3266
+ command: "session pins",
3267
+ sessionId: normalizedSessionId,
3268
+ pinLimit,
3269
+ count: result.count,
3270
+ pins: result.pins,
3271
+ };
3272
+ if (shouldEmitJson(options, command)) {
3273
+ console.log(JSON.stringify(payload, null, 2));
3274
+ return payload;
3275
+ }
3276
+ if (!result.count) {
3277
+ console.log(pc.gray("No pinned messages in this session."));
3278
+ return payload;
3279
+ }
3280
+ console.log(pc.bold(`📌 Pinned messages (${result.count}/${pinLimit})`));
3281
+ for (const pin of result.pins) {
3282
+ const seqLabel = pin.targetSequenceId ? `#${pin.targetSequenceId}` : "(unknown sequence)";
3283
+ const author = pin.author || "unknown";
3284
+ const pinnedBy = pin.pinnedBy ? ` · pinned by ${pin.pinnedBy}` : "";
3285
+ const when = pin.pinnedAt ? ` · ${pin.pinnedAt}` : "";
3286
+ console.log("");
3287
+ console.log(pc.cyan(`${seqLabel} ${author}${pinnedBy}${when}`));
3288
+ if (pin.content) {
3289
+ for (const line of String(pin.content).split("\n")) {
3290
+ console.log(` ${line}`);
3291
+ }
3292
+ } else {
3293
+ console.log(pc.gray(" (no readable text content for this pinned event)"));
3294
+ }
3295
+ }
3296
+ return payload;
3297
+ });
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
+
3063
3414
  session
3064
3415
  .command("listen")
3065
3416
  .description("Background-poll a session for events addressed to this agent or broadcast")
@@ -3103,6 +3454,16 @@ export function registerSessionCommand(program) {
3103
3454
  .option("--from-now", "Advance the listen cursor to the latest durable event before polling")
3104
3455
  .option("--replay", "Emit matching historical events on the first poll")
3105
3456
  .option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
3457
+ .option(
3458
+ "--wake <command>",
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.",
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")
3106
3467
  .action(async (options) => {
3107
3468
  const normalizedSessionId = resolveSessionIdOption(options);
3108
3469
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
@@ -3126,6 +3487,32 @@ export function registerSessionCommand(program) {
3126
3487
  if (!["ndjson", "text"].includes(emitFormat)) {
3127
3488
  throw new Error("--emit must be one of: ndjson, text.");
3128
3489
  }
3490
+ // Optional wake hook: run a host command on each matched event so the
3491
+ // host can resume/wake its agent (the notify->resume bridge).
3492
+ const emitWakeNotice = (payload = {}) => {
3493
+ if (emitFormat === "ndjson") {
3494
+ console.log(
3495
+ JSON.stringify(
3496
+ createAgentEvent({
3497
+ event: "session_wake_hook",
3498
+ agentId,
3499
+ sessionId: normalizedSessionId,
3500
+ payload,
3501
+ }),
3502
+ ),
3503
+ );
3504
+ } else {
3505
+ const status = normalizeString(payload.status) || "fired";
3506
+ const detail = payload.reason ? ` (${payload.reason})` : payload.eventType ? ` ${payload.eventType}` : "";
3507
+ console.log(pc.cyan(`wake hook ${status}${detail}`));
3508
+ }
3509
+ };
3510
+ const wakeRunner = createSessionWakeRunner({
3511
+ command: options.wake,
3512
+ sessionId: normalizedSessionId,
3513
+ agentId,
3514
+ emit: emitWakeNotice,
3515
+ });
3129
3516
  const requestedTransport = normalizeString(options.transport).toLowerCase() || "auto";
3130
3517
  if (!["auto", "stream", "poll"].includes(requestedTransport)) {
3131
3518
  throw new Error("--transport must be one of: auto, stream, poll.");
@@ -3150,6 +3537,44 @@ export function registerSessionCommand(program) {
3150
3537
  const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
3151
3538
  let lastPresenceHeartbeatMs = 0;
3152
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
+
3153
3578
  if (emitFormat === "text") {
3154
3579
  console.log(
3155
3580
  pc.gray(
@@ -3214,6 +3639,9 @@ export function registerSessionCommand(program) {
3214
3639
  } else {
3215
3640
  console.log(formatEventLine(event));
3216
3641
  }
3642
+ // Fire the wake hook for any matched event (incl. ack/like) so the
3643
+ // host can resume its agent.
3644
+ wakeRunner.trigger(event);
3217
3645
  },
3218
3646
  onError: async (result) => {
3219
3647
  const reason = normalizeString(result?.reason) || "poll_failed";
@@ -3257,6 +3685,9 @@ export function registerSessionCommand(program) {
3257
3685
  },
3258
3686
  });
3259
3687
  } finally {
3688
+ if (coachingTimer) {
3689
+ clearInterval(coachingTimer);
3690
+ }
3260
3691
  process.removeListener("SIGINT", onSigint);
3261
3692
  }
3262
3693
  });
package/src/legacy-cli.js CHANGED
@@ -226,6 +226,11 @@ function printUsage() {
226
226
  console.log(" sl session comment <id> <seq> \"msg\" Alias for threaded reply");
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
+ 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)");
233
+ console.log(" sl session listen --session <id> --agent <id> Background-poll a session for new events");
229
234
  console.log(" sl session recap now <id> --remote --json Summarize current owners, locks, and work");
230
235
  console.log(" sl session daemon --session <id> Run Senti recaps/checkpoints for long rooms");
231
236
  console.log(" sl session read <id> --tail 20 Read local session stream events");
@@ -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 = "",
@@ -1643,6 +1643,123 @@ export async function listSessionMessageActions(
1643
1643
  }
1644
1644
  }
1645
1645
 
1646
+ function pinnedEventContentText(event = {}) {
1647
+ const payload = event && typeof event === "object" ? event.payload || {} : {};
1648
+ return (
1649
+ normalizeString(payload.note) ||
1650
+ normalizeString(payload.message) ||
1651
+ normalizeString(payload.text) ||
1652
+ normalizeString(payload.content) ||
1653
+ normalizeString(payload.summary) ||
1654
+ ""
1655
+ );
1656
+ }
1657
+
1658
+ function pinnedEventAuthorId(event = {}) {
1659
+ const agent = event && typeof event === "object" ? event.agent || {} : {};
1660
+ return normalizeString(agent.id || agent.agentId) || normalizeString(event.agentId) || "";
1661
+ }
1662
+
1663
+ /**
1664
+ * Resolve the session's active pinned messages, enriched with each pinned
1665
+ * message's author and content so a CLI agent can actually read them (not just
1666
+ * see sequence numbers). Pins come from the action projection
1667
+ * (`projection.pinnedMessages`, capped at `projection.pinLimit`); content is
1668
+ * resolved per pinned sequence via `/events/before`. Bounded by the pin cap
1669
+ * (<= 10), so at most ~10 single-event lookups.
1670
+ */
1671
+ export async function fetchSessionPinnedMessages(
1672
+ sessionId,
1673
+ {
1674
+ targetPath = process.cwd(),
1675
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1676
+ resolveAuthSession = resolveActiveAuthSession,
1677
+ fetchImpl = fetchWithTimeout,
1678
+ nowMs = Date.now,
1679
+ listActions = listSessionMessageActions,
1680
+ fetchEventsBefore = pollSessionEventsBefore,
1681
+ } = {}
1682
+ ) {
1683
+ const normalizedSessionId = normalizeString(sessionId);
1684
+ if (!normalizedSessionId) {
1685
+ return { ok: false, reason: "invalid_session_id", pins: [], pinLimit: 0, count: 0 };
1686
+ }
1687
+
1688
+ const actionsResult = await listActions(normalizedSessionId, {
1689
+ targetPath,
1690
+ timeoutMs,
1691
+ resolveAuthSession,
1692
+ fetchImpl,
1693
+ nowMs,
1694
+ });
1695
+ if (!actionsResult || !actionsResult.ok) {
1696
+ return {
1697
+ ok: false,
1698
+ reason: normalizeString(actionsResult?.reason) || "actions_read_failed",
1699
+ pins: [],
1700
+ pinLimit: 0,
1701
+ count: 0,
1702
+ };
1703
+ }
1704
+
1705
+ const projection = actionsResult.projection && typeof actionsResult.projection === "object"
1706
+ ? actionsResult.projection
1707
+ : {};
1708
+ const pinLimit = Number(projection.pinLimit) || 0;
1709
+ const pinnedActions = Array.isArray(projection.pinnedMessages) ? projection.pinnedMessages : [];
1710
+
1711
+ const pins = await Promise.all(
1712
+ pinnedActions.map(async (action) => {
1713
+ const targetSequenceId = Number(action?.targetSequenceId) || 0;
1714
+ const base = {
1715
+ targetSequenceId,
1716
+ targetCursor: normalizeString(action?.targetCursor) || null,
1717
+ pinnedBy: normalizeString(action?.actorId) || "",
1718
+ pinnedByKind: normalizeString(action?.actorKind) || "",
1719
+ pinnedAt: normalizeString(action?.createdAt) || "",
1720
+ author: "",
1721
+ content: "",
1722
+ resolved: false,
1723
+ };
1724
+ if (targetSequenceId <= 0) {
1725
+ return base;
1726
+ }
1727
+ const eventsResult = await fetchEventsBefore(normalizedSessionId, {
1728
+ targetPath,
1729
+ beforeSequence: targetSequenceId + 1,
1730
+ limit: 1,
1731
+ timeoutMs,
1732
+ resolveAuthSession,
1733
+ fetchImpl,
1734
+ nowMs,
1735
+ });
1736
+ if (eventsResult && eventsResult.ok && Array.isArray(eventsResult.events)) {
1737
+ const match =
1738
+ eventsResult.events.find(
1739
+ (event) => Number(event?.sequenceId) === targetSequenceId,
1740
+ ) ||
1741
+ eventsResult.events[eventsResult.events.length - 1] ||
1742
+ null;
1743
+ if (match) {
1744
+ base.author = pinnedEventAuthorId(match);
1745
+ base.content = pinnedEventContentText(match);
1746
+ base.resolved = true;
1747
+ }
1748
+ }
1749
+ return base;
1750
+ }),
1751
+ );
1752
+
1753
+ return {
1754
+ ok: true,
1755
+ reason: "",
1756
+ sessionId: normalizedSessionId,
1757
+ pins,
1758
+ pinLimit,
1759
+ count: pins.length,
1760
+ };
1761
+ }
1762
+
1646
1763
  export async function createSessionMessageAction(
1647
1764
  sessionId,
1648
1765
  {