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 +1 -1
- package/src/commands/guide.js +52 -0
- package/src/commands/session.js +285 -9
- package/src/coord/events-log.js +1 -1
- package/src/guide/enrich.js +173 -0
- package/src/guide/generator.js +167 -10
- package/src/legacy-cli.js +17 -0
- package/src/session/first-message.js +99 -0
- package/src/session/invitations.js +241 -0
- package/src/session/listeners.js +126 -0
- package/src/session/stream.js +31 -2
- package/src/session/wake/listen-wake.js +144 -0
package/package.json
CHANGED
package/src/commands/guide.js
CHANGED
|
@@ -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
|
package/src/commands/session.js
CHANGED
|
@@ -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
|
|
2748
|
-
|
|
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
|
|
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>`
|
|
2757
|
-
//
|
|
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:
|
|
2834
|
+
awaitRemoteSync: hasConcreteAgentIdentity,
|
|
2766
2835
|
});
|
|
2767
2836
|
const agentJoinRelayed =
|
|
2768
2837
|
joined.emittedJoinEvent !== false &&
|
|
2769
|
-
|
|
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") {
|