sentinelayer-cli 0.24.0 → 0.25.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.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -62,6 +62,11 @@ import {
62
62
  updateSessionTitle,
63
63
  } from "../session/store.js";
64
64
  import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
65
+ import {
66
+ acceptSessionInvitation,
67
+ normalizeSessionOnboarding,
68
+ writeSessionOnboardingBrief,
69
+ } from "../session/invitations.js";
65
70
  import { postFirstSentiMessage } from "../session/first-message.js";
66
71
  import { createListenerHostWake } from "../session/wake/listen-wake.js";
67
72
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
@@ -124,6 +129,13 @@ function normalizeString(value) {
124
129
  return String(value || "").trim();
125
130
  }
126
131
 
132
+ function optionWasSetByCli(command, optionName) {
133
+ if (!command || typeof command.getOptionValueSource !== "function") {
134
+ return false;
135
+ }
136
+ return command.getOptionValueSource(optionName) === "cli";
137
+ }
138
+
127
139
  const SESSION_SAY_CONFIRM_ATTEMPTS = 3;
128
140
  const SESSION_SAY_CONFIRM_DELAY_MS = 250;
129
141
  const SESSION_SAY_CONFIRM_PAGE_LIMIT = 200;
@@ -2719,6 +2731,9 @@ export function registerSessionCommand(program) {
2719
2731
  "--agent <id>",
2720
2732
  "Granted agent id to emit an agent_join event as. Behaves like post-agent for human/placeholder ids — those are recorded in the local registry only.",
2721
2733
  )
2734
+ .option("--invite-token <token>", "Invitation token to accept before joining the session")
2735
+ .option("--seat-key <key>", "Reserved session seat key to claim while accepting an invite")
2736
+ .option("--idempotency-key <key>", "Explicit idempotency key for invite acceptance")
2722
2737
  .option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
2723
2738
  .option("--model <model>", "Agent model hint", "cli")
2724
2739
  .option("--path <path>", "Workspace path for the session", ".")
@@ -2729,12 +2744,35 @@ export function registerSessionCommand(program) {
2729
2744
  throw new Error("session id is required.");
2730
2745
  }
2731
2746
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
2747
+ const explicitAgent = normalizeString(options.agent);
2748
+ const legacyName = normalizeString(options.name);
2749
+ const inviteToken = normalizeString(options.inviteToken);
2750
+ let invitationAccept = null;
2751
+ let invitationAcceptResult = null;
2752
+ if (inviteToken) {
2753
+ invitationAccept = await acceptSessionInvitation(normalizedSessionId, {
2754
+ targetPath,
2755
+ invitationToken: inviteToken,
2756
+ seatKey: options.seatKey,
2757
+ agentId: explicitAgent || legacyName,
2758
+ idempotencyKey: options.idempotencyKey,
2759
+ });
2760
+ invitationAcceptResult =
2761
+ invitationAccept?.result && typeof invitationAccept.result === "object"
2762
+ ? invitationAccept.result
2763
+ : null;
2764
+ }
2732
2765
 
2733
2766
  // PR #483 contract: verify the session exists and the caller has access
2734
2767
  // BEFORE materializing local cache state. Without this we'd silently
2735
2768
  // create a phantom local NDJSON for a session that's archived or owned
2736
2769
  // by another tenant — which is the bug Carter reported when asking for
2737
2770
  // a clean "share an id from web → join in CLI" flow.
2771
+ //
2772
+ // Invitation acceptance is the only exception to the old ordering:
2773
+ // before the invite is accepted the user may legitimately receive 403,
2774
+ // so --invite-token performs the guarded accept mutation first and this
2775
+ // verification proves membership immediately afterward.
2738
2776
  const verification = await verifyRemoteSession(normalizedSessionId, { targetPath });
2739
2777
  if (!verification.ok) {
2740
2778
  if (verification.status === 404 || verification.reason === "not_found") {
@@ -2766,17 +2804,26 @@ export function registerSessionCommand(program) {
2766
2804
  targetPath,
2767
2805
  });
2768
2806
 
2769
- const explicitAgent = normalizeString(options.agent);
2770
- const agentSeed = explicitAgent || normalizeString(options.name);
2807
+ const acceptedOnboarding = normalizeSessionOnboarding(
2808
+ invitationAcceptResult?.onboarding,
2809
+ invitationAcceptResult?.claimedSeat,
2810
+ );
2811
+ const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
2812
+ const agentSeed = explicitAgent || acceptedAgentId || legacyName;
2771
2813
  const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
2772
- const role = normalizeString(options.role) || "coder";
2814
+ const roleWasExplicit = optionWasSetByCli(command, "role");
2815
+ const role =
2816
+ normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
2817
+ "coder";
2773
2818
  const model = normalizeString(options.model) || "cli";
2819
+ const hasConcreteAgentIdentity = Boolean(explicitAgent || acceptedAgentId);
2774
2820
 
2775
2821
  // `registerAgent` already writes the canonical `agent_join` event to the
2776
2822
  // local NDJSON and best-effort relays it to /events via appendToStream
2777
2823
  // → syncSessionEventToApi. That gives us the exact `post-agent` parity
2778
- // the spec calls for when `--agent <granted>` is provided. We don't
2779
- // double-emit; we just record whether the explicit agent path was used
2824
+ // the spec calls for when `--agent <granted>` or an accepted reserved
2825
+ // seat provides a concrete agent id. We don't double-emit; we record
2826
+ // whether a durable agent identity path was used
2780
2827
  // so the JSON output can advertise it to callers (and tests).
2781
2828
  const joined = await registerAgent(normalizedSessionId, {
2782
2829
  targetPath,
@@ -2784,15 +2831,21 @@ export function registerSessionCommand(program) {
2784
2831
  model,
2785
2832
  role,
2786
2833
  trackProcessExit: false,
2787
- awaitRemoteSync: Boolean(explicitAgent),
2834
+ awaitRemoteSync: hasConcreteAgentIdentity,
2788
2835
  });
2789
2836
  const agentJoinRelayed =
2790
2837
  joined.emittedJoinEvent !== false &&
2791
- Boolean(explicitAgent) &&
2838
+ hasConcreteAgentIdentity &&
2792
2839
  Boolean(resolvedAgentId) &&
2793
2840
  resolvedAgentId !== "cli-user" &&
2794
2841
  resolvedAgentId !== "unknown" &&
2795
2842
  !resolvedAgentId.startsWith("human-");
2843
+ const onboardingBrief = await writeSessionOnboardingBrief(normalizedSessionId, {
2844
+ targetPath,
2845
+ onboarding: acceptedOnboarding,
2846
+ claimedSeat: invitationAcceptResult?.claimedSeat || null,
2847
+ agentId: joined.agentId,
2848
+ });
2796
2849
 
2797
2850
  const eventCount = Number(remoteSession.eventCount ?? remoteSession.events ?? 0);
2798
2851
  const agents = Array.isArray(remoteSession.agents) ? remoteSession.agents : [];
@@ -2824,6 +2877,21 @@ export function registerSessionCommand(program) {
2824
2877
  agentCount: Number.isFinite(agentCount) ? agentCount : 0,
2825
2878
  lastActivityAt: lastActivityIso || null,
2826
2879
  agentJoinRelayed,
2880
+ invitationAccepted: Boolean(invitationAcceptResult?.ok || invitationAccept),
2881
+ invitationAccept: invitationAccept
2882
+ ? {
2883
+ idempotencyKey: invitationAccept.idempotencyKey,
2884
+ claimedSeat: invitationAcceptResult?.claimedSeat || null,
2885
+ onboarding: acceptedOnboarding,
2886
+ capacity: invitationAcceptResult?.capacity || null,
2887
+ }
2888
+ : null,
2889
+ onboardingGuide: onboardingBrief
2890
+ ? {
2891
+ markdownPath: onboardingBrief.markdownPath,
2892
+ jsonPath: onboardingBrief.jsonPath,
2893
+ }
2894
+ : null,
2827
2895
  };
2828
2896
  if (shouldEmitJson(options, command)) {
2829
2897
  console.log(JSON.stringify(payload, null, 2));
@@ -2836,7 +2904,17 @@ export function registerSessionCommand(program) {
2836
2904
  `Joined session ${titleLabel} (${normalizedSessionId}) — ${payload.eventCount} events, ${payload.agentCount} agents, last activity ${ageLabel}`,
2837
2905
  ),
2838
2906
  );
2907
+ if (payload.invitationAccepted) {
2908
+ const seat = payload.invitationAccept?.claimedSeat || {};
2909
+ const seatLabel = normalizeString(seat.seatKey)
2910
+ ? `; claimed ${normalizeString(seat.seatType) || "reserved"} seat ${seat.seatKey}`
2911
+ : "";
2912
+ console.log(pc.green(`Invitation accepted${seatLabel}`));
2913
+ }
2839
2914
  console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
2915
+ if (payload.onboardingGuide?.markdownPath) {
2916
+ console.log(pc.gray(`onboarding=${payload.onboardingGuide.markdownPath}`));
2917
+ }
2840
2918
  });
2841
2919
 
2842
2920
  session
@@ -19,7 +19,7 @@ import {
19
19
  resolveSentinelDir,
20
20
  } from "./paths.js";
21
21
 
22
- const EVENTS_LOCK_TIMEOUT_MS = 5000;
22
+ const EVENTS_LOCK_TIMEOUT_MS = 15_000;
23
23
  const EVENTS_LOCK_STALE_MS = 30_000;
24
24
  const EVENTS_LOCK_POLL_MS = 25;
25
25
 
package/src/legacy-cli.js CHANGED
@@ -2294,6 +2294,9 @@ ${renderCoordinationNumberedList()}
2294
2294
  - Finding: \`sl session say <id> "finding: [P2] missing rate limit on POST /api/auth/login"\`
2295
2295
  - Help: \`sl session say <id> "help: unexpected change in package.json - who modified it?"\`
2296
2296
  - Done: \`sl session say <id> "done: PR merged, auth hardening complete"\`
2297
+
2298
+ ## Reserved Seat Onboarding
2299
+ - If \`sl session join\` returns \`onboardingGuide.markdownPath\`, read that file before posting; it contains your reserved-seat SOUL instructions, role, and first actions.
2297
2300
  `;
2298
2301
  }
2299
2302
 
@@ -0,0 +1,241 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+
6
+ import { requestJsonMutation } from "../auth/http.js";
7
+ import { resolveActiveAuthSession } from "../auth/service.js";
8
+ import { renderCoordinationBulletList } from "./coordination-guidance.js";
9
+ import { resolveSessionPaths } from "./paths.js";
10
+
11
+ export const SESSION_INVITATION_ACCEPT_ROUTE_ID =
12
+ "POST /api/v1/sessions/{session_id}/invitations/accept";
13
+ export const SESSION_MUTATION_ORIGIN = "https://sentinelayer.com";
14
+
15
+ function normalizeString(value) {
16
+ return String(value || "").trim();
17
+ }
18
+
19
+ function safeFileToken(value, fallback = "onboarding") {
20
+ const normalized = normalizeString(value)
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9._-]+/g, "-")
23
+ .replace(/^-+|-+$/g, "");
24
+ return (normalized || fallback).slice(0, 80);
25
+ }
26
+
27
+ export function canonicalSessionRef(value) {
28
+ const raw = normalizeString(value);
29
+ if (!raw) return "unavailable";
30
+ return raw.toLowerCase();
31
+ }
32
+
33
+ export function createSessionMutationCsrfToken({
34
+ bearerToken,
35
+ sessionId,
36
+ routeId,
37
+ idempotencyKey,
38
+ } = {}) {
39
+ const secret = normalizeString(bearerToken);
40
+ if (!secret) return "";
41
+ const message = [
42
+ "session-mutation-csrf:v1",
43
+ canonicalSessionRef(sessionId),
44
+ normalizeString(routeId),
45
+ normalizeString(idempotencyKey),
46
+ ].join("\0");
47
+ return createHmac("sha256", secret).update(message, "utf-8").digest("hex");
48
+ }
49
+
50
+ export function createSessionMutationHeaders({
51
+ bearerToken,
52
+ sessionId,
53
+ routeId,
54
+ idempotencyKey,
55
+ origin = SESSION_MUTATION_ORIGIN,
56
+ } = {}) {
57
+ const token = normalizeString(bearerToken);
58
+ const normalizedOrigin = normalizeString(origin) || SESSION_MUTATION_ORIGIN;
59
+ return {
60
+ Authorization: `Bearer ${token}`,
61
+ Origin: normalizedOrigin,
62
+ "Sec-Fetch-Site": "same-site",
63
+ "X-Sentinelayer-Session-Mutation": "session-mutation",
64
+ "X-CSRF-Token": createSessionMutationCsrfToken({
65
+ bearerToken: token,
66
+ sessionId,
67
+ routeId,
68
+ idempotencyKey,
69
+ }),
70
+ };
71
+ }
72
+
73
+ export function createSessionMutationIdempotencyKey(operationName = "invitation-accept") {
74
+ const operation = normalizeString(operationName)
75
+ .toLowerCase()
76
+ .replace(/[^a-z0-9]+/g, "-")
77
+ .replace(/^-+|-+$/g, "")
78
+ .slice(0, 32) || "session-mutation";
79
+ return `sl-cli-${operation}-${randomUUID()}`;
80
+ }
81
+
82
+ export async function acceptSessionInvitation(
83
+ sessionId,
84
+ {
85
+ invitationToken,
86
+ seatKey = "",
87
+ agentId = "",
88
+ targetPath = process.cwd(),
89
+ idempotencyKey = "",
90
+ origin = SESSION_MUTATION_ORIGIN,
91
+ resolveAuthSession = resolveActiveAuthSession,
92
+ requestMutation = requestJsonMutation,
93
+ } = {},
94
+ ) {
95
+ const normalizedSessionId = normalizeString(sessionId);
96
+ const normalizedInvitationToken = normalizeString(invitationToken);
97
+ if (!normalizedSessionId) {
98
+ throw new Error("session id is required.");
99
+ }
100
+ if (!normalizedInvitationToken) {
101
+ throw new Error("invite token is required.");
102
+ }
103
+
104
+ const auth = await resolveAuthSession({
105
+ cwd: targetPath,
106
+ env: process.env,
107
+ autoRotate: false,
108
+ });
109
+ if (!auth?.token || !auth?.apiUrl) {
110
+ throw new Error("Not authenticated. Run `sl auth login` first.");
111
+ }
112
+
113
+ const resolvedIdempotencyKey =
114
+ normalizeString(idempotencyKey) || createSessionMutationIdempotencyKey("session-invite-accept");
115
+ const apiUrl = normalizeString(auth.apiUrl).replace(/\/+$/, "");
116
+ const body = {
117
+ token: normalizedInvitationToken,
118
+ };
119
+ const normalizedSeatKey = normalizeString(seatKey);
120
+ const normalizedAgentId = normalizeString(agentId);
121
+ if (normalizedSeatKey) body.seatKey = normalizedSeatKey;
122
+ if (normalizedAgentId) body.agentId = normalizedAgentId;
123
+
124
+ const result = await requestMutation(
125
+ `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/invitations/accept`,
126
+ {
127
+ method: "POST",
128
+ operationName: "session.invitation_accept",
129
+ idempotencyKey: resolvedIdempotencyKey,
130
+ headers: createSessionMutationHeaders({
131
+ bearerToken: auth.token,
132
+ sessionId: normalizedSessionId,
133
+ routeId: SESSION_INVITATION_ACCEPT_ROUTE_ID,
134
+ idempotencyKey: resolvedIdempotencyKey,
135
+ origin,
136
+ }),
137
+ body,
138
+ },
139
+ );
140
+
141
+ return {
142
+ idempotencyKey: resolvedIdempotencyKey,
143
+ result,
144
+ };
145
+ }
146
+
147
+ export function normalizeSessionOnboarding(onboarding = {}, claimedSeat = null) {
148
+ const onboardingSource =
149
+ onboarding && typeof onboarding === "object" && !Array.isArray(onboarding) ? onboarding : {};
150
+ const seatSource =
151
+ claimedSeat && typeof claimedSeat === "object" && !Array.isArray(claimedSeat) ? claimedSeat : {};
152
+ const normalized = {
153
+ seatKey: normalizeString(onboardingSource.seatKey) || normalizeString(seatSource.seatKey) || null,
154
+ agentId:
155
+ normalizeString(onboardingSource.agentId) ||
156
+ normalizeString(seatSource.claimedAgentId) ||
157
+ normalizeString(seatSource.agentId) ||
158
+ null,
159
+ displayName:
160
+ normalizeString(onboardingSource.displayName) || normalizeString(seatSource.displayName) || null,
161
+ role: normalizeString(onboardingSource.role) || normalizeString(seatSource.role) || null,
162
+ instructions:
163
+ normalizeString(onboardingSource.instructions) || normalizeString(seatSource.instructions) || null,
164
+ };
165
+ return Object.values(normalized).some(Boolean) ? normalized : null;
166
+ }
167
+
168
+ function renderOnboardingMarkdown({ sessionId, onboarding, claimedSeat, agentId }) {
169
+ const lines = [
170
+ "# Senti Session Onboarding",
171
+ "",
172
+ `Session: ${sessionId}`,
173
+ `Agent: ${normalizeString(onboarding.agentId) || normalizeString(agentId) || "unknown"}`,
174
+ ];
175
+ if (onboarding.displayName) lines.push(`Display name: ${onboarding.displayName}`);
176
+ if (onboarding.role) lines.push(`Role: ${onboarding.role}`);
177
+ if (onboarding.seatKey) lines.push(`Seat key: ${onboarding.seatKey}`);
178
+ if (claimedSeat?.seatType) lines.push(`Seat type: ${normalizeString(claimedSeat.seatType)}`);
179
+ lines.push("", "## SOUL Instructions", "");
180
+ lines.push(onboarding.instructions || "No seat-specific instructions were provided.");
181
+ lines.push("", "## Session Etiquette", "", renderCoordinationBulletList());
182
+ lines.push(
183
+ "",
184
+ "## First Actions",
185
+ "",
186
+ `- Read recent room context: \`sl session read ${sessionId} --remote --tail 50 --json\``,
187
+ `- Start a quiet listener: \`sl session listen --session ${sessionId} --agent <your-name> --interval 60 --active-interval 5 --emit ndjson --no-presence\``,
188
+ `- Post a short plan before editing: \`sl session say ${sessionId} \"plan: <scope>; files: <paths>\"\``,
189
+ "- Review `AGENTS.md` / `CLAUDE.md`, then keep `tasks/todo.md` current for work status.",
190
+ "- Add any Carter correction pattern to `tasks/lessons.md` before continuing the next slice.",
191
+ );
192
+ return `${lines.join("\n")}\n`;
193
+ }
194
+
195
+ export async function writeSessionOnboardingBrief(
196
+ sessionId,
197
+ {
198
+ onboarding,
199
+ claimedSeat = null,
200
+ agentId = "",
201
+ targetPath = process.cwd(),
202
+ } = {},
203
+ ) {
204
+ const normalizedSessionId = normalizeString(sessionId);
205
+ const normalizedOnboarding = normalizeSessionOnboarding(onboarding, claimedSeat);
206
+ if (!normalizedSessionId || !normalizedOnboarding) {
207
+ return null;
208
+ }
209
+ const paths = resolveSessionPaths(normalizedSessionId, { targetPath });
210
+ const owner = safeFileToken(normalizedOnboarding.agentId || agentId || normalizedOnboarding.seatKey);
211
+ const dir = path.join(paths.sentiDir, "onboarding");
212
+ const jsonPath = path.join(dir, `${owner}.json`);
213
+ const markdownPath = path.join(dir, `${owner}.md`);
214
+ const payload = {
215
+ schemaVersion: "1.0.0",
216
+ sessionId: normalizedSessionId,
217
+ agentId: normalizeString(agentId) || normalizedOnboarding.agentId || null,
218
+ onboarding: normalizedOnboarding,
219
+ claimedSeat: claimedSeat && typeof claimedSeat === "object" ? claimedSeat : null,
220
+ writtenAt: new Date().toISOString(),
221
+ };
222
+ await fsp.mkdir(dir, { recursive: true });
223
+ await Promise.all([
224
+ fsp.writeFile(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8"),
225
+ fsp.writeFile(
226
+ markdownPath,
227
+ renderOnboardingMarkdown({
228
+ sessionId: normalizedSessionId,
229
+ onboarding: normalizedOnboarding,
230
+ claimedSeat,
231
+ agentId,
232
+ }),
233
+ "utf-8",
234
+ ),
235
+ ]);
236
+ return {
237
+ jsonPath,
238
+ markdownPath,
239
+ onboarding: normalizedOnboarding,
240
+ };
241
+ }
@@ -15,6 +15,10 @@ const DEFAULT_LOCK_POLL_MS = 25;
15
15
  const DEFAULT_MAX_STREAM_EVENTS = 10_000;
16
16
  const DEFAULT_REMOTE_REFRESH_TTL_SECONDS = 24 * 60 * 60;
17
17
  const REMOTE_REFRESH_TIMEOUT_MS = 2_500;
18
+ const METADATA_WRITE_RETRY_ATTEMPTS = 5;
19
+ const METADATA_WRITE_RETRY_DELAY_MS = 50;
20
+
21
+ let metadataWriteCounter = 0;
18
22
 
19
23
  function normalizeString(value) {
20
24
  return String(value || "").trim();
@@ -68,11 +72,36 @@ async function readSessionMetadata(paths) {
68
72
  }
69
73
  }
70
74
 
75
+ function isRetryableMetadataWriteError(error) {
76
+ const code = error && typeof error === "object" ? error.code : "";
77
+ return code === "EPERM" || code === "EACCES" || code === "EBUSY";
78
+ }
79
+
80
+ async function replaceMetadataFile(tmpPath, metadataPath) {
81
+ for (let attempt = 1; attempt <= METADATA_WRITE_RETRY_ATTEMPTS; attempt += 1) {
82
+ try {
83
+ await fsp.rename(tmpPath, metadataPath);
84
+ return;
85
+ } catch (error) {
86
+ if (!isRetryableMetadataWriteError(error) || attempt >= METADATA_WRITE_RETRY_ATTEMPTS) {
87
+ throw error;
88
+ }
89
+ await sleep(METADATA_WRITE_RETRY_DELAY_MS * attempt);
90
+ }
91
+ }
92
+ }
93
+
71
94
  async function writeSessionMetadata(paths, metadata = {}) {
72
95
  await fsp.mkdir(paths.sessionDir, { recursive: true });
73
- const tmpPath = `${paths.metadataPath}.${process.pid}.${Date.now()}.tmp`;
96
+ metadataWriteCounter = (metadataWriteCounter + 1) % Number.MAX_SAFE_INTEGER;
97
+ const tmpPath = `${paths.metadataPath}.${process.pid}.${Date.now()}.${metadataWriteCounter}.tmp`;
74
98
  await fsp.writeFile(tmpPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf-8");
75
- await fsp.rename(tmpPath, paths.metadataPath);
99
+ try {
100
+ await replaceMetadataFile(tmpPath, paths.metadataPath);
101
+ } catch (error) {
102
+ await fsp.rm(tmpPath, { force: true }).catch(() => {});
103
+ throw error;
104
+ }
76
105
  }
77
106
 
78
107
  function remoteStatusAllowsLocalRefresh(remoteSession = {}) {