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 +1 -1
- package/src/commands/session.js +437 -6
- package/src/legacy-cli.js +5 -0
- package/src/session/coordination-guidance.js +17 -0
- package/src/session/sync.js +117 -0
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = "",
|
package/src/session/sync.js
CHANGED
|
@@ -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
|
{
|