sentinelayer-cli 0.22.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.22.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,
@@ -781,6 +787,42 @@ function sentiAutostartDisabled() {
781
787
  return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
782
788
  }
783
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
+
784
826
  function buildResumeContext(candidate, { reuseWindowSeconds = 3600 } = {}) {
785
827
  if (!candidate) return null;
786
828
  const source = normalizeString(candidate._source) || "unknown";
@@ -1201,7 +1243,7 @@ async function ensureLocalSessionForRemoteCommand(
1201
1243
  return { materialized: true, refreshed: false, session: created };
1202
1244
  }
1203
1245
 
1204
- async function ensureWorkspaceSession({
1246
+ export async function ensureWorkspaceSession({
1205
1247
  targetPath,
1206
1248
  ttlSeconds = DEFAULT_TTL_SECONDS,
1207
1249
  template = null,
@@ -2178,7 +2220,7 @@ export function registerSessionCommand(program) {
2178
2220
  session
2179
2221
  .command("start")
2180
2222
  .description(
2181
- "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.",
2182
2224
  )
2183
2225
  .option("--path <path>", "Workspace path for the session", ".")
2184
2226
  .option("--title <title>", "Human-readable label (shown in web sidebar + transcript)")
@@ -2208,6 +2250,10 @@ export function registerSessionCommand(program) {
2208
2250
  "Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
2209
2251
  "3600",
2210
2252
  )
2253
+ .option(
2254
+ "--no-daemon",
2255
+ "Do not spawn the detached Senti daemon (session will be unmanaged: no greetings, recaps, or checkpoints)",
2256
+ )
2211
2257
  .option("--json", "Emit machine-readable output")
2212
2258
  .action(async (options, command) => {
2213
2259
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
@@ -2290,19 +2336,25 @@ export function registerSessionCommand(program) {
2290
2336
  }).catch(() => {});
2291
2337
  }
2292
2338
 
2293
- // Auto-start the Senti orchestrator daemon. Without this, every
2294
- // session ran with `Senti actions: 1` (just the welcome alert)
2295
- // because nothing kicked the daemon ticking agents joining
2296
- // never got greeted, mentions never routed, recaps never fired.
2297
- // Best-effort + non-blocking: the daemon registers itself in an
2298
- // in-memory map keyed by (sessionId, targetPath) and tolerates
2299
- // being started for an already-active session (returns the
2300
- // existing handle). If the daemon fails to start (unauth env,
2301
- // missing model proxy), the session keeps working — Senti just
2302
- // stays quiet, same as before this change.
2303
- if (!sentiAutostartDisabled()) {
2304
- 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
+ });
2305
2356
  }
2357
+ payload.sentiDaemon = sentiDaemon;
2306
2358
 
2307
2359
  if (shouldEmitJson(options, command)) {
2308
2360
  console.log(JSON.stringify(payload, null, 2));
@@ -2324,6 +2376,7 @@ export function registerSessionCommand(program) {
2324
2376
  }
2325
2377
  console.log("");
2326
2378
  console.log(`Dashboard: ${dashboardUrl}`);
2379
+ printSentiDaemonStatusLine(sentiDaemon, { cliCommand, sessionId: created.sessionId });
2327
2380
  return;
2328
2381
  }
2329
2382
 
@@ -2349,6 +2402,13 @@ export function registerSessionCommand(program) {
2349
2402
  console.log(
2350
2403
  `status=${created.status} created_at=${created.createdAt} expires_at=${created.expiresAt} ttl_seconds=${ttlSeconds}`,
2351
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
+ );
2352
2412
  if (remoteSync.status === "auth_required") {
2353
2413
  console.log(
2354
2414
  pc.yellow(
@@ -2361,7 +2421,9 @@ export function registerSessionCommand(program) {
2361
2421
  if (!resumed) {
2362
2422
  console.log(
2363
2423
  pc.gray(
2364
- `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.`,
2365
2427
  ),
2366
2428
  );
2367
2429
  }
@@ -3694,8 +3756,11 @@ export function registerSessionCommand(program) {
3694
3756
 
3695
3757
  session
3696
3758
  .command("daemon [sessionId]")
3697
- .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
+ )
3698
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")
3699
3764
  .option("--path <path>", "Workspace path for the session", ".")
3700
3765
  .option("--tick-interval <seconds>", "Seconds between health ticks (default 30)", "30")
3701
3766
  .option("--stale-agent-seconds <seconds>", "Seconds before an inactive agent is flagged stale (default 90)", "90")
@@ -3723,6 +3788,18 @@ export function registerSessionCommand(program) {
3723
3788
  "tick-interval",
3724
3789
  30,
3725
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
+ }
3726
3803
  const daemon = await startSenti(normalizedSessionId, {
3727
3804
  targetPath,
3728
3805
  autoStart: false,
@@ -3824,10 +3901,28 @@ export function registerSessionCommand(program) {
3824
3901
  } else {
3825
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"}`));
3826
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
+ }
3827
3918
  }
3828
3919
  } finally {
3829
3920
  process.removeListener("SIGINT", stop);
3830
3921
  process.removeListener("SIGTERM", stop);
3922
+ await removeDaemonPidRecord(normalizedSessionId, {
3923
+ targetPath,
3924
+ onlyForPid: process.pid,
3925
+ }).catch(() => {});
3831
3926
  const stopped = await daemon.stop("signal");
3832
3927
  if (emitJson) {
3833
3928
  console.log(
package/src/legacy-cli.js CHANGED
@@ -2182,6 +2182,7 @@ export function buildHandoffPrompt({
2182
2182
  buildFromExistingRepo,
2183
2183
  authMode,
2184
2184
  codingAgent,
2185
+ sessionId,
2185
2186
  }) {
2186
2187
  const codingAgentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
2187
2188
  const codingAgentConfigPath = codingAgentProfile.configFile || "none";
@@ -2245,7 +2246,16 @@ Repo context:
2245
2246
  - Target repo: ${repoSlug || "not provided"}
2246
2247
  - Workspace mode: ${buildFromExistingRepo ? "existing codebase" : "new scaffold"}
2247
2248
 
2248
- ## 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
+ }
2249
2259
 
2250
2260
  ${renderCoordinationNumberedList()}
2251
2261
 
@@ -2577,6 +2587,7 @@ async function writeInitConfigLockfile({
2577
2587
  secretName,
2578
2588
  repoSlug,
2579
2589
  workflowPath,
2590
+ sessionId,
2580
2591
  }) {
2581
2592
  const lockDir = path.join(projectDir, ".sentinelayer");
2582
2593
  const configPath = path.join(lockDir, "config.json");
@@ -2588,6 +2599,7 @@ async function writeInitConfigLockfile({
2588
2599
  required_secret_name: String(secretName || "SENTINELAYER_TOKEN").trim() || "SENTINELAYER_TOKEN",
2589
2600
  repo_slug: normalizeRepoSlug(repoSlug || ""),
2590
2601
  workflow_path: path.relative(projectDir, workflowPath).replace(/\\/g, "/"),
2602
+ session_id: String(sessionId || "").trim(),
2591
2603
  };
2592
2604
 
2593
2605
  await fsp.mkdir(lockDir, { recursive: true });
@@ -3145,6 +3157,26 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3145
3157
  expectedSpecId: generatedSpecId || workflowSpecIdFromTemplate,
3146
3158
  });
3147
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
+
3148
3180
  const configLockfilePath = await writeInitConfigLockfile({
3149
3181
  projectDir,
3150
3182
  specId: workflowSpecId || generatedSpecId || workflowSpecIdFromTemplate,
@@ -3152,6 +3184,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3152
3184
  secretName,
3153
3185
  repoSlug: interview.repoSlug || detectRepoSlug(projectDir) || "",
3154
3186
  workflowPath,
3187
+ sessionId: projectSession?.sessionId || "",
3155
3188
  });
3156
3189
 
3157
3190
  await writeTextFile(
@@ -3177,6 +3210,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3177
3210
  buildFromExistingRepo: interview.buildFromExistingRepo,
3178
3211
  authMode: effectiveAuthMode,
3179
3212
  codingAgent: interview.codingAgent,
3213
+ sessionId: projectSession?.sessionId || "",
3180
3214
  })
3181
3215
  );
3182
3216
  await writeTextFile(
@@ -3189,6 +3223,19 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3189
3223
  codingAgent: interview.codingAgent,
3190
3224
  });
3191
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
+
3192
3239
  await ensureSentinelStartScript(projectDir, effectiveProjectName);
3193
3240
 
3194
3241
  // Code scaffold: write starter source files, skip existing
@@ -3281,6 +3328,32 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3281
3328
  printSection("Complete");
3282
3329
  console.log(pc.green(`✔ Sentinelayer orchestration initialized in ${projectDir}`));
3283
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
+ }
3284
3357
  if (workflowSpecId) {
3285
3358
  console.log(pc.green(`✔ Omar workflow spec binding validated: ${workflowSpecId}`));
3286
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
+ }
@@ -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
+ }