sentinelayer-cli 0.22.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 +111 -16
- package/src/legacy-cli.js +74 -1
- package/src/session/audit-reporter.js +164 -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,
|
|
@@ -781,6 +787,42 @@ function sentiAutostartDisabled() {
|
|
|
781
787
|
return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
|
|
782
788
|
}
|
|
783
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
|
+
|
|
784
826
|
function buildResumeContext(candidate, { reuseWindowSeconds = 3600 } = {}) {
|
|
785
827
|
if (!candidate) return null;
|
|
786
828
|
const source = normalizeString(candidate._source) || "unknown";
|
|
@@ -1201,7 +1243,7 @@ async function ensureLocalSessionForRemoteCommand(
|
|
|
1201
1243
|
return { materialized: true, refreshed: false, session: created };
|
|
1202
1244
|
}
|
|
1203
1245
|
|
|
1204
|
-
async function ensureWorkspaceSession({
|
|
1246
|
+
export async function ensureWorkspaceSession({
|
|
1205
1247
|
targetPath,
|
|
1206
1248
|
ttlSeconds = DEFAULT_TTL_SECONDS,
|
|
1207
1249
|
template = null,
|
|
@@ -2178,7 +2220,7 @@ export function registerSessionCommand(program) {
|
|
|
2178
2220
|
session
|
|
2179
2221
|
.command("start")
|
|
2180
2222
|
.description(
|
|
2181
|
-
"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.",
|
|
2182
2224
|
)
|
|
2183
2225
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
2184
2226
|
.option("--title <title>", "Human-readable label (shown in web sidebar + transcript)")
|
|
@@ -2208,6 +2250,10 @@ export function registerSessionCommand(program) {
|
|
|
2208
2250
|
"Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
|
|
2209
2251
|
"3600",
|
|
2210
2252
|
)
|
|
2253
|
+
.option(
|
|
2254
|
+
"--no-daemon",
|
|
2255
|
+
"Do not spawn the detached Senti daemon (session will be unmanaged: no greetings, recaps, or checkpoints)",
|
|
2256
|
+
)
|
|
2211
2257
|
.option("--json", "Emit machine-readable output")
|
|
2212
2258
|
.action(async (options, command) => {
|
|
2213
2259
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
@@ -2290,19 +2336,25 @@ export function registerSessionCommand(program) {
|
|
|
2290
2336
|
}).catch(() => {});
|
|
2291
2337
|
}
|
|
2292
2338
|
|
|
2293
|
-
//
|
|
2294
|
-
//
|
|
2295
|
-
//
|
|
2296
|
-
//
|
|
2297
|
-
//
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
if (
|
|
2304
|
-
|
|
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
|
+
});
|
|
2305
2356
|
}
|
|
2357
|
+
payload.sentiDaemon = sentiDaemon;
|
|
2306
2358
|
|
|
2307
2359
|
if (shouldEmitJson(options, command)) {
|
|
2308
2360
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -2324,6 +2376,7 @@ export function registerSessionCommand(program) {
|
|
|
2324
2376
|
}
|
|
2325
2377
|
console.log("");
|
|
2326
2378
|
console.log(`Dashboard: ${dashboardUrl}`);
|
|
2379
|
+
printSentiDaemonStatusLine(sentiDaemon, { cliCommand, sessionId: created.sessionId });
|
|
2327
2380
|
return;
|
|
2328
2381
|
}
|
|
2329
2382
|
|
|
@@ -2349,6 +2402,13 @@ export function registerSessionCommand(program) {
|
|
|
2349
2402
|
console.log(
|
|
2350
2403
|
`status=${created.status} created_at=${created.createdAt} expires_at=${created.expiresAt} ttl_seconds=${ttlSeconds}`,
|
|
2351
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
|
+
);
|
|
2352
2412
|
if (remoteSync.status === "auth_required") {
|
|
2353
2413
|
console.log(
|
|
2354
2414
|
pc.yellow(
|
|
@@ -2361,7 +2421,9 @@ export function registerSessionCommand(program) {
|
|
|
2361
2421
|
if (!resumed) {
|
|
2362
2422
|
console.log(
|
|
2363
2423
|
pc.gray(
|
|
2364
|
-
|
|
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.`,
|
|
2365
2427
|
),
|
|
2366
2428
|
);
|
|
2367
2429
|
}
|
|
@@ -3694,8 +3756,11 @@ export function registerSessionCommand(program) {
|
|
|
3694
3756
|
|
|
3695
3757
|
session
|
|
3696
3758
|
.command("daemon [sessionId]")
|
|
3697
|
-
.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
|
+
)
|
|
3698
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")
|
|
3699
3764
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
3700
3765
|
.option("--tick-interval <seconds>", "Seconds between health ticks (default 30)", "30")
|
|
3701
3766
|
.option("--stale-agent-seconds <seconds>", "Seconds before an inactive agent is flagged stale (default 90)", "90")
|
|
@@ -3723,6 +3788,18 @@ export function registerSessionCommand(program) {
|
|
|
3723
3788
|
"tick-interval",
|
|
3724
3789
|
30,
|
|
3725
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
|
+
}
|
|
3726
3803
|
const daemon = await startSenti(normalizedSessionId, {
|
|
3727
3804
|
targetPath,
|
|
3728
3805
|
autoStart: false,
|
|
@@ -3824,10 +3901,28 @@ export function registerSessionCommand(program) {
|
|
|
3824
3901
|
} else {
|
|
3825
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"}`));
|
|
3826
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
|
+
}
|
|
3827
3918
|
}
|
|
3828
3919
|
} finally {
|
|
3829
3920
|
process.removeListener("SIGINT", stop);
|
|
3830
3921
|
process.removeListener("SIGTERM", stop);
|
|
3922
|
+
await removeDaemonPidRecord(normalizedSessionId, {
|
|
3923
|
+
targetPath,
|
|
3924
|
+
onlyForPid: process.pid,
|
|
3925
|
+
}).catch(() => {});
|
|
3831
3926
|
const stopped = await daemon.stop("signal");
|
|
3832
3927
|
if (emitJson) {
|
|
3833
3928
|
console.log(
|
package/src/legacy-cli.js
CHANGED
|
@@ -2182,6 +2182,7 @@ export function buildHandoffPrompt({
|
|
|
2182
2182
|
buildFromExistingRepo,
|
|
2183
2183
|
authMode,
|
|
2184
2184
|
codingAgent,
|
|
2185
|
+
sessionId,
|
|
2185
2186
|
}) {
|
|
2186
2187
|
const codingAgentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
|
|
2187
2188
|
const codingAgentConfigPath = codingAgentProfile.configFile || "none";
|
|
@@ -2245,7 +2246,16 @@ Repo context:
|
|
|
2245
2246
|
- Target repo: ${repoSlug || "not provided"}
|
|
2246
2247
|
- Workspace mode: ${buildFromExistingRepo ? "existing codebase" : "new scaffold"}
|
|
2247
2248
|
|
|
2248
|
-
|
|
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
|
+
}
|
|
2249
2259
|
|
|
2250
2260
|
${renderCoordinationNumberedList()}
|
|
2251
2261
|
|
|
@@ -2577,6 +2587,7 @@ async function writeInitConfigLockfile({
|
|
|
2577
2587
|
secretName,
|
|
2578
2588
|
repoSlug,
|
|
2579
2589
|
workflowPath,
|
|
2590
|
+
sessionId,
|
|
2580
2591
|
}) {
|
|
2581
2592
|
const lockDir = path.join(projectDir, ".sentinelayer");
|
|
2582
2593
|
const configPath = path.join(lockDir, "config.json");
|
|
@@ -2588,6 +2599,7 @@ async function writeInitConfigLockfile({
|
|
|
2588
2599
|
required_secret_name: String(secretName || "SENTINELAYER_TOKEN").trim() || "SENTINELAYER_TOKEN",
|
|
2589
2600
|
repo_slug: normalizeRepoSlug(repoSlug || ""),
|
|
2590
2601
|
workflow_path: path.relative(projectDir, workflowPath).replace(/\\/g, "/"),
|
|
2602
|
+
session_id: String(sessionId || "").trim(),
|
|
2591
2603
|
};
|
|
2592
2604
|
|
|
2593
2605
|
await fsp.mkdir(lockDir, { recursive: true });
|
|
@@ -3145,6 +3157,26 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3145
3157
|
expectedSpecId: generatedSpecId || workflowSpecIdFromTemplate,
|
|
3146
3158
|
});
|
|
3147
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
|
+
|
|
3148
3180
|
const configLockfilePath = await writeInitConfigLockfile({
|
|
3149
3181
|
projectDir,
|
|
3150
3182
|
specId: workflowSpecId || generatedSpecId || workflowSpecIdFromTemplate,
|
|
@@ -3152,6 +3184,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3152
3184
|
secretName,
|
|
3153
3185
|
repoSlug: interview.repoSlug || detectRepoSlug(projectDir) || "",
|
|
3154
3186
|
workflowPath,
|
|
3187
|
+
sessionId: projectSession?.sessionId || "",
|
|
3155
3188
|
});
|
|
3156
3189
|
|
|
3157
3190
|
await writeTextFile(
|
|
@@ -3177,6 +3210,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3177
3210
|
buildFromExistingRepo: interview.buildFromExistingRepo,
|
|
3178
3211
|
authMode: effectiveAuthMode,
|
|
3179
3212
|
codingAgent: interview.codingAgent,
|
|
3213
|
+
sessionId: projectSession?.sessionId || "",
|
|
3180
3214
|
})
|
|
3181
3215
|
);
|
|
3182
3216
|
await writeTextFile(
|
|
@@ -3189,6 +3223,19 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3189
3223
|
codingAgent: interview.codingAgent,
|
|
3190
3224
|
});
|
|
3191
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
|
+
|
|
3192
3239
|
await ensureSentinelStartScript(projectDir, effectiveProjectName);
|
|
3193
3240
|
|
|
3194
3241
|
// Code scaffold: write starter source files, skip existing
|
|
@@ -3281,6 +3328,32 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3281
3328
|
printSection("Complete");
|
|
3282
3329
|
console.log(pc.green(`✔ Sentinelayer orchestration initialized in ${projectDir}`));
|
|
3283
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
|
+
}
|
|
3284
3357
|
if (workflowSpecId) {
|
|
3285
3358
|
console.log(pc.green(`✔ Omar workflow spec binding validated: ${workflowSpecId}`));
|
|
3286
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
|
+
}
|
|
@@ -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
|
+
}
|