sentinelayer-cli 0.21.0 → 0.23.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/README.md CHANGED
@@ -101,6 +101,28 @@ Inputs for non-interactive mode:
101
101
 
102
102
  ## Multi-Agent Session Workflow
103
103
 
104
+ Create a managed session (the golden path — one command):
105
+
106
+ ```bash
107
+ sl session start --title "my room" --force-new
108
+ ```
109
+
110
+ `session start` resumes this workspace's most recent active session when it was
111
+ active within the last hour (`--force-new` always mints a fresh one) and spawns
112
+ the **detached Senti daemon** that manages the room — agent greetings, mention
113
+ routing, recaps, durable checkpoints — surviving your terminal. `--no-daemon`
114
+ opts out; `sl session daemon <id>` runs the manager in the foreground. One
115
+ daemon per session is enforced via `senti-daemon.json` in the session directory
116
+ (logs in `senti-daemon.log` next to it), and the daemon exits on its own when
117
+ the session expires.
118
+
119
+ Then point your agents at it:
120
+
121
+ ```bash
122
+ sl session join <session-id> --agent <agent-name>
123
+ sl session say <session-id> "status: starting on auth middleware" --agent <agent-name>
124
+ ```
125
+
104
126
  Sentinelayer includes a deterministic session coordination surface for multi-agent coding loops:
105
127
 
106
128
  - session event stream and replay (`start`, `join`, `say`, `read`, `status`, `leave`, `list`, `kill`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -753,6 +753,23 @@ export async function runAuditOrchestrator({
753
753
  });
754
754
  const agentPath = path.join(agentsDirectory, `${agent.id}.json`);
755
755
  await fsp.writeFile(agentPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
756
+ emitAuditLifecycleEvent(
757
+ onEvent,
758
+ runId,
759
+ "agent_complete",
760
+ {
761
+ phase: "dispatch",
762
+ agentId: agent.id,
763
+ persona: agent.persona,
764
+ domain: agent.domain,
765
+ status: agentStatus,
766
+ findingCount: findings.length,
767
+ summary,
768
+ confidence,
769
+ durationMs: result.durationMs,
770
+ },
771
+ `${agent.id} persona complete: ${findings.length} finding(s).`
772
+ );
756
773
  return {
757
774
  ...result,
758
775
  artifactPath: agentPath,
@@ -9,6 +9,7 @@ import { writeAuditComparisonArtifact } from "../audit/replay.js";
9
9
  import { loadAuditRegistry, selectAuditAgents } from "../audit/registry.js";
10
10
  import { resolveOutputRoot } from "../config/service.js";
11
11
  import { createAgentEvent } from "../events/schema.js";
12
+ import { createAuditSessionReporter, resolveAuditSessionId } from "../session/audit-reporter.js";
12
13
  import { buildLegacyArgs } from "./legacy-args.js";
13
14
 
14
15
  function shouldEmitJson(options, command) {
@@ -89,6 +90,11 @@ export function registerAuditCommand(program, invokeLegacy) {
89
90
  .option("--no-seed-from-deterministic", "Run personas without deterministic baseline or specialist seed findings")
90
91
  .option("--reuse-omargate <runId>", "Reuse deterministic findings from an OmarGate run id or latest")
91
92
  .option("--stream", "Emit NDJSON agent events to stdout")
93
+ .option(
94
+ "--session <id>",
95
+ "Senti session id to relay audit progress into (defaults to the workspace's most recent active session)"
96
+ )
97
+ .option("--no-session", "Disable senti session progress relay")
92
98
  .option("--json", "Emit machine-readable output")
93
99
  .action(async (targetPathArg, options, command) => {
94
100
  const emitJson = shouldEmitJson(options, command);
@@ -105,18 +111,49 @@ export function registerAuditCommand(program, invokeLegacy) {
105
111
  throw new Error("No agents selected for audit run.");
106
112
  }
107
113
 
108
- const result = await runAuditOrchestrator({
114
+ const auditSessionId = await resolveAuditSessionId({
115
+ targetPath,
116
+ explicitSessionId: typeof options.session === "string" ? options.session : "",
117
+ disabled: options.session === false,
118
+ });
119
+ const sessionReporter = createAuditSessionReporter({
120
+ sessionId: auditSessionId,
109
121
  targetPath,
110
- agents: selected.selected,
111
- maxParallel: parseMaxParallel(options.maxParallel),
112
- outputDir: options.outputDir,
113
- dryRun: Boolean(options.dryRun),
114
- refreshIngest: Boolean(options.refresh),
115
- isolation: parseIsolationMode(options.isolation),
116
- seedFromDeterministic: options.seedFromDeterministic !== false,
117
- reuseOmarGate: options.reuseOmargate,
118
- onEvent: buildAuditOrchestratorEventHandler(emitStream),
119
122
  });
123
+ const streamHandler = buildAuditOrchestratorEventHandler(emitStream);
124
+ const onEvent =
125
+ streamHandler || sessionReporter
126
+ ? (evt) => {
127
+ if (streamHandler) {
128
+ streamHandler(evt);
129
+ }
130
+ if (sessionReporter) {
131
+ sessionReporter.handleEvent(evt);
132
+ }
133
+ }
134
+ : null;
135
+
136
+ let result;
137
+ try {
138
+ result = await runAuditOrchestrator({
139
+ targetPath,
140
+ agents: selected.selected,
141
+ maxParallel: parseMaxParallel(options.maxParallel),
142
+ outputDir: options.outputDir,
143
+ dryRun: Boolean(options.dryRun),
144
+ refreshIngest: Boolean(options.refresh),
145
+ isolation: parseIsolationMode(options.isolation),
146
+ seedFromDeterministic: options.seedFromDeterministic !== false,
147
+ reuseOmarGate: options.reuseOmargate,
148
+ onEvent,
149
+ });
150
+ } catch (error) {
151
+ if (sessionReporter) {
152
+ await sessionReporter.failed(error);
153
+ }
154
+ throw error;
155
+ }
156
+ const sessionRelay = sessionReporter ? await sessionReporter.completed(result) : null;
120
157
 
121
158
  const payload = {
122
159
  command: "audit",
@@ -144,12 +181,23 @@ export function registerAuditCommand(program, invokeLegacy) {
144
181
  ddPackageFindingsPath: result.ddPackage?.findingsIndexPath || "",
145
182
  ddPackageSummaryPath: result.ddPackage?.executiveSummaryPath || "",
146
183
  ingestRefresh: result.ingest?.refresh || null,
184
+ sessionId: auditSessionId || "",
185
+ sessionRelay: sessionRelay || null,
147
186
  };
148
187
 
149
188
  if (emitJson) {
150
189
  console.log(JSON.stringify(payload, null, 2));
151
190
  } else if (!emitStream) {
152
191
  printAuditSummary(result);
192
+ if (auditSessionId) {
193
+ console.log(
194
+ pc.gray(
195
+ `Senti session: ${auditSessionId} (posted ${sessionRelay?.posted ?? 0} update(s)${
196
+ sessionRelay?.failed ? `, ${sessionRelay.failed} failed` : ""
197
+ })`
198
+ )
199
+ );
200
+ }
153
201
  }
154
202
 
155
203
  if (result.summary.blocking) {
@@ -33,6 +33,12 @@ import {
33
33
  unregisterAgent,
34
34
  } from "../session/agent-registry.js";
35
35
  import { startSenti, stopSenti } from "../session/daemon.js";
36
+ import {
37
+ getDaemonStatus,
38
+ removeDaemonPidRecord,
39
+ spawnDetachedSentiDaemon,
40
+ writeDaemonPidRecord,
41
+ } from "../session/daemon-spawn.js";
36
42
  import { listRuntimeRuns } from "../session/runtime-bridge.js";
37
43
  import {
38
44
  listFileLocks,
@@ -77,6 +83,7 @@ import {
77
83
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
78
84
  import { mergeLiveSources } from "../session/live-source.js";
79
85
  import { listenSessionEvents } from "../session/listener.js";
86
+ import { SESSION_LIVE_SUCCESS_TIPS } from "../session/coordination-guidance.js";
80
87
  import { buildSessionRecap } from "../session/recap.js";
81
88
  import { computeTranscriptStats } from "../session/transcript.js";
82
89
  import { deriveSessionTitle } from "../session/senti-naming.js";
@@ -780,6 +787,42 @@ function sentiAutostartDisabled() {
780
787
  return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
781
788
  }
782
789
 
790
+ export function formatSentiDaemonStatusLine(sentiDaemon = {}, { cliCommand = "sl", sessionId = "" } = {}) {
791
+ if (sentiDaemon.spawned) {
792
+ return {
793
+ tone: "green",
794
+ text: `Senti: managing this session (daemon pid ${sentiDaemon.pid}, detached — survives this terminal). Log: ${sentiDaemon.logPath}`,
795
+ };
796
+ }
797
+ if (sentiDaemon.reason === "already_running") {
798
+ return {
799
+ tone: "green",
800
+ text: `Senti: already managing this session (daemon pid ${sentiDaemon.pid}).`,
801
+ };
802
+ }
803
+ if (sentiDaemon.reason === "disabled" || sentiDaemon.reason === "opt_out") {
804
+ return {
805
+ tone: "gray",
806
+ text: `Senti daemon skipped (${sentiDaemon.reason === "opt_out" ? "--no-daemon" : "SENTINELAYER_SKIP_SENTI_AUTOSTART=1"}); session is unmanaged. Start manually: ${cliCommand} session daemon ${sessionId}`,
807
+ };
808
+ }
809
+ return {
810
+ tone: "yellow",
811
+ text: `! Senti daemon not started (${sentiDaemon.reason || "unknown"}); session is unmanaged. Start manually: ${cliCommand} session daemon ${sessionId}`,
812
+ };
813
+ }
814
+
815
+ function printSentiDaemonStatusLine(sentiDaemon, context) {
816
+ const line = formatSentiDaemonStatusLine(sentiDaemon, context);
817
+ if (line.tone === "green") {
818
+ console.log(pc.green(line.text));
819
+ } else if (line.tone === "yellow") {
820
+ console.log(pc.yellow(line.text));
821
+ } else {
822
+ console.log(pc.gray(line.text));
823
+ }
824
+ }
825
+
783
826
  function buildResumeContext(candidate, { reuseWindowSeconds = 3600 } = {}) {
784
827
  if (!candidate) return null;
785
828
  const source = normalizeString(candidate._source) || "unknown";
@@ -1200,7 +1243,7 @@ async function ensureLocalSessionForRemoteCommand(
1200
1243
  return { materialized: true, refreshed: false, session: created };
1201
1244
  }
1202
1245
 
1203
- async function ensureWorkspaceSession({
1246
+ export async function ensureWorkspaceSession({
1204
1247
  targetPath,
1205
1248
  ttlSeconds = DEFAULT_TTL_SECONDS,
1206
1249
  template = null,
@@ -1405,6 +1448,41 @@ function formatListenerCatchupNotice(catchup = {}) {
1405
1448
  ].join(" ");
1406
1449
  }
1407
1450
 
1451
+ // Periodic in-session coaching reminder surfaced by `session listen`. Keeps
1452
+ // agents continually nudged toward good coordination (ack, claim work, reply
1453
+ // in-thread, surface findings). `tick` makes each emission idempotent so the
1454
+ // same reminder is not deduped across the run.
1455
+ export function buildSessionCoachingEvent({
1456
+ sessionId,
1457
+ agentId,
1458
+ agentModel = "cli",
1459
+ displayName = "",
1460
+ listenerId = "",
1461
+ tick = 0,
1462
+ tips = SESSION_LIVE_SUCCESS_TIPS,
1463
+ } = {}) {
1464
+ const tipList = Array.isArray(tips) && tips.length ? tips : SESSION_LIVE_SUCCESS_TIPS;
1465
+ return createAgentEvent({
1466
+ event: "session_coaching",
1467
+ sessionId,
1468
+ agent: {
1469
+ id: agentId,
1470
+ model: normalizeString(agentModel) || "cli",
1471
+ role: "listener",
1472
+ displayName: normalizeString(displayName) || agentId,
1473
+ clientKind: "cli",
1474
+ },
1475
+ eventId: `session-coaching-${listenerId || agentId}-${tick}`,
1476
+ idempotencyToken: `session-coaching:${listenerId || agentId}:${tick}`,
1477
+ payload: compactPayload({
1478
+ source: "session_listen",
1479
+ kind: "coaching",
1480
+ message: "Session success reminders:",
1481
+ tips: [...tipList],
1482
+ }),
1483
+ });
1484
+ }
1485
+
1408
1486
  function buildListenerCatchupEvent({
1409
1487
  sessionId,
1410
1488
  agentId,
@@ -1708,6 +1786,41 @@ async function resolveSessionAgentEnvelope(
1708
1786
  return Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined));
1709
1787
  }
1710
1788
 
1789
+ // Builds the lock/unlock say-convention directive the session daemon parses
1790
+ // into the authoritative file-lock registry. Kept pure + exported for testing.
1791
+ export function buildSessionLockDirective(verb, file, intent = "") {
1792
+ const normalizedFile = normalizeString(file);
1793
+ const normalizedIntent = normalizeString(intent);
1794
+ if (verb === "unlock") {
1795
+ return `unlock: ${normalizedFile} - ${normalizedIntent || "done"}`;
1796
+ }
1797
+ return normalizedIntent ? `lock: ${normalizedFile} - ${normalizedIntent}` : `lock: ${normalizedFile}`;
1798
+ }
1799
+
1800
+ // Posts a coordination directive (e.g. "lock: <file> - <intent>") as a session
1801
+ // message so the session daemon processes it into the authoritative file-lock
1802
+ // registry. Used by `session lock`/`unlock` as ergonomic sugar over the
1803
+ // say-convention; locks are advisory + daemon-enforced with TTL auto-release,
1804
+ // so this is a best-effort post.
1805
+ async function postSessionDirectiveMessage(sessionId, message, {
1806
+ agentId,
1807
+ targetPath = process.cwd(),
1808
+ } = {}) {
1809
+ const clientMessageId = `cli-${randomUUID()}`;
1810
+ const agent = await resolveSessionAgentEnvelope(sessionId, agentId, { targetPath });
1811
+ await ensureSessionSayAgentRegistered(sessionId, agent, { targetPath });
1812
+ const event = createAgentEvent({
1813
+ event: "session_message",
1814
+ agent,
1815
+ sessionId,
1816
+ payload: { message, channel: "session", clientMessageId },
1817
+ });
1818
+ event.eventId = clientMessageId;
1819
+ event.idempotencyToken = clientMessageId;
1820
+ const result = await syncSessionEventToApi(sessionId, event, { targetPath });
1821
+ return { event, result };
1822
+ }
1823
+
1711
1824
  async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
1712
1825
  const normalizedItems = Array.isArray(items) ? items : [];
1713
1826
  const normalizedConcurrency = Math.max(
@@ -2107,7 +2220,7 @@ export function registerSessionCommand(program) {
2107
2220
  session
2108
2221
  .command("start")
2109
2222
  .description(
2110
- "Start (or resume) a persistent session. By default reuses the most recent active session for this workspace; pass --force-new to always mint a fresh id.",
2223
+ "Start (or resume) a managed session. Reuses this workspace's most recent active session when it was active within the last hour (--force-new always mints a fresh id), then spawns the detached Senti daemon that manages it — agent greetings, mention routing, recaps, checkpoints — surviving this terminal. Pass --no-daemon for an unmanaged session.",
2111
2224
  )
2112
2225
  .option("--path <path>", "Workspace path for the session", ".")
2113
2226
  .option("--title <title>", "Human-readable label (shown in web sidebar + transcript)")
@@ -2137,6 +2250,10 @@ export function registerSessionCommand(program) {
2137
2250
  "Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
2138
2251
  "3600",
2139
2252
  )
2253
+ .option(
2254
+ "--no-daemon",
2255
+ "Do not spawn the detached Senti daemon (session will be unmanaged: no greetings, recaps, or checkpoints)",
2256
+ )
2140
2257
  .option("--json", "Emit machine-readable output")
2141
2258
  .action(async (options, command) => {
2142
2259
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
@@ -2219,19 +2336,25 @@ export function registerSessionCommand(program) {
2219
2336
  }).catch(() => {});
2220
2337
  }
2221
2338
 
2222
- // Auto-start the Senti orchestrator daemon. Without this, every
2223
- // session ran with `Senti actions: 1` (just the welcome alert)
2224
- // because nothing kicked the daemon ticking agents joining
2225
- // never got greeted, mentions never routed, recaps never fired.
2226
- // Best-effort + non-blocking: the daemon registers itself in an
2227
- // in-memory map keyed by (sessionId, targetPath) and tolerates
2228
- // being started for an already-active session (returns the
2229
- // existing handle). If the daemon fails to start (unauth env,
2230
- // missing model proxy), the session keeps working — Senti just
2231
- // stays quiet, same as before this change.
2232
- if (!sentiAutostartDisabled()) {
2233
- void startSenti(created.sessionId, { targetPath }).catch(() => {});
2339
+ // Make the session managed by default: spawn the Senti daemon as a
2340
+ // DETACHED process so greetings, mention routing, recaps, and
2341
+ // checkpoints keep running after this CLI command (and terminal)
2342
+ // exits. The old in-process `startSenti` died the moment this
2343
+ // action returned, so every session was effectively unmanaged.
2344
+ // Deduped via the session's pid file; best-effort and never blocks
2345
+ // session creation.
2346
+ let sentiDaemon = { spawned: false, pid: null, reason: "skipped", logPath: "" };
2347
+ if (sentiAutostartDisabled()) {
2348
+ sentiDaemon.reason = "disabled";
2349
+ } else if (options.daemon === false) {
2350
+ sentiDaemon.reason = "opt_out";
2351
+ } else {
2352
+ sentiDaemon = await spawnDetachedSentiDaemon({
2353
+ sessionId: created.sessionId,
2354
+ targetPath,
2355
+ });
2234
2356
  }
2357
+ payload.sentiDaemon = sentiDaemon;
2235
2358
 
2236
2359
  if (shouldEmitJson(options, command)) {
2237
2360
  console.log(JSON.stringify(payload, null, 2));
@@ -2253,6 +2376,7 @@ export function registerSessionCommand(program) {
2253
2376
  }
2254
2377
  console.log("");
2255
2378
  console.log(`Dashboard: ${dashboardUrl}`);
2379
+ printSentiDaemonStatusLine(sentiDaemon, { cliCommand, sessionId: created.sessionId });
2256
2380
  return;
2257
2381
  }
2258
2382
 
@@ -2278,6 +2402,13 @@ export function registerSessionCommand(program) {
2278
2402
  console.log(
2279
2403
  `status=${created.status} created_at=${created.createdAt} expires_at=${created.expiresAt} ttl_seconds=${ttlSeconds}`,
2280
2404
  );
2405
+ console.log(pc.gray(`Dashboard: ${dashboardUrl}`));
2406
+ printSentiDaemonStatusLine(sentiDaemon, { cliCommand, sessionId: created.sessionId });
2407
+ console.log(
2408
+ pc.gray(
2409
+ `Agents join with: ${cliCommand} session join ${created.sessionId} --agent <name>`,
2410
+ ),
2411
+ );
2281
2412
  if (remoteSync.status === "auth_required") {
2282
2413
  console.log(
2283
2414
  pc.yellow(
@@ -2290,7 +2421,9 @@ export function registerSessionCommand(program) {
2290
2421
  if (!resumed) {
2291
2422
  console.log(
2292
2423
  pc.gray(
2293
- `Tip: subsequent \`${cliCommand} session start\` in this workspace within an hour will resume this session. Pass --force-new to override.`,
2424
+ options.forceNew
2425
+ ? `Tip: fresh session minted (--force-new honored). Subsequent \`${cliCommand} session start\` here within an hour will resume this new session.`
2426
+ : `Tip: subsequent \`${cliCommand} session start\` in this workspace within an hour will resume this session. Pass --force-new to override.`,
2294
2427
  ),
2295
2428
  );
2296
2429
  }
@@ -3225,6 +3358,121 @@ export function registerSessionCommand(program) {
3225
3358
  return payload;
3226
3359
  });
3227
3360
 
3361
+ const runLockDirectiveCommand = async (verb, sessionId, files, options, command) => {
3362
+ const normalizedSessionId = normalizeString(sessionId);
3363
+ if (!normalizedSessionId) {
3364
+ throw new Error("session id is required.");
3365
+ }
3366
+ const fileList = (Array.isArray(files) ? files : [files])
3367
+ .map((file) => normalizeString(file))
3368
+ .filter(Boolean);
3369
+ if (fileList.length === 0) {
3370
+ throw new Error(`session ${verb} requires at least one file path.`);
3371
+ }
3372
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3373
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3374
+ const identity = await resolveMessageActionIdentity({
3375
+ sessionId: normalizedSessionId,
3376
+ optionAgent: options.agent,
3377
+ targetPath,
3378
+ env: process.env,
3379
+ });
3380
+ if (shouldBlockImplicitCliUserSessionSay(identity)) {
3381
+ throw new Error(
3382
+ identity.identityWarning ||
3383
+ `session ${verb} requires an agent identity; pass --agent <id>, set SENTINELAYER_AGENT_ID, or run session join --agent <id> first.`,
3384
+ );
3385
+ }
3386
+ const intent = normalizeString(options.intent);
3387
+ const processed = [];
3388
+ for (const file of fileList) {
3389
+ const directive = buildSessionLockDirective(verb, file, intent);
3390
+ await postSessionDirectiveMessage(normalizedSessionId, directive, {
3391
+ agentId: identity.agentId,
3392
+ targetPath,
3393
+ });
3394
+ processed.push(file);
3395
+ }
3396
+ const payload = {
3397
+ command: `session ${verb}`,
3398
+ sessionId: normalizedSessionId,
3399
+ agentId: identity.agentId,
3400
+ files: processed,
3401
+ };
3402
+ if (shouldEmitJson(options, command)) {
3403
+ console.log(JSON.stringify(payload, null, 2));
3404
+ return payload;
3405
+ }
3406
+ const action = verb === "lock" ? "Requested lock on" : "Released";
3407
+ console.log(pc.green(`${action} ${processed.length} file(s) as ${identity.agentId}: ${processed.join(", ")}`));
3408
+ if (verb === "lock") {
3409
+ console.log(
3410
+ pc.gray("Senti enforces fail-closed; locks auto-release on TTL. Release with `sl session unlock`."),
3411
+ );
3412
+ }
3413
+ return payload;
3414
+ };
3415
+
3416
+ session
3417
+ .command("lock <sessionId> <files...>")
3418
+ .description("Claim exclusive file locks via Senti (fail-closed, TTL auto-release)")
3419
+ .option("--intent <text>", "Why you're locking these files (shown to peers)")
3420
+ .option("--agent <id>", "Agent id claiming the lock (defaults to the joined session agent)")
3421
+ .option("--path <path>", "Workspace path for the session", ".")
3422
+ .option("--json", "Emit machine-readable output")
3423
+ .action(async (sessionId, files, options, command) => {
3424
+ await runLockDirectiveCommand("lock", sessionId, files, options, command);
3425
+ });
3426
+
3427
+ session
3428
+ .command("unlock <sessionId> <files...>")
3429
+ .description("Release file locks you hold (Senti only lets the holder release)")
3430
+ .option("--intent <text>", "Optional note on the release")
3431
+ .option("--agent <id>", "Agent id releasing the lock (defaults to the joined session agent)")
3432
+ .option("--path <path>", "Workspace path for the session", ".")
3433
+ .option("--json", "Emit machine-readable output")
3434
+ .action(async (sessionId, files, options, command) => {
3435
+ await runLockDirectiveCommand("unlock", sessionId, files, options, command);
3436
+ });
3437
+
3438
+ session
3439
+ .command("locks <sessionId>")
3440
+ .description("List active file locks for the session (who holds what, and when they expire)")
3441
+ .option("--path <path>", "Workspace path for the session", ".")
3442
+ .option("--json", "Emit machine-readable output")
3443
+ .action(async (sessionId, options, command) => {
3444
+ const normalizedSessionId = normalizeString(sessionId);
3445
+ if (!normalizedSessionId) {
3446
+ throw new Error("session id is required.");
3447
+ }
3448
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3449
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3450
+ const locks = await listFileLocks(normalizedSessionId, { targetPath });
3451
+ const lockList = Array.isArray(locks) ? locks : [];
3452
+ const payload = {
3453
+ command: "session locks",
3454
+ sessionId: normalizedSessionId,
3455
+ count: lockList.length,
3456
+ locks: lockList,
3457
+ };
3458
+ if (shouldEmitJson(options, command)) {
3459
+ console.log(JSON.stringify(payload, null, 2));
3460
+ return payload;
3461
+ }
3462
+ if (lockList.length === 0) {
3463
+ console.log(pc.gray("No active file locks."));
3464
+ return payload;
3465
+ }
3466
+ console.log(pc.bold(`Active file locks (${lockList.length})`));
3467
+ for (const lock of lockList) {
3468
+ const file = normalizeString(lock.file || lock.filePath) || "(unknown file)";
3469
+ const holder = normalizeString(lock.agentId) || "unknown";
3470
+ const expires = normalizeString(lock.expiresAt);
3471
+ console.log(pc.cyan(` ${file}`) + pc.gray(` held by ${holder}${expires ? ` · expires ${expires}` : ""}`));
3472
+ }
3473
+ return payload;
3474
+ });
3475
+
3228
3476
  session
3229
3477
  .command("listen")
3230
3478
  .description("Background-poll a session for events addressed to this agent or broadcast")
@@ -3272,6 +3520,12 @@ export function registerSessionCommand(program) {
3272
3520
  "--wake <command>",
3273
3521
  "Wake hook: run this shell command on each matched event (notify->resume bridge). Event JSON is piped to stdin; SL_WAKE_* env vars are set.",
3274
3522
  )
3523
+ .option(
3524
+ "--coaching-interval <seconds>",
3525
+ "Seconds between in-session success reminders (ack, claim work, reply in-thread). Default 900; 0 disables.",
3526
+ "900",
3527
+ )
3528
+ .option("--no-coaching", "Do not emit periodic in-session success reminders")
3275
3529
  .action(async (options) => {
3276
3530
  const normalizedSessionId = resolveSessionIdOption(options);
3277
3531
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
@@ -3345,6 +3599,44 @@ export function registerSessionCommand(program) {
3345
3599
  const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
3346
3600
  let lastPresenceHeartbeatMs = 0;
3347
3601
 
3602
+ // Periodic in-session success reminders (ack, claim work, reply
3603
+ // in-thread). Long-running interactive listeners only — skipped under
3604
+ // --max-polls (smoke/test) and when --no-coaching is set.
3605
+ const coachingIntervalSeconds =
3606
+ options.coaching === false
3607
+ ? 0
3608
+ : parsePositiveInteger(options.coachingInterval, "coaching-interval", 900);
3609
+ let coachingTick = 0;
3610
+ const emitCoaching = () => {
3611
+ if (emitFormat === "ndjson") {
3612
+ console.log(
3613
+ JSON.stringify(
3614
+ buildSessionCoachingEvent({
3615
+ sessionId: normalizedSessionId,
3616
+ agentId,
3617
+ agentModel,
3618
+ displayName,
3619
+ listenerId,
3620
+ tick: coachingTick++,
3621
+ }),
3622
+ ),
3623
+ );
3624
+ } else {
3625
+ console.log(pc.cyan("Session success reminders:"));
3626
+ for (const tip of SESSION_LIVE_SUCCESS_TIPS) {
3627
+ console.log(pc.gray(` - ${tip}`));
3628
+ }
3629
+ }
3630
+ };
3631
+ let coachingTimer = null;
3632
+ if (coachingIntervalSeconds > 0 && maxPolls === null) {
3633
+ emitCoaching();
3634
+ coachingTimer = setInterval(emitCoaching, coachingIntervalSeconds * 1000);
3635
+ if (coachingTimer && typeof coachingTimer.unref === "function") {
3636
+ coachingTimer.unref();
3637
+ }
3638
+ }
3639
+
3348
3640
  if (emitFormat === "text") {
3349
3641
  console.log(
3350
3642
  pc.gray(
@@ -3455,14 +3747,20 @@ export function registerSessionCommand(program) {
3455
3747
  },
3456
3748
  });
3457
3749
  } finally {
3750
+ if (coachingTimer) {
3751
+ clearInterval(coachingTimer);
3752
+ }
3458
3753
  process.removeListener("SIGINT", onSigint);
3459
3754
  }
3460
3755
  });
3461
3756
 
3462
3757
  session
3463
3758
  .command("daemon [sessionId]")
3464
- .description("Run the Senti session daemon: hydrate events, emit recaps, and generate checkpoints")
3759
+ .description(
3760
+ "Run the Senti daemon that manages a session: greet joining agents, route mentions, emit recaps, and generate durable checkpoints. `session start` spawns this automatically as a detached background process — run it manually only for foreground monitoring or after --no-daemon. Records its pid in the session dir (senti-daemon.json) and exits when the session expires.",
3761
+ )
3465
3762
  .option("--session <id>", "Session id to monitor")
3763
+ .option("--force", "Take over even if senti-daemon.json reports another live daemon for this session")
3466
3764
  .option("--path <path>", "Workspace path for the session", ".")
3467
3765
  .option("--tick-interval <seconds>", "Seconds between health ticks (default 30)", "30")
3468
3766
  .option("--stale-agent-seconds <seconds>", "Seconds before an inactive agent is flagged stale (default 90)", "90")
@@ -3490,6 +3788,18 @@ export function registerSessionCommand(program) {
3490
3788
  "tick-interval",
3491
3789
  30,
3492
3790
  );
3791
+ // One manager per session: refuse to double-run unless --force.
3792
+ // A stale pid file (reboot, hard kill) reads as not-running and is
3793
+ // safely overwritten.
3794
+ if (!options.once) {
3795
+ const existingDaemon = await getDaemonStatus(normalizedSessionId, { targetPath });
3796
+ if (existingDaemon.running && existingDaemon.pid !== process.pid && !options.force) {
3797
+ throw new Error(
3798
+ `A Senti daemon is already managing session ${normalizedSessionId} (pid ${existingDaemon.pid}). Use --force to take over.`,
3799
+ );
3800
+ }
3801
+ await writeDaemonPidRecord(normalizedSessionId, { targetPath, tickIntervalMs });
3802
+ }
3493
3803
  const daemon = await startSenti(normalizedSessionId, {
3494
3804
  targetPath,
3495
3805
  autoStart: false,
@@ -3591,10 +3901,28 @@ export function registerSessionCommand(program) {
3591
3901
  } else {
3592
3902
  console.log(pc.gray(`tick ${payload.summary.generatedAt}: recap=${payload.summary.recap.reason || payload.summary.recap.mode || "ok"} checkpoint=${payload.summary.checkpoint.reason || payload.summary.checkpoint.checkpointId || "ok"}`));
3593
3903
  }
3904
+ // A daemon must not outlive its session: stop cleanly once the
3905
+ // session expires or its local cache disappears.
3906
+ const liveSession = await getSession(normalizedSessionId, { targetPath }).catch(() => null);
3907
+ const expiresAtMs = Date.parse(liveSession?.expiresAt || "");
3908
+ if (
3909
+ !liveSession ||
3910
+ liveSession.status !== "active" ||
3911
+ (Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now())
3912
+ ) {
3913
+ if (!emitJson) {
3914
+ console.log(pc.gray("senti daemon: session expired or closed; stopping."));
3915
+ }
3916
+ break;
3917
+ }
3594
3918
  }
3595
3919
  } finally {
3596
3920
  process.removeListener("SIGINT", stop);
3597
3921
  process.removeListener("SIGTERM", stop);
3922
+ await removeDaemonPidRecord(normalizedSessionId, {
3923
+ targetPath,
3924
+ onlyForPid: process.pid,
3925
+ }).catch(() => {});
3598
3926
  const stopped = await daemon.stop("signal");
3599
3927
  if (emitJson) {
3600
3928
  console.log(
package/src/legacy-cli.js CHANGED
@@ -227,6 +227,9 @@ function printUsage() {
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
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)");
230
233
  console.log(" sl session listen --session <id> --agent <id> Background-poll a session for new events");
231
234
  console.log(" sl session recap now <id> --remote --json Summarize current owners, locks, and work");
232
235
  console.log(" sl session daemon --session <id> Run Senti recaps/checkpoints for long rooms");
@@ -2179,6 +2182,7 @@ export function buildHandoffPrompt({
2179
2182
  buildFromExistingRepo,
2180
2183
  authMode,
2181
2184
  codingAgent,
2185
+ sessionId,
2182
2186
  }) {
2183
2187
  const codingAgentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
2184
2188
  const codingAgentConfigPath = codingAgentProfile.configFile || "none";
@@ -2242,7 +2246,16 @@ Repo context:
2242
2246
  - Target repo: ${repoSlug || "not provided"}
2243
2247
  - Workspace mode: ${buildFromExistingRepo ? "existing codebase" : "new scaffold"}
2244
2248
 
2245
- ## Multi-Agent Coordination (if session active)
2249
+ ${
2250
+ String(sessionId || "").trim()
2251
+ ? `## Multi-Agent Coordination
2252
+
2253
+ Project senti session (auto-created at init): \`${String(sessionId).trim()}\`
2254
+ - Join before starting work: \`sl session join ${String(sessionId).trim()} --agent <your-agent-name>\`
2255
+ - Post status updates as you work: \`sl session say ${String(sessionId).trim()} "<update>" --agent <your-agent-name>\`
2256
+ - Audit runs (\`sentinel /audit\`) relay per-persona progress into this session automatically, so swarm agents can watch each other's findings without losing context.`
2257
+ : `## Multi-Agent Coordination (if session active)`
2258
+ }
2246
2259
 
2247
2260
  ${renderCoordinationNumberedList()}
2248
2261
 
@@ -2574,6 +2587,7 @@ async function writeInitConfigLockfile({
2574
2587
  secretName,
2575
2588
  repoSlug,
2576
2589
  workflowPath,
2590
+ sessionId,
2577
2591
  }) {
2578
2592
  const lockDir = path.join(projectDir, ".sentinelayer");
2579
2593
  const configPath = path.join(lockDir, "config.json");
@@ -2585,6 +2599,7 @@ async function writeInitConfigLockfile({
2585
2599
  required_secret_name: String(secretName || "SENTINELAYER_TOKEN").trim() || "SENTINELAYER_TOKEN",
2586
2600
  repo_slug: normalizeRepoSlug(repoSlug || ""),
2587
2601
  workflow_path: path.relative(projectDir, workflowPath).replace(/\\/g, "/"),
2602
+ session_id: String(sessionId || "").trim(),
2588
2603
  };
2589
2604
 
2590
2605
  await fsp.mkdir(lockDir, { recursive: true });
@@ -3142,6 +3157,26 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3142
3157
  expectedSpecId: generatedSpecId || workflowSpecIdFromTemplate,
3143
3158
  });
3144
3159
  }
3160
+ // Project senti session: every new project gets its own coordination room
3161
+ // so agents (audit personas, builders, reviewers) can post progress and see
3162
+ // each other's messages without losing context. Local-first + best-effort:
3163
+ // an offline/unauthenticated init still completes.
3164
+ let projectSession = null;
3165
+ if (!boolFromEnv(process.env.SENTINELAYER_SKIP_PROJECT_SESSION)) {
3166
+ try {
3167
+ const { bootstrapProjectSession } = await import("./session/project-bootstrap.js");
3168
+ projectSession = await bootstrapProjectSession({
3169
+ projectDir,
3170
+ projectName: effectiveProjectName,
3171
+ skipGuides: true,
3172
+ });
3173
+ } catch (error) {
3174
+ console.log(
3175
+ pc.yellow(`! Senti project session bootstrap skipped: ${error?.message || error}`)
3176
+ );
3177
+ }
3178
+ }
3179
+
3145
3180
  const configLockfilePath = await writeInitConfigLockfile({
3146
3181
  projectDir,
3147
3182
  specId: workflowSpecId || generatedSpecId || workflowSpecIdFromTemplate,
@@ -3149,6 +3184,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3149
3184
  secretName,
3150
3185
  repoSlug: interview.repoSlug || detectRepoSlug(projectDir) || "",
3151
3186
  workflowPath,
3187
+ sessionId: projectSession?.sessionId || "",
3152
3188
  });
3153
3189
 
3154
3190
  await writeTextFile(
@@ -3174,6 +3210,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3174
3210
  buildFromExistingRepo: interview.buildFromExistingRepo,
3175
3211
  authMode: effectiveAuthMode,
3176
3212
  codingAgent: interview.codingAgent,
3213
+ sessionId: projectSession?.sessionId || "",
3177
3214
  })
3178
3215
  );
3179
3216
  await writeTextFile(
@@ -3186,6 +3223,19 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3186
3223
  codingAgent: interview.codingAgent,
3187
3224
  });
3188
3225
 
3226
+ // Guides go in after the coding-agent config so the config scaffold above
3227
+ // doesn't see a guide-created AGENTS.md/CLAUDE.md and skip itself.
3228
+ if (projectSession?.sessionId) {
3229
+ try {
3230
+ const { setupSessionGuides } = await import("./session/setup-guides.js");
3231
+ projectSession.guides = await setupSessionGuides(projectSession.sessionId, {
3232
+ targetPath: projectDir,
3233
+ });
3234
+ } catch (error) {
3235
+ console.log(pc.yellow(`! Session coordination guides skipped: ${error?.message || error}`));
3236
+ }
3237
+ }
3238
+
3189
3239
  await ensureSentinelStartScript(projectDir, effectiveProjectName);
3190
3240
 
3191
3241
  // Code scaffold: write starter source files, skip existing
@@ -3278,6 +3328,32 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3278
3328
  printSection("Complete");
3279
3329
  console.log(pc.green(`✔ Sentinelayer orchestration initialized in ${projectDir}`));
3280
3330
  console.log(pc.green(`✔ Config lockfile written: ${configLockfilePath}`));
3331
+ if (projectSession?.sessionId) {
3332
+ console.log(pc.green(`✔ Senti project session created: ${projectSession.sessionId}`));
3333
+ console.log(pc.green(` Dashboard: ${projectSession.dashboardUrl}`));
3334
+ if (projectSession.daemon?.spawned) {
3335
+ console.log(
3336
+ pc.green(` Senti: managing this session (daemon pid ${projectSession.daemon.pid}, detached).`)
3337
+ );
3338
+ } else if (projectSession.daemon?.reason && projectSession.daemon.reason !== "disabled") {
3339
+ console.log(
3340
+ pc.yellow(
3341
+ ` Senti daemon not started (${projectSession.daemon.reason}); run: sl session daemon ${projectSession.sessionId}`
3342
+ )
3343
+ );
3344
+ }
3345
+ console.log(
3346
+ pc.gray(
3347
+ ` Agents coordinate here: sl session join ${projectSession.sessionId} --agent <name>; audit runs post progress automatically.`
3348
+ )
3349
+ );
3350
+ } else {
3351
+ console.log(
3352
+ pc.yellow(
3353
+ "! Senti project session not created. Run `sl session start` inside the project to create the coordination room."
3354
+ )
3355
+ );
3356
+ }
3281
3357
  if (workflowSpecId) {
3282
3358
  console.log(pc.green(`✔ Omar workflow spec binding validated: ${workflowSpecId}`));
3283
3359
  } else {
@@ -0,0 +1,164 @@
1
+ import { createAgentEvent } from "../events/schema.js";
2
+ import { registerAgent } from "./agent-registry.js";
3
+ import { listActiveSessions } from "./store.js";
4
+ import { appendToStream } from "./stream.js";
5
+
6
+ const ORCHESTRATOR_AGENT_ID = "audit-orchestrator";
7
+
8
+ function normalizeString(value) {
9
+ return String(value || "").trim();
10
+ }
11
+
12
+ function formatSeveritySummary(summary = {}) {
13
+ return `P0=${Number(summary.P0 || 0)} P1=${Number(summary.P1 || 0)} P2=${Number(summary.P2 || 0)} P3=${Number(summary.P3 || 0)}`;
14
+ }
15
+
16
+ function formatDurationSeconds(durationMs) {
17
+ return `${Math.max(0, Math.round(Number(durationMs || 0) / 1000))}s`;
18
+ }
19
+
20
+ /**
21
+ * Resolve which senti session an audit run should report into.
22
+ * Explicit id wins; otherwise the workspace's most recently active local
23
+ * session (the one `create-sentinelayer` bootstraps for new projects).
24
+ * Returns "" when relay is disabled or no session exists — audit runs
25
+ * never require a session.
26
+ */
27
+ export async function resolveAuditSessionId({
28
+ targetPath = process.cwd(),
29
+ explicitSessionId = "",
30
+ disabled = false,
31
+ } = {}) {
32
+ if (disabled) {
33
+ return "";
34
+ }
35
+ const explicit = normalizeString(explicitSessionId);
36
+ if (explicit) {
37
+ return explicit;
38
+ }
39
+ const sessions = await listActiveSessions({ targetPath }).catch(() => []);
40
+ if (!Array.isArray(sessions) || sessions.length === 0) {
41
+ return "";
42
+ }
43
+ const sorted = [...sessions].sort((left, right) =>
44
+ normalizeString(right.lastInteractionAt || right.updatedAt || right.createdAt).localeCompare(
45
+ normalizeString(left.lastInteractionAt || left.updatedAt || left.createdAt)
46
+ )
47
+ );
48
+ return normalizeString(sorted[0]?.sessionId);
49
+ }
50
+
51
+ /**
52
+ * Relay audit-orchestrator lifecycle events into a senti session so swarm
53
+ * personas can see each other's progress (start, per-agent completion,
54
+ * final summary) in the project's shared room.
55
+ *
56
+ * Posts are queued sequentially so transcript order matches audit order,
57
+ * and every post is best-effort: a session outage never fails an audit.
58
+ */
59
+ export function createAuditSessionReporter({ sessionId, targetPath = process.cwd() } = {}) {
60
+ const normalizedSessionId = normalizeString(sessionId);
61
+ if (!normalizedSessionId) {
62
+ return null;
63
+ }
64
+
65
+ const registeredAgents = new Set();
66
+ let postedCount = 0;
67
+ let failedCount = 0;
68
+ let queue = Promise.resolve();
69
+
70
+ const post = (agentId, message) => {
71
+ const id = normalizeString(agentId) || ORCHESTRATOR_AGENT_ID;
72
+ queue = queue.then(async () => {
73
+ try {
74
+ if (!registeredAgents.has(id)) {
75
+ registeredAgents.add(id);
76
+ await registerAgent(normalizedSessionId, {
77
+ agentId: id,
78
+ model: "audit-persona",
79
+ role: "auditor",
80
+ targetPath,
81
+ trackProcessExit: false,
82
+ }).catch(() => {});
83
+ }
84
+ const event = createAgentEvent({
85
+ event: "session_message",
86
+ agent: { id, persona: id },
87
+ sessionId: normalizedSessionId,
88
+ payload: { message, channel: "session" },
89
+ });
90
+ await appendToStream(normalizedSessionId, event, { targetPath, awaitRemoteSync: true });
91
+ postedCount += 1;
92
+ } catch {
93
+ failedCount += 1;
94
+ }
95
+ });
96
+ return queue;
97
+ };
98
+
99
+ const handleEvent = (evt) => {
100
+ if (!evt || typeof evt !== "object") {
101
+ return;
102
+ }
103
+ const payload = evt.payload && typeof evt.payload === "object" ? evt.payload : {};
104
+ switch (evt.event) {
105
+ case "phase_start":
106
+ if (payload.phase === "dispatch") {
107
+ void post(
108
+ ORCHESTRATOR_AGENT_ID,
109
+ `🔍 Audit dispatch started: ${Number(payload.agentCount || 0)} persona(s), max ${Number(payload.maxParallel || 1)} in parallel.`
110
+ );
111
+ }
112
+ break;
113
+ case "dispatch":
114
+ void post(
115
+ payload.agentId,
116
+ `▶ Starting ${normalizeString(payload.persona) || normalizeString(payload.agentId)} audit (${normalizeString(payload.domain) || "general"}).`
117
+ );
118
+ break;
119
+ case "agent_complete":
120
+ void post(
121
+ payload.agentId,
122
+ `✅ ${normalizeString(payload.agentId)} audit complete: ${Number(payload.findingCount || 0)} finding(s) (${formatSeveritySummary(payload.summary)}), status=${normalizeString(payload.status) || "ok"}, ${formatDurationSeconds(payload.durationMs)}.`
123
+ );
124
+ break;
125
+ case "phase_complete":
126
+ if (payload.phase === "dispatch") {
127
+ void post(
128
+ ORCHESTRATOR_AGENT_ID,
129
+ `Dispatch complete: ${Number(payload.agentCount || 0)} persona result(s) in ${formatDurationSeconds(payload.durationMs)}.`
130
+ );
131
+ }
132
+ break;
133
+ default:
134
+ break;
135
+ }
136
+ };
137
+
138
+ const stats = () => ({ posted: postedCount, failed: failedCount });
139
+
140
+ const completed = async (result = {}) => {
141
+ await post(
142
+ ORCHESTRATOR_AGENT_ID,
143
+ `🏁 Audit run ${normalizeString(result.runId)} complete — ${formatSeveritySummary(result.summary)} across ${Array.isArray(result.agentResults) ? result.agentResults.length : 0} persona(s). Report: ${normalizeString(result.reportMarkdownPath)}`
144
+ );
145
+ await queue;
146
+ return stats();
147
+ };
148
+
149
+ const failed = async (error) => {
150
+ await post(
151
+ ORCHESTRATOR_AGENT_ID,
152
+ `❌ Audit run failed: ${normalizeString(error?.message) || "unknown error"}`
153
+ );
154
+ await queue;
155
+ return stats();
156
+ };
157
+
158
+ return {
159
+ sessionId: normalizedSessionId,
160
+ handleEvent,
161
+ completed,
162
+ failed,
163
+ };
164
+ }
@@ -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 = "",
@@ -0,0 +1,192 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+
7
+ import { resolveSessionPaths } from "./paths.js";
8
+
9
+ const PID_FILE_NAME = "senti-daemon.json";
10
+ const LOG_FILE_NAME = "senti-daemon.log";
11
+
12
+ function normalizeString(value) {
13
+ return String(value || "").trim();
14
+ }
15
+
16
+ export function sentiDaemonDisabled(env = process.env) {
17
+ return (
18
+ normalizeString(env.SENTINELAYER_SKIP_SENTI_AUTOSTART) === "1" ||
19
+ normalizeString(env.SENTINELAYER_SKIP_SENTI_DAEMON) === "1"
20
+ );
21
+ }
22
+
23
+ export function resolveDaemonPidPath(sessionId, { targetPath = process.cwd() } = {}) {
24
+ const paths = resolveSessionPaths(sessionId, { targetPath });
25
+ return path.join(paths.sessionDir, PID_FILE_NAME);
26
+ }
27
+
28
+ export function resolveDaemonLogPath(sessionId, { targetPath = process.cwd() } = {}) {
29
+ const paths = resolveSessionPaths(sessionId, { targetPath });
30
+ return path.join(paths.sessionDir, LOG_FILE_NAME);
31
+ }
32
+
33
+ export function isProcessAlive(pid) {
34
+ const numericPid = Number(pid);
35
+ if (!Number.isInteger(numericPid) || numericPid <= 0) {
36
+ return false;
37
+ }
38
+ try {
39
+ process.kill(numericPid, 0);
40
+ return true;
41
+ } catch (error) {
42
+ // EPERM means the process exists but belongs to another user.
43
+ return error?.code === "EPERM";
44
+ }
45
+ }
46
+
47
+ export async function readDaemonPidRecord(sessionId, { targetPath = process.cwd() } = {}) {
48
+ const pidPath = resolveDaemonPidPath(sessionId, { targetPath });
49
+ try {
50
+ const raw = await fsp.readFile(pidPath, "utf-8");
51
+ const parsed = JSON.parse(raw);
52
+ if (!parsed || typeof parsed !== "object") {
53
+ return null;
54
+ }
55
+ return parsed;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export async function writeDaemonPidRecord(
62
+ sessionId,
63
+ { targetPath = process.cwd(), pid = process.pid, tickIntervalMs = 30000 } = {}
64
+ ) {
65
+ const pidPath = resolveDaemonPidPath(sessionId, { targetPath });
66
+ const record = {
67
+ pid: Number(pid),
68
+ sessionId: normalizeString(sessionId),
69
+ targetPath: path.resolve(String(targetPath || ".")),
70
+ tickIntervalMs: Number(tickIntervalMs) || 30000,
71
+ startedAt: new Date().toISOString(),
72
+ };
73
+ await fsp.mkdir(path.dirname(pidPath), { recursive: true });
74
+ await fsp.writeFile(pidPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
75
+ return record;
76
+ }
77
+
78
+ export async function removeDaemonPidRecord(
79
+ sessionId,
80
+ { targetPath = process.cwd(), onlyForPid = null } = {}
81
+ ) {
82
+ const pidPath = resolveDaemonPidPath(sessionId, { targetPath });
83
+ if (onlyForPid != null) {
84
+ const existing = await readDaemonPidRecord(sessionId, { targetPath });
85
+ if (existing && Number(existing.pid) !== Number(onlyForPid)) {
86
+ return false;
87
+ }
88
+ }
89
+ try {
90
+ await fsp.unlink(pidPath);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Whether a detached Senti daemon is currently managing the session.
99
+ * Stale pid files (machine reboot, hard kill on Windows) are reported as
100
+ * not running so the caller can safely respawn over them.
101
+ */
102
+ export async function getDaemonStatus(sessionId, { targetPath = process.cwd() } = {}) {
103
+ const record = await readDaemonPidRecord(sessionId, { targetPath });
104
+ if (!record) {
105
+ return { running: false, pid: null, stale: false, record: null };
106
+ }
107
+ const alive = isProcessAlive(record.pid);
108
+ return {
109
+ running: alive,
110
+ pid: alive ? Number(record.pid) : null,
111
+ stale: !alive,
112
+ record,
113
+ };
114
+ }
115
+
116
+ export function resolveCliEntryPath() {
117
+ const entry = normalizeString(process.argv[1]);
118
+ return entry ? path.resolve(entry) : "";
119
+ }
120
+
121
+ /**
122
+ * Spawn `sl session daemon <id>` as a detached background process so the
123
+ * session stays managed (greetings, recaps, checkpoints, mention routing)
124
+ * after the creating terminal exits. Deduped via the session's pid file;
125
+ * output goes to senti-daemon.log in the session directory.
126
+ *
127
+ * Never throws: returns { spawned, pid, reason, logPath } so callers can
128
+ * report status without ever failing session creation.
129
+ */
130
+ export async function spawnDetachedSentiDaemon({
131
+ sessionId,
132
+ targetPath = process.cwd(),
133
+ cliPath = "",
134
+ env = process.env,
135
+ } = {}) {
136
+ const normalizedSessionId = normalizeString(sessionId);
137
+ if (!normalizedSessionId) {
138
+ return { spawned: false, pid: null, reason: "missing_session_id", logPath: "" };
139
+ }
140
+ if (sentiDaemonDisabled(env)) {
141
+ return { spawned: false, pid: null, reason: "disabled", logPath: "" };
142
+ }
143
+
144
+ const status = await getDaemonStatus(normalizedSessionId, { targetPath });
145
+ if (status.running) {
146
+ return {
147
+ spawned: false,
148
+ pid: status.pid,
149
+ reason: "already_running",
150
+ logPath: resolveDaemonLogPath(normalizedSessionId, { targetPath }),
151
+ };
152
+ }
153
+
154
+ const entryPath = normalizeString(cliPath) || resolveCliEntryPath();
155
+ if (!entryPath) {
156
+ return { spawned: false, pid: null, reason: "cli_entry_unresolved", logPath: "" };
157
+ }
158
+
159
+ const logPath = resolveDaemonLogPath(normalizedSessionId, { targetPath });
160
+ let logFd = null;
161
+ try {
162
+ await fsp.mkdir(path.dirname(logPath), { recursive: true });
163
+ logFd = fs.openSync(logPath, "a");
164
+ const child = spawn(
165
+ process.execPath,
166
+ [entryPath, "session", "daemon", normalizedSessionId, "--path", path.resolve(String(targetPath || "."))],
167
+ {
168
+ detached: true,
169
+ stdio: ["ignore", logFd, logFd],
170
+ windowsHide: true,
171
+ env,
172
+ }
173
+ );
174
+ child.unref();
175
+ return { spawned: true, pid: child.pid ?? null, reason: "spawned", logPath };
176
+ } catch (error) {
177
+ return {
178
+ spawned: false,
179
+ pid: null,
180
+ reason: `spawn_failed: ${normalizeString(error?.message) || "unknown"}`,
181
+ logPath,
182
+ };
183
+ } finally {
184
+ if (logFd != null) {
185
+ try {
186
+ fs.closeSync(logFd);
187
+ } catch {
188
+ // fd already closed
189
+ }
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,115 @@
1
+ import path from "node:path";
2
+
3
+ import { ensureWorkspaceSession } from "../commands/session.js";
4
+ import { createAgentEvent } from "../events/schema.js";
5
+ import { spawnDetachedSentiDaemon } from "./daemon-spawn.js";
6
+ import { setupSessionGuides } from "./setup-guides.js";
7
+ import { appendToStream } from "./stream.js";
8
+ import { syncSessionMetadataToApi } from "./sync.js";
9
+ import { buildDashboardUrl } from "./templates.js";
10
+
11
+ export const PROJECT_BOOTSTRAP_AGENT = Object.freeze({
12
+ id: "project-bootstrap",
13
+ persona: "Project Bootstrap",
14
+ shortName: "Bootstrap",
15
+ color: "green",
16
+ avatar: "P",
17
+ });
18
+
19
+ function normalizeString(value) {
20
+ return String(value || "").trim();
21
+ }
22
+
23
+ export function buildProjectSessionWelcomeMessage({ projectName, sessionId } = {}) {
24
+ const name = normalizeString(projectName) || "this project";
25
+ return [
26
+ `🏗️ Project session for "${name}" is live (created by \`create-sentinelayer\`).`,
27
+ "",
28
+ "This is the project's shared coordination room. Agents working on this codebase should:",
29
+ `- Join before starting work: \`sl session join ${sessionId} --agent <your-agent-name>\``,
30
+ `- Post status updates as you work: \`sl session say ${sessionId} "<update>" --agent <your-agent-name>\``,
31
+ "- Audit runs (`sentinel audit`) post per-persona progress here automatically, so swarm agents can see each other's findings without losing context.",
32
+ ].join("\n");
33
+ }
34
+
35
+ /**
36
+ * Create the project's senti session as part of `create-sentinelayer` init:
37
+ * a fresh workspace session rooted at the new project directory, with
38
+ * coordination guides written into AGENTS.md / CLAUDE.md and a welcome
39
+ * message announcing the room to joining agents.
40
+ *
41
+ * Local-first: session creation always succeeds offline; dashboard metadata
42
+ * sync and the welcome-message relay are best-effort and never throw.
43
+ *
44
+ * Pass `skipGuides: true` when the caller writes coding-agent config files
45
+ * after this call (guide upsert would otherwise create AGENTS.md/CLAUDE.md
46
+ * first and make the config scaffold skip itself) — then call
47
+ * `setupSessionGuides` once those files exist.
48
+ */
49
+ export async function bootstrapProjectSession({
50
+ projectDir,
51
+ projectName,
52
+ ttlSeconds,
53
+ skipGuides = false,
54
+ } = {}) {
55
+ const targetPath = path.resolve(normalizeString(projectDir) || ".");
56
+ const title = normalizeString(projectName) || path.basename(targetPath);
57
+
58
+ const ensured = await ensureWorkspaceSession({
59
+ targetPath,
60
+ title,
61
+ resume: false,
62
+ forceNew: true,
63
+ ...(Number.isFinite(Number(ttlSeconds)) && Number(ttlSeconds) > 0
64
+ ? { ttlSeconds: Math.floor(Number(ttlSeconds)) }
65
+ : {}),
66
+ });
67
+ const created = ensured.created;
68
+ const sessionId = created.sessionId;
69
+
70
+ // Best-effort dashboard visibility — session creation stays local-first.
71
+ await syncSessionMetadataToApi(sessionId, {
72
+ targetPath,
73
+ sessionId,
74
+ status: created.status,
75
+ createdAt: created.createdAt,
76
+ expiresAt: created.expiresAt,
77
+ title: ensured.title || title,
78
+ template: created.template,
79
+ codebaseContext: created.codebaseContext,
80
+ }).catch(() => {});
81
+
82
+ const guides = skipGuides ? null : await setupSessionGuides(sessionId, { targetPath });
83
+
84
+ const welcomeEvent = createAgentEvent({
85
+ event: "session_message",
86
+ agent: PROJECT_BOOTSTRAP_AGENT,
87
+ sessionId,
88
+ payload: {
89
+ message: buildProjectSessionWelcomeMessage({ projectName: title, sessionId }),
90
+ channel: "session",
91
+ },
92
+ });
93
+ let welcomePosted = true;
94
+ try {
95
+ await appendToStream(sessionId, welcomeEvent, { targetPath, awaitRemoteSync: true });
96
+ } catch {
97
+ welcomePosted = false;
98
+ }
99
+
100
+ // Project rooms are managed by default too: the detached Senti daemon
101
+ // greets joining agents and keeps recaps/checkpoints flowing. Honors
102
+ // SENTINELAYER_SKIP_SENTI_AUTOSTART / SENTINELAYER_SKIP_SENTI_DAEMON
103
+ // and never fails the bootstrap.
104
+ const daemon = await spawnDetachedSentiDaemon({ sessionId, targetPath });
105
+
106
+ return {
107
+ sessionId,
108
+ title: ensured.title || title,
109
+ targetPath,
110
+ dashboardUrl: buildDashboardUrl(sessionId),
111
+ guides,
112
+ welcomePosted,
113
+ daemon,
114
+ };
115
+ }