sentinelayer-cli 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -4,6 +4,13 @@ import path from "node:path";
4
4
 
5
5
  import pc from "picocolors";
6
6
 
7
+ import {
8
+ createMultiProviderApiClient,
9
+ resolveApiKey,
10
+ resolveModel,
11
+ resolveProvider,
12
+ } from "../ai/client.js";
13
+ import { enrichGuideTickets } from "../guide/enrich.js";
7
14
  import {
8
15
  defaultGuideExportFileName,
9
16
  generateBuildGuide,
@@ -12,6 +19,40 @@ import {
12
19
  } from "../guide/generator.js";
13
20
  import { renderTerminalMarkdown } from "../ui/markdown.js";
14
21
 
22
+ // Optionally split each phase into per-PR tickets with an LLM. Best-effort:
23
+ // any failure leaves the deterministic tickets untouched. Returns the number
24
+ // of phases enriched (0 when disabled or unavailable).
25
+ async function maybeEnrichGuide(guideDoc, options) {
26
+ if (!options || !options.enrich) return 0;
27
+ try {
28
+ const provider = resolveProvider({ provider: options.provider });
29
+ const model = resolveModel({ provider, model: options.model });
30
+ const apiKey = resolveApiKey({ provider, explicitApiKey: options.apiKey });
31
+ if (!apiKey) {
32
+ console.error(
33
+ pc.yellow(`! --enrich skipped: no API key for provider '${provider}'. Set the provider key or pass --api-key.`)
34
+ );
35
+ return 0;
36
+ }
37
+ const limits = {};
38
+ if (options.maxPhases) limits.maxPhases = Number(options.maxPhases);
39
+ if (options.maxPrsPerPhase) limits.maxTicketsPerPhase = Number(options.maxPrsPerPhase);
40
+ const { tickets, enrichedPhases } = await enrichGuideTickets({
41
+ guide: guideDoc,
42
+ client: createMultiProviderApiClient(),
43
+ provider,
44
+ model,
45
+ apiKey,
46
+ limits,
47
+ });
48
+ if (enrichedPhases > 0) guideDoc.tickets = tickets;
49
+ return enrichedPhases;
50
+ } catch (error) {
51
+ console.error(pc.yellow(`! --enrich failed, using deterministic tickets: ${error?.message || error}`));
52
+ return 0;
53
+ }
54
+ }
55
+
15
56
  function shouldEmitJson(options, command) {
16
57
  const local = Boolean(options && options.json);
17
58
  const globalFromCommand =
@@ -101,6 +142,12 @@ export function registerGuideCommand(program) {
101
142
  .option("--path <path>", "Target workspace path", ".")
102
143
  .option("--spec-file <path>", "Spec file path relative to --path")
103
144
  .option("--output-file <path>", "Output export file path relative to --path")
145
+ .option("--enrich", "Split each phase into per-PR tickets with an LLM (opt-in, capped)")
146
+ .option("--provider <provider>", "LLM provider for --enrich (openai|anthropic|google)")
147
+ .option("--model <model>", "LLM model for --enrich")
148
+ .option("--api-key <key>", "Explicit API key for --enrich (else from env)")
149
+ .option("--max-phases <n>", "Cap how many phases --enrich expands")
150
+ .option("--max-prs-per-phase <n>", "Cap PRs per phase for --enrich")
104
151
  .option("--json", "Emit machine-readable output")
105
152
  .action(async (options, command) => {
106
153
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
@@ -116,6 +163,7 @@ export function registerGuideCommand(program) {
116
163
  projectPath: targetPath,
117
164
  specPath,
118
165
  });
166
+ const enrichedPhases = await maybeEnrichGuide(guideDoc, options);
119
167
  const exportBody = renderGuideExport({ format, guide: guideDoc });
120
168
 
121
169
  await fsp.mkdir(path.dirname(outputPath), { recursive: true });
@@ -128,6 +176,7 @@ export function registerGuideCommand(program) {
128
176
  specPath,
129
177
  outputPath,
130
178
  issueCount: guideDoc.tickets.length,
179
+ enrichedPhases,
131
180
  };
132
181
 
133
182
  if (shouldEmitJson(options, command)) {
@@ -139,6 +188,9 @@ export function registerGuideCommand(program) {
139
188
  console.log(pc.gray(`Format: ${format}`));
140
189
  console.log(pc.gray(`Output: ${outputPath}`));
141
190
  console.log(pc.gray(`Issues: ${guideDoc.tickets.length}`));
191
+ if (enrichedPhases > 0) {
192
+ console.log(pc.gray(`LLM-enriched phases: ${enrichedPhases}`));
193
+ }
142
194
  });
143
195
 
144
196
  guide
@@ -61,6 +61,14 @@ import {
61
61
  refreshSessionCacheForRemoteActivity,
62
62
  updateSessionTitle,
63
63
  } from "../session/store.js";
64
+ import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
65
+ import {
66
+ acceptSessionInvitation,
67
+ normalizeSessionOnboarding,
68
+ writeSessionOnboardingBrief,
69
+ } from "../session/invitations.js";
70
+ import { postFirstSentiMessage } from "../session/first-message.js";
71
+ import { createListenerHostWake } from "../session/wake/listen-wake.js";
64
72
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
65
73
  import {
66
74
  addSessionEventIdentityKeys,
@@ -121,6 +129,13 @@ function normalizeString(value) {
121
129
  return String(value || "").trim();
122
130
  }
123
131
 
132
+ function optionWasSetByCli(command, optionName) {
133
+ if (!command || typeof command.getOptionValueSource !== "function") {
134
+ return false;
135
+ }
136
+ return command.getOptionValueSource(optionName) === "cli";
137
+ }
138
+
124
139
  const SESSION_SAY_CONFIRM_ATTEMPTS = 3;
125
140
  const SESSION_SAY_CONFIRM_DELAY_MS = 250;
126
141
  const SESSION_SAY_CONFIRM_PAGE_LIMIT = 200;
@@ -2236,6 +2251,10 @@ export function registerSessionCommand(program) {
2236
2251
  "--force-new",
2237
2252
  "Always create a new session even if a recent active one exists for this workspace",
2238
2253
  )
2254
+ .option(
2255
+ "--force",
2256
+ "Alias of --force-new (both always mint a fresh session)",
2257
+ )
2239
2258
  .option(
2240
2259
  "--resume",
2241
2260
  "Reuse the most recent active session for this workspace when one is inside the reuse window",
@@ -2279,7 +2298,7 @@ export function registerSessionCommand(program) {
2279
2298
  template,
2280
2299
  title: titleArg,
2281
2300
  resume: options.resume !== false,
2282
- forceNew: Boolean(options.forceNew),
2301
+ forceNew: Boolean(options.forceNew || options.force),
2283
2302
  reuseWindowSeconds,
2284
2303
  });
2285
2304
  const created = ensured.created;
@@ -2356,6 +2375,21 @@ export function registerSessionCommand(program) {
2356
2375
  }
2357
2376
  payload.sentiDaemon = sentiDaemon;
2358
2377
 
2378
+ // Pin the deterministic first-Senti-message as the opening event of a
2379
+ // NEW room so every joining agent reads the operating protocol
2380
+ // (identity, mandatory commands, reaction/threading/lock/evidence
2381
+ // rules, cadence). Skipped on resume (already posted) and opt-outable
2382
+ // via SENTINELAYER_SKIP_FIRST_MESSAGE=1. Best-effort, never blocks.
2383
+ let firstMessage = { posted: false, reason: "skipped" };
2384
+ const skipFirstMessage = String(process.env.SENTINELAYER_SKIP_FIRST_MESSAGE || "").trim() === "1";
2385
+ if (!resumed && !skipFirstMessage) {
2386
+ firstMessage = await postFirstSentiMessage({
2387
+ sessionId: created.sessionId,
2388
+ targetPath,
2389
+ }).catch((error) => ({ posted: false, reason: normalizeString(error?.message) || "error" }));
2390
+ }
2391
+ payload.firstMessage = firstMessage;
2392
+
2359
2393
  if (shouldEmitJson(options, command)) {
2360
2394
  console.log(JSON.stringify(payload, null, 2));
2361
2395
  return;
@@ -2421,7 +2455,7 @@ export function registerSessionCommand(program) {
2421
2455
  if (!resumed) {
2422
2456
  console.log(
2423
2457
  pc.gray(
2424
- options.forceNew
2458
+ (options.forceNew || options.force)
2425
2459
  ? `Tip: fresh session minted (--force-new honored). Subsequent \`${cliCommand} session start\` here within an hour will resume this new session.`
2426
2460
  : `Tip: subsequent \`${cliCommand} session start\` in this workspace within an hour will resume this session. Pass --force-new to override.`,
2427
2461
  ),
@@ -2697,6 +2731,9 @@ export function registerSessionCommand(program) {
2697
2731
  "--agent <id>",
2698
2732
  "Granted agent id to emit an agent_join event as. Behaves like post-agent for human/placeholder ids — those are recorded in the local registry only.",
2699
2733
  )
2734
+ .option("--invite-token <token>", "Invitation token to accept before joining the session")
2735
+ .option("--seat-key <key>", "Reserved session seat key to claim while accepting an invite")
2736
+ .option("--idempotency-key <key>", "Explicit idempotency key for invite acceptance")
2700
2737
  .option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
2701
2738
  .option("--model <model>", "Agent model hint", "cli")
2702
2739
  .option("--path <path>", "Workspace path for the session", ".")
@@ -2707,12 +2744,35 @@ export function registerSessionCommand(program) {
2707
2744
  throw new Error("session id is required.");
2708
2745
  }
2709
2746
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
2747
+ const explicitAgent = normalizeString(options.agent);
2748
+ const legacyName = normalizeString(options.name);
2749
+ const inviteToken = normalizeString(options.inviteToken);
2750
+ let invitationAccept = null;
2751
+ let invitationAcceptResult = null;
2752
+ if (inviteToken) {
2753
+ invitationAccept = await acceptSessionInvitation(normalizedSessionId, {
2754
+ targetPath,
2755
+ invitationToken: inviteToken,
2756
+ seatKey: options.seatKey,
2757
+ agentId: explicitAgent || legacyName,
2758
+ idempotencyKey: options.idempotencyKey,
2759
+ });
2760
+ invitationAcceptResult =
2761
+ invitationAccept?.result && typeof invitationAccept.result === "object"
2762
+ ? invitationAccept.result
2763
+ : null;
2764
+ }
2710
2765
 
2711
2766
  // PR #483 contract: verify the session exists and the caller has access
2712
2767
  // BEFORE materializing local cache state. Without this we'd silently
2713
2768
  // create a phantom local NDJSON for a session that's archived or owned
2714
2769
  // by another tenant — which is the bug Carter reported when asking for
2715
2770
  // a clean "share an id from web → join in CLI" flow.
2771
+ //
2772
+ // Invitation acceptance is the only exception to the old ordering:
2773
+ // before the invite is accepted the user may legitimately receive 403,
2774
+ // so --invite-token performs the guarded accept mutation first and this
2775
+ // verification proves membership immediately afterward.
2716
2776
  const verification = await verifyRemoteSession(normalizedSessionId, { targetPath });
2717
2777
  if (!verification.ok) {
2718
2778
  if (verification.status === 404 || verification.reason === "not_found") {
@@ -2744,17 +2804,26 @@ export function registerSessionCommand(program) {
2744
2804
  targetPath,
2745
2805
  });
2746
2806
 
2747
- const explicitAgent = normalizeString(options.agent);
2748
- const agentSeed = explicitAgent || normalizeString(options.name);
2807
+ const acceptedOnboarding = normalizeSessionOnboarding(
2808
+ invitationAcceptResult?.onboarding,
2809
+ invitationAcceptResult?.claimedSeat,
2810
+ );
2811
+ const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
2812
+ const agentSeed = explicitAgent || acceptedAgentId || legacyName;
2749
2813
  const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
2750
- const role = normalizeString(options.role) || "coder";
2814
+ const roleWasExplicit = optionWasSetByCli(command, "role");
2815
+ const role =
2816
+ normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
2817
+ "coder";
2751
2818
  const model = normalizeString(options.model) || "cli";
2819
+ const hasConcreteAgentIdentity = Boolean(explicitAgent || acceptedAgentId);
2752
2820
 
2753
2821
  // `registerAgent` already writes the canonical `agent_join` event to the
2754
2822
  // local NDJSON and best-effort relays it to /events via appendToStream
2755
2823
  // → syncSessionEventToApi. That gives us the exact `post-agent` parity
2756
- // the spec calls for when `--agent <granted>` is provided. We don't
2757
- // double-emit; we just record whether the explicit agent path was used
2824
+ // the spec calls for when `--agent <granted>` or an accepted reserved
2825
+ // seat provides a concrete agent id. We don't double-emit; we record
2826
+ // whether a durable agent identity path was used
2758
2827
  // so the JSON output can advertise it to callers (and tests).
2759
2828
  const joined = await registerAgent(normalizedSessionId, {
2760
2829
  targetPath,
@@ -2762,15 +2831,21 @@ export function registerSessionCommand(program) {
2762
2831
  model,
2763
2832
  role,
2764
2833
  trackProcessExit: false,
2765
- awaitRemoteSync: Boolean(explicitAgent),
2834
+ awaitRemoteSync: hasConcreteAgentIdentity,
2766
2835
  });
2767
2836
  const agentJoinRelayed =
2768
2837
  joined.emittedJoinEvent !== false &&
2769
- Boolean(explicitAgent) &&
2838
+ hasConcreteAgentIdentity &&
2770
2839
  Boolean(resolvedAgentId) &&
2771
2840
  resolvedAgentId !== "cli-user" &&
2772
2841
  resolvedAgentId !== "unknown" &&
2773
2842
  !resolvedAgentId.startsWith("human-");
2843
+ const onboardingBrief = await writeSessionOnboardingBrief(normalizedSessionId, {
2844
+ targetPath,
2845
+ onboarding: acceptedOnboarding,
2846
+ claimedSeat: invitationAcceptResult?.claimedSeat || null,
2847
+ agentId: joined.agentId,
2848
+ });
2774
2849
 
2775
2850
  const eventCount = Number(remoteSession.eventCount ?? remoteSession.events ?? 0);
2776
2851
  const agents = Array.isArray(remoteSession.agents) ? remoteSession.agents : [];
@@ -2802,6 +2877,21 @@ export function registerSessionCommand(program) {
2802
2877
  agentCount: Number.isFinite(agentCount) ? agentCount : 0,
2803
2878
  lastActivityAt: lastActivityIso || null,
2804
2879
  agentJoinRelayed,
2880
+ invitationAccepted: Boolean(invitationAcceptResult?.ok || invitationAccept),
2881
+ invitationAccept: invitationAccept
2882
+ ? {
2883
+ idempotencyKey: invitationAccept.idempotencyKey,
2884
+ claimedSeat: invitationAcceptResult?.claimedSeat || null,
2885
+ onboarding: acceptedOnboarding,
2886
+ capacity: invitationAcceptResult?.capacity || null,
2887
+ }
2888
+ : null,
2889
+ onboardingGuide: onboardingBrief
2890
+ ? {
2891
+ markdownPath: onboardingBrief.markdownPath,
2892
+ jsonPath: onboardingBrief.jsonPath,
2893
+ }
2894
+ : null,
2805
2895
  };
2806
2896
  if (shouldEmitJson(options, command)) {
2807
2897
  console.log(JSON.stringify(payload, null, 2));
@@ -2814,7 +2904,17 @@ export function registerSessionCommand(program) {
2814
2904
  `Joined session ${titleLabel} (${normalizedSessionId}) — ${payload.eventCount} events, ${payload.agentCount} agents, last activity ${ageLabel}`,
2815
2905
  ),
2816
2906
  );
2907
+ if (payload.invitationAccepted) {
2908
+ const seat = payload.invitationAccept?.claimedSeat || {};
2909
+ const seatLabel = normalizeString(seat.seatKey)
2910
+ ? `; claimed ${normalizeString(seat.seatType) || "reserved"} seat ${seat.seatKey}`
2911
+ : "";
2912
+ console.log(pc.green(`Invitation accepted${seatLabel}`));
2913
+ }
2817
2914
  console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
2915
+ if (payload.onboardingGuide?.markdownPath) {
2916
+ console.log(pc.gray(`onboarding=${payload.onboardingGuide.markdownPath}`));
2917
+ }
2818
2918
  });
2819
2919
 
2820
2920
  session
@@ -3473,6 +3573,108 @@ export function registerSessionCommand(program) {
3473
3573
  return payload;
3474
3574
  });
3475
3575
 
3576
+ session
3577
+ .command("listeners <sessionId>")
3578
+ .description(
3579
+ "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.",
3580
+ )
3581
+ .option("--path <path>", "Workspace path for the session", ".")
3582
+ .option("--limit <n>", "Recent events to scan for heartbeats (default 200)", "200")
3583
+ .option("--json", "Emit machine-readable output")
3584
+ .action(async (sessionId, options, command) => {
3585
+ const normalizedSessionId = normalizeString(sessionId);
3586
+ if (!normalizedSessionId) {
3587
+ throw new Error("session id is required.");
3588
+ }
3589
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3590
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3591
+ const limit = parsePositiveInteger(options.limit, "limit", 200);
3592
+ const result = await fetchSessionListeners(normalizedSessionId, { targetPath, limit });
3593
+ const listeners = Array.isArray(result.listeners) ? result.listeners : [];
3594
+ const live = listeners.filter((row) => row.status === "active" || row.status === "idle").length;
3595
+ const payload = {
3596
+ command: "session listeners",
3597
+ sessionId: normalizedSessionId,
3598
+ ok: Boolean(result.ok),
3599
+ reason: result.ok ? undefined : result.reason,
3600
+ count: listeners.length,
3601
+ liveCount: live,
3602
+ listeners,
3603
+ };
3604
+ if (shouldEmitJson(options, command)) {
3605
+ console.log(JSON.stringify(payload, null, 2));
3606
+ return payload;
3607
+ }
3608
+ if (!result.ok) {
3609
+ console.log(pc.yellow(`Could not read listeners (${result.reason}).`));
3610
+ return payload;
3611
+ }
3612
+ if (listeners.length === 0) {
3613
+ console.log(pc.gray("No listeners detected (no recent presence heartbeats)."));
3614
+ return payload;
3615
+ }
3616
+ console.log(pc.bold(`Listeners (${live} live / ${listeners.length} seen)`));
3617
+ for (const row of listeners) {
3618
+ const line = formatListenerLine(row);
3619
+ if (row.status === "active") console.log(pc.green(` ${line}`));
3620
+ else if (row.status === "idle") console.log(pc.cyan(` ${line}`));
3621
+ else console.log(pc.gray(` ${line}`));
3622
+ }
3623
+ return payload;
3624
+ });
3625
+
3626
+ session
3627
+ .command("stop-listener <sessionId>")
3628
+ .description(
3629
+ "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.",
3630
+ )
3631
+ .option("--agent <id>", "Agent whose listener to stop (omit to stop all listeners in the room)")
3632
+ .option("--path <path>", "Workspace path for the session", ".")
3633
+ .option("--json", "Emit machine-readable output")
3634
+ .action(async (sessionId, options, command) => {
3635
+ const normalizedSessionId = normalizeString(sessionId);
3636
+ if (!normalizedSessionId) {
3637
+ throw new Error("session id is required.");
3638
+ }
3639
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3640
+ const targetAgent = normalizeString(options.agent);
3641
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3642
+ const event = createAgentEvent({
3643
+ event: "listener_stop",
3644
+ agent: { id: "session-control", model: "control", persona: "Session Control" },
3645
+ sessionId: normalizedSessionId,
3646
+ payload: {
3647
+ // targetAgentId routes the directive to that agent's listener (an
3648
+ // event recipient); omitting it broadcasts to every listener.
3649
+ ...(targetAgent ? { targetAgentId: targetAgent } : { broadcast: true }),
3650
+ reason: "operator_stop",
3651
+ },
3652
+ });
3653
+ const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, { targetPath }).catch(
3654
+ (error) => ({ synced: false, reason: normalizeString(error?.message) || "sync_failed" }),
3655
+ );
3656
+ await appendToStream(normalizedSessionId, event, { targetPath, syncRemote: false }).catch(() => {});
3657
+ const payload = {
3658
+ command: "session stop-listener",
3659
+ sessionId: normalizedSessionId,
3660
+ targetAgent: targetAgent || null,
3661
+ scope: targetAgent ? "agent" : "all",
3662
+ remoteSync: remoteSync || undefined,
3663
+ };
3664
+ if (shouldEmitJson(options, command)) {
3665
+ console.log(JSON.stringify(payload, null, 2));
3666
+ return payload;
3667
+ }
3668
+ console.log(
3669
+ pc.yellow(
3670
+ targetAgent
3671
+ ? `Listener stop requested for ${targetAgent}; it will exit on its next poll.`
3672
+ : "Listener stop requested for ALL listeners in this room.",
3673
+ ),
3674
+ );
3675
+ return payload;
3676
+ });
3677
+
3476
3678
  session
3477
3679
  .command("listen")
3478
3680
  .description("Background-poll a session for events addressed to this agent or broadcast")
@@ -3520,6 +3722,14 @@ export function registerSessionCommand(program) {
3520
3722
  "--wake <command>",
3521
3723
  "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.",
3522
3724
  )
3725
+ .option(
3726
+ "--wake-host <name>",
3727
+ "Auto-wake: resume this host (claude|codex) on each addressed message so listening IS waking. Requires --resume-session.",
3728
+ )
3729
+ .option(
3730
+ "--resume-session <id>",
3731
+ "Host session/rollout id to resume on wake (the claude/codex session id, not the Senti id). Pairs with --wake-host.",
3732
+ )
3523
3733
  .option(
3524
3734
  "--coaching-interval <seconds>",
3525
3735
  "Seconds between in-session success reminders (ack, claim work, reply in-thread). Default 900; 0 disables.",
@@ -3575,6 +3785,25 @@ export function registerSessionCommand(program) {
3575
3785
  agentId,
3576
3786
  emit: emitWakeNotice,
3577
3787
  });
3788
+ // Auto-wake cutover: when --wake-host + --resume-session are given, an
3789
+ // addressed message INSTANTLY resumes the host (claude --resume / codex)
3790
+ // via the built wake bus — turning `listen` into a true waker on the
3791
+ // same poll. resolve-target routing inside ensures real-message-only,
3792
+ // addressed-to-us, never-self.
3793
+ const wakeHost = normalizeString(options.wakeHost);
3794
+ const triggerHostWake = wakeHost
3795
+ ? createListenerHostWake({
3796
+ host: wakeHost,
3797
+ resumeSessionId: options.resumeSession,
3798
+ agentId,
3799
+ sessionId: normalizedSessionId,
3800
+ })
3801
+ : null;
3802
+ if (wakeHost && !triggerHostWake) {
3803
+ throw new Error(
3804
+ "--wake-host requires a valid host (claude|codex) and --resume-session <host-session-id>.",
3805
+ );
3806
+ }
3578
3807
  const requestedTransport = normalizeString(options.transport).toLowerCase() || "auto";
3579
3808
  if (!["auto", "stream", "poll"].includes(requestedTransport)) {
3580
3809
  throw new Error("--transport must be one of: auto, stream, poll.");
@@ -3696,6 +3925,20 @@ export function registerSessionCommand(program) {
3696
3925
  }
3697
3926
  },
3698
3927
  onEvent: async (event) => {
3928
+ // Cut-listener: a `listener_stop` directive addressed to this
3929
+ // agent (from the web "stop listening" control or
3930
+ // `sl session stop-listener`) cleanly exits this listener to save
3931
+ // energy. Untargeted (no targetAgentId) stops every listener.
3932
+ if (normalizeString(event?.event) === "listener_stop") {
3933
+ const target = normalizeString(event?.payload?.targetAgentId);
3934
+ if (!target || target === agentId) {
3935
+ if (emitFormat !== "ndjson") {
3936
+ console.log(pc.yellow(`Listener stop requested for ${agentId}; exiting.`));
3937
+ }
3938
+ ac.abort();
3939
+ return;
3940
+ }
3941
+ }
3699
3942
  if (emitFormat === "ndjson") {
3700
3943
  console.log(JSON.stringify(event));
3701
3944
  } else {
@@ -3704,6 +3947,16 @@ export function registerSessionCommand(program) {
3704
3947
  // Fire the wake hook for any matched event (incl. ack/like) so the
3705
3948
  // host can resume its agent.
3706
3949
  wakeRunner.trigger(event);
3950
+ // Auto-wake: instantly resume the host on an addressed message.
3951
+ if (triggerHostWake) {
3952
+ void Promise.resolve(triggerHostWake.trigger(event)).then((outcome) => {
3953
+ if (outcome?.woken && emitFormat !== "ndjson") {
3954
+ console.log(pc.green(`auto-wake: resumed ${wakeHost} (${agentId})`));
3955
+ } else if (outcome && !outcome.woken && outcome.reason !== "not_routed" && emitFormat !== "ndjson") {
3956
+ console.log(pc.yellow(`auto-wake: ${wakeHost} resume failed (${outcome.reason})`));
3957
+ }
3958
+ });
3959
+ }
3707
3960
  },
3708
3961
  onError: async (result) => {
3709
3962
  const reason = normalizeString(result?.reason) || "poll_failed";
@@ -3726,6 +3979,29 @@ export function registerSessionCommand(program) {
3726
3979
  }
3727
3980
  },
3728
3981
  onLifecycle: async (lifecycle) => {
3982
+ // Wake-confirmation runs on every heartbeat regardless of presence:
3983
+ // re-resume agents that were woken but never acked within the
3984
+ // window; confirm + retire the ones that did. (Carter's receipt
3985
+ // idea — a wake isn't done until the agent actually reads it.)
3986
+ if (triggerHostWake && normalizeString(lifecycle?.type) === "heartbeat") {
3987
+ const outcome = await triggerHostWake
3988
+ .reconcile({
3989
+ nowMs: Date.now(),
3990
+ fetchActions: (seq) =>
3991
+ listSessionMessageActions(normalizedSessionId, {
3992
+ targetPath,
3993
+ targetSequenceId: seq,
3994
+ }),
3995
+ })
3996
+ .catch(() => null);
3997
+ if (outcome && (outcome.retried > 0 || outcome.deadLettered > 0) && emitFormat !== "ndjson") {
3998
+ console.log(
3999
+ pc.yellow(
4000
+ `auto-wake reconcile: re-resumed ${outcome.retried}, gave up on ${outcome.deadLettered} (no ack).`,
4001
+ ),
4002
+ );
4003
+ }
4004
+ }
3729
4005
  if (!publishPresence) return;
3730
4006
  const lifecycleType = normalizeString(lifecycle?.type);
3731
4007
  if (lifecycleType === "heartbeat") {
@@ -19,7 +19,7 @@ import {
19
19
  resolveSentinelDir,
20
20
  } from "./paths.js";
21
21
 
22
- const EVENTS_LOCK_TIMEOUT_MS = 5000;
22
+ const EVENTS_LOCK_TIMEOUT_MS = 15_000;
23
23
  const EVENTS_LOCK_STALE_MS = 30_000;
24
24
  const EVENTS_LOCK_POLL_MS = 25;
25
25