sentinelayer-cli 0.8.12 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/agents/devtestbot/config/definition.js +100 -0
- package/src/agents/devtestbot/config/system-prompt.js +92 -0
- package/src/agents/devtestbot/index.js +9 -0
- package/src/agents/devtestbot/runner.js +769 -0
- package/src/agents/devtestbot/tool.js +707 -0
- package/src/commands/legacy-args.js +4 -0
- package/src/commands/omargate.js +4 -0
- package/src/commands/session.js +415 -147
- package/src/commands/swarm.js +11 -2
- package/src/guide/generator.js +14 -0
- package/src/legacy-cli.js +34 -17
- package/src/prompt/generator.js +4 -16
- package/src/review/ai-review.js +95 -6
- package/src/review/dd-report-email-client.js +148 -0
- package/src/review/investor-dd-devtestbot.js +599 -0
- package/src/review/investor-dd-orchestrator.js +135 -3
- package/src/review/omargate-orchestrator.js +20 -2
- package/src/review/persona-prompts.js +34 -1
- package/src/review/report.js +61 -2
- package/src/session/coordination-guidance.js +48 -0
- package/src/session/daemon.js +3 -2
- package/src/session/listener.js +236 -0
- package/src/session/setup-guides.js +3 -15
- package/src/session/store.js +54 -5
- package/src/spec/generator.js +8 -10
- package/src/swarm/registry.js +20 -0
- package/src/swarm/runtime.js +139 -1
|
@@ -59,7 +59,11 @@ export function buildLegacyArgs(baseArgs, { commandOptions = {}, command } = {})
|
|
|
59
59
|
appendPassthroughFlag(args, "--provider", commandOptions.provider);
|
|
60
60
|
appendPassthroughFlag(args, "--reuse-omargate", commandOptions.reuseOmargate);
|
|
61
61
|
appendPassthroughFlag(args, "--notify-email", commandOptions.notifyEmail);
|
|
62
|
+
appendPassthroughFlag(args, "--email-on-complete", commandOptions.emailOnComplete);
|
|
62
63
|
appendPassthroughFlag(args, "--notify-session", commandOptions.notifySession);
|
|
64
|
+
appendPassthroughFlag(args, "--devtestbot-base-url", commandOptions.devtestbotBaseUrl);
|
|
65
|
+
appendPassthroughFlag(args, "--devtestbot-scope", commandOptions.devtestbotScope);
|
|
66
|
+
appendNegatedBooleanFlag(args, "--no-devtestbot", commandOptions.devtestbot);
|
|
63
67
|
// Omar Gate per-persona filter flags (A-CLI-1).
|
|
64
68
|
appendPassthroughFlag(args, "--persona", commandOptions.persona);
|
|
65
69
|
appendPassthroughFlag(args, "--skip-persona", commandOptions.skipPersona);
|
package/src/commands/omargate.js
CHANGED
|
@@ -48,9 +48,13 @@ export function registerOmarGateCommand(program, invokeLegacy) {
|
|
|
48
48
|
.option("--skip-persona <csv>", "Skip these personas (comma-separated IDs)")
|
|
49
49
|
.option("--stream", "Emit NDJSON events to stdout as personas work file-by-file")
|
|
50
50
|
.option("--notify-email <addr>", "Send final report to this email (default: account email)")
|
|
51
|
+
.option("--email-on-complete <addr>", "Trigger the API-side DD report email after the run completes")
|
|
51
52
|
.option("--notify-session <session-id>", "Stream progress into this Senti session (default: auto-start)")
|
|
52
53
|
.option("--no-email", "Skip email dispatch")
|
|
53
54
|
.option("--no-dashboard", "Skip dashboard card persistence")
|
|
55
|
+
.option("--devtestbot-base-url <url>", "Approved absolute URL for devTestBot browser lanes")
|
|
56
|
+
.option("--devtestbot-scope <scope>", "devTestBot runtime scope (default: orchestrator decides)")
|
|
57
|
+
.option("--no-devtestbot", "Skip the automated devTestBot phase")
|
|
54
58
|
.option("--dry-run", "Validate config + emit plan.json; skip LLM calls")
|
|
55
59
|
.option("--json", "Emit machine-readable final output")
|
|
56
60
|
.action(async (options, command) => {
|
package/src/commands/session.js
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
listActiveSessions,
|
|
48
48
|
listAllSessions,
|
|
49
49
|
recordSessionProvisionedIdentities,
|
|
50
|
+
updateSessionTitle,
|
|
50
51
|
} from "../session/store.js";
|
|
51
52
|
import { appendToStream, readStream, tailStream } from "../session/stream.js";
|
|
52
53
|
import { readSessionPreview } from "../session/preview.js";
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
} from "../session/sync.js";
|
|
58
59
|
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
59
60
|
import { mergeLiveSources } from "../session/live-source.js";
|
|
61
|
+
import { listenSessionEvents } from "../session/listener.js";
|
|
60
62
|
import { deriveSessionTitle } from "../session/senti-naming.js";
|
|
61
63
|
import {
|
|
62
64
|
buildDashboardUrl,
|
|
@@ -89,6 +91,233 @@ function parsePositiveInteger(rawValue, field, fallbackValue) {
|
|
|
89
91
|
return Math.floor(normalized);
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
function normalizeComparablePath(value) {
|
|
95
|
+
return String(value || "")
|
|
96
|
+
.trim()
|
|
97
|
+
.replace(/\\/g, "/")
|
|
98
|
+
.replace(/\/+$/g, "")
|
|
99
|
+
.toLowerCase();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function latestSessionActivityMs(entry = {}) {
|
|
103
|
+
for (const key of ["lastInteractionAt", "lastActivityAt", "createdAt"]) {
|
|
104
|
+
const epoch = Date.parse(normalizeString(entry[key]));
|
|
105
|
+
if (Number.isFinite(epoch)) return epoch;
|
|
106
|
+
}
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function remoteSessionLookupDisabled() {
|
|
111
|
+
return String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function mergeResumeCandidate(existing, incoming) {
|
|
115
|
+
if (!existing) return incoming;
|
|
116
|
+
const existingActivity = Number(existing._activityMs || 0);
|
|
117
|
+
const incomingActivity = Number(incoming._activityMs || 0);
|
|
118
|
+
const preferIncomingPaths = existing._source !== "local" && incoming._source === "local";
|
|
119
|
+
const base = preferIncomingPaths ? incoming : existing;
|
|
120
|
+
const other = preferIncomingPaths ? existing : incoming;
|
|
121
|
+
return {
|
|
122
|
+
...base,
|
|
123
|
+
title: normalizeString(base.title) || normalizeString(other.title) || null,
|
|
124
|
+
lastActivityAt:
|
|
125
|
+
normalizeString(incoming.lastActivityAt) || normalizeString(existing.lastActivityAt) || null,
|
|
126
|
+
lastInteractionAt:
|
|
127
|
+
normalizeString(incoming.lastInteractionAt) || normalizeString(existing.lastInteractionAt) || null,
|
|
128
|
+
_activityMs: Math.max(existingActivity, incomingActivity),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function findReusableSessionCandidate({
|
|
133
|
+
targetPath,
|
|
134
|
+
reuseWindowSeconds = 3600,
|
|
135
|
+
resume = true,
|
|
136
|
+
forceNew = false,
|
|
137
|
+
} = {}) {
|
|
138
|
+
if (forceNew || resume === false) return null;
|
|
139
|
+
const cutoffMs = Date.now() - reuseWindowSeconds * 1000;
|
|
140
|
+
const byId = new Map();
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const active = await listActiveSessions({ targetPath });
|
|
144
|
+
for (const entry of active) {
|
|
145
|
+
const activityMs = latestSessionActivityMs(entry);
|
|
146
|
+
if (!activityMs || activityMs < cutoffMs) continue;
|
|
147
|
+
const candidate = {
|
|
148
|
+
...entry,
|
|
149
|
+
_source: "local",
|
|
150
|
+
_activityMs: activityMs,
|
|
151
|
+
};
|
|
152
|
+
byId.set(entry.sessionId, mergeResumeCandidate(byId.get(entry.sessionId), candidate));
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
/* local lookup failure is non-fatal */
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!remoteSessionLookupDisabled()) {
|
|
159
|
+
try {
|
|
160
|
+
const remote = await listSessionsFromApi({
|
|
161
|
+
targetPath,
|
|
162
|
+
includeArchived: false,
|
|
163
|
+
limit: 50,
|
|
164
|
+
});
|
|
165
|
+
if (remote && remote.ok) {
|
|
166
|
+
const normalizedTarget = normalizeComparablePath(targetPath);
|
|
167
|
+
for (const entry of remote.sessions || []) {
|
|
168
|
+
const codebase = normalizeComparablePath(entry.codebasePath || entry.targetPath);
|
|
169
|
+
if (!codebase || codebase !== normalizedTarget) continue;
|
|
170
|
+
if (entry.archiveStatus && entry.archiveStatus !== "active") continue;
|
|
171
|
+
const activityMs = latestSessionActivityMs(entry);
|
|
172
|
+
if (!activityMs || activityMs < cutoffMs) continue;
|
|
173
|
+
const candidate = {
|
|
174
|
+
sessionId: entry.sessionId,
|
|
175
|
+
createdAt: entry.createdAt,
|
|
176
|
+
lastActivityAt: entry.lastActivityAt,
|
|
177
|
+
expiresAt: entry.expiresAt,
|
|
178
|
+
status: entry.status || "active",
|
|
179
|
+
template: entry.templateName || null,
|
|
180
|
+
title: entry.title || null,
|
|
181
|
+
_source: "remote",
|
|
182
|
+
_activityMs: activityMs,
|
|
183
|
+
};
|
|
184
|
+
byId.set(entry.sessionId, mergeResumeCandidate(byId.get(entry.sessionId), candidate));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
/* remote lookup failure is non-fatal */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const candidates = [...byId.values()];
|
|
193
|
+
candidates.sort((left, right) => Number(right._activityMs || 0) - Number(left._activityMs || 0));
|
|
194
|
+
return candidates[0] || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function pushSessionTitleToApi(sessionId, title, { targetPath } = {}) {
|
|
198
|
+
const normalizedTitle = normalizeString(title);
|
|
199
|
+
if (!normalizedTitle || remoteSessionLookupDisabled()) return;
|
|
200
|
+
try {
|
|
201
|
+
const session = await resolveActiveAuthSession({
|
|
202
|
+
cwd: targetPath,
|
|
203
|
+
env: process.env,
|
|
204
|
+
autoRotate: false,
|
|
205
|
+
});
|
|
206
|
+
if (!session?.token || !session?.apiUrl) return;
|
|
207
|
+
const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
|
|
208
|
+
await requestJsonMutation(
|
|
209
|
+
`${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/title`,
|
|
210
|
+
{
|
|
211
|
+
method: "POST",
|
|
212
|
+
operationName: "session.set_title",
|
|
213
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
214
|
+
body: { title: normalizedTitle },
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
/* best-effort */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function ensureWorkspaceSession({
|
|
223
|
+
targetPath,
|
|
224
|
+
ttlSeconds = DEFAULT_TTL_SECONDS,
|
|
225
|
+
template = null,
|
|
226
|
+
title = "",
|
|
227
|
+
resume = true,
|
|
228
|
+
forceNew = false,
|
|
229
|
+
reuseWindowSeconds = 3600,
|
|
230
|
+
} = {}) {
|
|
231
|
+
const titleArg = normalizeString(title);
|
|
232
|
+
const fallbackTitle = deriveSessionTitle(targetPath);
|
|
233
|
+
const startedAt = Date.now();
|
|
234
|
+
const resumedCandidate = await findReusableSessionCandidate({
|
|
235
|
+
targetPath,
|
|
236
|
+
reuseWindowSeconds,
|
|
237
|
+
resume,
|
|
238
|
+
forceNew,
|
|
239
|
+
});
|
|
240
|
+
let created;
|
|
241
|
+
const resumeTitle =
|
|
242
|
+
titleArg || normalizeString(resumedCandidate?.title) || fallbackTitle;
|
|
243
|
+
|
|
244
|
+
if (resumedCandidate) {
|
|
245
|
+
if (resumedCandidate._source === "remote" && !resumedCandidate.sessionDir) {
|
|
246
|
+
created = await createSession({
|
|
247
|
+
targetPath,
|
|
248
|
+
ttlSeconds,
|
|
249
|
+
sessionId: resumedCandidate.sessionId,
|
|
250
|
+
title: resumeTitle,
|
|
251
|
+
createdAt: resumedCandidate.createdAt,
|
|
252
|
+
expiresAt: resumedCandidate.expiresAt,
|
|
253
|
+
lastInteractionAt:
|
|
254
|
+
resumedCandidate.lastInteractionAt ||
|
|
255
|
+
resumedCandidate.lastActivityAt ||
|
|
256
|
+
resumedCandidate.createdAt,
|
|
257
|
+
});
|
|
258
|
+
} else {
|
|
259
|
+
created = {
|
|
260
|
+
sessionId: resumedCandidate.sessionId,
|
|
261
|
+
sessionDir: resumedCandidate.sessionDir || null,
|
|
262
|
+
metadataPath: resumedCandidate.metadataPath || null,
|
|
263
|
+
streamPath: resumedCandidate.streamPath || null,
|
|
264
|
+
createdAt: resumedCandidate.createdAt,
|
|
265
|
+
updatedAt: resumedCandidate.updatedAt || null,
|
|
266
|
+
lastInteractionAt: resumedCandidate.lastInteractionAt || null,
|
|
267
|
+
expiresAt: resumedCandidate.expiresAt,
|
|
268
|
+
elapsedTimer: resumedCandidate.elapsedTimer || 0,
|
|
269
|
+
renewalCount: resumedCandidate.renewalCount || 0,
|
|
270
|
+
status: resumedCandidate.status || "active",
|
|
271
|
+
template: resumedCandidate.template || null,
|
|
272
|
+
title: normalizeString(resumedCandidate.title) || null,
|
|
273
|
+
codebaseContext: resumedCandidate.codebaseContext || null,
|
|
274
|
+
};
|
|
275
|
+
if (resumeTitle && resumeTitle !== created.title) {
|
|
276
|
+
const updated = await updateSessionTitle(created.sessionId, {
|
|
277
|
+
targetPath,
|
|
278
|
+
title: resumeTitle,
|
|
279
|
+
}).catch(() => null);
|
|
280
|
+
if (updated) {
|
|
281
|
+
created = {
|
|
282
|
+
...created,
|
|
283
|
+
...updated,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
created = await createSession({
|
|
290
|
+
targetPath,
|
|
291
|
+
ttlSeconds,
|
|
292
|
+
template,
|
|
293
|
+
title: titleArg || fallbackTitle,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const effectiveTitle = titleArg || normalizeString(created.title) || fallbackTitle;
|
|
298
|
+
const titleAuto = !titleArg && !resumedCandidate;
|
|
299
|
+
const shouldPushTitle = Boolean(
|
|
300
|
+
titleArg ||
|
|
301
|
+
titleAuto ||
|
|
302
|
+
(resumedCandidate && effectiveTitle && !normalizeString(resumedCandidate.title))
|
|
303
|
+
);
|
|
304
|
+
if (shouldPushTitle) {
|
|
305
|
+
void pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
created: {
|
|
310
|
+
...created,
|
|
311
|
+
title: effectiveTitle || null,
|
|
312
|
+
resumed: Boolean(resumedCandidate),
|
|
313
|
+
},
|
|
314
|
+
resumedCandidate,
|
|
315
|
+
durationMs: Date.now() - startedAt,
|
|
316
|
+
title: effectiveTitle || null,
|
|
317
|
+
titleAuto,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
92
321
|
function normalizeAgentId(value, fallbackValue = "cli-user") {
|
|
93
322
|
const normalized = normalizeString(value)
|
|
94
323
|
.toLowerCase()
|
|
@@ -279,6 +508,15 @@ export function registerSessionCommand(program) {
|
|
|
279
508
|
"--force-new",
|
|
280
509
|
"Always create a new session even if a recent active one exists for this workspace",
|
|
281
510
|
)
|
|
511
|
+
.option(
|
|
512
|
+
"--resume",
|
|
513
|
+
"Reuse the most recent active session for this workspace when one is inside the reuse window",
|
|
514
|
+
true,
|
|
515
|
+
)
|
|
516
|
+
.option(
|
|
517
|
+
"--no-resume",
|
|
518
|
+
"Disable automatic resume and mint a new session unless --force-new is also present",
|
|
519
|
+
)
|
|
282
520
|
.option(
|
|
283
521
|
"--reuse-window-seconds <seconds>",
|
|
284
522
|
"Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
|
|
@@ -302,149 +540,22 @@ export function registerSessionCommand(program) {
|
|
|
302
540
|
"reuse-window-seconds",
|
|
303
541
|
3600,
|
|
304
542
|
);
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
// `--force-new` opts back into the old "always mint" behavior.
|
|
319
|
-
let resumed = null;
|
|
320
|
-
if (!options.forceNew) {
|
|
321
|
-
const cutoffMs = Date.now() - reuseWindowSeconds * 1000;
|
|
322
|
-
const candidates = [];
|
|
323
|
-
try {
|
|
324
|
-
const active = await listActiveSessions({ targetPath });
|
|
325
|
-
for (const entry of active) {
|
|
326
|
-
const createdMs = Date.parse(entry.createdAt || "");
|
|
327
|
-
if (Number.isFinite(createdMs) && createdMs >= cutoffMs) {
|
|
328
|
-
candidates.push({ ...entry, _source: "local" });
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
} catch {
|
|
332
|
-
/* local lookup failure is non-fatal */
|
|
333
|
-
}
|
|
334
|
-
try {
|
|
335
|
-
const remote = await listSessionsFromApi({
|
|
336
|
-
targetPath,
|
|
337
|
-
includeArchived: false,
|
|
338
|
-
limit: 50,
|
|
339
|
-
});
|
|
340
|
-
if (remote && remote.ok) {
|
|
341
|
-
const normalizedTarget = String(targetPath).toLowerCase();
|
|
342
|
-
for (const entry of remote.sessions || []) {
|
|
343
|
-
const codebase = String(entry.codebasePath || "").toLowerCase();
|
|
344
|
-
if (!codebase || codebase !== normalizedTarget) continue;
|
|
345
|
-
if (entry.archiveStatus && entry.archiveStatus !== "active") continue;
|
|
346
|
-
const lastMs = Date.parse(entry.lastActivityAt || entry.createdAt || "");
|
|
347
|
-
if (Number.isFinite(lastMs) && lastMs >= cutoffMs) {
|
|
348
|
-
candidates.push({
|
|
349
|
-
sessionId: entry.sessionId,
|
|
350
|
-
createdAt: entry.createdAt,
|
|
351
|
-
lastActivityAt: entry.lastActivityAt,
|
|
352
|
-
expiresAt: entry.expiresAt,
|
|
353
|
-
status: entry.status || "active",
|
|
354
|
-
template: entry.templateName || null,
|
|
355
|
-
title: entry.title || null,
|
|
356
|
-
_source: "remote",
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
} catch {
|
|
362
|
-
/* remote lookup failure is non-fatal */
|
|
363
|
-
}
|
|
364
|
-
if (candidates.length > 0) {
|
|
365
|
-
// Prefer the most recent activity. Local + remote may name the
|
|
366
|
-
// same session; dedupe on sessionId before picking.
|
|
367
|
-
const seen = new Set();
|
|
368
|
-
const deduped = [];
|
|
369
|
-
for (const entry of candidates) {
|
|
370
|
-
if (seen.has(entry.sessionId)) continue;
|
|
371
|
-
seen.add(entry.sessionId);
|
|
372
|
-
deduped.push(entry);
|
|
373
|
-
}
|
|
374
|
-
deduped.sort((a, b) =>
|
|
375
|
-
String(b.lastActivityAt || b.createdAt || "").localeCompare(
|
|
376
|
-
String(a.lastActivityAt || a.createdAt || ""),
|
|
377
|
-
),
|
|
378
|
-
);
|
|
379
|
-
resumed = deduped[0];
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const startedAt = Date.now();
|
|
384
|
-
let created;
|
|
385
|
-
if (resumed) {
|
|
386
|
-
// Surface the resumed session's metadata in the same shape
|
|
387
|
-
// createSession returns so downstream code stays unchanged.
|
|
388
|
-
created = {
|
|
389
|
-
sessionId: resumed.sessionId,
|
|
390
|
-
sessionDir: resumed.sessionDir || null,
|
|
391
|
-
metadataPath: resumed.metadataPath || null,
|
|
392
|
-
streamPath: resumed.streamPath || null,
|
|
393
|
-
createdAt: resumed.createdAt,
|
|
394
|
-
expiresAt: resumed.expiresAt,
|
|
395
|
-
elapsedTimer: 0,
|
|
396
|
-
renewalCount: resumed.renewalCount || 0,
|
|
397
|
-
status: resumed.status || "active",
|
|
398
|
-
template: resumed.template || null,
|
|
399
|
-
codebaseContext: resumed.codebaseContext || null,
|
|
400
|
-
resumed: true,
|
|
401
|
-
};
|
|
402
|
-
} else {
|
|
403
|
-
created = await createSession({
|
|
404
|
-
targetPath,
|
|
405
|
-
ttlSeconds,
|
|
406
|
-
template,
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
const durationMs = Date.now() - startedAt;
|
|
543
|
+
const titleArg = normalizeString(options.title);
|
|
544
|
+
const ensured = await ensureWorkspaceSession({
|
|
545
|
+
targetPath,
|
|
546
|
+
ttlSeconds,
|
|
547
|
+
template,
|
|
548
|
+
title: titleArg,
|
|
549
|
+
resume: options.resume !== false,
|
|
550
|
+
forceNew: Boolean(options.forceNew),
|
|
551
|
+
reuseWindowSeconds,
|
|
552
|
+
});
|
|
553
|
+
const created = ensured.created;
|
|
554
|
+
const resumed = Boolean(ensured.resumedCandidate);
|
|
555
|
+
const durationMs = ensured.durationMs;
|
|
410
556
|
const launchPlan = template ? buildTemplateLaunchPlan(created.sessionId, template) : [];
|
|
411
557
|
const dashboardUrl = buildDashboardUrl(created.sessionId);
|
|
412
|
-
|
|
413
|
-
// never fills with anonymous "<null>" rows. The caller can still
|
|
414
|
-
// override with --title. We skip the auto-title for resumed sessions
|
|
415
|
-
// because the room already has a name we don't want to clobber.
|
|
416
|
-
const titleArg = normalizeString(options.title);
|
|
417
|
-
const autoTitle = !resumed && !titleArg ? deriveSessionTitle(targetPath) : "";
|
|
418
|
-
const effectiveTitle = titleArg || autoTitle;
|
|
419
|
-
|
|
420
|
-
// If a title needs to land on the dashboard, push it. We always push
|
|
421
|
-
// when the caller passed --title, AND we push the auto-derived title
|
|
422
|
-
// for fresh (non-resumed) sessions so the room is never anonymous on
|
|
423
|
-
// the web. Best-effort, non-blocking.
|
|
424
|
-
if (effectiveTitle) {
|
|
425
|
-
void (async () => {
|
|
426
|
-
try {
|
|
427
|
-
const session = await resolveActiveAuthSession({
|
|
428
|
-
cwd: targetPath,
|
|
429
|
-
env: process.env,
|
|
430
|
-
autoRotate: false,
|
|
431
|
-
});
|
|
432
|
-
if (!session?.token || !session?.apiUrl) return;
|
|
433
|
-
const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
|
|
434
|
-
await requestJsonMutation(
|
|
435
|
-
`${apiUrl}/api/v1/sessions/${encodeURIComponent(created.sessionId)}/title`,
|
|
436
|
-
{
|
|
437
|
-
method: "POST",
|
|
438
|
-
operationName: "session.set_title",
|
|
439
|
-
headers: { Authorization: `Bearer ${session.token}` },
|
|
440
|
-
body: { title: effectiveTitle },
|
|
441
|
-
},
|
|
442
|
-
);
|
|
443
|
-
} catch (_error) {
|
|
444
|
-
/* best-effort */
|
|
445
|
-
}
|
|
446
|
-
})();
|
|
447
|
-
}
|
|
558
|
+
const effectiveTitle = ensured.title;
|
|
448
559
|
|
|
449
560
|
const payload = {
|
|
450
561
|
command: "session start",
|
|
@@ -455,7 +566,9 @@ export function registerSessionCommand(program) {
|
|
|
455
566
|
metadataPath: created.metadataPath,
|
|
456
567
|
streamPath: created.streamPath,
|
|
457
568
|
createdAt: created.createdAt,
|
|
569
|
+
updatedAt: created.updatedAt,
|
|
458
570
|
expiresAt: created.expiresAt,
|
|
571
|
+
lastInteractionAt: created.lastInteractionAt,
|
|
459
572
|
ttlSeconds,
|
|
460
573
|
elapsedTimer: created.elapsedTimer,
|
|
461
574
|
renewalCount: created.renewalCount,
|
|
@@ -463,9 +576,9 @@ export function registerSessionCommand(program) {
|
|
|
463
576
|
template: created.template,
|
|
464
577
|
launchPlan,
|
|
465
578
|
dashboardUrl,
|
|
466
|
-
resumed
|
|
579
|
+
resumed,
|
|
467
580
|
title: effectiveTitle || null,
|
|
468
|
-
titleAuto: Boolean(
|
|
581
|
+
titleAuto: Boolean(ensured.titleAuto),
|
|
469
582
|
};
|
|
470
583
|
|
|
471
584
|
// Best-effort admin visibility sync. Session creation remains local-first.
|
|
@@ -475,6 +588,7 @@ export function registerSessionCommand(program) {
|
|
|
475
588
|
status: created.status,
|
|
476
589
|
createdAt: created.createdAt,
|
|
477
590
|
expiresAt: created.expiresAt,
|
|
591
|
+
title: effectiveTitle || null,
|
|
478
592
|
ttlSeconds,
|
|
479
593
|
template: created.template,
|
|
480
594
|
codebaseContext: created.codebaseContext,
|
|
@@ -535,6 +649,62 @@ export function registerSessionCommand(program) {
|
|
|
535
649
|
await program.parseAsync(args, { from: "user" });
|
|
536
650
|
});
|
|
537
651
|
|
|
652
|
+
session
|
|
653
|
+
.command("ensure")
|
|
654
|
+
.description("Join or create the canonical session for this workspace and emit JSON")
|
|
655
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
656
|
+
.option("--title <title>", "Title applied if a new or unnamed resumed session needs one")
|
|
657
|
+
.option(
|
|
658
|
+
"--ttl-seconds <seconds>",
|
|
659
|
+
`Session time-to-live in seconds when a new session is minted (default ${DEFAULT_TTL_SECONDS})`
|
|
660
|
+
)
|
|
661
|
+
.option(
|
|
662
|
+
"--force-new",
|
|
663
|
+
"Always create a new session even if a recent active one exists for this workspace",
|
|
664
|
+
)
|
|
665
|
+
.option(
|
|
666
|
+
"--resume",
|
|
667
|
+
"Reuse the most recent active session for this workspace when one is inside the reuse window",
|
|
668
|
+
true,
|
|
669
|
+
)
|
|
670
|
+
.option("--no-resume", "Disable automatic resume and mint a new session")
|
|
671
|
+
.option(
|
|
672
|
+
"--reuse-window-seconds <seconds>",
|
|
673
|
+
"Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
|
|
674
|
+
"3600",
|
|
675
|
+
)
|
|
676
|
+
.option("--json", "Emit machine-readable output (default for this command)")
|
|
677
|
+
.action(async (options) => {
|
|
678
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
679
|
+
const ttlSeconds = parsePositiveInteger(
|
|
680
|
+
options.ttlSeconds,
|
|
681
|
+
"ttl-seconds",
|
|
682
|
+
DEFAULT_TTL_SECONDS,
|
|
683
|
+
);
|
|
684
|
+
const reuseWindowSeconds = parsePositiveInteger(
|
|
685
|
+
options.reuseWindowSeconds,
|
|
686
|
+
"reuse-window-seconds",
|
|
687
|
+
3600,
|
|
688
|
+
);
|
|
689
|
+
const ensured = await ensureWorkspaceSession({
|
|
690
|
+
targetPath,
|
|
691
|
+
ttlSeconds,
|
|
692
|
+
title: normalizeString(options.title),
|
|
693
|
+
resume: options.resume !== false,
|
|
694
|
+
forceNew: Boolean(options.forceNew),
|
|
695
|
+
reuseWindowSeconds,
|
|
696
|
+
});
|
|
697
|
+
const payload = {
|
|
698
|
+
command: "session ensure",
|
|
699
|
+
targetPath,
|
|
700
|
+
sessionId: ensured.created.sessionId,
|
|
701
|
+
title: ensured.title || null,
|
|
702
|
+
resumed: Boolean(ensured.resumedCandidate),
|
|
703
|
+
dashboardUrl: buildDashboardUrl(ensured.created.sessionId),
|
|
704
|
+
};
|
|
705
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
706
|
+
});
|
|
707
|
+
|
|
538
708
|
session
|
|
539
709
|
.command("set-title <sessionId> <title>")
|
|
540
710
|
.description("Set the human-readable title on a session (visible in web sidebar + transcript).")
|
|
@@ -564,10 +734,15 @@ export function registerSessionCommand(program) {
|
|
|
564
734
|
body: { title: normalizedTitle },
|
|
565
735
|
},
|
|
566
736
|
);
|
|
737
|
+
const localUpdated = await updateSessionTitle(normalizedSessionId, {
|
|
738
|
+
targetPath,
|
|
739
|
+
title: normalizedTitle,
|
|
740
|
+
}).catch(() => null);
|
|
567
741
|
const payload = {
|
|
568
742
|
command: "session set-title",
|
|
569
743
|
sessionId: normalizedSessionId,
|
|
570
744
|
title: normalizedTitle,
|
|
745
|
+
localUpdated: Boolean(localUpdated),
|
|
571
746
|
result,
|
|
572
747
|
};
|
|
573
748
|
if (shouldEmitJson(options, command)) {
|
|
@@ -697,6 +872,7 @@ export function registerSessionCommand(program) {
|
|
|
697
872
|
.command("say <sessionId> <message>")
|
|
698
873
|
.description("Send a message to the session")
|
|
699
874
|
.option("--agent <id>", "Agent id to emit from", "cli-user")
|
|
875
|
+
.option("--to <agent>", "Direct the message to a specific agent id")
|
|
700
876
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
701
877
|
.option("--json", "Emit machine-readable output")
|
|
702
878
|
.action(async (sessionId, message, options, command) => {
|
|
@@ -710,14 +886,19 @@ export function registerSessionCommand(program) {
|
|
|
710
886
|
}
|
|
711
887
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
712
888
|
const agentId = normalizeAgentId(options.agent, "cli-user");
|
|
889
|
+
const to = normalizeString(options.to);
|
|
890
|
+
const eventPayload = {
|
|
891
|
+
message: normalizedMessage,
|
|
892
|
+
channel: "session",
|
|
893
|
+
};
|
|
894
|
+
if (to) {
|
|
895
|
+
eventPayload.to = to;
|
|
896
|
+
}
|
|
713
897
|
const event = createAgentEvent({
|
|
714
898
|
event: "session_message",
|
|
715
899
|
agentId,
|
|
716
900
|
sessionId: normalizedSessionId,
|
|
717
|
-
payload:
|
|
718
|
-
message: normalizedMessage,
|
|
719
|
-
channel: "session",
|
|
720
|
-
},
|
|
901
|
+
payload: eventPayload,
|
|
721
902
|
});
|
|
722
903
|
const persisted = await appendToStream(normalizedSessionId, event, {
|
|
723
904
|
targetPath,
|
|
@@ -736,6 +917,93 @@ export function registerSessionCommand(program) {
|
|
|
736
917
|
console.log(formatEventLine(persisted));
|
|
737
918
|
});
|
|
738
919
|
|
|
920
|
+
session
|
|
921
|
+
.command("listen")
|
|
922
|
+
.description("Background-poll a session for events addressed to this agent or broadcast")
|
|
923
|
+
.requiredOption("--session <id>", "Session id to listen to")
|
|
924
|
+
.option(
|
|
925
|
+
"--agent <id>",
|
|
926
|
+
"Agent id to receive messages for",
|
|
927
|
+
process.env.SENTINELAYER_AGENT_ID || "cli-user",
|
|
928
|
+
)
|
|
929
|
+
.option("--interval <seconds>", "Polling interval in seconds (default 60)", "60")
|
|
930
|
+
.option("--emit <format>", "Output format: ndjson or text", "ndjson")
|
|
931
|
+
.option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
|
|
932
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
933
|
+
.option("--since <cursor>", "Override the persisted listen cursor")
|
|
934
|
+
.option("--replay", "Emit matching historical events on the first poll")
|
|
935
|
+
.option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
|
|
936
|
+
.action(async (options) => {
|
|
937
|
+
const normalizedSessionId = resolveSessionIdOption(options);
|
|
938
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
939
|
+
const agentId = normalizeAgentId(options.agent, "cli-user");
|
|
940
|
+
const intervalSeconds = parsePositiveInteger(options.interval, "interval", 60);
|
|
941
|
+
const limit = parsePositiveInteger(options.limit, "limit", 200);
|
|
942
|
+
const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
|
|
943
|
+
if (!["ndjson", "text"].includes(emitFormat)) {
|
|
944
|
+
throw new Error("--emit must be one of: ndjson, text.");
|
|
945
|
+
}
|
|
946
|
+
const maxPolls =
|
|
947
|
+
options.maxPolls === undefined
|
|
948
|
+
? null
|
|
949
|
+
: parsePositiveInteger(options.maxPolls, "max-polls", 1);
|
|
950
|
+
const since = options.since === undefined ? undefined : String(options.since);
|
|
951
|
+
const ac = new AbortController();
|
|
952
|
+
const onSigint = () => ac.abort();
|
|
953
|
+
process.on("SIGINT", onSigint);
|
|
954
|
+
|
|
955
|
+
if (emitFormat === "text") {
|
|
956
|
+
console.log(
|
|
957
|
+
pc.gray(
|
|
958
|
+
`Listening to session ${normalizedSessionId} as ${agentId}; interval=${intervalSeconds}s. Press Ctrl+C to stop.`,
|
|
959
|
+
),
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
try {
|
|
964
|
+
await listenSessionEvents({
|
|
965
|
+
sessionId: normalizedSessionId,
|
|
966
|
+
targetPath,
|
|
967
|
+
agentId,
|
|
968
|
+
intervalSeconds,
|
|
969
|
+
limit,
|
|
970
|
+
since,
|
|
971
|
+
replay: Boolean(options.replay),
|
|
972
|
+
maxPolls,
|
|
973
|
+
signal: ac.signal,
|
|
974
|
+
onEvent: async (event) => {
|
|
975
|
+
if (emitFormat === "ndjson") {
|
|
976
|
+
console.log(JSON.stringify(event));
|
|
977
|
+
} else {
|
|
978
|
+
console.log(formatEventLine(event));
|
|
979
|
+
}
|
|
980
|
+
},
|
|
981
|
+
onError: async (result) => {
|
|
982
|
+
const reason = normalizeString(result?.reason) || "poll_failed";
|
|
983
|
+
if (emitFormat === "ndjson") {
|
|
984
|
+
console.log(
|
|
985
|
+
JSON.stringify(
|
|
986
|
+
createAgentEvent({
|
|
987
|
+
event: "session_listen_error",
|
|
988
|
+
agentId,
|
|
989
|
+
sessionId: normalizedSessionId,
|
|
990
|
+
payload: {
|
|
991
|
+
reason,
|
|
992
|
+
cursor: result?.cursor || null,
|
|
993
|
+
},
|
|
994
|
+
}),
|
|
995
|
+
),
|
|
996
|
+
);
|
|
997
|
+
} else {
|
|
998
|
+
console.log(pc.yellow(`Listen poll skipped (${reason}).`));
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
} finally {
|
|
1003
|
+
process.removeListener("SIGINT", onSigint);
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
|
|
739
1007
|
session
|
|
740
1008
|
.command("read <sessionId>")
|
|
741
1009
|
.description("Read recent session messages")
|