sentinelayer-cli 0.30.12 → 0.30.14

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.
@@ -1,12 +1,18 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import process from "node:process";
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
4
 
5
5
  import { createAgentEvent } from "../events/schema.js";
6
+ import {
7
+ buildCliCommandMcpTools,
8
+ createCliCommandMcpToolHandlers,
9
+ } from "./cli-command-tools.js";
6
10
  import { appendToStream } from "../session/stream.js";
11
+ import { listFileLocks, lockFile, unlockFile } from "../session/file-locks.js";
7
12
  import { eventMatchesAgent } from "../session/listener.js";
8
13
  import { isSessionControlEvent } from "../session/control-events.js";
9
14
  import {
15
+ createSessionMessageAction,
10
16
  listSessionMessageActions,
11
17
  pollSessionEvents,
12
18
  pollSessionEventsBefore,
@@ -27,6 +33,16 @@ const SESSION_MCP_CONFIRM_TOTAL_TIMEOUT_MS = 10_000;
27
33
  const SESSION_MCP_CONFIRM_REQUEST_TIMEOUT_MS = 2_000;
28
34
  const JSON_RPC_VERSION = "2.0";
29
35
  const CONTENT_LENGTH_PREFIX = "content-length:";
36
+ const SESSION_MESSAGE_ACTION_TYPES = new Set([
37
+ "ack",
38
+ "working_on",
39
+ "reply",
40
+ "like",
41
+ "dislike",
42
+ "disregard",
43
+ "view",
44
+ ]);
45
+ const SESSION_MESSAGE_ACTION_ALIASES = new Map([["comment", "reply"]]);
30
46
 
31
47
  function normalizeString(value) {
32
48
  return String(value == null ? "" : value).trim();
@@ -51,6 +67,17 @@ function normalizePositiveInteger(value, fallbackValue) {
51
67
  return Math.floor(normalized);
52
68
  }
53
69
 
70
+ function normalizeOptionalPositiveInteger(value, fieldName) {
71
+ if (value === undefined || value === null || String(value).trim() === "") {
72
+ return null;
73
+ }
74
+ const normalized = Number(value);
75
+ if (!Number.isFinite(normalized) || normalized <= 0) {
76
+ throw new Error(`${fieldName} must be a positive integer.`);
77
+ }
78
+ return Math.floor(normalized);
79
+ }
80
+
54
81
  function confirmationRemainingMs(deadlineMs, nowMs) {
55
82
  const now = Number(nowMs()) || Date.now();
56
83
  return Math.max(0, Math.floor(deadlineMs - now));
@@ -85,6 +112,22 @@ function normalizeRecipients(value) {
85
112
  return recipients;
86
113
  }
87
114
 
115
+ function normalizeFileList(value) {
116
+ const items = Array.isArray(value) ? value : String(value || "").split(/[\n,]+/g);
117
+ const seen = new Set();
118
+ const files = [];
119
+ for (const item of items) {
120
+ const file = normalizeString(item);
121
+ if (!file || seen.has(file)) continue;
122
+ seen.add(file);
123
+ files.push(file);
124
+ }
125
+ if (files.length === 0) {
126
+ throw new Error("files must include at least one path.");
127
+ }
128
+ return files;
129
+ }
130
+
88
131
  function isPlainObject(value) {
89
132
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
90
133
  }
@@ -99,6 +142,10 @@ function cleanObject(record = {}) {
99
142
  return cleaned;
100
143
  }
101
144
 
145
+ function shortSha256(value) {
146
+ return createHash("sha256").update(String(value || "")).digest("hex").slice(0, 32);
147
+ }
148
+
102
149
  function eventAgentId(event = {}) {
103
150
  return normalizeAgentId(event?.agent?.id || event?.agentId || event?.agent_id, "");
104
151
  }
@@ -413,6 +460,51 @@ function requireAgentId(input = {}) {
413
460
  return agentId;
414
461
  }
415
462
 
463
+ function normalizeSessionMessageActionType(value) {
464
+ const raw = normalizeString(value).toLowerCase();
465
+ const normalized = SESSION_MESSAGE_ACTION_ALIASES.get(raw) || raw;
466
+ if (!SESSION_MESSAGE_ACTION_TYPES.has(normalized)) {
467
+ throw new Error(
468
+ `actionType must be one of: ${[...SESSION_MESSAGE_ACTION_TYPES].join(", ")}; aliases: comment=reply.`,
469
+ );
470
+ }
471
+ return normalized;
472
+ }
473
+
474
+ function requireSessionActionTarget(input = {}) {
475
+ const targetSequenceId = normalizeOptionalPositiveInteger(
476
+ input.targetSequenceId || input.target_sequence_id || input.sequenceId || input.sequence_id,
477
+ "targetSequenceId",
478
+ );
479
+ const targetCursor = normalizeString(input.targetCursor || input.target_cursor || input.cursor);
480
+ const targetActionId = normalizeString(input.targetActionId || input.target_action_id || input.actionId);
481
+ if (!targetSequenceId && !targetCursor && !targetActionId) {
482
+ throw new Error("Provide targetSequenceId, targetCursor, or targetActionId.");
483
+ }
484
+ return {
485
+ targetSequenceId,
486
+ targetCursor,
487
+ targetActionId,
488
+ };
489
+ }
490
+
491
+ function defaultActionIdempotencyKey({
492
+ actionType,
493
+ targetSequenceId = null,
494
+ targetCursor = "",
495
+ targetActionId = "",
496
+ note = "",
497
+ agentId = "",
498
+ } = {}) {
499
+ const target = targetSequenceId
500
+ ? `seq:${targetSequenceId}`
501
+ : targetActionId
502
+ ? `action:${targetActionId}`
503
+ : `cursor:${normalizeString(targetCursor)}`;
504
+ const actor = normalizeAgentId(agentId, "unknown");
505
+ return `mcp:${normalizeString(actionType).toLowerCase()}:${target}:${actor}:${shortSha256(note)}`;
506
+ }
507
+
416
508
  function buildAgentEnvelope(agentId, input = {}) {
417
509
  return cleanObject({
418
510
  id: agentId,
@@ -463,6 +555,67 @@ function buildSessionMessageEvent({
463
555
  });
464
556
  }
465
557
 
558
+ async function runSessionAction({
559
+ input = {},
560
+ actionType,
561
+ note = "",
562
+ targetPath,
563
+ createSessionMessageActionFn,
564
+ } = {}) {
565
+ const sessionId = requireSessionId(input);
566
+ const agentId = requireAgentId(input);
567
+ const normalizedActionType = normalizeSessionMessageActionType(actionType || input.actionType || input.action_type);
568
+ const target = requireSessionActionTarget(input);
569
+ const normalizedNote = truncateText(note || input.note || input.message || input.text || "").text;
570
+ const idempotencyKey =
571
+ normalizeString(input.idempotencyKey || input.idempotency_key) ||
572
+ defaultActionIdempotencyKey({
573
+ actionType: normalizedActionType,
574
+ ...target,
575
+ note: normalizedNote,
576
+ agentId,
577
+ });
578
+
579
+ if (Boolean(input.dryRun || input.dry_run)) {
580
+ return {
581
+ ok: true,
582
+ dryRun: true,
583
+ sessionId,
584
+ agentId,
585
+ actionType: normalizedActionType,
586
+ ...target,
587
+ note: normalizedNote,
588
+ idempotencyKey,
589
+ };
590
+ }
591
+
592
+ const result = await createSessionMessageActionFn(sessionId, {
593
+ actionType: normalizedActionType,
594
+ targetPath,
595
+ ...target,
596
+ note: normalizedNote,
597
+ metadata: {
598
+ source: "mcp",
599
+ agentId,
600
+ },
601
+ idempotencyKey,
602
+ timeoutMs: normalizePositiveInteger(input.timeoutMs || input.timeout_ms, 15_000),
603
+ });
604
+
605
+ return {
606
+ ok: Boolean(result?.ok),
607
+ reason: normalizeString(result?.reason),
608
+ duplicate: Boolean(result?.duplicate),
609
+ sessionId,
610
+ agentId,
611
+ actionType: normalizedActionType,
612
+ ...target,
613
+ note: normalizedNote,
614
+ idempotencyKey,
615
+ action: result?.action || null,
616
+ };
617
+ }
618
+
466
619
  async function persistSessionEvent({
467
620
  sessionId,
468
621
  event,
@@ -576,6 +729,10 @@ export function createSessionMcpToolHandlers({
576
729
  pollSessionEventsFn = pollSessionEvents,
577
730
  pollSessionEventsBeforeFn = pollSessionEventsBefore,
578
731
  listSessionMessageActionsFn = listSessionMessageActions,
732
+ createSessionMessageActionFn = createSessionMessageAction,
733
+ lockFileFn = lockFile,
734
+ unlockFileFn = unlockFile,
735
+ listFileLocksFn = listFileLocks,
579
736
  syncSessionEventToApiFn = syncSessionEventToApi,
580
737
  appendToStreamFn = appendToStream,
581
738
  sleepFn = sleep,
@@ -678,6 +835,123 @@ export function createSessionMcpToolHandlers({
678
835
  });
679
836
  },
680
837
 
838
+ async session_action(input = {}) {
839
+ return runSessionAction({
840
+ input,
841
+ targetPath,
842
+ createSessionMessageActionFn,
843
+ });
844
+ },
845
+
846
+ async session_react(input = {}) {
847
+ const reaction = normalizeSessionMessageActionType(input.reaction || input.actionType || input.action_type);
848
+ if (!["ack", "like", "dislike"].includes(reaction)) {
849
+ throw new Error("reaction must be one of: ack, like, dislike.");
850
+ }
851
+ return runSessionAction({
852
+ input,
853
+ actionType: reaction,
854
+ targetPath,
855
+ createSessionMessageActionFn,
856
+ });
857
+ },
858
+
859
+ async session_reply(input = {}) {
860
+ return runSessionAction({
861
+ input,
862
+ actionType: "reply",
863
+ note: input.message || input.text || input.note,
864
+ targetPath,
865
+ createSessionMessageActionFn,
866
+ });
867
+ },
868
+
869
+ async session_lock(input = {}) {
870
+ const sessionId = requireSessionId(input);
871
+ const agentId = requireAgentId(input);
872
+ const files = normalizeFileList(input.files || input.file || input.paths);
873
+ const intent = normalizeString(input.intent || input.reason);
874
+ const ttlSeconds = normalizePositiveInteger(input.ttlSeconds || input.ttl_seconds, 300);
875
+ const results = [];
876
+ for (const file of files) {
877
+ const result = await lockFileFn(sessionId, agentId, file, {
878
+ intent,
879
+ ttlSeconds,
880
+ targetPath,
881
+ syncRemote: input.syncRemote !== false && input.sync_remote !== false,
882
+ awaitRemoteSync: input.awaitRemoteSync !== false && input.await_remote_sync !== false,
883
+ });
884
+ results.push({
885
+ file: normalizeString(result?.file) || file,
886
+ locked: Boolean(result?.locked),
887
+ reason: normalizeString(result?.reason) || (result?.locked ? "locked" : "held_by_other_agent"),
888
+ heldBy: normalizeString(result?.heldBy) || null,
889
+ since: normalizeString(result?.since) || null,
890
+ lock: result?.lock || null,
891
+ });
892
+ }
893
+ const failed = results.filter((result) => !result.locked);
894
+ return {
895
+ ok: failed.length === 0,
896
+ reason: failed.length === 0 ? "" : "lock_conflict",
897
+ sessionId,
898
+ agentId,
899
+ lockedCount: results.length - failed.length,
900
+ failedCount: failed.length,
901
+ results,
902
+ };
903
+ },
904
+
905
+ async session_unlock(input = {}) {
906
+ const sessionId = requireSessionId(input);
907
+ const agentId = requireAgentId(input);
908
+ const files = normalizeFileList(input.files || input.file || input.paths);
909
+ const reason = normalizeString(input.reason || input.intent) || "manual_release";
910
+ const results = [];
911
+ for (const file of files) {
912
+ const result = await unlockFileFn(sessionId, agentId, file, {
913
+ reason,
914
+ force: false,
915
+ targetPath,
916
+ syncRemote: input.syncRemote !== false && input.sync_remote !== false,
917
+ awaitRemoteSync: input.awaitRemoteSync !== false && input.await_remote_sync !== false,
918
+ });
919
+ results.push({
920
+ file: normalizeString(result?.file) || file,
921
+ unlocked: Boolean(result?.unlocked),
922
+ reason: normalizeString(result?.reason) || (result?.unlocked ? "unlocked" : "not_locked"),
923
+ heldBy: normalizeString(result?.heldBy) || null,
924
+ since: normalizeString(result?.since) || null,
925
+ lock: result?.lock || null,
926
+ });
927
+ }
928
+ const failed = results.filter((result) => !result.unlocked && result.reason !== "not_locked");
929
+ return {
930
+ ok: failed.length === 0,
931
+ reason: failed.length === 0 ? "" : "unlock_failed",
932
+ sessionId,
933
+ agentId,
934
+ unlockedCount: results.filter((result) => result.unlocked).length,
935
+ skippedCount: results.filter((result) => !result.unlocked && result.reason === "not_locked").length,
936
+ failedCount: failed.length,
937
+ results,
938
+ };
939
+ },
940
+
941
+ async session_locks(input = {}) {
942
+ const sessionId = requireSessionId(input);
943
+ const locks = await listFileLocksFn(sessionId, {
944
+ targetPath,
945
+ });
946
+ return {
947
+ ok: true,
948
+ reason: "",
949
+ sessionId,
950
+ lockCount: Array.isArray(locks) ? locks.length : 0,
951
+ locks: Array.isArray(locks) ? locks : [],
952
+ };
953
+ },
954
+
681
955
  async attention_request(input = {}) {
682
956
  const sessionId = requireSessionId(input);
683
957
  const agentId = requireAgentId(input);
@@ -766,6 +1040,136 @@ export const SESSION_MCP_TOOLS = Object.freeze([
766
1040
  },
767
1041
  },
768
1042
  },
1043
+ {
1044
+ name: "session_action",
1045
+ title: "Record Senti Session Action",
1046
+ description:
1047
+ "Record a low-noise message action such as ack, working_on, disregard, view, like, dislike, or reply against a target session event.",
1048
+ inputSchema: {
1049
+ type: "object",
1050
+ additionalProperties: false,
1051
+ required: ["sessionId", "agentId", "actionType"],
1052
+ properties: {
1053
+ sessionId: { type: "string", minLength: 1 },
1054
+ agentId: { type: "string", minLength: 1 },
1055
+ actionType: { type: "string", enum: [...SESSION_MESSAGE_ACTION_TYPES] },
1056
+ targetSequenceId: { type: "integer", minimum: 1 },
1057
+ targetCursor: { type: "string" },
1058
+ targetActionId: { type: "string" },
1059
+ note: { type: "string", maxLength: MAX_MESSAGE_CHARS },
1060
+ idempotencyKey: { type: "string" },
1061
+ timeoutMs: { type: "integer", minimum: 1, default: 15000 },
1062
+ dryRun: { type: "boolean", default: false },
1063
+ },
1064
+ },
1065
+ },
1066
+ {
1067
+ name: "session_react",
1068
+ title: "React To Senti Message",
1069
+ description:
1070
+ "Acknowledge or react to a target session event with ack, like, or dislike.",
1071
+ inputSchema: {
1072
+ type: "object",
1073
+ additionalProperties: false,
1074
+ required: ["sessionId", "agentId", "reaction"],
1075
+ properties: {
1076
+ sessionId: { type: "string", minLength: 1 },
1077
+ agentId: { type: "string", minLength: 1 },
1078
+ reaction: { type: "string", enum: ["ack", "like", "dislike"] },
1079
+ targetSequenceId: { type: "integer", minimum: 1 },
1080
+ targetCursor: { type: "string" },
1081
+ targetActionId: { type: "string" },
1082
+ idempotencyKey: { type: "string" },
1083
+ timeoutMs: { type: "integer", minimum: 1, default: 15000 },
1084
+ dryRun: { type: "boolean", default: false },
1085
+ },
1086
+ },
1087
+ },
1088
+ {
1089
+ name: "session_reply",
1090
+ title: "Reply In Senti Thread",
1091
+ description:
1092
+ "Add a threaded reply/comment under a specific session event using the session action channel.",
1093
+ inputSchema: {
1094
+ type: "object",
1095
+ additionalProperties: false,
1096
+ required: ["sessionId", "agentId", "targetSequenceId", "message"],
1097
+ properties: {
1098
+ sessionId: { type: "string", minLength: 1 },
1099
+ agentId: { type: "string", minLength: 1 },
1100
+ targetSequenceId: { type: "integer", minimum: 1 },
1101
+ targetCursor: { type: "string" },
1102
+ targetActionId: { type: "string" },
1103
+ message: { type: "string", minLength: 1, maxLength: MAX_MESSAGE_CHARS },
1104
+ idempotencyKey: { type: "string" },
1105
+ timeoutMs: { type: "integer", minimum: 1, default: 15000 },
1106
+ dryRun: { type: "boolean", default: false },
1107
+ },
1108
+ },
1109
+ },
1110
+ {
1111
+ name: "session_lock",
1112
+ title: "Lock Senti Files",
1113
+ description:
1114
+ "Claim session-scoped file locks before editing files, using the same fail-closed lock registry as the CLI.",
1115
+ inputSchema: {
1116
+ type: "object",
1117
+ additionalProperties: false,
1118
+ required: ["sessionId", "agentId", "files"],
1119
+ properties: {
1120
+ sessionId: { type: "string", minLength: 1 },
1121
+ agentId: { type: "string", minLength: 1 },
1122
+ files: {
1123
+ anyOf: [
1124
+ { type: "string" },
1125
+ { type: "array", items: { type: "string" }, minItems: 1 },
1126
+ ],
1127
+ },
1128
+ intent: { type: "string" },
1129
+ ttlSeconds: { type: "integer", minimum: 1, default: 300 },
1130
+ syncRemote: { type: "boolean", default: true },
1131
+ awaitRemoteSync: { type: "boolean", default: true },
1132
+ },
1133
+ },
1134
+ },
1135
+ {
1136
+ name: "session_unlock",
1137
+ title: "Unlock Senti Files",
1138
+ description:
1139
+ "Release session-scoped file locks held by an agent.",
1140
+ inputSchema: {
1141
+ type: "object",
1142
+ additionalProperties: false,
1143
+ required: ["sessionId", "agentId", "files"],
1144
+ properties: {
1145
+ sessionId: { type: "string", minLength: 1 },
1146
+ agentId: { type: "string", minLength: 1 },
1147
+ files: {
1148
+ anyOf: [
1149
+ { type: "string" },
1150
+ { type: "array", items: { type: "string" }, minItems: 1 },
1151
+ ],
1152
+ },
1153
+ reason: { type: "string" },
1154
+ syncRemote: { type: "boolean", default: true },
1155
+ awaitRemoteSync: { type: "boolean", default: true },
1156
+ },
1157
+ },
1158
+ },
1159
+ {
1160
+ name: "session_locks",
1161
+ title: "List Senti File Locks",
1162
+ description:
1163
+ "List active file locks for a session.",
1164
+ inputSchema: {
1165
+ type: "object",
1166
+ additionalProperties: false,
1167
+ required: ["sessionId"],
1168
+ properties: {
1169
+ sessionId: { type: "string", minLength: 1 },
1170
+ },
1171
+ },
1172
+ },
769
1173
  {
770
1174
  name: "attention_request",
771
1175
  title: "Request Senti Attention",
@@ -798,12 +1202,15 @@ export const SESSION_MCP_TOOLS = Object.freeze([
798
1202
  ]);
799
1203
 
800
1204
  function toMcpTool(tool) {
801
- return {
1205
+ return cleanObject({
802
1206
  name: tool.name,
803
1207
  title: tool.title,
804
1208
  description: tool.description,
805
1209
  inputSchema: tool.inputSchema,
806
- };
1210
+ annotations: tool.annotations,
1211
+ security: tool.security,
1212
+ metadata: tool.metadata,
1213
+ });
807
1214
  }
808
1215
 
809
1216
  function buildToolResult(payload, { isError = false } = {}) {
@@ -878,7 +1285,7 @@ export async function handleMcpJsonRpcMessage(
878
1285
  version: "0.20.0",
879
1286
  },
880
1287
  instructions:
881
- "Use poll_inbox with the returned cursor before acting; use send_message for durable session posts and attention_request for help_request events.",
1288
+ "Use native Senti tools for coordination actions and generated sl.* bridge tools for the rest of SentinelLayer CLI. Poll first with poll_inbox, use session_action/session_react/session_reply/session_lock/session_unlock/session_locks for low-noise session work, use send_message for durable posts, and use generated command tools such as sl.auth.status or sl.review.scan for ordinary CLI commands.",
882
1289
  });
883
1290
  }
884
1291
 
@@ -932,6 +1339,24 @@ export async function handleMcpJsonRpcMessage(
932
1339
  return isNotification ? null : jsonRpcError(id, -32601, "Method not found");
933
1340
  }
934
1341
 
1342
+ export async function createSessionMcpRuntime({
1343
+ targetPath = process.cwd(),
1344
+ sessionHandlers = createSessionMcpToolHandlers({ targetPath }),
1345
+ sessionTools = SESSION_MCP_TOOLS,
1346
+ buildCliCommandMcpToolsFn = buildCliCommandMcpTools,
1347
+ createCliCommandMcpToolHandlersFn = createCliCommandMcpToolHandlers,
1348
+ } = {}) {
1349
+ const commandTools = await buildCliCommandMcpToolsFn();
1350
+ return {
1351
+ tools: [...sessionTools, ...commandTools],
1352
+ handlers: {
1353
+ ...sessionHandlers,
1354
+ ...createCliCommandMcpToolHandlersFn(commandTools, { targetPath }),
1355
+ },
1356
+ commandToolCount: commandTools.length,
1357
+ };
1358
+ }
1359
+
935
1360
  function findHeaderSeparator(buffer) {
936
1361
  const crlf = buffer.indexOf("\r\n\r\n");
937
1362
  if (crlf >= 0) {
@@ -1001,9 +1426,14 @@ export async function runMcpStdioServer({
1001
1426
  stderr = process.stderr,
1002
1427
  targetPath = process.cwd(),
1003
1428
  framing = "newline",
1004
- handlers = createSessionMcpToolHandlers({ targetPath }),
1005
- tools = SESSION_MCP_TOOLS,
1429
+ handlers = null,
1430
+ tools = null,
1006
1431
  } = {}) {
1432
+ if (!handlers || !tools) {
1433
+ const runtime = await createSessionMcpRuntime({ targetPath });
1434
+ handlers = handlers || runtime.handlers;
1435
+ tools = tools || runtime.tools;
1436
+ }
1007
1437
  let buffer = Buffer.alloc(0);
1008
1438
  let chain = Promise.resolve();
1009
1439
 
@@ -65,6 +65,14 @@ Focus areas:
65
65
  - CORS misconfiguration allowing unauthorized origins
66
66
  - Insecure deserialization and dynamic code execution (eval, Function)
67
67
 
68
+ MCP / agent-security (clearance-layer specific):
69
+ - Identity or tenant derived from a model/tool ARGUMENT instead of a validated token claim (confused-deputy: tool args are prompt-injectable — authorize on the verified token sub/claims, never a passed-in user_id/tenant)
70
+ - Hosted MCP server passing the inbound client token through to an upstream API instead of a token exchange (RFC 8693 on-behalf-of); token forwarded beyond its audience
71
+ - MCP resource server not validating token AUDIENCE (RFC 8707): accepts a bearer without checking aud == this server, or no PKCE on the OAuth client
72
+ - Agent sandbox / microVM handed a long-lived or user credential instead of a short-lived, narrowly-scoped, per-session token; secrets injected via env or baked into a VM image/snapshot
73
+ - Sandbox egress not default-denied, or the cloud metadata endpoint (169.254.169.254 / IMDS) reachable from guest code
74
+ - VM memory snapshot reused across sessions or tenants (entropy/RNG/credential bleed)
75
+
68
76
  Evidence standard: Every finding MUST include file:line, exploit scenario, and remediation.
69
77
  Do NOT report hypothetical issues without concrete code evidence.`,
70
78
  },
@@ -1,10 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
- import fs from "node:fs";
3
2
  import fsp from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import process from "node:process";
6
5
 
7
6
  import { resolveSessionPaths } from "./paths.js";
7
+ import {
8
+ appendRotatingLogLine,
9
+ normalizeRotatingLogOptions,
10
+ } from "./rotating-log.js";
8
11
 
9
12
  const PID_FILE_NAME = "senti-daemon.json";
10
13
  const LOG_FILE_NAME = "senti-daemon.log";
@@ -132,6 +135,8 @@ export async function spawnDetachedSentiDaemon({
132
135
  targetPath = process.cwd(),
133
136
  cliPath = "",
134
137
  env = process.env,
138
+ logMaxBytes = env.SENTINELAYER_SENTI_LOG_MAX_BYTES,
139
+ logMaxFiles = env.SENTINELAYER_SENTI_LOG_MAX_FILES,
135
140
  } = {}) {
136
141
  const normalizedSessionId = normalizeString(sessionId);
137
142
  if (!normalizedSessionId) {
@@ -157,22 +162,45 @@ export async function spawnDetachedSentiDaemon({
157
162
  }
158
163
 
159
164
  const logPath = resolveDaemonLogPath(normalizedSessionId, { targetPath });
160
- let logFd = null;
165
+ const logOptions = normalizeRotatingLogOptions({ maxBytes: logMaxBytes, maxFiles: logMaxFiles });
161
166
  try {
162
- await fsp.mkdir(path.dirname(logPath), { recursive: true });
163
- logFd = fs.openSync(logPath, "a");
167
+ appendRotatingLogLine(
168
+ logPath,
169
+ `${new Date().toISOString()} spawning Senti daemon for ${normalizedSessionId}`,
170
+ logOptions,
171
+ );
164
172
  const child = spawn(
165
173
  process.execPath,
166
- [entryPath, "session", "daemon", normalizedSessionId, "--path", path.resolve(String(targetPath || "."))],
174
+ [
175
+ entryPath,
176
+ "session",
177
+ "daemon",
178
+ normalizedSessionId,
179
+ "--path",
180
+ path.resolve(String(targetPath || ".")),
181
+ "--log-file",
182
+ logPath,
183
+ "--log-max-bytes",
184
+ String(logOptions.maxBytes),
185
+ "--log-max-files",
186
+ String(logOptions.maxFiles),
187
+ ],
167
188
  {
168
189
  detached: true,
169
- stdio: ["ignore", logFd, logFd],
190
+ stdio: ["ignore", "ignore", "ignore"],
170
191
  windowsHide: true,
171
192
  env,
172
193
  }
173
194
  );
174
195
  child.unref();
175
- return { spawned: true, pid: child.pid ?? null, reason: "spawned", logPath };
196
+ return {
197
+ spawned: true,
198
+ pid: child.pid ?? null,
199
+ reason: "spawned",
200
+ logPath,
201
+ logMaxBytes: logOptions.maxBytes,
202
+ logMaxFiles: logOptions.maxFiles,
203
+ };
176
204
  } catch (error) {
177
205
  return {
178
206
  spawned: false,
@@ -180,13 +208,5 @@ export async function spawnDetachedSentiDaemon({
180
208
  reason: `spawn_failed: ${normalizeString(error?.message) || "unknown"}`,
181
209
  logPath,
182
210
  };
183
- } finally {
184
- if (logFd != null) {
185
- try {
186
- fs.closeSync(logFd);
187
- } catch {
188
- // fd already closed
189
- }
190
- }
191
211
  }
192
212
  }