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.
- package/README.md +22 -0
- package/package.json +1 -1
- package/src/audit/orchestrator.js +17 -0
- package/src/commands/audit.js +58 -10
- package/src/commands/guide.js +52 -0
- package/src/commands/session.js +310 -17
- package/src/guide/enrich.js +173 -0
- package/src/guide/generator.js +167 -10
- package/src/legacy-cli.js +88 -1
- package/src/session/audit-reporter.js +164 -0
- package/src/session/daemon-spawn.js +192 -0
- package/src/session/first-message.js +99 -0
- package/src/session/listeners.js +126 -0
- package/src/session/project-bootstrap.js +115 -0
- package/src/session/wake/listen-wake.js +144 -0
package/src/commands/session.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
2294
|
-
//
|
|
2295
|
-
//
|
|
2296
|
-
//
|
|
2297
|
-
//
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
if (
|
|
2304
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|