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 +1 -1
- package/src/commands/session.js +85 -7
- package/src/coord/events-log.js +1 -1
- package/src/legacy-cli.js +3 -0
- package/src/session/invitations.js +241 -0
- package/src/session/stream.js +31 -2
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -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
|
|
2770
|
-
|
|
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
|
|
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>`
|
|
2779
|
-
//
|
|
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:
|
|
2834
|
+
awaitRemoteSync: hasConcreteAgentIdentity,
|
|
2788
2835
|
});
|
|
2789
2836
|
const agentJoinRelayed =
|
|
2790
2837
|
joined.emittedJoinEvent !== false &&
|
|
2791
|
-
|
|
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
|
package/src/coord/events-log.js
CHANGED
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
|
+
}
|
package/src/session/stream.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = {}) {
|