sentinelayer-cli 0.20.0 → 0.21.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.21.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,
@@ -1537,6 +1539,102 @@ export function shouldBlockImplicitCliUserSessionSay(identity = {}) {
1537
1539
  return identity?.source === "fallback" && normalizeString(identity?.agentId) === "cli-user";
1538
1540
  }
1539
1541
 
1542
+ /**
1543
+ * Wake hook for `session listen --wake "<command>"`. This is the reusable
1544
+ * notify->resume bridge: when the listener emits an event addressed to this
1545
+ * agent (or broadcast — including low-noise actions like ack/like), it runs a
1546
+ * host command so the host can resume/wake its agent. The event JSON is piped
1547
+ * to the command's stdin and key fields are exposed as SL_WAKE_* env vars.
1548
+ *
1549
+ * Bursts are coalesced: if a wake is already running, the latest event is
1550
+ * queued and fired once when the current one finishes, so a flood of activity
1551
+ * triggers one trailing wake instead of a storm of processes.
1552
+ */
1553
+ export function createSessionWakeRunner({
1554
+ command,
1555
+ sessionId,
1556
+ agentId,
1557
+ emit = () => {},
1558
+ spawnImpl = defaultSpawn,
1559
+ } = {}) {
1560
+ const wakeCommand = normalizeString(command);
1561
+ let busy = false;
1562
+ let pending = null;
1563
+
1564
+ const run = (event) => {
1565
+ if (!wakeCommand) return;
1566
+ if (busy) {
1567
+ pending = event ?? {};
1568
+ return;
1569
+ }
1570
+ busy = true;
1571
+ const env = {
1572
+ ...process.env,
1573
+ SL_WAKE_SESSION_ID: normalizeString(sessionId),
1574
+ SL_WAKE_AGENT_ID: normalizeString(agentId),
1575
+ SL_WAKE_EVENT_TYPE: normalizeString(event?.event),
1576
+ SL_WAKE_EVENT_CURSOR: normalizeString(event?.cursor),
1577
+ SL_WAKE_EVENT_SEQUENCE: String(event?.sequenceId ?? event?.sequence_id ?? ""),
1578
+ SL_WAKE_ACTOR_ID: normalizeString(event?.agent?.id || event?.agentId),
1579
+ };
1580
+ let child;
1581
+ try {
1582
+ child = spawnImpl(wakeCommand, { shell: true, env, stdio: ["pipe", "ignore", "ignore"] });
1583
+ } catch (error) {
1584
+ busy = false;
1585
+ emit({ status: "error", reason: normalizeString(error?.message) || "spawn_failed" });
1586
+ return;
1587
+ }
1588
+ emit({
1589
+ status: "fired",
1590
+ eventType: env.SL_WAKE_EVENT_TYPE,
1591
+ cursor: env.SL_WAKE_EVENT_CURSOR,
1592
+ actorId: env.SL_WAKE_ACTOR_ID,
1593
+ });
1594
+ try {
1595
+ if (child && child.stdin) {
1596
+ child.stdin.write(JSON.stringify(event ?? {}));
1597
+ child.stdin.end();
1598
+ }
1599
+ } catch {
1600
+ // Broken pipe (command ignored stdin) is non-fatal for a wake hook.
1601
+ }
1602
+ const finish = (reason) => {
1603
+ busy = false;
1604
+ if (reason) emit({ status: "error", reason });
1605
+ const next = pending;
1606
+ pending = null;
1607
+ if (next !== null) run(next);
1608
+ };
1609
+ if (child && typeof child.on === "function") {
1610
+ child.on("error", (error) => finish(normalizeString(error?.message) || "wake_failed"));
1611
+ child.on("exit", (code) => finish(code && code !== 0 ? `exit_${code}` : ""));
1612
+ } else {
1613
+ finish("");
1614
+ }
1615
+ };
1616
+
1617
+ return { trigger: run, hasCommand: Boolean(wakeCommand) };
1618
+ }
1619
+
1620
+ // Message actions (ack/like/dislike/reply/view/working_on) must be authored by
1621
+ // a concrete agent identity. The CLI's bare `cli-user` default is a reserved
1622
+ // label the API rejects (api_422), so treat it as "unset" and resolve the real
1623
+ // agent the same way `session say` does (explicit --agent > SENTINELAYER_AGENT_ID
1624
+ // > the single joined agent). Returns the resolved identity; callers should use
1625
+ // shouldBlockImplicitCliUserSessionSay() to refuse the implicit cli-user
1626
+ // fallback before sending a request that is guaranteed to fail.
1627
+ export async function resolveMessageActionIdentity({
1628
+ sessionId,
1629
+ optionAgent = "",
1630
+ targetPath = process.cwd(),
1631
+ env = process.env,
1632
+ } = {}) {
1633
+ const explicit = normalizeString(optionAgent);
1634
+ const agentSeed = explicit && explicit.toLowerCase() !== "cli-user" ? explicit : "";
1635
+ return resolveSessionSayIdentity({ sessionId, agentId: agentSeed, targetPath, env });
1636
+ }
1637
+
1540
1638
  async function ensureSessionSayAgentRegistered(
1541
1639
  sessionId,
1542
1640
  agent = {},
@@ -2879,7 +2977,23 @@ export function registerSessionCommand(program) {
2879
2977
  }
2880
2978
  await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
2881
2979
  const note = normalizeString(noteOverride) || normalizeString(options.note);
2882
- const agentId = await defaultAgentId(options.agent, targetPath);
2980
+ // Resolve the authoring agent. The bare `cli-user` default is rejected by
2981
+ // the API (api_422); resolveMessageActionIdentity treats it as unset and
2982
+ // falls back to the joined agent. If no concrete identity resolves, fail
2983
+ // with actionable guidance instead of firing a request guaranteed to 422.
2984
+ const identity = await resolveMessageActionIdentity({
2985
+ sessionId: normalizedSessionId,
2986
+ optionAgent: options.agent,
2987
+ targetPath,
2988
+ env: process.env,
2989
+ });
2990
+ if (shouldBlockImplicitCliUserSessionSay(identity)) {
2991
+ throw new Error(
2992
+ identity.identityWarning ||
2993
+ `${commandName} requires an agent identity; pass --agent <id>, set SENTINELAYER_AGENT_ID, or run session join --agent <id> first.`,
2994
+ );
2995
+ }
2996
+ const agentId = identity.agentId;
2883
2997
  const idempotencyKey =
2884
2998
  normalizeString(options.idempotencyKey) ||
2885
2999
  defaultActionIdempotencyKey({
@@ -2970,7 +3084,7 @@ export function registerSessionCommand(program) {
2970
3084
  .option("--target-cursor <cursor>", "Target event cursor")
2971
3085
  .option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
2972
3086
  .option("--note <text>", "Optional action note or reply body")
2973
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3087
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
2974
3088
  .option("--idempotency-key <key>", "Explicit idempotency key")
2975
3089
  .option("--path <path>", "Workspace path for the session", ".")
2976
3090
  .option("--json", "Emit machine-readable output")
@@ -2984,7 +3098,7 @@ export function registerSessionCommand(program) {
2984
3098
  .option("--target-sequence <n>", "Target event sequence id")
2985
3099
  .option("--target-cursor <cursor>", "Target event cursor")
2986
3100
  .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")
3101
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
2988
3102
  .option("--idempotency-key <key>", "Explicit idempotency key")
2989
3103
  .option("--path <path>", "Workspace path for the session", ".")
2990
3104
  .option("--json", "Emit machine-readable output")
@@ -3005,7 +3119,7 @@ export function registerSessionCommand(program) {
3005
3119
  session
3006
3120
  .command("reply <sessionId> <targetSequenceId> <message...>")
3007
3121
  .description("Reply to a target session event using the message-action channel")
3008
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3122
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
3009
3123
  .option("--idempotency-key <key>", "Explicit idempotency key")
3010
3124
  .option("--path <path>", "Workspace path for the session", ".")
3011
3125
  .option("--json", "Emit machine-readable output")
@@ -3025,7 +3139,7 @@ export function registerSessionCommand(program) {
3025
3139
  session
3026
3140
  .command("comment <sessionId> <targetSequenceId> <message...>")
3027
3141
  .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")
3142
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
3029
3143
  .option("--idempotency-key <key>", "Explicit idempotency key")
3030
3144
  .option("--path <path>", "Workspace path for the session", ".")
3031
3145
  .option("--json", "Emit machine-readable output")
@@ -3045,7 +3159,7 @@ export function registerSessionCommand(program) {
3045
3159
  session
3046
3160
  .command("view <sessionId> <targetSequenceId>")
3047
3161
  .description("Manually backfill a read receipt for a target session event")
3048
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3162
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
3049
3163
  .option("--idempotency-key <key>", "Explicit idempotency key")
3050
3164
  .option("--path <path>", "Workspace path for the session", ".")
3051
3165
  .option("--json", "Emit machine-readable output")
@@ -3060,6 +3174,57 @@ export function registerSessionCommand(program) {
3060
3174
  });
3061
3175
  });
3062
3176
 
3177
+ session
3178
+ .command("pins <sessionId>")
3179
+ .description("List the session's pinned messages with their content so agents can read them")
3180
+ .option("--path <path>", "Workspace path for the session", ".")
3181
+ .option("--json", "Emit machine-readable output")
3182
+ .action(async (sessionId, options, command) => {
3183
+ const normalizedSessionId = normalizeString(sessionId);
3184
+ if (!normalizedSessionId) {
3185
+ throw new Error("session id is required.");
3186
+ }
3187
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3188
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3189
+ const result = await fetchSessionPinnedMessages(normalizedSessionId, { targetPath });
3190
+ if (!result.ok) {
3191
+ throw new Error(`Could not load pinned messages (${result.reason || "unknown"}).`);
3192
+ }
3193
+ const pinLimit = result.pinLimit || 10;
3194
+ const payload = {
3195
+ command: "session pins",
3196
+ sessionId: normalizedSessionId,
3197
+ pinLimit,
3198
+ count: result.count,
3199
+ pins: result.pins,
3200
+ };
3201
+ if (shouldEmitJson(options, command)) {
3202
+ console.log(JSON.stringify(payload, null, 2));
3203
+ return payload;
3204
+ }
3205
+ if (!result.count) {
3206
+ console.log(pc.gray("No pinned messages in this session."));
3207
+ return payload;
3208
+ }
3209
+ console.log(pc.bold(`📌 Pinned messages (${result.count}/${pinLimit})`));
3210
+ for (const pin of result.pins) {
3211
+ const seqLabel = pin.targetSequenceId ? `#${pin.targetSequenceId}` : "(unknown sequence)";
3212
+ const author = pin.author || "unknown";
3213
+ const pinnedBy = pin.pinnedBy ? ` · pinned by ${pin.pinnedBy}` : "";
3214
+ const when = pin.pinnedAt ? ` · ${pin.pinnedAt}` : "";
3215
+ console.log("");
3216
+ console.log(pc.cyan(`${seqLabel} ${author}${pinnedBy}${when}`));
3217
+ if (pin.content) {
3218
+ for (const line of String(pin.content).split("\n")) {
3219
+ console.log(` ${line}`);
3220
+ }
3221
+ } else {
3222
+ console.log(pc.gray(" (no readable text content for this pinned event)"));
3223
+ }
3224
+ }
3225
+ return payload;
3226
+ });
3227
+
3063
3228
  session
3064
3229
  .command("listen")
3065
3230
  .description("Background-poll a session for events addressed to this agent or broadcast")
@@ -3103,6 +3268,10 @@ export function registerSessionCommand(program) {
3103
3268
  .option("--from-now", "Advance the listen cursor to the latest durable event before polling")
3104
3269
  .option("--replay", "Emit matching historical events on the first poll")
3105
3270
  .option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
3271
+ .option(
3272
+ "--wake <command>",
3273
+ "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
+ )
3106
3275
  .action(async (options) => {
3107
3276
  const normalizedSessionId = resolveSessionIdOption(options);
3108
3277
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
@@ -3126,6 +3295,32 @@ export function registerSessionCommand(program) {
3126
3295
  if (!["ndjson", "text"].includes(emitFormat)) {
3127
3296
  throw new Error("--emit must be one of: ndjson, text.");
3128
3297
  }
3298
+ // Optional wake hook: run a host command on each matched event so the
3299
+ // host can resume/wake its agent (the notify->resume bridge).
3300
+ const emitWakeNotice = (payload = {}) => {
3301
+ if (emitFormat === "ndjson") {
3302
+ console.log(
3303
+ JSON.stringify(
3304
+ createAgentEvent({
3305
+ event: "session_wake_hook",
3306
+ agentId,
3307
+ sessionId: normalizedSessionId,
3308
+ payload,
3309
+ }),
3310
+ ),
3311
+ );
3312
+ } else {
3313
+ const status = normalizeString(payload.status) || "fired";
3314
+ const detail = payload.reason ? ` (${payload.reason})` : payload.eventType ? ` ${payload.eventType}` : "";
3315
+ console.log(pc.cyan(`wake hook ${status}${detail}`));
3316
+ }
3317
+ };
3318
+ const wakeRunner = createSessionWakeRunner({
3319
+ command: options.wake,
3320
+ sessionId: normalizedSessionId,
3321
+ agentId,
3322
+ emit: emitWakeNotice,
3323
+ });
3129
3324
  const requestedTransport = normalizeString(options.transport).toLowerCase() || "auto";
3130
3325
  if (!["auto", "stream", "poll"].includes(requestedTransport)) {
3131
3326
  throw new Error("--transport must be one of: auto, stream, poll.");
@@ -3214,6 +3409,9 @@ export function registerSessionCommand(program) {
3214
3409
  } else {
3215
3410
  console.log(formatEventLine(event));
3216
3411
  }
3412
+ // Fire the wake hook for any matched event (incl. ack/like) so the
3413
+ // host can resume its agent.
3414
+ wakeRunner.trigger(event);
3217
3415
  },
3218
3416
  onError: async (result) => {
3219
3417
  const reason = normalizeString(result?.reason) || "poll_failed";
package/src/legacy-cli.js CHANGED
@@ -226,6 +226,8 @@ 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 listen --session <id> --agent <id> Background-poll a session for new events");
229
231
  console.log(" sl session recap now <id> --remote --json Summarize current owners, locks, and work");
230
232
  console.log(" sl session daemon --session <id> Run Senti recaps/checkpoints for long rooms");
231
233
  console.log(" sl session read <id> --tail 20 Read local session stream events");
@@ -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
  {