sentinelayer-cli 0.22.0 → 0.24.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.
@@ -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,
@@ -55,6 +61,9 @@ import {
55
61
  refreshSessionCacheForRemoteActivity,
56
62
  updateSessionTitle,
57
63
  } from "../session/store.js";
64
+ import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
65
+ import { postFirstSentiMessage } from "../session/first-message.js";
66
+ import { createListenerHostWake } from "../session/wake/listen-wake.js";
58
67
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
59
68
  import {
60
69
  addSessionEventIdentityKeys,
@@ -781,6 +790,42 @@ function sentiAutostartDisabled() {
781
790
  return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
782
791
  }
783
792
 
793
+ export function formatSentiDaemonStatusLine(sentiDaemon = {}, { cliCommand = "sl", sessionId = "" } = {}) {
794
+ if (sentiDaemon.spawned) {
795
+ return {
796
+ tone: "green",
797
+ text: `Senti: managing this session (daemon pid ${sentiDaemon.pid}, detached — survives this terminal). Log: ${sentiDaemon.logPath}`,
798
+ };
799
+ }
800
+ if (sentiDaemon.reason === "already_running") {
801
+ return {
802
+ tone: "green",
803
+ text: `Senti: already managing this session (daemon pid ${sentiDaemon.pid}).`,
804
+ };
805
+ }
806
+ if (sentiDaemon.reason === "disabled" || sentiDaemon.reason === "opt_out") {
807
+ return {
808
+ tone: "gray",
809
+ text: `Senti daemon skipped (${sentiDaemon.reason === "opt_out" ? "--no-daemon" : "SENTINELAYER_SKIP_SENTI_AUTOSTART=1"}); session is unmanaged. Start manually: ${cliCommand} session daemon ${sessionId}`,
810
+ };
811
+ }
812
+ return {
813
+ tone: "yellow",
814
+ text: `! Senti daemon not started (${sentiDaemon.reason || "unknown"}); session is unmanaged. Start manually: ${cliCommand} session daemon ${sessionId}`,
815
+ };
816
+ }
817
+
818
+ function printSentiDaemonStatusLine(sentiDaemon, context) {
819
+ const line = formatSentiDaemonStatusLine(sentiDaemon, context);
820
+ if (line.tone === "green") {
821
+ console.log(pc.green(line.text));
822
+ } else if (line.tone === "yellow") {
823
+ console.log(pc.yellow(line.text));
824
+ } else {
825
+ console.log(pc.gray(line.text));
826
+ }
827
+ }
828
+
784
829
  function buildResumeContext(candidate, { reuseWindowSeconds = 3600 } = {}) {
785
830
  if (!candidate) return null;
786
831
  const source = normalizeString(candidate._source) || "unknown";
@@ -1201,7 +1246,7 @@ async function ensureLocalSessionForRemoteCommand(
1201
1246
  return { materialized: true, refreshed: false, session: created };
1202
1247
  }
1203
1248
 
1204
- async function ensureWorkspaceSession({
1249
+ export async function ensureWorkspaceSession({
1205
1250
  targetPath,
1206
1251
  ttlSeconds = DEFAULT_TTL_SECONDS,
1207
1252
  template = null,
@@ -2178,7 +2223,7 @@ export function registerSessionCommand(program) {
2178
2223
  session
2179
2224
  .command("start")
2180
2225
  .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.",
2226
+ "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
2227
  )
2183
2228
  .option("--path <path>", "Workspace path for the session", ".")
2184
2229
  .option("--title <title>", "Human-readable label (shown in web sidebar + transcript)")
@@ -2194,6 +2239,10 @@ export function registerSessionCommand(program) {
2194
2239
  "--force-new",
2195
2240
  "Always create a new session even if a recent active one exists for this workspace",
2196
2241
  )
2242
+ .option(
2243
+ "--force",
2244
+ "Alias of --force-new (both always mint a fresh session)",
2245
+ )
2197
2246
  .option(
2198
2247
  "--resume",
2199
2248
  "Reuse the most recent active session for this workspace when one is inside the reuse window",
@@ -2208,6 +2257,10 @@ export function registerSessionCommand(program) {
2208
2257
  "Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
2209
2258
  "3600",
2210
2259
  )
2260
+ .option(
2261
+ "--no-daemon",
2262
+ "Do not spawn the detached Senti daemon (session will be unmanaged: no greetings, recaps, or checkpoints)",
2263
+ )
2211
2264
  .option("--json", "Emit machine-readable output")
2212
2265
  .action(async (options, command) => {
2213
2266
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
@@ -2233,7 +2286,7 @@ export function registerSessionCommand(program) {
2233
2286
  template,
2234
2287
  title: titleArg,
2235
2288
  resume: options.resume !== false,
2236
- forceNew: Boolean(options.forceNew),
2289
+ forceNew: Boolean(options.forceNew || options.force),
2237
2290
  reuseWindowSeconds,
2238
2291
  });
2239
2292
  const created = ensured.created;
@@ -2290,19 +2343,40 @@ export function registerSessionCommand(program) {
2290
2343
  }).catch(() => {});
2291
2344
  }
2292
2345
 
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(() => {});
2346
+ // Make the session managed by default: spawn the Senti daemon as a
2347
+ // DETACHED process so greetings, mention routing, recaps, and
2348
+ // checkpoints keep running after this CLI command (and terminal)
2349
+ // exits. The old in-process `startSenti` died the moment this
2350
+ // action returned, so every session was effectively unmanaged.
2351
+ // Deduped via the session's pid file; best-effort and never blocks
2352
+ // session creation.
2353
+ let sentiDaemon = { spawned: false, pid: null, reason: "skipped", logPath: "" };
2354
+ if (sentiAutostartDisabled()) {
2355
+ sentiDaemon.reason = "disabled";
2356
+ } else if (options.daemon === false) {
2357
+ sentiDaemon.reason = "opt_out";
2358
+ } else {
2359
+ sentiDaemon = await spawnDetachedSentiDaemon({
2360
+ sessionId: created.sessionId,
2361
+ targetPath,
2362
+ });
2305
2363
  }
2364
+ payload.sentiDaemon = sentiDaemon;
2365
+
2366
+ // Pin the deterministic first-Senti-message as the opening event of a
2367
+ // NEW room so every joining agent reads the operating protocol
2368
+ // (identity, mandatory commands, reaction/threading/lock/evidence
2369
+ // rules, cadence). Skipped on resume (already posted) and opt-outable
2370
+ // via SENTINELAYER_SKIP_FIRST_MESSAGE=1. Best-effort, never blocks.
2371
+ let firstMessage = { posted: false, reason: "skipped" };
2372
+ const skipFirstMessage = String(process.env.SENTINELAYER_SKIP_FIRST_MESSAGE || "").trim() === "1";
2373
+ if (!resumed && !skipFirstMessage) {
2374
+ firstMessage = await postFirstSentiMessage({
2375
+ sessionId: created.sessionId,
2376
+ targetPath,
2377
+ }).catch((error) => ({ posted: false, reason: normalizeString(error?.message) || "error" }));
2378
+ }
2379
+ payload.firstMessage = firstMessage;
2306
2380
 
2307
2381
  if (shouldEmitJson(options, command)) {
2308
2382
  console.log(JSON.stringify(payload, null, 2));
@@ -2324,6 +2398,7 @@ export function registerSessionCommand(program) {
2324
2398
  }
2325
2399
  console.log("");
2326
2400
  console.log(`Dashboard: ${dashboardUrl}`);
2401
+ printSentiDaemonStatusLine(sentiDaemon, { cliCommand, sessionId: created.sessionId });
2327
2402
  return;
2328
2403
  }
2329
2404
 
@@ -2349,6 +2424,13 @@ export function registerSessionCommand(program) {
2349
2424
  console.log(
2350
2425
  `status=${created.status} created_at=${created.createdAt} expires_at=${created.expiresAt} ttl_seconds=${ttlSeconds}`,
2351
2426
  );
2427
+ console.log(pc.gray(`Dashboard: ${dashboardUrl}`));
2428
+ printSentiDaemonStatusLine(sentiDaemon, { cliCommand, sessionId: created.sessionId });
2429
+ console.log(
2430
+ pc.gray(
2431
+ `Agents join with: ${cliCommand} session join ${created.sessionId} --agent <name>`,
2432
+ ),
2433
+ );
2352
2434
  if (remoteSync.status === "auth_required") {
2353
2435
  console.log(
2354
2436
  pc.yellow(
@@ -2361,7 +2443,9 @@ export function registerSessionCommand(program) {
2361
2443
  if (!resumed) {
2362
2444
  console.log(
2363
2445
  pc.gray(
2364
- `Tip: subsequent \`${cliCommand} session start\` in this workspace within an hour will resume this session. Pass --force-new to override.`,
2446
+ (options.forceNew || options.force)
2447
+ ? `Tip: fresh session minted (--force-new honored). Subsequent \`${cliCommand} session start\` here within an hour will resume this new session.`
2448
+ : `Tip: subsequent \`${cliCommand} session start\` in this workspace within an hour will resume this session. Pass --force-new to override.`,
2365
2449
  ),
2366
2450
  );
2367
2451
  }
@@ -3411,6 +3495,108 @@ export function registerSessionCommand(program) {
3411
3495
  return payload;
3412
3496
  });
3413
3497
 
3498
+ session
3499
+ .command("listeners <sessionId>")
3500
+ .description(
3501
+ "List who is actively listening to the session and at what poll cadence (active/idle/stale/stopped), derived from listener presence heartbeats. Mirrors the web roster.",
3502
+ )
3503
+ .option("--path <path>", "Workspace path for the session", ".")
3504
+ .option("--limit <n>", "Recent events to scan for heartbeats (default 200)", "200")
3505
+ .option("--json", "Emit machine-readable output")
3506
+ .action(async (sessionId, options, command) => {
3507
+ const normalizedSessionId = normalizeString(sessionId);
3508
+ if (!normalizedSessionId) {
3509
+ throw new Error("session id is required.");
3510
+ }
3511
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3512
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3513
+ const limit = parsePositiveInteger(options.limit, "limit", 200);
3514
+ const result = await fetchSessionListeners(normalizedSessionId, { targetPath, limit });
3515
+ const listeners = Array.isArray(result.listeners) ? result.listeners : [];
3516
+ const live = listeners.filter((row) => row.status === "active" || row.status === "idle").length;
3517
+ const payload = {
3518
+ command: "session listeners",
3519
+ sessionId: normalizedSessionId,
3520
+ ok: Boolean(result.ok),
3521
+ reason: result.ok ? undefined : result.reason,
3522
+ count: listeners.length,
3523
+ liveCount: live,
3524
+ listeners,
3525
+ };
3526
+ if (shouldEmitJson(options, command)) {
3527
+ console.log(JSON.stringify(payload, null, 2));
3528
+ return payload;
3529
+ }
3530
+ if (!result.ok) {
3531
+ console.log(pc.yellow(`Could not read listeners (${result.reason}).`));
3532
+ return payload;
3533
+ }
3534
+ if (listeners.length === 0) {
3535
+ console.log(pc.gray("No listeners detected (no recent presence heartbeats)."));
3536
+ return payload;
3537
+ }
3538
+ console.log(pc.bold(`Listeners (${live} live / ${listeners.length} seen)`));
3539
+ for (const row of listeners) {
3540
+ const line = formatListenerLine(row);
3541
+ if (row.status === "active") console.log(pc.green(` ${line}`));
3542
+ else if (row.status === "idle") console.log(pc.cyan(` ${line}`));
3543
+ else console.log(pc.gray(` ${line}`));
3544
+ }
3545
+ return payload;
3546
+ });
3547
+
3548
+ session
3549
+ .command("stop-listener <sessionId>")
3550
+ .description(
3551
+ "Ask an agent's listener to stop (save energy). Posts a listener_stop directive the listener honors on its next poll, then exits cleanly. Targets one agent with --agent; omit it to stop every listener in the room.",
3552
+ )
3553
+ .option("--agent <id>", "Agent whose listener to stop (omit to stop all listeners in the room)")
3554
+ .option("--path <path>", "Workspace path for the session", ".")
3555
+ .option("--json", "Emit machine-readable output")
3556
+ .action(async (sessionId, options, command) => {
3557
+ const normalizedSessionId = normalizeString(sessionId);
3558
+ if (!normalizedSessionId) {
3559
+ throw new Error("session id is required.");
3560
+ }
3561
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3562
+ const targetAgent = normalizeString(options.agent);
3563
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3564
+ const event = createAgentEvent({
3565
+ event: "listener_stop",
3566
+ agent: { id: "session-control", model: "control", persona: "Session Control" },
3567
+ sessionId: normalizedSessionId,
3568
+ payload: {
3569
+ // targetAgentId routes the directive to that agent's listener (an
3570
+ // event recipient); omitting it broadcasts to every listener.
3571
+ ...(targetAgent ? { targetAgentId: targetAgent } : { broadcast: true }),
3572
+ reason: "operator_stop",
3573
+ },
3574
+ });
3575
+ const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, { targetPath }).catch(
3576
+ (error) => ({ synced: false, reason: normalizeString(error?.message) || "sync_failed" }),
3577
+ );
3578
+ await appendToStream(normalizedSessionId, event, { targetPath, syncRemote: false }).catch(() => {});
3579
+ const payload = {
3580
+ command: "session stop-listener",
3581
+ sessionId: normalizedSessionId,
3582
+ targetAgent: targetAgent || null,
3583
+ scope: targetAgent ? "agent" : "all",
3584
+ remoteSync: remoteSync || undefined,
3585
+ };
3586
+ if (shouldEmitJson(options, command)) {
3587
+ console.log(JSON.stringify(payload, null, 2));
3588
+ return payload;
3589
+ }
3590
+ console.log(
3591
+ pc.yellow(
3592
+ targetAgent
3593
+ ? `Listener stop requested for ${targetAgent}; it will exit on its next poll.`
3594
+ : "Listener stop requested for ALL listeners in this room.",
3595
+ ),
3596
+ );
3597
+ return payload;
3598
+ });
3599
+
3414
3600
  session
3415
3601
  .command("listen")
3416
3602
  .description("Background-poll a session for events addressed to this agent or broadcast")
@@ -3458,6 +3644,14 @@ export function registerSessionCommand(program) {
3458
3644
  "--wake <command>",
3459
3645
  "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.",
3460
3646
  )
3647
+ .option(
3648
+ "--wake-host <name>",
3649
+ "Auto-wake: resume this host (claude|codex) on each addressed message so listening IS waking. Requires --resume-session.",
3650
+ )
3651
+ .option(
3652
+ "--resume-session <id>",
3653
+ "Host session/rollout id to resume on wake (the claude/codex session id, not the Senti id). Pairs with --wake-host.",
3654
+ )
3461
3655
  .option(
3462
3656
  "--coaching-interval <seconds>",
3463
3657
  "Seconds between in-session success reminders (ack, claim work, reply in-thread). Default 900; 0 disables.",
@@ -3513,6 +3707,25 @@ export function registerSessionCommand(program) {
3513
3707
  agentId,
3514
3708
  emit: emitWakeNotice,
3515
3709
  });
3710
+ // Auto-wake cutover: when --wake-host + --resume-session are given, an
3711
+ // addressed message INSTANTLY resumes the host (claude --resume / codex)
3712
+ // via the built wake bus — turning `listen` into a true waker on the
3713
+ // same poll. resolve-target routing inside ensures real-message-only,
3714
+ // addressed-to-us, never-self.
3715
+ const wakeHost = normalizeString(options.wakeHost);
3716
+ const triggerHostWake = wakeHost
3717
+ ? createListenerHostWake({
3718
+ host: wakeHost,
3719
+ resumeSessionId: options.resumeSession,
3720
+ agentId,
3721
+ sessionId: normalizedSessionId,
3722
+ })
3723
+ : null;
3724
+ if (wakeHost && !triggerHostWake) {
3725
+ throw new Error(
3726
+ "--wake-host requires a valid host (claude|codex) and --resume-session <host-session-id>.",
3727
+ );
3728
+ }
3516
3729
  const requestedTransport = normalizeString(options.transport).toLowerCase() || "auto";
3517
3730
  if (!["auto", "stream", "poll"].includes(requestedTransport)) {
3518
3731
  throw new Error("--transport must be one of: auto, stream, poll.");
@@ -3634,6 +3847,20 @@ export function registerSessionCommand(program) {
3634
3847
  }
3635
3848
  },
3636
3849
  onEvent: async (event) => {
3850
+ // Cut-listener: a `listener_stop` directive addressed to this
3851
+ // agent (from the web "stop listening" control or
3852
+ // `sl session stop-listener`) cleanly exits this listener to save
3853
+ // energy. Untargeted (no targetAgentId) stops every listener.
3854
+ if (normalizeString(event?.event) === "listener_stop") {
3855
+ const target = normalizeString(event?.payload?.targetAgentId);
3856
+ if (!target || target === agentId) {
3857
+ if (emitFormat !== "ndjson") {
3858
+ console.log(pc.yellow(`Listener stop requested for ${agentId}; exiting.`));
3859
+ }
3860
+ ac.abort();
3861
+ return;
3862
+ }
3863
+ }
3637
3864
  if (emitFormat === "ndjson") {
3638
3865
  console.log(JSON.stringify(event));
3639
3866
  } else {
@@ -3642,6 +3869,16 @@ export function registerSessionCommand(program) {
3642
3869
  // Fire the wake hook for any matched event (incl. ack/like) so the
3643
3870
  // host can resume its agent.
3644
3871
  wakeRunner.trigger(event);
3872
+ // Auto-wake: instantly resume the host on an addressed message.
3873
+ if (triggerHostWake) {
3874
+ void Promise.resolve(triggerHostWake.trigger(event)).then((outcome) => {
3875
+ if (outcome?.woken && emitFormat !== "ndjson") {
3876
+ console.log(pc.green(`auto-wake: resumed ${wakeHost} (${agentId})`));
3877
+ } else if (outcome && !outcome.woken && outcome.reason !== "not_routed" && emitFormat !== "ndjson") {
3878
+ console.log(pc.yellow(`auto-wake: ${wakeHost} resume failed (${outcome.reason})`));
3879
+ }
3880
+ });
3881
+ }
3645
3882
  },
3646
3883
  onError: async (result) => {
3647
3884
  const reason = normalizeString(result?.reason) || "poll_failed";
@@ -3664,6 +3901,29 @@ export function registerSessionCommand(program) {
3664
3901
  }
3665
3902
  },
3666
3903
  onLifecycle: async (lifecycle) => {
3904
+ // Wake-confirmation runs on every heartbeat regardless of presence:
3905
+ // re-resume agents that were woken but never acked within the
3906
+ // window; confirm + retire the ones that did. (Carter's receipt
3907
+ // idea — a wake isn't done until the agent actually reads it.)
3908
+ if (triggerHostWake && normalizeString(lifecycle?.type) === "heartbeat") {
3909
+ const outcome = await triggerHostWake
3910
+ .reconcile({
3911
+ nowMs: Date.now(),
3912
+ fetchActions: (seq) =>
3913
+ listSessionMessageActions(normalizedSessionId, {
3914
+ targetPath,
3915
+ targetSequenceId: seq,
3916
+ }),
3917
+ })
3918
+ .catch(() => null);
3919
+ if (outcome && (outcome.retried > 0 || outcome.deadLettered > 0) && emitFormat !== "ndjson") {
3920
+ console.log(
3921
+ pc.yellow(
3922
+ `auto-wake reconcile: re-resumed ${outcome.retried}, gave up on ${outcome.deadLettered} (no ack).`,
3923
+ ),
3924
+ );
3925
+ }
3926
+ }
3667
3927
  if (!publishPresence) return;
3668
3928
  const lifecycleType = normalizeString(lifecycle?.type);
3669
3929
  if (lifecycleType === "heartbeat") {
@@ -3694,8 +3954,11 @@ export function registerSessionCommand(program) {
3694
3954
 
3695
3955
  session
3696
3956
  .command("daemon [sessionId]")
3697
- .description("Run the Senti session daemon: hydrate events, emit recaps, and generate checkpoints")
3957
+ .description(
3958
+ "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.",
3959
+ )
3698
3960
  .option("--session <id>", "Session id to monitor")
3961
+ .option("--force", "Take over even if senti-daemon.json reports another live daemon for this session")
3699
3962
  .option("--path <path>", "Workspace path for the session", ".")
3700
3963
  .option("--tick-interval <seconds>", "Seconds between health ticks (default 30)", "30")
3701
3964
  .option("--stale-agent-seconds <seconds>", "Seconds before an inactive agent is flagged stale (default 90)", "90")
@@ -3723,6 +3986,18 @@ export function registerSessionCommand(program) {
3723
3986
  "tick-interval",
3724
3987
  30,
3725
3988
  );
3989
+ // One manager per session: refuse to double-run unless --force.
3990
+ // A stale pid file (reboot, hard kill) reads as not-running and is
3991
+ // safely overwritten.
3992
+ if (!options.once) {
3993
+ const existingDaemon = await getDaemonStatus(normalizedSessionId, { targetPath });
3994
+ if (existingDaemon.running && existingDaemon.pid !== process.pid && !options.force) {
3995
+ throw new Error(
3996
+ `A Senti daemon is already managing session ${normalizedSessionId} (pid ${existingDaemon.pid}). Use --force to take over.`,
3997
+ );
3998
+ }
3999
+ await writeDaemonPidRecord(normalizedSessionId, { targetPath, tickIntervalMs });
4000
+ }
3726
4001
  const daemon = await startSenti(normalizedSessionId, {
3727
4002
  targetPath,
3728
4003
  autoStart: false,
@@ -3824,10 +4099,28 @@ export function registerSessionCommand(program) {
3824
4099
  } else {
3825
4100
  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
4101
  }
4102
+ // A daemon must not outlive its session: stop cleanly once the
4103
+ // session expires or its local cache disappears.
4104
+ const liveSession = await getSession(normalizedSessionId, { targetPath }).catch(() => null);
4105
+ const expiresAtMs = Date.parse(liveSession?.expiresAt || "");
4106
+ if (
4107
+ !liveSession ||
4108
+ liveSession.status !== "active" ||
4109
+ (Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now())
4110
+ ) {
4111
+ if (!emitJson) {
4112
+ console.log(pc.gray("senti daemon: session expired or closed; stopping."));
4113
+ }
4114
+ break;
4115
+ }
3827
4116
  }
3828
4117
  } finally {
3829
4118
  process.removeListener("SIGINT", stop);
3830
4119
  process.removeListener("SIGTERM", stop);
4120
+ await removeDaemonPidRecord(normalizedSessionId, {
4121
+ targetPath,
4122
+ onlyForPid: process.pid,
4123
+ }).catch(() => {});
3831
4124
  const stopped = await daemon.stop("signal");
3832
4125
  if (emitJson) {
3833
4126
  console.log(
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Optional LLM enrichment for the deterministic decomposition.
3
+ *
4
+ * The heuristic in generator.js produces one ticket per phase. This layer asks
5
+ * a model to split each phase into concrete, independently-mergeable per-PR
6
+ * tickets with sharper acceptance criteria. It is:
7
+ * - opt-in (the caller passes a client),
8
+ * - capped (bounded phases × tickets, so cost is bounded),
9
+ * - fail-safe (any phase that errors or returns junk keeps its heuristic
10
+ * ticket — enrichment never throws and never drops work).
11
+ */
12
+
13
+ export const DEFAULT_ENRICH_LIMITS = Object.freeze({
14
+ maxPhases: 12,
15
+ maxTicketsPerPhase: 4,
16
+ });
17
+
18
+ function clampInt(value, fallback, min, max) {
19
+ const n = Math.floor(Number(value));
20
+ if (!Number.isFinite(n)) return fallback;
21
+ return Math.max(min, Math.min(max, n));
22
+ }
23
+
24
+ export function buildEnrichPrompt(phase, maxTickets) {
25
+ const fields = phase?.fields || {};
26
+ const lines = [
27
+ `Phase: ${String(phase?.title || "").trim()}`,
28
+ fields.objective ? `Objective: ${fields.objective}` : "",
29
+ fields.files ? `Files: ${fields.files}` : "",
30
+ fields.tests ? `Tests: ${fields.tests}` : "",
31
+ Array.isArray(phase?.tasks) && phase.tasks.length
32
+ ? `Tasks:\n${phase.tasks.map((task) => `- ${task}`).join("\n")}`
33
+ : "",
34
+ ]
35
+ .filter(Boolean)
36
+ .join("\n");
37
+
38
+ return `You are splitting ONE software build phase into concrete, independently-mergeable pull requests.
39
+ Return ONLY a JSON array (no prose, no markdown fences) of 1 to ${maxTickets} objects. Each object:
40
+ {"title": "imperative summary, <= 80 chars", "summary": "one sentence", "acceptance_criteria": ["short testable bullet", "..."]}
41
+ Rules: keep each PR small and shippable on its own; order them by dependency; 2-4 acceptance criteria each; no commentary.
42
+
43
+ ${lines}`;
44
+ }
45
+
46
+ /** Pull a JSON array out of a model response, tolerating code fences / prose. */
47
+ export function extractJsonArray(text) {
48
+ const raw = String(text || "").trim();
49
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
50
+ const body = (fenced ? fenced[1] : raw).trim();
51
+ const start = body.indexOf("[");
52
+ const end = body.lastIndexOf("]");
53
+ if (start === -1 || end === -1 || end <= start) return null;
54
+ try {
55
+ const parsed = JSON.parse(body.slice(start, end + 1));
56
+ return Array.isArray(parsed) ? parsed : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function normalizeSubTickets(parsed, maxTickets) {
63
+ if (!Array.isArray(parsed)) return null;
64
+ const out = [];
65
+ for (const item of parsed.slice(0, maxTickets)) {
66
+ const title = String(item?.title || "").trim();
67
+ if (!title) continue;
68
+ const summary = String(item?.summary || "").trim();
69
+ const acceptanceCriteria = Array.isArray(item?.acceptance_criteria)
70
+ ? item.acceptance_criteria
71
+ .map((value) => String(value || "").trim())
72
+ .filter(Boolean)
73
+ .slice(0, 6)
74
+ : [];
75
+ out.push({ title: title.slice(0, 120), summary, acceptanceCriteria });
76
+ }
77
+ return out.length ? out : null;
78
+ }
79
+
80
+ /** Enrich a single phase → array of {title, summary, acceptanceCriteria} or null. */
81
+ export async function enrichPhase({ phase, client, provider, model, apiKey, env, maxTicketsPerPhase }) {
82
+ if (!client || typeof client.invoke !== "function") return null;
83
+ const cap = clampInt(maxTicketsPerPhase, DEFAULT_ENRICH_LIMITS.maxTicketsPerPhase, 1, 10);
84
+ try {
85
+ const result = await client.invoke({
86
+ provider,
87
+ model,
88
+ prompt: buildEnrichPrompt(phase, cap),
89
+ apiKey,
90
+ env,
91
+ stream: false,
92
+ });
93
+ return normalizeSubTickets(extractJsonArray(result?.text), cap);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function subTicketDescription({ summary, acceptanceCriteria, dependencyLine }) {
100
+ const acBlock = acceptanceCriteria.length
101
+ ? acceptanceCriteria.map((item, index) => `${index + 1}. ${item}`).join("\n")
102
+ : "1. Phase outcomes are verified by deterministic checks.";
103
+ return [
104
+ `Dependencies: ${dependencyLine}`,
105
+ "",
106
+ summary ? `${summary}\n` : "",
107
+ "Acceptance criteria:",
108
+ acBlock,
109
+ ]
110
+ .filter((line) => line !== "")
111
+ .join("\n");
112
+ }
113
+
114
+ /**
115
+ * Produce an enriched ticket list from a generated guide. Each enriched phase
116
+ * expands into per-PR sub-tickets; phases beyond the cap, or that fail, keep
117
+ * their original heuristic ticket. Returns { tickets, enrichedPhases }.
118
+ */
119
+ export async function enrichGuideTickets({ guide, client, provider, model, apiKey, env, limits } = {}) {
120
+ const phases = Array.isArray(guide?.phases) ? guide.phases : [];
121
+ const baseTickets = Array.isArray(guide?.tickets) ? guide.tickets : [];
122
+ const maxPhases = clampInt(limits?.maxPhases, DEFAULT_ENRICH_LIMITS.maxPhases, 1, 50);
123
+ const maxTicketsPerPhase = clampInt(
124
+ limits?.maxTicketsPerPhase,
125
+ DEFAULT_ENRICH_LIMITS.maxTicketsPerPhase,
126
+ 1,
127
+ 10,
128
+ );
129
+
130
+ const tickets = [];
131
+ let enrichedPhases = 0;
132
+
133
+ for (let index = 0; index < phases.length; index += 1) {
134
+ const phase = phases[index];
135
+ const baseTicket = baseTickets[index];
136
+ const subTickets =
137
+ index < maxPhases
138
+ ? await enrichPhase({ phase, client, provider, model, apiKey, env, maxTicketsPerPhase })
139
+ : null;
140
+
141
+ if (!subTickets) {
142
+ if (baseTicket) tickets.push(baseTicket);
143
+ continue;
144
+ }
145
+
146
+ enrichedPhases += 1;
147
+ const issueNumber = index + 1;
148
+ const phaseDeps = baseTicket?.dependencies || phase?.dependencies || [];
149
+ const baseLabels = baseTicket?.labels || ["sentinelayer", "build-guide", `phase-${issueNumber}`];
150
+
151
+ subTickets.forEach((sub, subIndex) => {
152
+ const dependencies =
153
+ subIndex === 0 ? phaseDeps : [tickets[tickets.length - 1].title];
154
+ const dependencyLine = dependencies.length > 0 ? dependencies.join(", ") : "none (entry phase)";
155
+ tickets.push({
156
+ id: `phase-${issueNumber}.${subIndex + 1}`,
157
+ phase_id: baseTicket?.phase_id || phase?.phaseId || "",
158
+ title: sub.title,
159
+ estimate_hours: baseTicket?.estimate_hours || { min: 4, max: 8 },
160
+ dependencies,
161
+ dependency_ids: subIndex === 0 ? baseTicket?.dependency_ids || [] : [],
162
+ labels: [...baseLabels, "pr"],
163
+ description: subTicketDescription({
164
+ summary: sub.summary,
165
+ acceptanceCriteria: sub.acceptanceCriteria,
166
+ dependencyLine,
167
+ }),
168
+ });
169
+ });
170
+ }
171
+
172
+ return { tickets, enrichedPhases };
173
+ }