sentinelayer-cli 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/package.json +1 -1
- package/src/audit/orchestrator.js +17 -0
- package/src/commands/audit.js +58 -10
- package/src/commands/session.js +344 -16
- package/src/legacy-cli.js +77 -1
- package/src/session/audit-reporter.js +164 -0
- package/src/session/coordination-guidance.js +17 -0
- package/src/session/daemon-spawn.js +192 -0
- package/src/session/project-bootstrap.js +115 -0
package/README.md
CHANGED
|
@@ -101,6 +101,28 @@ Inputs for non-interactive mode:
|
|
|
101
101
|
|
|
102
102
|
## Multi-Agent Session Workflow
|
|
103
103
|
|
|
104
|
+
Create a managed session (the golden path — one command):
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
sl session start --title "my room" --force-new
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`session start` resumes this workspace's most recent active session when it was
|
|
111
|
+
active within the last hour (`--force-new` always mints a fresh one) and spawns
|
|
112
|
+
the **detached Senti daemon** that manages the room — agent greetings, mention
|
|
113
|
+
routing, recaps, durable checkpoints — surviving your terminal. `--no-daemon`
|
|
114
|
+
opts out; `sl session daemon <id>` runs the manager in the foreground. One
|
|
115
|
+
daemon per session is enforced via `senti-daemon.json` in the session directory
|
|
116
|
+
(logs in `senti-daemon.log` next to it), and the daemon exits on its own when
|
|
117
|
+
the session expires.
|
|
118
|
+
|
|
119
|
+
Then point your agents at it:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
sl session join <session-id> --agent <agent-name>
|
|
123
|
+
sl session say <session-id> "status: starting on auth middleware" --agent <agent-name>
|
|
124
|
+
```
|
|
125
|
+
|
|
104
126
|
Sentinelayer includes a deterministic session coordination surface for multi-agent coding loops:
|
|
105
127
|
|
|
106
128
|
- session event stream and replay (`start`, `join`, `say`, `read`, `status`, `leave`, `list`, `kill`)
|
package/package.json
CHANGED
|
@@ -753,6 +753,23 @@ export async function runAuditOrchestrator({
|
|
|
753
753
|
});
|
|
754
754
|
const agentPath = path.join(agentsDirectory, `${agent.id}.json`);
|
|
755
755
|
await fsp.writeFile(agentPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
|
|
756
|
+
emitAuditLifecycleEvent(
|
|
757
|
+
onEvent,
|
|
758
|
+
runId,
|
|
759
|
+
"agent_complete",
|
|
760
|
+
{
|
|
761
|
+
phase: "dispatch",
|
|
762
|
+
agentId: agent.id,
|
|
763
|
+
persona: agent.persona,
|
|
764
|
+
domain: agent.domain,
|
|
765
|
+
status: agentStatus,
|
|
766
|
+
findingCount: findings.length,
|
|
767
|
+
summary,
|
|
768
|
+
confidence,
|
|
769
|
+
durationMs: result.durationMs,
|
|
770
|
+
},
|
|
771
|
+
`${agent.id} persona complete: ${findings.length} finding(s).`
|
|
772
|
+
);
|
|
756
773
|
return {
|
|
757
774
|
...result,
|
|
758
775
|
artifactPath: agentPath,
|
package/src/commands/audit.js
CHANGED
|
@@ -9,6 +9,7 @@ import { writeAuditComparisonArtifact } from "../audit/replay.js";
|
|
|
9
9
|
import { loadAuditRegistry, selectAuditAgents } from "../audit/registry.js";
|
|
10
10
|
import { resolveOutputRoot } from "../config/service.js";
|
|
11
11
|
import { createAgentEvent } from "../events/schema.js";
|
|
12
|
+
import { createAuditSessionReporter, resolveAuditSessionId } from "../session/audit-reporter.js";
|
|
12
13
|
import { buildLegacyArgs } from "./legacy-args.js";
|
|
13
14
|
|
|
14
15
|
function shouldEmitJson(options, command) {
|
|
@@ -89,6 +90,11 @@ export function registerAuditCommand(program, invokeLegacy) {
|
|
|
89
90
|
.option("--no-seed-from-deterministic", "Run personas without deterministic baseline or specialist seed findings")
|
|
90
91
|
.option("--reuse-omargate <runId>", "Reuse deterministic findings from an OmarGate run id or latest")
|
|
91
92
|
.option("--stream", "Emit NDJSON agent events to stdout")
|
|
93
|
+
.option(
|
|
94
|
+
"--session <id>",
|
|
95
|
+
"Senti session id to relay audit progress into (defaults to the workspace's most recent active session)"
|
|
96
|
+
)
|
|
97
|
+
.option("--no-session", "Disable senti session progress relay")
|
|
92
98
|
.option("--json", "Emit machine-readable output")
|
|
93
99
|
.action(async (targetPathArg, options, command) => {
|
|
94
100
|
const emitJson = shouldEmitJson(options, command);
|
|
@@ -105,18 +111,49 @@ export function registerAuditCommand(program, invokeLegacy) {
|
|
|
105
111
|
throw new Error("No agents selected for audit run.");
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
const
|
|
114
|
+
const auditSessionId = await resolveAuditSessionId({
|
|
115
|
+
targetPath,
|
|
116
|
+
explicitSessionId: typeof options.session === "string" ? options.session : "",
|
|
117
|
+
disabled: options.session === false,
|
|
118
|
+
});
|
|
119
|
+
const sessionReporter = createAuditSessionReporter({
|
|
120
|
+
sessionId: auditSessionId,
|
|
109
121
|
targetPath,
|
|
110
|
-
agents: selected.selected,
|
|
111
|
-
maxParallel: parseMaxParallel(options.maxParallel),
|
|
112
|
-
outputDir: options.outputDir,
|
|
113
|
-
dryRun: Boolean(options.dryRun),
|
|
114
|
-
refreshIngest: Boolean(options.refresh),
|
|
115
|
-
isolation: parseIsolationMode(options.isolation),
|
|
116
|
-
seedFromDeterministic: options.seedFromDeterministic !== false,
|
|
117
|
-
reuseOmarGate: options.reuseOmargate,
|
|
118
|
-
onEvent: buildAuditOrchestratorEventHandler(emitStream),
|
|
119
122
|
});
|
|
123
|
+
const streamHandler = buildAuditOrchestratorEventHandler(emitStream);
|
|
124
|
+
const onEvent =
|
|
125
|
+
streamHandler || sessionReporter
|
|
126
|
+
? (evt) => {
|
|
127
|
+
if (streamHandler) {
|
|
128
|
+
streamHandler(evt);
|
|
129
|
+
}
|
|
130
|
+
if (sessionReporter) {
|
|
131
|
+
sessionReporter.handleEvent(evt);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
: null;
|
|
135
|
+
|
|
136
|
+
let result;
|
|
137
|
+
try {
|
|
138
|
+
result = await runAuditOrchestrator({
|
|
139
|
+
targetPath,
|
|
140
|
+
agents: selected.selected,
|
|
141
|
+
maxParallel: parseMaxParallel(options.maxParallel),
|
|
142
|
+
outputDir: options.outputDir,
|
|
143
|
+
dryRun: Boolean(options.dryRun),
|
|
144
|
+
refreshIngest: Boolean(options.refresh),
|
|
145
|
+
isolation: parseIsolationMode(options.isolation),
|
|
146
|
+
seedFromDeterministic: options.seedFromDeterministic !== false,
|
|
147
|
+
reuseOmarGate: options.reuseOmargate,
|
|
148
|
+
onEvent,
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (sessionReporter) {
|
|
152
|
+
await sessionReporter.failed(error);
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
const sessionRelay = sessionReporter ? await sessionReporter.completed(result) : null;
|
|
120
157
|
|
|
121
158
|
const payload = {
|
|
122
159
|
command: "audit",
|
|
@@ -144,12 +181,23 @@ export function registerAuditCommand(program, invokeLegacy) {
|
|
|
144
181
|
ddPackageFindingsPath: result.ddPackage?.findingsIndexPath || "",
|
|
145
182
|
ddPackageSummaryPath: result.ddPackage?.executiveSummaryPath || "",
|
|
146
183
|
ingestRefresh: result.ingest?.refresh || null,
|
|
184
|
+
sessionId: auditSessionId || "",
|
|
185
|
+
sessionRelay: sessionRelay || null,
|
|
147
186
|
};
|
|
148
187
|
|
|
149
188
|
if (emitJson) {
|
|
150
189
|
console.log(JSON.stringify(payload, null, 2));
|
|
151
190
|
} else if (!emitStream) {
|
|
152
191
|
printAuditSummary(result);
|
|
192
|
+
if (auditSessionId) {
|
|
193
|
+
console.log(
|
|
194
|
+
pc.gray(
|
|
195
|
+
`Senti session: ${auditSessionId} (posted ${sessionRelay?.posted ?? 0} update(s)${
|
|
196
|
+
sessionRelay?.failed ? `, ${sessionRelay.failed} failed` : ""
|
|
197
|
+
})`
|
|
198
|
+
)
|
|
199
|
+
);
|
|
200
|
+
}
|
|
153
201
|
}
|
|
154
202
|
|
|
155
203
|
if (result.summary.blocking) {
|
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,
|
|
@@ -77,6 +83,7 @@ import {
|
|
|
77
83
|
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
78
84
|
import { mergeLiveSources } from "../session/live-source.js";
|
|
79
85
|
import { listenSessionEvents } from "../session/listener.js";
|
|
86
|
+
import { SESSION_LIVE_SUCCESS_TIPS } from "../session/coordination-guidance.js";
|
|
80
87
|
import { buildSessionRecap } from "../session/recap.js";
|
|
81
88
|
import { computeTranscriptStats } from "../session/transcript.js";
|
|
82
89
|
import { deriveSessionTitle } from "../session/senti-naming.js";
|
|
@@ -780,6 +787,42 @@ function sentiAutostartDisabled() {
|
|
|
780
787
|
return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
|
|
781
788
|
}
|
|
782
789
|
|
|
790
|
+
export function formatSentiDaemonStatusLine(sentiDaemon = {}, { cliCommand = "sl", sessionId = "" } = {}) {
|
|
791
|
+
if (sentiDaemon.spawned) {
|
|
792
|
+
return {
|
|
793
|
+
tone: "green",
|
|
794
|
+
text: `Senti: managing this session (daemon pid ${sentiDaemon.pid}, detached — survives this terminal). Log: ${sentiDaemon.logPath}`,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
if (sentiDaemon.reason === "already_running") {
|
|
798
|
+
return {
|
|
799
|
+
tone: "green",
|
|
800
|
+
text: `Senti: already managing this session (daemon pid ${sentiDaemon.pid}).`,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
if (sentiDaemon.reason === "disabled" || sentiDaemon.reason === "opt_out") {
|
|
804
|
+
return {
|
|
805
|
+
tone: "gray",
|
|
806
|
+
text: `Senti daemon skipped (${sentiDaemon.reason === "opt_out" ? "--no-daemon" : "SENTINELAYER_SKIP_SENTI_AUTOSTART=1"}); session is unmanaged. Start manually: ${cliCommand} session daemon ${sessionId}`,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
tone: "yellow",
|
|
811
|
+
text: `! Senti daemon not started (${sentiDaemon.reason || "unknown"}); session is unmanaged. Start manually: ${cliCommand} session daemon ${sessionId}`,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function printSentiDaemonStatusLine(sentiDaemon, context) {
|
|
816
|
+
const line = formatSentiDaemonStatusLine(sentiDaemon, context);
|
|
817
|
+
if (line.tone === "green") {
|
|
818
|
+
console.log(pc.green(line.text));
|
|
819
|
+
} else if (line.tone === "yellow") {
|
|
820
|
+
console.log(pc.yellow(line.text));
|
|
821
|
+
} else {
|
|
822
|
+
console.log(pc.gray(line.text));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
783
826
|
function buildResumeContext(candidate, { reuseWindowSeconds = 3600 } = {}) {
|
|
784
827
|
if (!candidate) return null;
|
|
785
828
|
const source = normalizeString(candidate._source) || "unknown";
|
|
@@ -1200,7 +1243,7 @@ async function ensureLocalSessionForRemoteCommand(
|
|
|
1200
1243
|
return { materialized: true, refreshed: false, session: created };
|
|
1201
1244
|
}
|
|
1202
1245
|
|
|
1203
|
-
async function ensureWorkspaceSession({
|
|
1246
|
+
export async function ensureWorkspaceSession({
|
|
1204
1247
|
targetPath,
|
|
1205
1248
|
ttlSeconds = DEFAULT_TTL_SECONDS,
|
|
1206
1249
|
template = null,
|
|
@@ -1405,6 +1448,41 @@ function formatListenerCatchupNotice(catchup = {}) {
|
|
|
1405
1448
|
].join(" ");
|
|
1406
1449
|
}
|
|
1407
1450
|
|
|
1451
|
+
// Periodic in-session coaching reminder surfaced by `session listen`. Keeps
|
|
1452
|
+
// agents continually nudged toward good coordination (ack, claim work, reply
|
|
1453
|
+
// in-thread, surface findings). `tick` makes each emission idempotent so the
|
|
1454
|
+
// same reminder is not deduped across the run.
|
|
1455
|
+
export function buildSessionCoachingEvent({
|
|
1456
|
+
sessionId,
|
|
1457
|
+
agentId,
|
|
1458
|
+
agentModel = "cli",
|
|
1459
|
+
displayName = "",
|
|
1460
|
+
listenerId = "",
|
|
1461
|
+
tick = 0,
|
|
1462
|
+
tips = SESSION_LIVE_SUCCESS_TIPS,
|
|
1463
|
+
} = {}) {
|
|
1464
|
+
const tipList = Array.isArray(tips) && tips.length ? tips : SESSION_LIVE_SUCCESS_TIPS;
|
|
1465
|
+
return createAgentEvent({
|
|
1466
|
+
event: "session_coaching",
|
|
1467
|
+
sessionId,
|
|
1468
|
+
agent: {
|
|
1469
|
+
id: agentId,
|
|
1470
|
+
model: normalizeString(agentModel) || "cli",
|
|
1471
|
+
role: "listener",
|
|
1472
|
+
displayName: normalizeString(displayName) || agentId,
|
|
1473
|
+
clientKind: "cli",
|
|
1474
|
+
},
|
|
1475
|
+
eventId: `session-coaching-${listenerId || agentId}-${tick}`,
|
|
1476
|
+
idempotencyToken: `session-coaching:${listenerId || agentId}:${tick}`,
|
|
1477
|
+
payload: compactPayload({
|
|
1478
|
+
source: "session_listen",
|
|
1479
|
+
kind: "coaching",
|
|
1480
|
+
message: "Session success reminders:",
|
|
1481
|
+
tips: [...tipList],
|
|
1482
|
+
}),
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1408
1486
|
function buildListenerCatchupEvent({
|
|
1409
1487
|
sessionId,
|
|
1410
1488
|
agentId,
|
|
@@ -1708,6 +1786,41 @@ async function resolveSessionAgentEnvelope(
|
|
|
1708
1786
|
return Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined));
|
|
1709
1787
|
}
|
|
1710
1788
|
|
|
1789
|
+
// Builds the lock/unlock say-convention directive the session daemon parses
|
|
1790
|
+
// into the authoritative file-lock registry. Kept pure + exported for testing.
|
|
1791
|
+
export function buildSessionLockDirective(verb, file, intent = "") {
|
|
1792
|
+
const normalizedFile = normalizeString(file);
|
|
1793
|
+
const normalizedIntent = normalizeString(intent);
|
|
1794
|
+
if (verb === "unlock") {
|
|
1795
|
+
return `unlock: ${normalizedFile} - ${normalizedIntent || "done"}`;
|
|
1796
|
+
}
|
|
1797
|
+
return normalizedIntent ? `lock: ${normalizedFile} - ${normalizedIntent}` : `lock: ${normalizedFile}`;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// Posts a coordination directive (e.g. "lock: <file> - <intent>") as a session
|
|
1801
|
+
// message so the session daemon processes it into the authoritative file-lock
|
|
1802
|
+
// registry. Used by `session lock`/`unlock` as ergonomic sugar over the
|
|
1803
|
+
// say-convention; locks are advisory + daemon-enforced with TTL auto-release,
|
|
1804
|
+
// so this is a best-effort post.
|
|
1805
|
+
async function postSessionDirectiveMessage(sessionId, message, {
|
|
1806
|
+
agentId,
|
|
1807
|
+
targetPath = process.cwd(),
|
|
1808
|
+
} = {}) {
|
|
1809
|
+
const clientMessageId = `cli-${randomUUID()}`;
|
|
1810
|
+
const agent = await resolveSessionAgentEnvelope(sessionId, agentId, { targetPath });
|
|
1811
|
+
await ensureSessionSayAgentRegistered(sessionId, agent, { targetPath });
|
|
1812
|
+
const event = createAgentEvent({
|
|
1813
|
+
event: "session_message",
|
|
1814
|
+
agent,
|
|
1815
|
+
sessionId,
|
|
1816
|
+
payload: { message, channel: "session", clientMessageId },
|
|
1817
|
+
});
|
|
1818
|
+
event.eventId = clientMessageId;
|
|
1819
|
+
event.idempotencyToken = clientMessageId;
|
|
1820
|
+
const result = await syncSessionEventToApi(sessionId, event, { targetPath });
|
|
1821
|
+
return { event, result };
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1711
1824
|
async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
|
|
1712
1825
|
const normalizedItems = Array.isArray(items) ? items : [];
|
|
1713
1826
|
const normalizedConcurrency = Math.max(
|
|
@@ -2107,7 +2220,7 @@ export function registerSessionCommand(program) {
|
|
|
2107
2220
|
session
|
|
2108
2221
|
.command("start")
|
|
2109
2222
|
.description(
|
|
2110
|
-
"Start (or resume) a
|
|
2223
|
+
"Start (or resume) a managed session. Reuses this workspace's most recent active session when it was active within the last hour (--force-new always mints a fresh id), then spawns the detached Senti daemon that manages it — agent greetings, mention routing, recaps, checkpoints — surviving this terminal. Pass --no-daemon for an unmanaged session.",
|
|
2111
2224
|
)
|
|
2112
2225
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
2113
2226
|
.option("--title <title>", "Human-readable label (shown in web sidebar + transcript)")
|
|
@@ -2137,6 +2250,10 @@ export function registerSessionCommand(program) {
|
|
|
2137
2250
|
"Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
|
|
2138
2251
|
"3600",
|
|
2139
2252
|
)
|
|
2253
|
+
.option(
|
|
2254
|
+
"--no-daemon",
|
|
2255
|
+
"Do not spawn the detached Senti daemon (session will be unmanaged: no greetings, recaps, or checkpoints)",
|
|
2256
|
+
)
|
|
2140
2257
|
.option("--json", "Emit machine-readable output")
|
|
2141
2258
|
.action(async (options, command) => {
|
|
2142
2259
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
@@ -2219,19 +2336,25 @@ export function registerSessionCommand(program) {
|
|
|
2219
2336
|
}).catch(() => {});
|
|
2220
2337
|
}
|
|
2221
2338
|
|
|
2222
|
-
//
|
|
2223
|
-
//
|
|
2224
|
-
//
|
|
2225
|
-
//
|
|
2226
|
-
//
|
|
2227
|
-
//
|
|
2228
|
-
//
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
if (
|
|
2233
|
-
|
|
2339
|
+
// Make the session managed by default: spawn the Senti daemon as a
|
|
2340
|
+
// DETACHED process so greetings, mention routing, recaps, and
|
|
2341
|
+
// checkpoints keep running after this CLI command (and terminal)
|
|
2342
|
+
// exits. The old in-process `startSenti` died the moment this
|
|
2343
|
+
// action returned, so every session was effectively unmanaged.
|
|
2344
|
+
// Deduped via the session's pid file; best-effort and never blocks
|
|
2345
|
+
// session creation.
|
|
2346
|
+
let sentiDaemon = { spawned: false, pid: null, reason: "skipped", logPath: "" };
|
|
2347
|
+
if (sentiAutostartDisabled()) {
|
|
2348
|
+
sentiDaemon.reason = "disabled";
|
|
2349
|
+
} else if (options.daemon === false) {
|
|
2350
|
+
sentiDaemon.reason = "opt_out";
|
|
2351
|
+
} else {
|
|
2352
|
+
sentiDaemon = await spawnDetachedSentiDaemon({
|
|
2353
|
+
sessionId: created.sessionId,
|
|
2354
|
+
targetPath,
|
|
2355
|
+
});
|
|
2234
2356
|
}
|
|
2357
|
+
payload.sentiDaemon = sentiDaemon;
|
|
2235
2358
|
|
|
2236
2359
|
if (shouldEmitJson(options, command)) {
|
|
2237
2360
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -2253,6 +2376,7 @@ export function registerSessionCommand(program) {
|
|
|
2253
2376
|
}
|
|
2254
2377
|
console.log("");
|
|
2255
2378
|
console.log(`Dashboard: ${dashboardUrl}`);
|
|
2379
|
+
printSentiDaemonStatusLine(sentiDaemon, { cliCommand, sessionId: created.sessionId });
|
|
2256
2380
|
return;
|
|
2257
2381
|
}
|
|
2258
2382
|
|
|
@@ -2278,6 +2402,13 @@ export function registerSessionCommand(program) {
|
|
|
2278
2402
|
console.log(
|
|
2279
2403
|
`status=${created.status} created_at=${created.createdAt} expires_at=${created.expiresAt} ttl_seconds=${ttlSeconds}`,
|
|
2280
2404
|
);
|
|
2405
|
+
console.log(pc.gray(`Dashboard: ${dashboardUrl}`));
|
|
2406
|
+
printSentiDaemonStatusLine(sentiDaemon, { cliCommand, sessionId: created.sessionId });
|
|
2407
|
+
console.log(
|
|
2408
|
+
pc.gray(
|
|
2409
|
+
`Agents join with: ${cliCommand} session join ${created.sessionId} --agent <name>`,
|
|
2410
|
+
),
|
|
2411
|
+
);
|
|
2281
2412
|
if (remoteSync.status === "auth_required") {
|
|
2282
2413
|
console.log(
|
|
2283
2414
|
pc.yellow(
|
|
@@ -2290,7 +2421,9 @@ export function registerSessionCommand(program) {
|
|
|
2290
2421
|
if (!resumed) {
|
|
2291
2422
|
console.log(
|
|
2292
2423
|
pc.gray(
|
|
2293
|
-
|
|
2424
|
+
options.forceNew
|
|
2425
|
+
? `Tip: fresh session minted (--force-new honored). Subsequent \`${cliCommand} session start\` here within an hour will resume this new session.`
|
|
2426
|
+
: `Tip: subsequent \`${cliCommand} session start\` in this workspace within an hour will resume this session. Pass --force-new to override.`,
|
|
2294
2427
|
),
|
|
2295
2428
|
);
|
|
2296
2429
|
}
|
|
@@ -3225,6 +3358,121 @@ export function registerSessionCommand(program) {
|
|
|
3225
3358
|
return payload;
|
|
3226
3359
|
});
|
|
3227
3360
|
|
|
3361
|
+
const runLockDirectiveCommand = async (verb, sessionId, files, options, command) => {
|
|
3362
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
3363
|
+
if (!normalizedSessionId) {
|
|
3364
|
+
throw new Error("session id is required.");
|
|
3365
|
+
}
|
|
3366
|
+
const fileList = (Array.isArray(files) ? files : [files])
|
|
3367
|
+
.map((file) => normalizeString(file))
|
|
3368
|
+
.filter(Boolean);
|
|
3369
|
+
if (fileList.length === 0) {
|
|
3370
|
+
throw new Error(`session ${verb} requires at least one file path.`);
|
|
3371
|
+
}
|
|
3372
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
3373
|
+
await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
|
|
3374
|
+
const identity = await resolveMessageActionIdentity({
|
|
3375
|
+
sessionId: normalizedSessionId,
|
|
3376
|
+
optionAgent: options.agent,
|
|
3377
|
+
targetPath,
|
|
3378
|
+
env: process.env,
|
|
3379
|
+
});
|
|
3380
|
+
if (shouldBlockImplicitCliUserSessionSay(identity)) {
|
|
3381
|
+
throw new Error(
|
|
3382
|
+
identity.identityWarning ||
|
|
3383
|
+
`session ${verb} requires an agent identity; pass --agent <id>, set SENTINELAYER_AGENT_ID, or run session join --agent <id> first.`,
|
|
3384
|
+
);
|
|
3385
|
+
}
|
|
3386
|
+
const intent = normalizeString(options.intent);
|
|
3387
|
+
const processed = [];
|
|
3388
|
+
for (const file of fileList) {
|
|
3389
|
+
const directive = buildSessionLockDirective(verb, file, intent);
|
|
3390
|
+
await postSessionDirectiveMessage(normalizedSessionId, directive, {
|
|
3391
|
+
agentId: identity.agentId,
|
|
3392
|
+
targetPath,
|
|
3393
|
+
});
|
|
3394
|
+
processed.push(file);
|
|
3395
|
+
}
|
|
3396
|
+
const payload = {
|
|
3397
|
+
command: `session ${verb}`,
|
|
3398
|
+
sessionId: normalizedSessionId,
|
|
3399
|
+
agentId: identity.agentId,
|
|
3400
|
+
files: processed,
|
|
3401
|
+
};
|
|
3402
|
+
if (shouldEmitJson(options, command)) {
|
|
3403
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3404
|
+
return payload;
|
|
3405
|
+
}
|
|
3406
|
+
const action = verb === "lock" ? "Requested lock on" : "Released";
|
|
3407
|
+
console.log(pc.green(`${action} ${processed.length} file(s) as ${identity.agentId}: ${processed.join(", ")}`));
|
|
3408
|
+
if (verb === "lock") {
|
|
3409
|
+
console.log(
|
|
3410
|
+
pc.gray("Senti enforces fail-closed; locks auto-release on TTL. Release with `sl session unlock`."),
|
|
3411
|
+
);
|
|
3412
|
+
}
|
|
3413
|
+
return payload;
|
|
3414
|
+
};
|
|
3415
|
+
|
|
3416
|
+
session
|
|
3417
|
+
.command("lock <sessionId> <files...>")
|
|
3418
|
+
.description("Claim exclusive file locks via Senti (fail-closed, TTL auto-release)")
|
|
3419
|
+
.option("--intent <text>", "Why you're locking these files (shown to peers)")
|
|
3420
|
+
.option("--agent <id>", "Agent id claiming the lock (defaults to the joined session agent)")
|
|
3421
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
3422
|
+
.option("--json", "Emit machine-readable output")
|
|
3423
|
+
.action(async (sessionId, files, options, command) => {
|
|
3424
|
+
await runLockDirectiveCommand("lock", sessionId, files, options, command);
|
|
3425
|
+
});
|
|
3426
|
+
|
|
3427
|
+
session
|
|
3428
|
+
.command("unlock <sessionId> <files...>")
|
|
3429
|
+
.description("Release file locks you hold (Senti only lets the holder release)")
|
|
3430
|
+
.option("--intent <text>", "Optional note on the release")
|
|
3431
|
+
.option("--agent <id>", "Agent id releasing the lock (defaults to the joined session agent)")
|
|
3432
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
3433
|
+
.option("--json", "Emit machine-readable output")
|
|
3434
|
+
.action(async (sessionId, files, options, command) => {
|
|
3435
|
+
await runLockDirectiveCommand("unlock", sessionId, files, options, command);
|
|
3436
|
+
});
|
|
3437
|
+
|
|
3438
|
+
session
|
|
3439
|
+
.command("locks <sessionId>")
|
|
3440
|
+
.description("List active file locks for the session (who holds what, and when they expire)")
|
|
3441
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
3442
|
+
.option("--json", "Emit machine-readable output")
|
|
3443
|
+
.action(async (sessionId, options, command) => {
|
|
3444
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
3445
|
+
if (!normalizedSessionId) {
|
|
3446
|
+
throw new Error("session id is required.");
|
|
3447
|
+
}
|
|
3448
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
3449
|
+
await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
|
|
3450
|
+
const locks = await listFileLocks(normalizedSessionId, { targetPath });
|
|
3451
|
+
const lockList = Array.isArray(locks) ? locks : [];
|
|
3452
|
+
const payload = {
|
|
3453
|
+
command: "session locks",
|
|
3454
|
+
sessionId: normalizedSessionId,
|
|
3455
|
+
count: lockList.length,
|
|
3456
|
+
locks: lockList,
|
|
3457
|
+
};
|
|
3458
|
+
if (shouldEmitJson(options, command)) {
|
|
3459
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3460
|
+
return payload;
|
|
3461
|
+
}
|
|
3462
|
+
if (lockList.length === 0) {
|
|
3463
|
+
console.log(pc.gray("No active file locks."));
|
|
3464
|
+
return payload;
|
|
3465
|
+
}
|
|
3466
|
+
console.log(pc.bold(`Active file locks (${lockList.length})`));
|
|
3467
|
+
for (const lock of lockList) {
|
|
3468
|
+
const file = normalizeString(lock.file || lock.filePath) || "(unknown file)";
|
|
3469
|
+
const holder = normalizeString(lock.agentId) || "unknown";
|
|
3470
|
+
const expires = normalizeString(lock.expiresAt);
|
|
3471
|
+
console.log(pc.cyan(` ${file}`) + pc.gray(` held by ${holder}${expires ? ` · expires ${expires}` : ""}`));
|
|
3472
|
+
}
|
|
3473
|
+
return payload;
|
|
3474
|
+
});
|
|
3475
|
+
|
|
3228
3476
|
session
|
|
3229
3477
|
.command("listen")
|
|
3230
3478
|
.description("Background-poll a session for events addressed to this agent or broadcast")
|
|
@@ -3272,6 +3520,12 @@ export function registerSessionCommand(program) {
|
|
|
3272
3520
|
"--wake <command>",
|
|
3273
3521
|
"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.",
|
|
3274
3522
|
)
|
|
3523
|
+
.option(
|
|
3524
|
+
"--coaching-interval <seconds>",
|
|
3525
|
+
"Seconds between in-session success reminders (ack, claim work, reply in-thread). Default 900; 0 disables.",
|
|
3526
|
+
"900",
|
|
3527
|
+
)
|
|
3528
|
+
.option("--no-coaching", "Do not emit periodic in-session success reminders")
|
|
3275
3529
|
.action(async (options) => {
|
|
3276
3530
|
const normalizedSessionId = resolveSessionIdOption(options);
|
|
3277
3531
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
@@ -3345,6 +3599,44 @@ export function registerSessionCommand(program) {
|
|
|
3345
3599
|
const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
|
|
3346
3600
|
let lastPresenceHeartbeatMs = 0;
|
|
3347
3601
|
|
|
3602
|
+
// Periodic in-session success reminders (ack, claim work, reply
|
|
3603
|
+
// in-thread). Long-running interactive listeners only — skipped under
|
|
3604
|
+
// --max-polls (smoke/test) and when --no-coaching is set.
|
|
3605
|
+
const coachingIntervalSeconds =
|
|
3606
|
+
options.coaching === false
|
|
3607
|
+
? 0
|
|
3608
|
+
: parsePositiveInteger(options.coachingInterval, "coaching-interval", 900);
|
|
3609
|
+
let coachingTick = 0;
|
|
3610
|
+
const emitCoaching = () => {
|
|
3611
|
+
if (emitFormat === "ndjson") {
|
|
3612
|
+
console.log(
|
|
3613
|
+
JSON.stringify(
|
|
3614
|
+
buildSessionCoachingEvent({
|
|
3615
|
+
sessionId: normalizedSessionId,
|
|
3616
|
+
agentId,
|
|
3617
|
+
agentModel,
|
|
3618
|
+
displayName,
|
|
3619
|
+
listenerId,
|
|
3620
|
+
tick: coachingTick++,
|
|
3621
|
+
}),
|
|
3622
|
+
),
|
|
3623
|
+
);
|
|
3624
|
+
} else {
|
|
3625
|
+
console.log(pc.cyan("Session success reminders:"));
|
|
3626
|
+
for (const tip of SESSION_LIVE_SUCCESS_TIPS) {
|
|
3627
|
+
console.log(pc.gray(` - ${tip}`));
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
};
|
|
3631
|
+
let coachingTimer = null;
|
|
3632
|
+
if (coachingIntervalSeconds > 0 && maxPolls === null) {
|
|
3633
|
+
emitCoaching();
|
|
3634
|
+
coachingTimer = setInterval(emitCoaching, coachingIntervalSeconds * 1000);
|
|
3635
|
+
if (coachingTimer && typeof coachingTimer.unref === "function") {
|
|
3636
|
+
coachingTimer.unref();
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3348
3640
|
if (emitFormat === "text") {
|
|
3349
3641
|
console.log(
|
|
3350
3642
|
pc.gray(
|
|
@@ -3455,14 +3747,20 @@ export function registerSessionCommand(program) {
|
|
|
3455
3747
|
},
|
|
3456
3748
|
});
|
|
3457
3749
|
} finally {
|
|
3750
|
+
if (coachingTimer) {
|
|
3751
|
+
clearInterval(coachingTimer);
|
|
3752
|
+
}
|
|
3458
3753
|
process.removeListener("SIGINT", onSigint);
|
|
3459
3754
|
}
|
|
3460
3755
|
});
|
|
3461
3756
|
|
|
3462
3757
|
session
|
|
3463
3758
|
.command("daemon [sessionId]")
|
|
3464
|
-
.description(
|
|
3759
|
+
.description(
|
|
3760
|
+
"Run the Senti daemon that manages a session: greet joining agents, route mentions, emit recaps, and generate durable checkpoints. `session start` spawns this automatically as a detached background process — run it manually only for foreground monitoring or after --no-daemon. Records its pid in the session dir (senti-daemon.json) and exits when the session expires.",
|
|
3761
|
+
)
|
|
3465
3762
|
.option("--session <id>", "Session id to monitor")
|
|
3763
|
+
.option("--force", "Take over even if senti-daemon.json reports another live daemon for this session")
|
|
3466
3764
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
3467
3765
|
.option("--tick-interval <seconds>", "Seconds between health ticks (default 30)", "30")
|
|
3468
3766
|
.option("--stale-agent-seconds <seconds>", "Seconds before an inactive agent is flagged stale (default 90)", "90")
|
|
@@ -3490,6 +3788,18 @@ export function registerSessionCommand(program) {
|
|
|
3490
3788
|
"tick-interval",
|
|
3491
3789
|
30,
|
|
3492
3790
|
);
|
|
3791
|
+
// One manager per session: refuse to double-run unless --force.
|
|
3792
|
+
// A stale pid file (reboot, hard kill) reads as not-running and is
|
|
3793
|
+
// safely overwritten.
|
|
3794
|
+
if (!options.once) {
|
|
3795
|
+
const existingDaemon = await getDaemonStatus(normalizedSessionId, { targetPath });
|
|
3796
|
+
if (existingDaemon.running && existingDaemon.pid !== process.pid && !options.force) {
|
|
3797
|
+
throw new Error(
|
|
3798
|
+
`A Senti daemon is already managing session ${normalizedSessionId} (pid ${existingDaemon.pid}). Use --force to take over.`,
|
|
3799
|
+
);
|
|
3800
|
+
}
|
|
3801
|
+
await writeDaemonPidRecord(normalizedSessionId, { targetPath, tickIntervalMs });
|
|
3802
|
+
}
|
|
3493
3803
|
const daemon = await startSenti(normalizedSessionId, {
|
|
3494
3804
|
targetPath,
|
|
3495
3805
|
autoStart: false,
|
|
@@ -3591,10 +3901,28 @@ export function registerSessionCommand(program) {
|
|
|
3591
3901
|
} else {
|
|
3592
3902
|
console.log(pc.gray(`tick ${payload.summary.generatedAt}: recap=${payload.summary.recap.reason || payload.summary.recap.mode || "ok"} checkpoint=${payload.summary.checkpoint.reason || payload.summary.checkpoint.checkpointId || "ok"}`));
|
|
3593
3903
|
}
|
|
3904
|
+
// A daemon must not outlive its session: stop cleanly once the
|
|
3905
|
+
// session expires or its local cache disappears.
|
|
3906
|
+
const liveSession = await getSession(normalizedSessionId, { targetPath }).catch(() => null);
|
|
3907
|
+
const expiresAtMs = Date.parse(liveSession?.expiresAt || "");
|
|
3908
|
+
if (
|
|
3909
|
+
!liveSession ||
|
|
3910
|
+
liveSession.status !== "active" ||
|
|
3911
|
+
(Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now())
|
|
3912
|
+
) {
|
|
3913
|
+
if (!emitJson) {
|
|
3914
|
+
console.log(pc.gray("senti daemon: session expired or closed; stopping."));
|
|
3915
|
+
}
|
|
3916
|
+
break;
|
|
3917
|
+
}
|
|
3594
3918
|
}
|
|
3595
3919
|
} finally {
|
|
3596
3920
|
process.removeListener("SIGINT", stop);
|
|
3597
3921
|
process.removeListener("SIGTERM", stop);
|
|
3922
|
+
await removeDaemonPidRecord(normalizedSessionId, {
|
|
3923
|
+
targetPath,
|
|
3924
|
+
onlyForPid: process.pid,
|
|
3925
|
+
}).catch(() => {});
|
|
3598
3926
|
const stopped = await daemon.stop("signal");
|
|
3599
3927
|
if (emitJson) {
|
|
3600
3928
|
console.log(
|
package/src/legacy-cli.js
CHANGED
|
@@ -227,6 +227,9 @@ function printUsage() {
|
|
|
227
227
|
console.log(" sl session read <id> --remote --agent <id> Read stream events and auto-record views");
|
|
228
228
|
console.log(" sl session view <id> <seq> Manually backfill a read receipt");
|
|
229
229
|
console.log(" sl session pins <id> --json List pinned messages with content (readable by agents)");
|
|
230
|
+
console.log(" sl session lock <id> <files...> --intent <why> Claim file locks (fail-closed, TTL auto-release)");
|
|
231
|
+
console.log(" sl session unlock <id> <files...> Release file locks you hold");
|
|
232
|
+
console.log(" sl session locks <id> --json List active file locks (holder + expiry)");
|
|
230
233
|
console.log(" sl session listen --session <id> --agent <id> Background-poll a session for new events");
|
|
231
234
|
console.log(" sl session recap now <id> --remote --json Summarize current owners, locks, and work");
|
|
232
235
|
console.log(" sl session daemon --session <id> Run Senti recaps/checkpoints for long rooms");
|
|
@@ -2179,6 +2182,7 @@ export function buildHandoffPrompt({
|
|
|
2179
2182
|
buildFromExistingRepo,
|
|
2180
2183
|
authMode,
|
|
2181
2184
|
codingAgent,
|
|
2185
|
+
sessionId,
|
|
2182
2186
|
}) {
|
|
2183
2187
|
const codingAgentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
|
|
2184
2188
|
const codingAgentConfigPath = codingAgentProfile.configFile || "none";
|
|
@@ -2242,7 +2246,16 @@ Repo context:
|
|
|
2242
2246
|
- Target repo: ${repoSlug || "not provided"}
|
|
2243
2247
|
- Workspace mode: ${buildFromExistingRepo ? "existing codebase" : "new scaffold"}
|
|
2244
2248
|
|
|
2245
|
-
|
|
2249
|
+
${
|
|
2250
|
+
String(sessionId || "").trim()
|
|
2251
|
+
? `## Multi-Agent Coordination
|
|
2252
|
+
|
|
2253
|
+
Project senti session (auto-created at init): \`${String(sessionId).trim()}\`
|
|
2254
|
+
- Join before starting work: \`sl session join ${String(sessionId).trim()} --agent <your-agent-name>\`
|
|
2255
|
+
- Post status updates as you work: \`sl session say ${String(sessionId).trim()} "<update>" --agent <your-agent-name>\`
|
|
2256
|
+
- Audit runs (\`sentinel /audit\`) relay per-persona progress into this session automatically, so swarm agents can watch each other's findings without losing context.`
|
|
2257
|
+
: `## Multi-Agent Coordination (if session active)`
|
|
2258
|
+
}
|
|
2246
2259
|
|
|
2247
2260
|
${renderCoordinationNumberedList()}
|
|
2248
2261
|
|
|
@@ -2574,6 +2587,7 @@ async function writeInitConfigLockfile({
|
|
|
2574
2587
|
secretName,
|
|
2575
2588
|
repoSlug,
|
|
2576
2589
|
workflowPath,
|
|
2590
|
+
sessionId,
|
|
2577
2591
|
}) {
|
|
2578
2592
|
const lockDir = path.join(projectDir, ".sentinelayer");
|
|
2579
2593
|
const configPath = path.join(lockDir, "config.json");
|
|
@@ -2585,6 +2599,7 @@ async function writeInitConfigLockfile({
|
|
|
2585
2599
|
required_secret_name: String(secretName || "SENTINELAYER_TOKEN").trim() || "SENTINELAYER_TOKEN",
|
|
2586
2600
|
repo_slug: normalizeRepoSlug(repoSlug || ""),
|
|
2587
2601
|
workflow_path: path.relative(projectDir, workflowPath).replace(/\\/g, "/"),
|
|
2602
|
+
session_id: String(sessionId || "").trim(),
|
|
2588
2603
|
};
|
|
2589
2604
|
|
|
2590
2605
|
await fsp.mkdir(lockDir, { recursive: true });
|
|
@@ -3142,6 +3157,26 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3142
3157
|
expectedSpecId: generatedSpecId || workflowSpecIdFromTemplate,
|
|
3143
3158
|
});
|
|
3144
3159
|
}
|
|
3160
|
+
// Project senti session: every new project gets its own coordination room
|
|
3161
|
+
// so agents (audit personas, builders, reviewers) can post progress and see
|
|
3162
|
+
// each other's messages without losing context. Local-first + best-effort:
|
|
3163
|
+
// an offline/unauthenticated init still completes.
|
|
3164
|
+
let projectSession = null;
|
|
3165
|
+
if (!boolFromEnv(process.env.SENTINELAYER_SKIP_PROJECT_SESSION)) {
|
|
3166
|
+
try {
|
|
3167
|
+
const { bootstrapProjectSession } = await import("./session/project-bootstrap.js");
|
|
3168
|
+
projectSession = await bootstrapProjectSession({
|
|
3169
|
+
projectDir,
|
|
3170
|
+
projectName: effectiveProjectName,
|
|
3171
|
+
skipGuides: true,
|
|
3172
|
+
});
|
|
3173
|
+
} catch (error) {
|
|
3174
|
+
console.log(
|
|
3175
|
+
pc.yellow(`! Senti project session bootstrap skipped: ${error?.message || error}`)
|
|
3176
|
+
);
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3145
3180
|
const configLockfilePath = await writeInitConfigLockfile({
|
|
3146
3181
|
projectDir,
|
|
3147
3182
|
specId: workflowSpecId || generatedSpecId || workflowSpecIdFromTemplate,
|
|
@@ -3149,6 +3184,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3149
3184
|
secretName,
|
|
3150
3185
|
repoSlug: interview.repoSlug || detectRepoSlug(projectDir) || "",
|
|
3151
3186
|
workflowPath,
|
|
3187
|
+
sessionId: projectSession?.sessionId || "",
|
|
3152
3188
|
});
|
|
3153
3189
|
|
|
3154
3190
|
await writeTextFile(
|
|
@@ -3174,6 +3210,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3174
3210
|
buildFromExistingRepo: interview.buildFromExistingRepo,
|
|
3175
3211
|
authMode: effectiveAuthMode,
|
|
3176
3212
|
codingAgent: interview.codingAgent,
|
|
3213
|
+
sessionId: projectSession?.sessionId || "",
|
|
3177
3214
|
})
|
|
3178
3215
|
);
|
|
3179
3216
|
await writeTextFile(
|
|
@@ -3186,6 +3223,19 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3186
3223
|
codingAgent: interview.codingAgent,
|
|
3187
3224
|
});
|
|
3188
3225
|
|
|
3226
|
+
// Guides go in after the coding-agent config so the config scaffold above
|
|
3227
|
+
// doesn't see a guide-created AGENTS.md/CLAUDE.md and skip itself.
|
|
3228
|
+
if (projectSession?.sessionId) {
|
|
3229
|
+
try {
|
|
3230
|
+
const { setupSessionGuides } = await import("./session/setup-guides.js");
|
|
3231
|
+
projectSession.guides = await setupSessionGuides(projectSession.sessionId, {
|
|
3232
|
+
targetPath: projectDir,
|
|
3233
|
+
});
|
|
3234
|
+
} catch (error) {
|
|
3235
|
+
console.log(pc.yellow(`! Session coordination guides skipped: ${error?.message || error}`));
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3189
3239
|
await ensureSentinelStartScript(projectDir, effectiveProjectName);
|
|
3190
3240
|
|
|
3191
3241
|
// Code scaffold: write starter source files, skip existing
|
|
@@ -3278,6 +3328,32 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3278
3328
|
printSection("Complete");
|
|
3279
3329
|
console.log(pc.green(`✔ Sentinelayer orchestration initialized in ${projectDir}`));
|
|
3280
3330
|
console.log(pc.green(`✔ Config lockfile written: ${configLockfilePath}`));
|
|
3331
|
+
if (projectSession?.sessionId) {
|
|
3332
|
+
console.log(pc.green(`✔ Senti project session created: ${projectSession.sessionId}`));
|
|
3333
|
+
console.log(pc.green(` Dashboard: ${projectSession.dashboardUrl}`));
|
|
3334
|
+
if (projectSession.daemon?.spawned) {
|
|
3335
|
+
console.log(
|
|
3336
|
+
pc.green(` Senti: managing this session (daemon pid ${projectSession.daemon.pid}, detached).`)
|
|
3337
|
+
);
|
|
3338
|
+
} else if (projectSession.daemon?.reason && projectSession.daemon.reason !== "disabled") {
|
|
3339
|
+
console.log(
|
|
3340
|
+
pc.yellow(
|
|
3341
|
+
` Senti daemon not started (${projectSession.daemon.reason}); run: sl session daemon ${projectSession.sessionId}`
|
|
3342
|
+
)
|
|
3343
|
+
);
|
|
3344
|
+
}
|
|
3345
|
+
console.log(
|
|
3346
|
+
pc.gray(
|
|
3347
|
+
` Agents coordinate here: sl session join ${projectSession.sessionId} --agent <name>; audit runs post progress automatically.`
|
|
3348
|
+
)
|
|
3349
|
+
);
|
|
3350
|
+
} else {
|
|
3351
|
+
console.log(
|
|
3352
|
+
pc.yellow(
|
|
3353
|
+
"! Senti project session not created. Run `sl session start` inside the project to create the coordination room."
|
|
3354
|
+
)
|
|
3355
|
+
);
|
|
3356
|
+
}
|
|
3281
3357
|
if (workflowSpecId) {
|
|
3282
3358
|
console.log(pc.green(`✔ Omar workflow spec binding validated: ${workflowSpecId}`));
|
|
3283
3359
|
} else {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createAgentEvent } from "../events/schema.js";
|
|
2
|
+
import { registerAgent } from "./agent-registry.js";
|
|
3
|
+
import { listActiveSessions } from "./store.js";
|
|
4
|
+
import { appendToStream } from "./stream.js";
|
|
5
|
+
|
|
6
|
+
const ORCHESTRATOR_AGENT_ID = "audit-orchestrator";
|
|
7
|
+
|
|
8
|
+
function normalizeString(value) {
|
|
9
|
+
return String(value || "").trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatSeveritySummary(summary = {}) {
|
|
13
|
+
return `P0=${Number(summary.P0 || 0)} P1=${Number(summary.P1 || 0)} P2=${Number(summary.P2 || 0)} P3=${Number(summary.P3 || 0)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatDurationSeconds(durationMs) {
|
|
17
|
+
return `${Math.max(0, Math.round(Number(durationMs || 0) / 1000))}s`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve which senti session an audit run should report into.
|
|
22
|
+
* Explicit id wins; otherwise the workspace's most recently active local
|
|
23
|
+
* session (the one `create-sentinelayer` bootstraps for new projects).
|
|
24
|
+
* Returns "" when relay is disabled or no session exists — audit runs
|
|
25
|
+
* never require a session.
|
|
26
|
+
*/
|
|
27
|
+
export async function resolveAuditSessionId({
|
|
28
|
+
targetPath = process.cwd(),
|
|
29
|
+
explicitSessionId = "",
|
|
30
|
+
disabled = false,
|
|
31
|
+
} = {}) {
|
|
32
|
+
if (disabled) {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
const explicit = normalizeString(explicitSessionId);
|
|
36
|
+
if (explicit) {
|
|
37
|
+
return explicit;
|
|
38
|
+
}
|
|
39
|
+
const sessions = await listActiveSessions({ targetPath }).catch(() => []);
|
|
40
|
+
if (!Array.isArray(sessions) || sessions.length === 0) {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
const sorted = [...sessions].sort((left, right) =>
|
|
44
|
+
normalizeString(right.lastInteractionAt || right.updatedAt || right.createdAt).localeCompare(
|
|
45
|
+
normalizeString(left.lastInteractionAt || left.updatedAt || left.createdAt)
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
return normalizeString(sorted[0]?.sessionId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Relay audit-orchestrator lifecycle events into a senti session so swarm
|
|
53
|
+
* personas can see each other's progress (start, per-agent completion,
|
|
54
|
+
* final summary) in the project's shared room.
|
|
55
|
+
*
|
|
56
|
+
* Posts are queued sequentially so transcript order matches audit order,
|
|
57
|
+
* and every post is best-effort: a session outage never fails an audit.
|
|
58
|
+
*/
|
|
59
|
+
export function createAuditSessionReporter({ sessionId, targetPath = process.cwd() } = {}) {
|
|
60
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
61
|
+
if (!normalizedSessionId) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const registeredAgents = new Set();
|
|
66
|
+
let postedCount = 0;
|
|
67
|
+
let failedCount = 0;
|
|
68
|
+
let queue = Promise.resolve();
|
|
69
|
+
|
|
70
|
+
const post = (agentId, message) => {
|
|
71
|
+
const id = normalizeString(agentId) || ORCHESTRATOR_AGENT_ID;
|
|
72
|
+
queue = queue.then(async () => {
|
|
73
|
+
try {
|
|
74
|
+
if (!registeredAgents.has(id)) {
|
|
75
|
+
registeredAgents.add(id);
|
|
76
|
+
await registerAgent(normalizedSessionId, {
|
|
77
|
+
agentId: id,
|
|
78
|
+
model: "audit-persona",
|
|
79
|
+
role: "auditor",
|
|
80
|
+
targetPath,
|
|
81
|
+
trackProcessExit: false,
|
|
82
|
+
}).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
const event = createAgentEvent({
|
|
85
|
+
event: "session_message",
|
|
86
|
+
agent: { id, persona: id },
|
|
87
|
+
sessionId: normalizedSessionId,
|
|
88
|
+
payload: { message, channel: "session" },
|
|
89
|
+
});
|
|
90
|
+
await appendToStream(normalizedSessionId, event, { targetPath, awaitRemoteSync: true });
|
|
91
|
+
postedCount += 1;
|
|
92
|
+
} catch {
|
|
93
|
+
failedCount += 1;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return queue;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleEvent = (evt) => {
|
|
100
|
+
if (!evt || typeof evt !== "object") {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const payload = evt.payload && typeof evt.payload === "object" ? evt.payload : {};
|
|
104
|
+
switch (evt.event) {
|
|
105
|
+
case "phase_start":
|
|
106
|
+
if (payload.phase === "dispatch") {
|
|
107
|
+
void post(
|
|
108
|
+
ORCHESTRATOR_AGENT_ID,
|
|
109
|
+
`🔍 Audit dispatch started: ${Number(payload.agentCount || 0)} persona(s), max ${Number(payload.maxParallel || 1)} in parallel.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case "dispatch":
|
|
114
|
+
void post(
|
|
115
|
+
payload.agentId,
|
|
116
|
+
`▶ Starting ${normalizeString(payload.persona) || normalizeString(payload.agentId)} audit (${normalizeString(payload.domain) || "general"}).`
|
|
117
|
+
);
|
|
118
|
+
break;
|
|
119
|
+
case "agent_complete":
|
|
120
|
+
void post(
|
|
121
|
+
payload.agentId,
|
|
122
|
+
`✅ ${normalizeString(payload.agentId)} audit complete: ${Number(payload.findingCount || 0)} finding(s) (${formatSeveritySummary(payload.summary)}), status=${normalizeString(payload.status) || "ok"}, ${formatDurationSeconds(payload.durationMs)}.`
|
|
123
|
+
);
|
|
124
|
+
break;
|
|
125
|
+
case "phase_complete":
|
|
126
|
+
if (payload.phase === "dispatch") {
|
|
127
|
+
void post(
|
|
128
|
+
ORCHESTRATOR_AGENT_ID,
|
|
129
|
+
`Dispatch complete: ${Number(payload.agentCount || 0)} persona result(s) in ${formatDurationSeconds(payload.durationMs)}.`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const stats = () => ({ posted: postedCount, failed: failedCount });
|
|
139
|
+
|
|
140
|
+
const completed = async (result = {}) => {
|
|
141
|
+
await post(
|
|
142
|
+
ORCHESTRATOR_AGENT_ID,
|
|
143
|
+
`🏁 Audit run ${normalizeString(result.runId)} complete — ${formatSeveritySummary(result.summary)} across ${Array.isArray(result.agentResults) ? result.agentResults.length : 0} persona(s). Report: ${normalizeString(result.reportMarkdownPath)}`
|
|
144
|
+
);
|
|
145
|
+
await queue;
|
|
146
|
+
return stats();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const failed = async (error) => {
|
|
150
|
+
await post(
|
|
151
|
+
ORCHESTRATOR_AGENT_ID,
|
|
152
|
+
`❌ Audit run failed: ${normalizeString(error?.message) || "unknown error"}`
|
|
153
|
+
);
|
|
154
|
+
await queue;
|
|
155
|
+
return stats();
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
sessionId: normalizedSessionId,
|
|
160
|
+
handleEvent,
|
|
161
|
+
completed,
|
|
162
|
+
failed,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -21,6 +21,23 @@ export function getCoordinationEtiquetteItems() {
|
|
|
21
21
|
return [...COORDINATION_ETIQUETTE_ITEMS];
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Short, punchy success reminders surfaced periodically by `session listen` so
|
|
25
|
+
// agents are continually nudged to coordinate well (Carter: "keep reminding
|
|
26
|
+
// agents how to be successful... always ack and say if you're working on
|
|
27
|
+
// something"). Kept tight on purpose — this fires on a timer, so it must stay
|
|
28
|
+
// low-noise.
|
|
29
|
+
export const SESSION_LIVE_SUCCESS_TIPS = Object.freeze([
|
|
30
|
+
"Ack messages you've read: `sl session react <id> ack --target-sequence <n>` — don't go silent.",
|
|
31
|
+
"Say what you're doing: claim work with `sl session action <id> working_on --target-sequence <n>`.",
|
|
32
|
+
'Reply in-thread with `sl session reply <id> <seq> "..."`; start a new top-level post only when needed.',
|
|
33
|
+
"Post findings and blockers in-session, and ask for help instead of stalling.",
|
|
34
|
+
"Prefer low-noise actions over new top-level messages; run `sl session actions` for the full list.",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export function getSessionLiveSuccessTips() {
|
|
38
|
+
return [...SESSION_LIVE_SUCCESS_TIPS];
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
export function renderCoordinationNumberedList({
|
|
25
42
|
items = COORDINATION_ETIQUETTE_ITEMS,
|
|
26
43
|
indent = "",
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import fsp from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
|
|
7
|
+
import { resolveSessionPaths } from "./paths.js";
|
|
8
|
+
|
|
9
|
+
const PID_FILE_NAME = "senti-daemon.json";
|
|
10
|
+
const LOG_FILE_NAME = "senti-daemon.log";
|
|
11
|
+
|
|
12
|
+
function normalizeString(value) {
|
|
13
|
+
return String(value || "").trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sentiDaemonDisabled(env = process.env) {
|
|
17
|
+
return (
|
|
18
|
+
normalizeString(env.SENTINELAYER_SKIP_SENTI_AUTOSTART) === "1" ||
|
|
19
|
+
normalizeString(env.SENTINELAYER_SKIP_SENTI_DAEMON) === "1"
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveDaemonPidPath(sessionId, { targetPath = process.cwd() } = {}) {
|
|
24
|
+
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
25
|
+
return path.join(paths.sessionDir, PID_FILE_NAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveDaemonLogPath(sessionId, { targetPath = process.cwd() } = {}) {
|
|
29
|
+
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
30
|
+
return path.join(paths.sessionDir, LOG_FILE_NAME);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isProcessAlive(pid) {
|
|
34
|
+
const numericPid = Number(pid);
|
|
35
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
process.kill(numericPid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// EPERM means the process exists but belongs to another user.
|
|
43
|
+
return error?.code === "EPERM";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function readDaemonPidRecord(sessionId, { targetPath = process.cwd() } = {}) {
|
|
48
|
+
const pidPath = resolveDaemonPidPath(sessionId, { targetPath });
|
|
49
|
+
try {
|
|
50
|
+
const raw = await fsp.readFile(pidPath, "utf-8");
|
|
51
|
+
const parsed = JSON.parse(raw);
|
|
52
|
+
if (!parsed || typeof parsed !== "object") {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return parsed;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function writeDaemonPidRecord(
|
|
62
|
+
sessionId,
|
|
63
|
+
{ targetPath = process.cwd(), pid = process.pid, tickIntervalMs = 30000 } = {}
|
|
64
|
+
) {
|
|
65
|
+
const pidPath = resolveDaemonPidPath(sessionId, { targetPath });
|
|
66
|
+
const record = {
|
|
67
|
+
pid: Number(pid),
|
|
68
|
+
sessionId: normalizeString(sessionId),
|
|
69
|
+
targetPath: path.resolve(String(targetPath || ".")),
|
|
70
|
+
tickIntervalMs: Number(tickIntervalMs) || 30000,
|
|
71
|
+
startedAt: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
await fsp.mkdir(path.dirname(pidPath), { recursive: true });
|
|
74
|
+
await fsp.writeFile(pidPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
|
|
75
|
+
return record;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function removeDaemonPidRecord(
|
|
79
|
+
sessionId,
|
|
80
|
+
{ targetPath = process.cwd(), onlyForPid = null } = {}
|
|
81
|
+
) {
|
|
82
|
+
const pidPath = resolveDaemonPidPath(sessionId, { targetPath });
|
|
83
|
+
if (onlyForPid != null) {
|
|
84
|
+
const existing = await readDaemonPidRecord(sessionId, { targetPath });
|
|
85
|
+
if (existing && Number(existing.pid) !== Number(onlyForPid)) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await fsp.unlink(pidPath);
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether a detached Senti daemon is currently managing the session.
|
|
99
|
+
* Stale pid files (machine reboot, hard kill on Windows) are reported as
|
|
100
|
+
* not running so the caller can safely respawn over them.
|
|
101
|
+
*/
|
|
102
|
+
export async function getDaemonStatus(sessionId, { targetPath = process.cwd() } = {}) {
|
|
103
|
+
const record = await readDaemonPidRecord(sessionId, { targetPath });
|
|
104
|
+
if (!record) {
|
|
105
|
+
return { running: false, pid: null, stale: false, record: null };
|
|
106
|
+
}
|
|
107
|
+
const alive = isProcessAlive(record.pid);
|
|
108
|
+
return {
|
|
109
|
+
running: alive,
|
|
110
|
+
pid: alive ? Number(record.pid) : null,
|
|
111
|
+
stale: !alive,
|
|
112
|
+
record,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function resolveCliEntryPath() {
|
|
117
|
+
const entry = normalizeString(process.argv[1]);
|
|
118
|
+
return entry ? path.resolve(entry) : "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Spawn `sl session daemon <id>` as a detached background process so the
|
|
123
|
+
* session stays managed (greetings, recaps, checkpoints, mention routing)
|
|
124
|
+
* after the creating terminal exits. Deduped via the session's pid file;
|
|
125
|
+
* output goes to senti-daemon.log in the session directory.
|
|
126
|
+
*
|
|
127
|
+
* Never throws: returns { spawned, pid, reason, logPath } so callers can
|
|
128
|
+
* report status without ever failing session creation.
|
|
129
|
+
*/
|
|
130
|
+
export async function spawnDetachedSentiDaemon({
|
|
131
|
+
sessionId,
|
|
132
|
+
targetPath = process.cwd(),
|
|
133
|
+
cliPath = "",
|
|
134
|
+
env = process.env,
|
|
135
|
+
} = {}) {
|
|
136
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
137
|
+
if (!normalizedSessionId) {
|
|
138
|
+
return { spawned: false, pid: null, reason: "missing_session_id", logPath: "" };
|
|
139
|
+
}
|
|
140
|
+
if (sentiDaemonDisabled(env)) {
|
|
141
|
+
return { spawned: false, pid: null, reason: "disabled", logPath: "" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const status = await getDaemonStatus(normalizedSessionId, { targetPath });
|
|
145
|
+
if (status.running) {
|
|
146
|
+
return {
|
|
147
|
+
spawned: false,
|
|
148
|
+
pid: status.pid,
|
|
149
|
+
reason: "already_running",
|
|
150
|
+
logPath: resolveDaemonLogPath(normalizedSessionId, { targetPath }),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const entryPath = normalizeString(cliPath) || resolveCliEntryPath();
|
|
155
|
+
if (!entryPath) {
|
|
156
|
+
return { spawned: false, pid: null, reason: "cli_entry_unresolved", logPath: "" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const logPath = resolveDaemonLogPath(normalizedSessionId, { targetPath });
|
|
160
|
+
let logFd = null;
|
|
161
|
+
try {
|
|
162
|
+
await fsp.mkdir(path.dirname(logPath), { recursive: true });
|
|
163
|
+
logFd = fs.openSync(logPath, "a");
|
|
164
|
+
const child = spawn(
|
|
165
|
+
process.execPath,
|
|
166
|
+
[entryPath, "session", "daemon", normalizedSessionId, "--path", path.resolve(String(targetPath || "."))],
|
|
167
|
+
{
|
|
168
|
+
detached: true,
|
|
169
|
+
stdio: ["ignore", logFd, logFd],
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
env,
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
child.unref();
|
|
175
|
+
return { spawned: true, pid: child.pid ?? null, reason: "spawned", logPath };
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return {
|
|
178
|
+
spawned: false,
|
|
179
|
+
pid: null,
|
|
180
|
+
reason: `spawn_failed: ${normalizeString(error?.message) || "unknown"}`,
|
|
181
|
+
logPath,
|
|
182
|
+
};
|
|
183
|
+
} finally {
|
|
184
|
+
if (logFd != null) {
|
|
185
|
+
try {
|
|
186
|
+
fs.closeSync(logFd);
|
|
187
|
+
} catch {
|
|
188
|
+
// fd already closed
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { ensureWorkspaceSession } from "../commands/session.js";
|
|
4
|
+
import { createAgentEvent } from "../events/schema.js";
|
|
5
|
+
import { spawnDetachedSentiDaemon } from "./daemon-spawn.js";
|
|
6
|
+
import { setupSessionGuides } from "./setup-guides.js";
|
|
7
|
+
import { appendToStream } from "./stream.js";
|
|
8
|
+
import { syncSessionMetadataToApi } from "./sync.js";
|
|
9
|
+
import { buildDashboardUrl } from "./templates.js";
|
|
10
|
+
|
|
11
|
+
export const PROJECT_BOOTSTRAP_AGENT = Object.freeze({
|
|
12
|
+
id: "project-bootstrap",
|
|
13
|
+
persona: "Project Bootstrap",
|
|
14
|
+
shortName: "Bootstrap",
|
|
15
|
+
color: "green",
|
|
16
|
+
avatar: "P",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function normalizeString(value) {
|
|
20
|
+
return String(value || "").trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildProjectSessionWelcomeMessage({ projectName, sessionId } = {}) {
|
|
24
|
+
const name = normalizeString(projectName) || "this project";
|
|
25
|
+
return [
|
|
26
|
+
`🏗️ Project session for "${name}" is live (created by \`create-sentinelayer\`).`,
|
|
27
|
+
"",
|
|
28
|
+
"This is the project's shared coordination room. Agents working on this codebase should:",
|
|
29
|
+
`- Join before starting work: \`sl session join ${sessionId} --agent <your-agent-name>\``,
|
|
30
|
+
`- Post status updates as you work: \`sl session say ${sessionId} "<update>" --agent <your-agent-name>\``,
|
|
31
|
+
"- Audit runs (`sentinel audit`) post per-persona progress here automatically, so swarm agents can see each other's findings without losing context.",
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create the project's senti session as part of `create-sentinelayer` init:
|
|
37
|
+
* a fresh workspace session rooted at the new project directory, with
|
|
38
|
+
* coordination guides written into AGENTS.md / CLAUDE.md and a welcome
|
|
39
|
+
* message announcing the room to joining agents.
|
|
40
|
+
*
|
|
41
|
+
* Local-first: session creation always succeeds offline; dashboard metadata
|
|
42
|
+
* sync and the welcome-message relay are best-effort and never throw.
|
|
43
|
+
*
|
|
44
|
+
* Pass `skipGuides: true` when the caller writes coding-agent config files
|
|
45
|
+
* after this call (guide upsert would otherwise create AGENTS.md/CLAUDE.md
|
|
46
|
+
* first and make the config scaffold skip itself) — then call
|
|
47
|
+
* `setupSessionGuides` once those files exist.
|
|
48
|
+
*/
|
|
49
|
+
export async function bootstrapProjectSession({
|
|
50
|
+
projectDir,
|
|
51
|
+
projectName,
|
|
52
|
+
ttlSeconds,
|
|
53
|
+
skipGuides = false,
|
|
54
|
+
} = {}) {
|
|
55
|
+
const targetPath = path.resolve(normalizeString(projectDir) || ".");
|
|
56
|
+
const title = normalizeString(projectName) || path.basename(targetPath);
|
|
57
|
+
|
|
58
|
+
const ensured = await ensureWorkspaceSession({
|
|
59
|
+
targetPath,
|
|
60
|
+
title,
|
|
61
|
+
resume: false,
|
|
62
|
+
forceNew: true,
|
|
63
|
+
...(Number.isFinite(Number(ttlSeconds)) && Number(ttlSeconds) > 0
|
|
64
|
+
? { ttlSeconds: Math.floor(Number(ttlSeconds)) }
|
|
65
|
+
: {}),
|
|
66
|
+
});
|
|
67
|
+
const created = ensured.created;
|
|
68
|
+
const sessionId = created.sessionId;
|
|
69
|
+
|
|
70
|
+
// Best-effort dashboard visibility — session creation stays local-first.
|
|
71
|
+
await syncSessionMetadataToApi(sessionId, {
|
|
72
|
+
targetPath,
|
|
73
|
+
sessionId,
|
|
74
|
+
status: created.status,
|
|
75
|
+
createdAt: created.createdAt,
|
|
76
|
+
expiresAt: created.expiresAt,
|
|
77
|
+
title: ensured.title || title,
|
|
78
|
+
template: created.template,
|
|
79
|
+
codebaseContext: created.codebaseContext,
|
|
80
|
+
}).catch(() => {});
|
|
81
|
+
|
|
82
|
+
const guides = skipGuides ? null : await setupSessionGuides(sessionId, { targetPath });
|
|
83
|
+
|
|
84
|
+
const welcomeEvent = createAgentEvent({
|
|
85
|
+
event: "session_message",
|
|
86
|
+
agent: PROJECT_BOOTSTRAP_AGENT,
|
|
87
|
+
sessionId,
|
|
88
|
+
payload: {
|
|
89
|
+
message: buildProjectSessionWelcomeMessage({ projectName: title, sessionId }),
|
|
90
|
+
channel: "session",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
let welcomePosted = true;
|
|
94
|
+
try {
|
|
95
|
+
await appendToStream(sessionId, welcomeEvent, { targetPath, awaitRemoteSync: true });
|
|
96
|
+
} catch {
|
|
97
|
+
welcomePosted = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Project rooms are managed by default too: the detached Senti daemon
|
|
101
|
+
// greets joining agents and keeps recaps/checkpoints flowing. Honors
|
|
102
|
+
// SENTINELAYER_SKIP_SENTI_AUTOSTART / SENTINELAYER_SKIP_SENTI_DAEMON
|
|
103
|
+
// and never fails the bootstrap.
|
|
104
|
+
const daemon = await spawnDetachedSentiDaemon({ sessionId, targetPath });
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
sessionId,
|
|
108
|
+
title: ensured.title || title,
|
|
109
|
+
targetPath,
|
|
110
|
+
dashboardUrl: buildDashboardUrl(sessionId),
|
|
111
|
+
guides,
|
|
112
|
+
welcomePosted,
|
|
113
|
+
daemon,
|
|
114
|
+
};
|
|
115
|
+
}
|