sentinelayer-cli 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/ai/identity-lifecycle.js +14 -2
- package/src/commands/mcp.js +60 -0
- package/src/commands/session.js +1459 -31
- package/src/legacy-cli.js +18 -11
- package/src/mcp/registry.js +151 -0
- package/src/mcp/session-stdio-server.js +977 -0
- package/src/scan/generator.js +3 -2
- package/src/session/agent-registry.js +118 -0
- package/src/session/checkpoints.js +71 -1
- package/src/session/coordination-guidance.js +3 -2
- package/src/session/listener.js +302 -68
- package/src/session/pricing-ledger.js +34 -4
- package/src/session/recap.js +4 -2
- package/src/session/sync.js +395 -0
- package/src/session/transcript.js +86 -36
- package/src/session/usage.js +5 -5
- package/src/session/wake/claude.js +175 -0
- package/src/session/wake/codex.js +394 -0
- package/src/session/wake/cursor-store.js +69 -0
- package/src/session/wake/dispatcher.js +184 -0
- package/src/session/wake/pump.js +135 -0
- package/src/session/wake/registry.js +80 -0
- package/src/session/wake/resolve-target.js +146 -0
- package/src/session/wake/sentid.js +103 -0
package/src/commands/session.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fsp from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { spawn as defaultSpawn } from "node:child_process";
|
|
5
7
|
|
|
6
8
|
import pc from "picocolors";
|
|
7
9
|
|
|
@@ -26,6 +28,7 @@ import { createAgentEvent } from "../events/schema.js";
|
|
|
26
28
|
import {
|
|
27
29
|
detectStaleAgents,
|
|
28
30
|
listAgents,
|
|
31
|
+
rememberAgentIdentity,
|
|
29
32
|
registerAgent,
|
|
30
33
|
unregisterAgent,
|
|
31
34
|
} from "../session/agent-registry.js";
|
|
@@ -61,9 +64,11 @@ import {
|
|
|
61
64
|
import { readSessionPreview } from "../session/preview.js";
|
|
62
65
|
import {
|
|
63
66
|
createSessionMessageAction,
|
|
67
|
+
fetchSessionPinnedMessages,
|
|
64
68
|
listSessionMessageActions,
|
|
65
69
|
listSessionsFromApi,
|
|
66
70
|
probeSessionAccess,
|
|
71
|
+
pollSessionEvents,
|
|
67
72
|
pollSessionEventsBefore,
|
|
68
73
|
searchSessionEvents,
|
|
69
74
|
syncSessionEventToApi,
|
|
@@ -85,8 +90,16 @@ import {
|
|
|
85
90
|
import {
|
|
86
91
|
createSessionCheckpoint,
|
|
87
92
|
generateSessionCheckpoint,
|
|
93
|
+
generateSessionCheckpointBatch,
|
|
88
94
|
listSessionCheckpoints,
|
|
89
95
|
} from "../session/checkpoints.js";
|
|
96
|
+
import {
|
|
97
|
+
buildCodexExecResumeInvocation,
|
|
98
|
+
buildCodexWakePrompt,
|
|
99
|
+
recordCodexWakeRegistration,
|
|
100
|
+
runCodexExecResume,
|
|
101
|
+
} from "../session/wake/codex.js";
|
|
102
|
+
import createSentid from "../session/wake/sentid.js";
|
|
90
103
|
import { authLoginHint, preferredCliCommand } from "../ui/command-hints.js";
|
|
91
104
|
import { parseCsvTokens } from "./ai/shared.js";
|
|
92
105
|
|
|
@@ -101,6 +114,164 @@ function normalizeString(value) {
|
|
|
101
114
|
return String(value || "").trim();
|
|
102
115
|
}
|
|
103
116
|
|
|
117
|
+
const SESSION_SAY_CONFIRM_ATTEMPTS = 3;
|
|
118
|
+
const SESSION_SAY_CONFIRM_DELAY_MS = 250;
|
|
119
|
+
const SESSION_SAY_CONFIRM_PAGE_LIMIT = 200;
|
|
120
|
+
const SESSION_SAY_CONFIRM_MAX_PAGES = 10;
|
|
121
|
+
const SESSION_SAY_LOCAL_ONLY_REASONS = new Set([
|
|
122
|
+
"no_session",
|
|
123
|
+
"not_authenticated",
|
|
124
|
+
"remote_sync_disabled_env",
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
function isLocalOnlySessionSayReason(reason) {
|
|
128
|
+
return SESSION_SAY_LOCAL_ONLY_REASONS.has(normalizeString(reason));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sessionEventMatchesClientMessageId(event, clientMessageId) {
|
|
132
|
+
const normalizedClientMessageId = normalizeString(clientMessageId);
|
|
133
|
+
if (!normalizedClientMessageId || !event || typeof event !== "object") {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
|
|
137
|
+
const candidates = [
|
|
138
|
+
event.id,
|
|
139
|
+
event.eventId,
|
|
140
|
+
event.event_id,
|
|
141
|
+
event.idempotencyToken,
|
|
142
|
+
event.idempotency_token,
|
|
143
|
+
event.clientMessageId,
|
|
144
|
+
event.client_message_id,
|
|
145
|
+
payload.id,
|
|
146
|
+
payload.messageId,
|
|
147
|
+
payload.message_id,
|
|
148
|
+
payload.eventId,
|
|
149
|
+
payload.event_id,
|
|
150
|
+
payload.idempotencyToken,
|
|
151
|
+
payload.idempotency_token,
|
|
152
|
+
payload.clientMessageId,
|
|
153
|
+
payload.client_message_id,
|
|
154
|
+
];
|
|
155
|
+
return candidates.some((candidate) => normalizeString(candidate) === normalizedClientMessageId);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readSessionConfirmationAnchor(sessionId, { targetPath } = {}) {
|
|
159
|
+
const result = await pollSessionEventsBefore(sessionId, {
|
|
160
|
+
targetPath,
|
|
161
|
+
limit: 1,
|
|
162
|
+
forceCircuitProbe: true,
|
|
163
|
+
});
|
|
164
|
+
if (!result?.ok) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
reason: normalizeString(result?.reason) || "confirmation_anchor_failed",
|
|
168
|
+
cursor: null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const events = Array.isArray(result.events) ? result.events : [];
|
|
172
|
+
const lastEvent = events[events.length - 1] || null;
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
reason: "",
|
|
176
|
+
cursor: normalizeString(lastEvent?.cursor) || normalizeString(result.cursor) || null,
|
|
177
|
+
sequenceId: Number(lastEvent?.sequenceId ?? lastEvent?.sequence_id) || null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function confirmSessionEventVisible(sessionId, clientMessageId, { targetPath, anchorCursor = null } = {}) {
|
|
182
|
+
let lastReason = "not_visible";
|
|
183
|
+
let checked = 0;
|
|
184
|
+
let pages = 0;
|
|
185
|
+
const normalizedAnchorCursor = normalizeString(anchorCursor) || null;
|
|
186
|
+
for (let attempt = 1; attempt <= SESSION_SAY_CONFIRM_ATTEMPTS; attempt += 1) {
|
|
187
|
+
let pageCursor = normalizedAnchorCursor;
|
|
188
|
+
for (let page = 1; page <= SESSION_SAY_CONFIRM_MAX_PAGES; page += 1) {
|
|
189
|
+
pages += 1;
|
|
190
|
+
const result = await pollSessionEvents(sessionId, {
|
|
191
|
+
targetPath,
|
|
192
|
+
since: pageCursor,
|
|
193
|
+
limit: SESSION_SAY_CONFIRM_PAGE_LIMIT,
|
|
194
|
+
forceCircuitProbe: true,
|
|
195
|
+
});
|
|
196
|
+
if (!result?.ok) {
|
|
197
|
+
lastReason = normalizeString(result?.reason) || "confirmation_poll_failed";
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
const events = Array.isArray(result.events) ? result.events : [];
|
|
201
|
+
checked += events.length;
|
|
202
|
+
const confirmedEvent = events.find((candidate) => sessionEventMatchesClientMessageId(candidate, clientMessageId));
|
|
203
|
+
if (confirmedEvent) {
|
|
204
|
+
return {
|
|
205
|
+
confirmed: true,
|
|
206
|
+
reason: "",
|
|
207
|
+
checked,
|
|
208
|
+
pages,
|
|
209
|
+
anchorCursor: normalizedAnchorCursor,
|
|
210
|
+
event: confirmedEvent,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
lastReason = "not_visible";
|
|
214
|
+
const nextCursor = normalizeString(result.cursor);
|
|
215
|
+
if (!events.length || !nextCursor || nextCursor === pageCursor) {
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
pageCursor = nextCursor;
|
|
219
|
+
}
|
|
220
|
+
if (attempt < SESSION_SAY_CONFIRM_ATTEMPTS) {
|
|
221
|
+
await sleep(SESSION_SAY_CONFIRM_DELAY_MS);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
confirmed: false,
|
|
226
|
+
reason: lastReason,
|
|
227
|
+
checked,
|
|
228
|
+
pages,
|
|
229
|
+
anchorCursor: normalizedAnchorCursor,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function formatSessionListText(value, { maxLength = 80 } = {}) {
|
|
234
|
+
const normalized = normalizeString(value)
|
|
235
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
236
|
+
.replace(/\s+/g, " ")
|
|
237
|
+
.replace(/"/g, "'");
|
|
238
|
+
if (!normalized) {
|
|
239
|
+
return "";
|
|
240
|
+
}
|
|
241
|
+
const limit = Math.max(8, Number(maxLength) || 80);
|
|
242
|
+
return normalized.length > limit
|
|
243
|
+
? `${normalized.slice(0, Math.max(0, limit - 3)).trimEnd()}...`
|
|
244
|
+
: normalized;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function sessionListCodebaseLabel(value) {
|
|
248
|
+
const normalized = formatSessionListText(value, { maxLength: 120 });
|
|
249
|
+
if (!normalized) {
|
|
250
|
+
return "";
|
|
251
|
+
}
|
|
252
|
+
const segments = normalized.split(/[\\/]+/).filter(Boolean);
|
|
253
|
+
if (segments.length <= 2) {
|
|
254
|
+
return formatSessionListText(normalized, { maxLength: 80 });
|
|
255
|
+
}
|
|
256
|
+
return formatSessionListText(`.../${segments.slice(-2).join("/")}`, {
|
|
257
|
+
maxLength: 80,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function formatSessionListLine(item = {}) {
|
|
262
|
+
const sessionId = normalizeString(item.sessionId) || "unknown-session";
|
|
263
|
+
const status = normalizeString(item.status) || "unknown";
|
|
264
|
+
const archiveStatus = normalizeString(item.archiveStatus);
|
|
265
|
+
const archive = archiveStatus ? ` archive=${archiveStatus}` : "";
|
|
266
|
+
const title = formatSessionListText(item.title || item.summaryText, { maxLength: 72 });
|
|
267
|
+
const codebase = sessionListCodebaseLabel(item.codebasePath);
|
|
268
|
+
const titlePart = title ? ` title="${title}"` : "";
|
|
269
|
+
const codebasePart = codebase ? ` codebase="${codebase}"` : "";
|
|
270
|
+
const created = item.createdAt || "?";
|
|
271
|
+
const lastActivity = item.lastActivityAt ? ` last=${item.lastActivityAt}` : "";
|
|
272
|
+
return `${sessionId} status=${status}${archive}${titlePart}${codebasePart} created=${created}${lastActivity}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
104
275
|
const SESSION_MESSAGE_ACTION_TYPES = new Set([
|
|
105
276
|
"ack",
|
|
106
277
|
"working_on",
|
|
@@ -150,7 +321,7 @@ const SESSION_MESSAGE_ACTION_DESCRIPTIONS = Object.freeze([
|
|
|
150
321
|
{
|
|
151
322
|
type: "view",
|
|
152
323
|
command: "sl session view <id> <sequence>",
|
|
153
|
-
description: "
|
|
324
|
+
description: "Manually backfill a read receipt for a target message; remote reads record views automatically.",
|
|
154
325
|
},
|
|
155
326
|
]);
|
|
156
327
|
|
|
@@ -219,6 +390,7 @@ function actionDisplayMessage(action = {}) {
|
|
|
219
390
|
function buildSessionActionEvent(sessionId, action = {}) {
|
|
220
391
|
const actionType = normalizeString(action.actionType ?? action.action_type).toLowerCase();
|
|
221
392
|
if (!SESSION_MESSAGE_ACTION_TYPES.has(actionType)) return null;
|
|
393
|
+
if (actionType === "view") return null;
|
|
222
394
|
const id =
|
|
223
395
|
normalizeString(action.id) ||
|
|
224
396
|
shortSha256(
|
|
@@ -265,6 +437,91 @@ function buildSessionActionEvents(sessionId, actions = []) {
|
|
|
265
437
|
.filter(Boolean);
|
|
266
438
|
}
|
|
267
439
|
|
|
440
|
+
function readProjectionUnacknowledgedHumanMessages(remoteActions = null) {
|
|
441
|
+
const messages = remoteActions?.projection?.unacknowledgedHumanMessages;
|
|
442
|
+
return Array.isArray(messages)
|
|
443
|
+
? messages
|
|
444
|
+
.filter((event) => event && typeof event === "object")
|
|
445
|
+
.sort((left, right) => humanAskSortValue(right) - humanAskSortValue(left))
|
|
446
|
+
: [];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function messageActionCreatedMs(action = {}) {
|
|
450
|
+
const epoch = Date.parse(normalizeString(action.createdAt || action.created_at || action.ts || action.timestamp));
|
|
451
|
+
return Number.isFinite(epoch) ? epoch : 0;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function messageActionActorId(action = {}) {
|
|
455
|
+
return normalizeString(action.actorId || action.actor_id || action.agentId || action.agent_id) || "unknown";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function isHumanMessageAction(action = {}) {
|
|
459
|
+
if (action?.isHumanActivity === true) return true;
|
|
460
|
+
if (normalizeString(action.actorKind || action.actor_kind).toLowerCase() === "human") return true;
|
|
461
|
+
return messageActionActorId(action).startsWith("human-");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function readProjectionRecentHumanActivity(remoteActions = null) {
|
|
465
|
+
const projected = remoteActions?.projection?.recentActivity;
|
|
466
|
+
const source = Array.isArray(projected) && projected.length > 0 ? projected : remoteActions?.actions;
|
|
467
|
+
return Array.isArray(source)
|
|
468
|
+
? source
|
|
469
|
+
.filter((action) => action && typeof action === "object" && isHumanMessageAction(action))
|
|
470
|
+
.sort((left, right) => {
|
|
471
|
+
const timeDiff = messageActionCreatedMs(right) - messageActionCreatedMs(left);
|
|
472
|
+
if (timeDiff !== 0) return timeDiff;
|
|
473
|
+
return normalizeString(right.id).localeCompare(normalizeString(left.id));
|
|
474
|
+
})
|
|
475
|
+
: [];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function formatMessageActionActivityLine(action = {}) {
|
|
479
|
+
const actionType = normalizeString(action.actionType || action.action_type) || "action";
|
|
480
|
+
const targetSequence = Number(action.targetSequenceId ?? action.target_sequence_id ?? 0);
|
|
481
|
+
const targetActionId = normalizeString(action.targetActionId || action.target_action_id);
|
|
482
|
+
const target = targetActionId
|
|
483
|
+
? `action:${targetActionId}`
|
|
484
|
+
: targetSequence > 0
|
|
485
|
+
? `#${Math.floor(targetSequence)}`
|
|
486
|
+
: normalizeString(action.targetCursor || action.target_cursor) || "unknown-target";
|
|
487
|
+
const ts = normalizeString(action.createdAt || action.created_at || action.ts || action.timestamp);
|
|
488
|
+
const note = normalizeString(action.note || action.message || "");
|
|
489
|
+
const shortNote = note.length > 220 ? `${note.slice(0, 217)}...` : note;
|
|
490
|
+
const suffix = shortNote ? `: ${shortNote}` : "";
|
|
491
|
+
return `${actionType} ${target} by ${messageActionActorId(action)}${ts ? ` ${ts}` : ""}${suffix}`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function humanAskSequence(event = {}) {
|
|
495
|
+
const value = Number(event.sequenceId ?? event.sequence_id ?? event.sequence ?? 0);
|
|
496
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function humanAskAgentId(event = {}) {
|
|
500
|
+
return normalizeString(event.agent?.id || event.agentId || event.agent_id) || "human";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function humanAskMessage(event = {}) {
|
|
504
|
+
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
|
|
505
|
+
return normalizeString(payload.message || payload.note || payload.reason || "");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function humanAskSortValue(event = {}) {
|
|
509
|
+
const sequence = humanAskSequence(event);
|
|
510
|
+
if (sequence) return sequence;
|
|
511
|
+
const epoch = Date.parse(normalizeString(event.ts || event.timestamp || event.createdAt || event.created_at));
|
|
512
|
+
return Number.isFinite(epoch) ? epoch : 0;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function formatHumanAskLine(event = {}) {
|
|
516
|
+
const sequence = humanAskSequence(event);
|
|
517
|
+
const sequenceLabel = sequence ? `#${sequence}` : normalizeString(event.cursor) || "unsequenced";
|
|
518
|
+
const ts = normalizeString(event.ts || event.timestamp || event.createdAt || event.created_at);
|
|
519
|
+
const message = humanAskMessage(event);
|
|
520
|
+
const shortMessage = message.length > 220 ? `${message.slice(0, 217)}...` : message;
|
|
521
|
+
const suffix = shortMessage ? `: ${shortMessage}` : "";
|
|
522
|
+
return `${sequenceLabel} ${humanAskAgentId(event)}${ts ? ` ${ts}` : ""}${suffix}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
268
525
|
function eventTimestampMs(event = {}) {
|
|
269
526
|
for (const key of ["ts", "timestamp", "createdAt", "at"]) {
|
|
270
527
|
const epoch = Date.parse(normalizeString(event?.[key]));
|
|
@@ -329,6 +586,99 @@ function defaultActionIdempotencyKey({
|
|
|
329
586
|
return `cli:${normalizeString(actionType).toLowerCase()}:${target}:${actor}:${noteHash}`;
|
|
330
587
|
}
|
|
331
588
|
|
|
589
|
+
function sessionReadViewTarget(event = {}) {
|
|
590
|
+
const eventType = normalizeString(event.event || event.type).toLowerCase();
|
|
591
|
+
if (eventType !== "session_message" || isSessionControlEvent(event)) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
const targetSequenceId = eventSequenceNumber(event);
|
|
595
|
+
const targetCursor = normalizeString(event.cursor);
|
|
596
|
+
if (!targetSequenceId && !targetCursor) {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
key: targetSequenceId ? `seq:${targetSequenceId}` : `cursor:${targetCursor}`,
|
|
601
|
+
targetSequenceId: targetSequenceId || null,
|
|
602
|
+
targetCursor,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function recordSessionReadViews(sessionId, events = [], {
|
|
607
|
+
targetPath,
|
|
608
|
+
agentId,
|
|
609
|
+
enabled = false,
|
|
610
|
+
maxTargets = 50,
|
|
611
|
+
} = {}) {
|
|
612
|
+
const maxAutoViewTargets = Math.max(0, Math.min(200, Number(maxTargets) || 0));
|
|
613
|
+
const summary = {
|
|
614
|
+
enabled: Boolean(enabled),
|
|
615
|
+
agentId: normalizeString(agentId) || "cli-user",
|
|
616
|
+
targetCount: 0,
|
|
617
|
+
attempted: 0,
|
|
618
|
+
recorded: 0,
|
|
619
|
+
duplicates: 0,
|
|
620
|
+
failed: 0,
|
|
621
|
+
skipped: 0,
|
|
622
|
+
reason: "",
|
|
623
|
+
};
|
|
624
|
+
if (!summary.enabled) {
|
|
625
|
+
summary.reason = "disabled";
|
|
626
|
+
return summary;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const seenTargets = new Set();
|
|
630
|
+
const targets = [];
|
|
631
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
632
|
+
const target = sessionReadViewTarget(event);
|
|
633
|
+
if (!target || seenTargets.has(target.key)) {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
seenTargets.add(target.key);
|
|
637
|
+
targets.push(target);
|
|
638
|
+
}
|
|
639
|
+
summary.targetCount = targets.length;
|
|
640
|
+
|
|
641
|
+
const writeTargets = maxAutoViewTargets > 0 ? targets.slice(-maxAutoViewTargets) : [];
|
|
642
|
+
summary.skipped = Math.max(0, targets.length - writeTargets.length);
|
|
643
|
+
if (summary.skipped > 0) {
|
|
644
|
+
summary.reason = "target_cap_reached";
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
for (const target of writeTargets) {
|
|
648
|
+
const result = await createSessionMessageAction(sessionId, {
|
|
649
|
+
actionType: "view",
|
|
650
|
+
targetPath,
|
|
651
|
+
targetSequenceId: target.targetSequenceId,
|
|
652
|
+
targetCursor: target.targetCursor,
|
|
653
|
+
metadata: {
|
|
654
|
+
source: "cli_read",
|
|
655
|
+
agentId: summary.agentId,
|
|
656
|
+
},
|
|
657
|
+
idempotencyKey: defaultActionIdempotencyKey({
|
|
658
|
+
actionType: "view",
|
|
659
|
+
targetSequenceId: target.targetSequenceId,
|
|
660
|
+
targetCursor: target.targetCursor,
|
|
661
|
+
agentId: summary.agentId,
|
|
662
|
+
}),
|
|
663
|
+
timeoutMs: 15_000,
|
|
664
|
+
});
|
|
665
|
+
summary.attempted += 1;
|
|
666
|
+
if (result?.ok && result.action) {
|
|
667
|
+
summary.recorded += 1;
|
|
668
|
+
if (result.duplicate) {
|
|
669
|
+
summary.duplicates += 1;
|
|
670
|
+
}
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
summary.failed += 1;
|
|
674
|
+
summary.reason = normalizeString(result?.reason) || "view_write_failed";
|
|
675
|
+
summary.skipped = Math.max(0, targets.length - summary.attempted);
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return summary;
|
|
680
|
+
}
|
|
681
|
+
|
|
332
682
|
function compareIsoDesc(left = "", right = "") {
|
|
333
683
|
return normalizeString(right).localeCompare(normalizeString(left));
|
|
334
684
|
}
|
|
@@ -402,6 +752,10 @@ function parsePositiveInteger(rawValue, field, fallbackValue) {
|
|
|
402
752
|
return Math.floor(normalized);
|
|
403
753
|
}
|
|
404
754
|
|
|
755
|
+
function parsePositiveMillisecondsFromSeconds(rawValue, field, fallbackSeconds) {
|
|
756
|
+
return parsePositiveInteger(rawValue, field, fallbackSeconds) * 1000;
|
|
757
|
+
}
|
|
758
|
+
|
|
405
759
|
function normalizeComparablePath(value) {
|
|
406
760
|
return String(value || "")
|
|
407
761
|
.trim()
|
|
@@ -961,6 +1315,140 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
|
|
|
961
1315
|
return normalized || fallbackValue;
|
|
962
1316
|
}
|
|
963
1317
|
|
|
1318
|
+
function canPublishListenerPresence(agentId) {
|
|
1319
|
+
const normalized = normalizeAgentId(agentId, "");
|
|
1320
|
+
if (!normalized) return false;
|
|
1321
|
+
if (["cli-user", "unknown", "human", "user", "operator"].includes(normalized)) return false;
|
|
1322
|
+
return !(
|
|
1323
|
+
normalized.startsWith("human-") ||
|
|
1324
|
+
normalized.startsWith("user-") ||
|
|
1325
|
+
normalized.startsWith("guest-")
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function listenerLifecycleEventName(type = "") {
|
|
1330
|
+
const normalized = normalizeString(type).toLowerCase();
|
|
1331
|
+
if (normalized === "started") return "session_listener_started";
|
|
1332
|
+
if (normalized === "stopped") return "session_listener_stopped";
|
|
1333
|
+
return "session_listener_heartbeat";
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function compactPayload(record = {}) {
|
|
1337
|
+
return Object.fromEntries(
|
|
1338
|
+
Object.entries(record).filter(([, value]) => value !== undefined)
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async function publishListenerPresenceEvent({
|
|
1343
|
+
sessionId,
|
|
1344
|
+
targetPath,
|
|
1345
|
+
agentId,
|
|
1346
|
+
agentModel = "cli",
|
|
1347
|
+
displayName = "",
|
|
1348
|
+
listenerId,
|
|
1349
|
+
lifecycle = {},
|
|
1350
|
+
} = {}) {
|
|
1351
|
+
const normalizedType = normalizeString(lifecycle.type) || "heartbeat";
|
|
1352
|
+
const eventName = listenerLifecycleEventName(normalizedType);
|
|
1353
|
+
const event = createAgentEvent({
|
|
1354
|
+
event: eventName,
|
|
1355
|
+
sessionId,
|
|
1356
|
+
agent: {
|
|
1357
|
+
id: agentId,
|
|
1358
|
+
model: normalizeString(agentModel) || "cli",
|
|
1359
|
+
role: "listener",
|
|
1360
|
+
displayName: normalizeString(displayName) || agentId,
|
|
1361
|
+
clientKind: "cli",
|
|
1362
|
+
},
|
|
1363
|
+
eventId: `session-listener-${listenerId}-${normalizedType}-${lifecycle.pollCount ?? 0}`,
|
|
1364
|
+
idempotencyToken: `session-listener:${listenerId}:${normalizedType}:${lifecycle.pollCount ?? 0}`,
|
|
1365
|
+
payload: compactPayload({
|
|
1366
|
+
source: "session_listen",
|
|
1367
|
+
listenerId,
|
|
1368
|
+
lifecycle: normalizedType,
|
|
1369
|
+
state: normalizeString(lifecycle.state) || normalizedType,
|
|
1370
|
+
active: lifecycle.active,
|
|
1371
|
+
cursor: lifecycle.cursor || null,
|
|
1372
|
+
cursorSuffix: lifecycle.cursorSuffix,
|
|
1373
|
+
cursorSource: lifecycle.cursorSource,
|
|
1374
|
+
pollCount: lifecycle.pollCount,
|
|
1375
|
+
matched: lifecycle.matched,
|
|
1376
|
+
emitted: lifecycle.emitted,
|
|
1377
|
+
persistedCursor: lifecycle.persistedCursor,
|
|
1378
|
+
idleIntervalSeconds: lifecycle.idleIntervalSeconds,
|
|
1379
|
+
activeIntervalSeconds: lifecycle.activeIntervalSeconds,
|
|
1380
|
+
activeWindowSeconds: lifecycle.activeWindowSeconds,
|
|
1381
|
+
lastHumanActivityAt: lifecycle.lastHumanActivityAt,
|
|
1382
|
+
lastSleepMs: lifecycle.lastSleepMs,
|
|
1383
|
+
nextPollMs: lifecycle.nextPollMs,
|
|
1384
|
+
reason: lifecycle.reason || null,
|
|
1385
|
+
startedAt: lifecycle.startedAt,
|
|
1386
|
+
stoppedAt: lifecycle.stoppedAt,
|
|
1387
|
+
aborted: lifecycle.aborted,
|
|
1388
|
+
stopping: lifecycle.stopping,
|
|
1389
|
+
}),
|
|
1390
|
+
});
|
|
1391
|
+
return syncSessionEventToApi(sessionId, event, { targetPath });
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function formatListenerCatchupNotice(catchup = {}) {
|
|
1395
|
+
const eventCount = Number(catchup.eventCount || 0);
|
|
1396
|
+
const matchingEventCount = Number(catchup.matchingEventCount || 0);
|
|
1397
|
+
const range = catchup.oldestEventAt && catchup.newestEventAt
|
|
1398
|
+
? ` (${catchup.oldestEventAt} -> ${catchup.newestEventAt})`
|
|
1399
|
+
: "";
|
|
1400
|
+
return [
|
|
1401
|
+
`Listener catch-up from stored cursor ${catchup.cursor || "<none>"}:`,
|
|
1402
|
+
`${eventCount} event${eventCount === 1 ? "" : "s"} in this page`,
|
|
1403
|
+
`${matchingEventCount} addressed/broadcast to this agent${range}.`,
|
|
1404
|
+
"Use --from-now only when you intentionally want to skip old backlog.",
|
|
1405
|
+
].join(" ");
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function buildListenerCatchupEvent({
|
|
1409
|
+
sessionId,
|
|
1410
|
+
agentId,
|
|
1411
|
+
agentModel = "cli",
|
|
1412
|
+
displayName = "",
|
|
1413
|
+
listenerId,
|
|
1414
|
+
catchup = {},
|
|
1415
|
+
} = {}) {
|
|
1416
|
+
const message = formatListenerCatchupNotice(catchup);
|
|
1417
|
+
const pollCount = Number(catchup.pollCount || 0);
|
|
1418
|
+
return createAgentEvent({
|
|
1419
|
+
event: "session_listen_catchup",
|
|
1420
|
+
sessionId,
|
|
1421
|
+
agent: {
|
|
1422
|
+
id: agentId,
|
|
1423
|
+
model: normalizeString(agentModel) || "cli",
|
|
1424
|
+
role: "listener",
|
|
1425
|
+
displayName: normalizeString(displayName) || agentId,
|
|
1426
|
+
clientKind: "cli",
|
|
1427
|
+
},
|
|
1428
|
+
eventId: `session-listener-${listenerId}-catchup-${pollCount}`,
|
|
1429
|
+
idempotencyToken: `session-listener:${listenerId}:catchup:${pollCount}`,
|
|
1430
|
+
payload: compactPayload({
|
|
1431
|
+
source: "session_listen",
|
|
1432
|
+
listenerId,
|
|
1433
|
+
lifecycle: "catchup",
|
|
1434
|
+
state: "catching_up",
|
|
1435
|
+
message,
|
|
1436
|
+
cursor: catchup.cursor || null,
|
|
1437
|
+
candidateCursor: catchup.candidateCursor || null,
|
|
1438
|
+
cursorSuffix: catchup.cursorSuffix,
|
|
1439
|
+
cursorSource: catchup.cursorSource,
|
|
1440
|
+
pollCount,
|
|
1441
|
+
eventCount: Number(catchup.eventCount || 0),
|
|
1442
|
+
matchingEventCount: Number(catchup.matchingEventCount || 0),
|
|
1443
|
+
preStartEventCount: Number(catchup.preStartEventCount || 0),
|
|
1444
|
+
limit: Number(catchup.limit || 0) || undefined,
|
|
1445
|
+
replay: Boolean(catchup.replay),
|
|
1446
|
+
oldestEventAt: catchup.oldestEventAt || null,
|
|
1447
|
+
newestEventAt: catchup.newestEventAt || null,
|
|
1448
|
+
}),
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
|
|
964
1452
|
// Preserve the literal default identity for `session say`. This command is
|
|
965
1453
|
// often used by agents as a low-friction relay; silently rewriting the default
|
|
966
1454
|
// `cli-user` to the authenticated human makes a forgotten --agent flag look
|
|
@@ -973,6 +1461,217 @@ async function defaultAgentId(value, _targetPath) {
|
|
|
973
1461
|
return resolveSessionSayAgentId(value);
|
|
974
1462
|
}
|
|
975
1463
|
|
|
1464
|
+
function formatAgentIdList(agentIds = []) {
|
|
1465
|
+
const normalized = agentIds.map((agentId) => normalizeString(agentId)).filter(Boolean);
|
|
1466
|
+
if (!normalized.length) return "";
|
|
1467
|
+
if (normalized.length <= 3) return normalized.join(", ");
|
|
1468
|
+
return `${normalized.slice(0, 3).join(", ")} +${normalized.length - 3} more`;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
export function sessionSayRegistryRole(value) {
|
|
1472
|
+
const normalized = normalizeString(value).toLowerCase();
|
|
1473
|
+
if (["coder", "reviewer", "tester", "daemon", "observer", "persona"].includes(normalized)) {
|
|
1474
|
+
return normalized;
|
|
1475
|
+
}
|
|
1476
|
+
return "coder";
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
export async function resolveSessionSayIdentity({
|
|
1480
|
+
sessionId,
|
|
1481
|
+
agentId = "",
|
|
1482
|
+
targetPath = process.cwd(),
|
|
1483
|
+
env = process.env,
|
|
1484
|
+
} = {}) {
|
|
1485
|
+
const explicitAgentId = normalizeString(agentId);
|
|
1486
|
+
if (explicitAgentId) {
|
|
1487
|
+
return {
|
|
1488
|
+
agentId: resolveSessionSayAgentId(explicitAgentId),
|
|
1489
|
+
source: "option",
|
|
1490
|
+
identityWarning: "",
|
|
1491
|
+
candidateAgentIds: [],
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const envAgentId = normalizeString(env?.SENTINELAYER_AGENT_ID || env?.SENTI_AGENT_ID);
|
|
1496
|
+
if (envAgentId && canPublishListenerPresence(envAgentId)) {
|
|
1497
|
+
return {
|
|
1498
|
+
agentId: resolveSessionSayAgentId(envAgentId),
|
|
1499
|
+
source: "env",
|
|
1500
|
+
identityWarning: "",
|
|
1501
|
+
candidateAgentIds: [],
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
let candidateAgentIds = [];
|
|
1506
|
+
try {
|
|
1507
|
+
const agents = await listAgents(sessionId, { targetPath, includeInactive: false });
|
|
1508
|
+
candidateAgentIds = agents
|
|
1509
|
+
.map((agent) => normalizeString(agent.agentId))
|
|
1510
|
+
.filter((candidate) => canPublishListenerPresence(candidate));
|
|
1511
|
+
} catch {
|
|
1512
|
+
candidateAgentIds = [];
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
if (candidateAgentIds.length === 1) {
|
|
1516
|
+
return {
|
|
1517
|
+
agentId: resolveSessionSayAgentId(candidateAgentIds[0]),
|
|
1518
|
+
source: "local-agent",
|
|
1519
|
+
identityWarning: "",
|
|
1520
|
+
candidateAgentIds,
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const warningReason = candidateAgentIds.length > 1
|
|
1525
|
+
? `multiple local joined agents are active (${formatAgentIdList(candidateAgentIds)})`
|
|
1526
|
+
: envAgentId && !canPublishListenerPresence(envAgentId)
|
|
1527
|
+
? `configured SENTINELAYER_AGENT_ID '${envAgentId}' is reserved or human-scoped`
|
|
1528
|
+
: "no agent identity is configured";
|
|
1529
|
+
|
|
1530
|
+
return {
|
|
1531
|
+
agentId: "cli-user",
|
|
1532
|
+
source: "fallback",
|
|
1533
|
+
identityWarning: `session say is sending as cli-user because ${warningReason}; pass --agent <id>, set SENTINELAYER_AGENT_ID, or run session join --agent <id> before posting.`,
|
|
1534
|
+
candidateAgentIds,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
export function shouldBlockImplicitCliUserSessionSay(identity = {}) {
|
|
1539
|
+
return identity?.source === "fallback" && normalizeString(identity?.agentId) === "cli-user";
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Wake hook for `session listen --wake "<command>"`. This is the reusable
|
|
1544
|
+
* notify->resume bridge: when the listener emits an event addressed to this
|
|
1545
|
+
* agent (or broadcast — including low-noise actions like ack/like), it runs a
|
|
1546
|
+
* host command so the host can resume/wake its agent. The event JSON is piped
|
|
1547
|
+
* to the command's stdin and key fields are exposed as SL_WAKE_* env vars.
|
|
1548
|
+
*
|
|
1549
|
+
* Bursts are coalesced: if a wake is already running, the latest event is
|
|
1550
|
+
* queued and fired once when the current one finishes, so a flood of activity
|
|
1551
|
+
* triggers one trailing wake instead of a storm of processes.
|
|
1552
|
+
*/
|
|
1553
|
+
export function createSessionWakeRunner({
|
|
1554
|
+
command,
|
|
1555
|
+
sessionId,
|
|
1556
|
+
agentId,
|
|
1557
|
+
emit = () => {},
|
|
1558
|
+
spawnImpl = defaultSpawn,
|
|
1559
|
+
} = {}) {
|
|
1560
|
+
const wakeCommand = normalizeString(command);
|
|
1561
|
+
let busy = false;
|
|
1562
|
+
let pending = null;
|
|
1563
|
+
|
|
1564
|
+
const run = (event) => {
|
|
1565
|
+
if (!wakeCommand) return;
|
|
1566
|
+
if (busy) {
|
|
1567
|
+
pending = event ?? {};
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
busy = true;
|
|
1571
|
+
const env = {
|
|
1572
|
+
...process.env,
|
|
1573
|
+
SL_WAKE_SESSION_ID: normalizeString(sessionId),
|
|
1574
|
+
SL_WAKE_AGENT_ID: normalizeString(agentId),
|
|
1575
|
+
SL_WAKE_EVENT_TYPE: normalizeString(event?.event),
|
|
1576
|
+
SL_WAKE_EVENT_CURSOR: normalizeString(event?.cursor),
|
|
1577
|
+
SL_WAKE_EVENT_SEQUENCE: String(event?.sequenceId ?? event?.sequence_id ?? ""),
|
|
1578
|
+
SL_WAKE_ACTOR_ID: normalizeString(event?.agent?.id || event?.agentId),
|
|
1579
|
+
};
|
|
1580
|
+
let child;
|
|
1581
|
+
try {
|
|
1582
|
+
child = spawnImpl(wakeCommand, { shell: true, env, stdio: ["pipe", "ignore", "ignore"] });
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
busy = false;
|
|
1585
|
+
emit({ status: "error", reason: normalizeString(error?.message) || "spawn_failed" });
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
emit({
|
|
1589
|
+
status: "fired",
|
|
1590
|
+
eventType: env.SL_WAKE_EVENT_TYPE,
|
|
1591
|
+
cursor: env.SL_WAKE_EVENT_CURSOR,
|
|
1592
|
+
actorId: env.SL_WAKE_ACTOR_ID,
|
|
1593
|
+
});
|
|
1594
|
+
try {
|
|
1595
|
+
if (child && child.stdin) {
|
|
1596
|
+
child.stdin.write(JSON.stringify(event ?? {}));
|
|
1597
|
+
child.stdin.end();
|
|
1598
|
+
}
|
|
1599
|
+
} catch {
|
|
1600
|
+
// Broken pipe (command ignored stdin) is non-fatal for a wake hook.
|
|
1601
|
+
}
|
|
1602
|
+
const finish = (reason) => {
|
|
1603
|
+
busy = false;
|
|
1604
|
+
if (reason) emit({ status: "error", reason });
|
|
1605
|
+
const next = pending;
|
|
1606
|
+
pending = null;
|
|
1607
|
+
if (next !== null) run(next);
|
|
1608
|
+
};
|
|
1609
|
+
if (child && typeof child.on === "function") {
|
|
1610
|
+
child.on("error", (error) => finish(normalizeString(error?.message) || "wake_failed"));
|
|
1611
|
+
child.on("exit", (code) => finish(code && code !== 0 ? `exit_${code}` : ""));
|
|
1612
|
+
} else {
|
|
1613
|
+
finish("");
|
|
1614
|
+
}
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
return { trigger: run, hasCommand: Boolean(wakeCommand) };
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// Message actions (ack/like/dislike/reply/view/working_on) must be authored by
|
|
1621
|
+
// a concrete agent identity. The CLI's bare `cli-user` default is a reserved
|
|
1622
|
+
// label the API rejects (api_422), so treat it as "unset" and resolve the real
|
|
1623
|
+
// agent the same way `session say` does (explicit --agent > SENTINELAYER_AGENT_ID
|
|
1624
|
+
// > the single joined agent). Returns the resolved identity; callers should use
|
|
1625
|
+
// shouldBlockImplicitCliUserSessionSay() to refuse the implicit cli-user
|
|
1626
|
+
// fallback before sending a request that is guaranteed to fail.
|
|
1627
|
+
export async function resolveMessageActionIdentity({
|
|
1628
|
+
sessionId,
|
|
1629
|
+
optionAgent = "",
|
|
1630
|
+
targetPath = process.cwd(),
|
|
1631
|
+
env = process.env,
|
|
1632
|
+
} = {}) {
|
|
1633
|
+
const explicit = normalizeString(optionAgent);
|
|
1634
|
+
const agentSeed = explicit && explicit.toLowerCase() !== "cli-user" ? explicit : "";
|
|
1635
|
+
return resolveSessionSayIdentity({ sessionId, agentId: agentSeed, targetPath, env });
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
async function ensureSessionSayAgentRegistered(
|
|
1639
|
+
sessionId,
|
|
1640
|
+
agent = {},
|
|
1641
|
+
{ targetPath = process.cwd() } = {},
|
|
1642
|
+
) {
|
|
1643
|
+
const agentId = normalizeString(agent.id);
|
|
1644
|
+
if (!canPublishListenerPresence(agentId)) {
|
|
1645
|
+
return { persisted: false, reason: "placeholder_agent" };
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
try {
|
|
1649
|
+
const activeAgents = await listAgents(sessionId, { targetPath, includeInactive: false });
|
|
1650
|
+
if (
|
|
1651
|
+
activeAgents.some(
|
|
1652
|
+
(existing) => normalizeString(existing.agentId).toLowerCase() === agentId.toLowerCase(),
|
|
1653
|
+
)
|
|
1654
|
+
) {
|
|
1655
|
+
return { persisted: false, reason: "already_registered" };
|
|
1656
|
+
}
|
|
1657
|
+
} catch {
|
|
1658
|
+
// If the local registry is unreadable, let rememberAgentIdentity surface the
|
|
1659
|
+
// filesystem problem with its normal error message.
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const registered = await rememberAgentIdentity(sessionId, {
|
|
1663
|
+
agentId,
|
|
1664
|
+
model: normalizeString(agent.model) || "cli",
|
|
1665
|
+
role: sessionSayRegistryRole(agent.role),
|
|
1666
|
+
targetPath,
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
return {
|
|
1670
|
+
persisted: true,
|
|
1671
|
+
agentId: registered.agentId,
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
|
|
976
1675
|
async function resolveSessionAgentEnvelope(
|
|
977
1676
|
sessionId,
|
|
978
1677
|
agentId,
|
|
@@ -1062,6 +1761,13 @@ function formatEventLine(event = {}) {
|
|
|
1062
1761
|
return `${ts} ${agentId} ${type}`;
|
|
1063
1762
|
}
|
|
1064
1763
|
|
|
1764
|
+
function isSessionControlEvent(event = {}) {
|
|
1765
|
+
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
|
|
1766
|
+
const type = normalizeString(event.event || event.type).toLowerCase();
|
|
1767
|
+
if (type.startsWith("session_listener_")) return true;
|
|
1768
|
+
return normalizeString(payload.source).toLowerCase() === "session_listen";
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1065
1771
|
function checkpointSequenceRange(checkpoint = {}) {
|
|
1066
1772
|
const start = Number(checkpoint.startSequence || 0);
|
|
1067
1773
|
const end = Number(checkpoint.endSequence || 0);
|
|
@@ -1926,6 +2632,7 @@ export function registerSessionCommand(program) {
|
|
|
1926
2632
|
awaitRemoteSync: Boolean(explicitAgent),
|
|
1927
2633
|
});
|
|
1928
2634
|
const agentJoinRelayed =
|
|
2635
|
+
joined.emittedJoinEvent !== false &&
|
|
1929
2636
|
Boolean(explicitAgent) &&
|
|
1930
2637
|
Boolean(resolvedAgentId) &&
|
|
1931
2638
|
resolvedAgentId !== "cli-user" &&
|
|
@@ -1980,7 +2687,10 @@ export function registerSessionCommand(program) {
|
|
|
1980
2687
|
session
|
|
1981
2688
|
.command("say <sessionId> <message>")
|
|
1982
2689
|
.description("Send a message to the session")
|
|
1983
|
-
.option(
|
|
2690
|
+
.option(
|
|
2691
|
+
"--agent <id>",
|
|
2692
|
+
"Agent id to emit from; defaults to SENTINELAYER_AGENT_ID, then the sole local joined agent, then cli-user",
|
|
2693
|
+
)
|
|
1984
2694
|
.option(
|
|
1985
2695
|
"--model <model>",
|
|
1986
2696
|
"Agent model/provider hint; defaults to local joined agent metadata or SENTINELAYER_AGENT_MODEL",
|
|
@@ -1999,6 +2709,8 @@ export function registerSessionCommand(program) {
|
|
|
1999
2709
|
.option("--to <agent>", "Direct the message to a specific agent id")
|
|
2000
2710
|
.option("--reply-to <sequence>", "Mark this message as a reply to a target sequence id")
|
|
2001
2711
|
.option("--reply-cursor <cursor>", "Mark this message as a reply to a target event cursor")
|
|
2712
|
+
.option("--force-cli-user", "Allow fallback sends as cli-user when no agent identity can be resolved")
|
|
2713
|
+
.option("--local-only", "Append only to the local session cache without remote send confirmation")
|
|
2002
2714
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
2003
2715
|
.option("--json", "Emit machine-readable output")
|
|
2004
2716
|
.action(async (sessionId, message, options, command) => {
|
|
@@ -2011,16 +2723,28 @@ export function registerSessionCommand(program) {
|
|
|
2011
2723
|
throw new Error("message is required.");
|
|
2012
2724
|
}
|
|
2013
2725
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
2014
|
-
const
|
|
2726
|
+
const identity = await resolveSessionSayIdentity({
|
|
2727
|
+
sessionId: normalizedSessionId,
|
|
2728
|
+
agentId: options.agent,
|
|
2729
|
+
targetPath,
|
|
2730
|
+
});
|
|
2731
|
+
const agentId = identity.agentId;
|
|
2732
|
+
if (shouldBlockImplicitCliUserSessionSay(identity) && !options.forceCliUser) {
|
|
2733
|
+
throw new Error(
|
|
2734
|
+
`${identity.identityWarning} Re-run with --force-cli-user only for intentional anonymous/operator relay posts.`,
|
|
2735
|
+
);
|
|
2736
|
+
}
|
|
2015
2737
|
const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
|
|
2016
2738
|
targetPath,
|
|
2017
2739
|
});
|
|
2018
2740
|
const to = normalizeString(options.to);
|
|
2019
2741
|
const replyToSequenceId = parseOptionalPositiveInteger(options.replyTo, "reply-to");
|
|
2020
2742
|
const replyToCursor = normalizeString(options.replyCursor);
|
|
2743
|
+
const clientMessageId = `cli-${randomUUID()}`;
|
|
2021
2744
|
const eventPayload = {
|
|
2022
2745
|
message: normalizedMessage,
|
|
2023
2746
|
channel: "session",
|
|
2747
|
+
clientMessageId,
|
|
2024
2748
|
};
|
|
2025
2749
|
if (to) {
|
|
2026
2750
|
eventPayload.to = to;
|
|
@@ -2031,13 +2755,15 @@ export function registerSessionCommand(program) {
|
|
|
2031
2755
|
if (replyToCursor) {
|
|
2032
2756
|
eventPayload.replyToCursor = replyToCursor;
|
|
2033
2757
|
}
|
|
2034
|
-
const clientMessageId = `cli-${randomUUID()}`;
|
|
2035
2758
|
const agent = await resolveSessionAgentEnvelope(normalizedSessionId, agentId, {
|
|
2036
2759
|
targetPath,
|
|
2037
2760
|
model: options.model,
|
|
2038
2761
|
role: options.role,
|
|
2039
2762
|
displayName: options.displayName,
|
|
2040
2763
|
});
|
|
2764
|
+
const agentRegistration = await ensureSessionSayAgentRegistered(normalizedSessionId, agent, {
|
|
2765
|
+
targetPath,
|
|
2766
|
+
});
|
|
2041
2767
|
const event = createAgentEvent({
|
|
2042
2768
|
event: "session_message",
|
|
2043
2769
|
agent,
|
|
@@ -2047,7 +2773,22 @@ export function registerSessionCommand(program) {
|
|
|
2047
2773
|
event.eventId = clientMessageId;
|
|
2048
2774
|
event.idempotencyToken = clientMessageId;
|
|
2049
2775
|
let remoteSync = null;
|
|
2050
|
-
|
|
2776
|
+
let remoteConfirmation = null;
|
|
2777
|
+
let remoteConfirmationAnchor = null;
|
|
2778
|
+
let localOnly = Boolean(options.localOnly) || remoteSessionLookupDisabled();
|
|
2779
|
+
if (!localOnly) {
|
|
2780
|
+
remoteConfirmationAnchor = await readSessionConfirmationAnchor(normalizedSessionId, { targetPath });
|
|
2781
|
+
if (!remoteConfirmationAnchor?.ok) {
|
|
2782
|
+
if (!localSession.materialized && isLocalOnlySessionSayReason(remoteConfirmationAnchor?.reason)) {
|
|
2783
|
+
localOnly = true;
|
|
2784
|
+
} else {
|
|
2785
|
+
throw new Error(
|
|
2786
|
+
`Remote send confirmation anchor failed (${remoteConfirmationAnchor?.reason || "unknown"}); local cache was not updated. Use --local-only only when you intentionally want an offline local note.`,
|
|
2787
|
+
);
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
if (!localOnly) {
|
|
2051
2792
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2052
2793
|
remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
|
|
2053
2794
|
targetPath,
|
|
@@ -2056,13 +2797,23 @@ export function registerSessionCommand(program) {
|
|
|
2056
2797
|
}
|
|
2057
2798
|
if (!remoteSync?.synced) {
|
|
2058
2799
|
throw new Error(
|
|
2059
|
-
`Remote send failed (${remoteSync?.reason || "unknown"}); local cache was not updated.`,
|
|
2800
|
+
`Remote send failed (${remoteSync?.reason || "unknown"}); local cache was not updated. Use --local-only only when you intentionally want an offline local note.`,
|
|
2060
2801
|
);
|
|
2802
|
+
} else {
|
|
2803
|
+
remoteConfirmation = await confirmSessionEventVisible(normalizedSessionId, clientMessageId, {
|
|
2804
|
+
targetPath,
|
|
2805
|
+
anchorCursor: remoteConfirmationAnchor?.cursor,
|
|
2806
|
+
});
|
|
2807
|
+
if (!remoteConfirmation?.confirmed) {
|
|
2808
|
+
throw new Error(
|
|
2809
|
+
`Remote send was accepted but not visible in canonical session events (${remoteConfirmation?.reason || "not_visible"}); local cache was not updated.`,
|
|
2810
|
+
);
|
|
2811
|
+
}
|
|
2061
2812
|
}
|
|
2062
2813
|
}
|
|
2063
2814
|
const persisted = await appendToStream(normalizedSessionId, event, {
|
|
2064
2815
|
targetPath,
|
|
2065
|
-
syncRemote:
|
|
2816
|
+
syncRemote: false,
|
|
2066
2817
|
});
|
|
2067
2818
|
const payload = {
|
|
2068
2819
|
command: "session say",
|
|
@@ -2072,12 +2823,21 @@ export function registerSessionCommand(program) {
|
|
|
2072
2823
|
event: persisted,
|
|
2073
2824
|
materializedLocalSession: localSession.materialized,
|
|
2074
2825
|
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
2826
|
+
identitySource: identity.source,
|
|
2827
|
+
identityWarning: identity.identityWarning || undefined,
|
|
2828
|
+
agentRegistration,
|
|
2075
2829
|
remoteSync: remoteSync || undefined,
|
|
2830
|
+
remoteConfirmationAnchor: remoteConfirmationAnchor || undefined,
|
|
2831
|
+
remoteConfirmation: remoteConfirmation || undefined,
|
|
2832
|
+
localOnly,
|
|
2076
2833
|
};
|
|
2077
2834
|
if (shouldEmitJson(options, command)) {
|
|
2078
2835
|
console.log(JSON.stringify(payload, null, 2));
|
|
2079
2836
|
return;
|
|
2080
2837
|
}
|
|
2838
|
+
if (identity.identityWarning) {
|
|
2839
|
+
console.error(pc.yellow(`Identity warning: ${identity.identityWarning}`));
|
|
2840
|
+
}
|
|
2081
2841
|
console.log(formatEventLine(persisted));
|
|
2082
2842
|
});
|
|
2083
2843
|
|
|
@@ -2109,11 +2869,13 @@ export function registerSessionCommand(program) {
|
|
|
2109
2869
|
targetPath,
|
|
2110
2870
|
});
|
|
2111
2871
|
const to = normalizeString(options.to);
|
|
2872
|
+
const clientMessageId = `cli-agent-${randomUUID()}`;
|
|
2112
2873
|
const eventPayload = {
|
|
2113
2874
|
message: normalizedMessage,
|
|
2114
2875
|
channel: "session",
|
|
2115
2876
|
source: "agent",
|
|
2116
2877
|
clientKind: "cli",
|
|
2878
|
+
clientMessageId,
|
|
2117
2879
|
};
|
|
2118
2880
|
if (to) {
|
|
2119
2881
|
eventPayload.to = to;
|
|
@@ -2125,7 +2887,6 @@ export function registerSessionCommand(program) {
|
|
|
2125
2887
|
role: normalizeString(options.role) || "coder",
|
|
2126
2888
|
clientKind: "cli",
|
|
2127
2889
|
};
|
|
2128
|
-
const clientMessageId = `cli-agent-${randomUUID()}`;
|
|
2129
2890
|
const event = createAgentEvent({
|
|
2130
2891
|
event: "session_message",
|
|
2131
2892
|
agent,
|
|
@@ -2135,6 +2896,19 @@ export function registerSessionCommand(program) {
|
|
|
2135
2896
|
event.eventId = clientMessageId;
|
|
2136
2897
|
event.idempotencyToken = clientMessageId;
|
|
2137
2898
|
|
|
2899
|
+
const remoteConfirmationAnchor = await readSessionConfirmationAnchor(normalizedSessionId, {
|
|
2900
|
+
targetPath,
|
|
2901
|
+
});
|
|
2902
|
+
if (!remoteConfirmationAnchor?.ok) {
|
|
2903
|
+
if (remoteConfirmationAnchor?.reason === "api_403") {
|
|
2904
|
+
throw new Error(
|
|
2905
|
+
`Agent post failed (api_403). Ensure this user has an active grant for '${agentId}'.`,
|
|
2906
|
+
);
|
|
2907
|
+
}
|
|
2908
|
+
throw new Error(
|
|
2909
|
+
`Agent post confirmation anchor failed (${remoteConfirmationAnchor?.reason || "unknown"}); local cache was not updated.`,
|
|
2910
|
+
);
|
|
2911
|
+
}
|
|
2138
2912
|
const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
|
|
2139
2913
|
targetPath,
|
|
2140
2914
|
});
|
|
@@ -2143,6 +2917,15 @@ export function registerSessionCommand(program) {
|
|
|
2143
2917
|
`Agent post failed (${remoteSync?.reason || "unknown"}). Ensure this user has an active grant for '${agentId}'.`,
|
|
2144
2918
|
);
|
|
2145
2919
|
}
|
|
2920
|
+
const remoteConfirmation = await confirmSessionEventVisible(normalizedSessionId, clientMessageId, {
|
|
2921
|
+
targetPath,
|
|
2922
|
+
anchorCursor: remoteConfirmationAnchor?.cursor,
|
|
2923
|
+
});
|
|
2924
|
+
if (!remoteConfirmation?.confirmed) {
|
|
2925
|
+
throw new Error(
|
|
2926
|
+
`Agent post was accepted but not visible in canonical session events (${remoteConfirmation?.reason || "not_visible"}); local cache was not updated.`,
|
|
2927
|
+
);
|
|
2928
|
+
}
|
|
2146
2929
|
|
|
2147
2930
|
const persisted = await appendToStream(normalizedSessionId, event, {
|
|
2148
2931
|
targetPath,
|
|
@@ -2157,6 +2940,8 @@ export function registerSessionCommand(program) {
|
|
|
2157
2940
|
materializedLocalSession: localSession.materialized,
|
|
2158
2941
|
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
2159
2942
|
remoteSync,
|
|
2943
|
+
remoteConfirmationAnchor,
|
|
2944
|
+
remoteConfirmation,
|
|
2160
2945
|
};
|
|
2161
2946
|
if (shouldEmitJson(options, command)) {
|
|
2162
2947
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -2192,7 +2977,23 @@ export function registerSessionCommand(program) {
|
|
|
2192
2977
|
}
|
|
2193
2978
|
await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
|
|
2194
2979
|
const note = normalizeString(noteOverride) || normalizeString(options.note);
|
|
2195
|
-
|
|
2980
|
+
// Resolve the authoring agent. The bare `cli-user` default is rejected by
|
|
2981
|
+
// the API (api_422); resolveMessageActionIdentity treats it as unset and
|
|
2982
|
+
// falls back to the joined agent. If no concrete identity resolves, fail
|
|
2983
|
+
// with actionable guidance instead of firing a request guaranteed to 422.
|
|
2984
|
+
const identity = await resolveMessageActionIdentity({
|
|
2985
|
+
sessionId: normalizedSessionId,
|
|
2986
|
+
optionAgent: options.agent,
|
|
2987
|
+
targetPath,
|
|
2988
|
+
env: process.env,
|
|
2989
|
+
});
|
|
2990
|
+
if (shouldBlockImplicitCliUserSessionSay(identity)) {
|
|
2991
|
+
throw new Error(
|
|
2992
|
+
identity.identityWarning ||
|
|
2993
|
+
`${commandName} requires an agent identity; pass --agent <id>, set SENTINELAYER_AGENT_ID, or run session join --agent <id> first.`,
|
|
2994
|
+
);
|
|
2995
|
+
}
|
|
2996
|
+
const agentId = identity.agentId;
|
|
2196
2997
|
const idempotencyKey =
|
|
2197
2998
|
normalizeString(options.idempotencyKey) ||
|
|
2198
2999
|
defaultActionIdempotencyKey({
|
|
@@ -2242,7 +3043,12 @@ export function registerSessionCommand(program) {
|
|
|
2242
3043
|
console.log(JSON.stringify(payload, null, 2));
|
|
2243
3044
|
return payload;
|
|
2244
3045
|
}
|
|
2245
|
-
|
|
3046
|
+
if (localAppend.event || actionEvent) {
|
|
3047
|
+
console.log(formatEventLine(localAppend.event || actionEvent));
|
|
3048
|
+
} else {
|
|
3049
|
+
const targetLabel = targetSequenceId ? `#${targetSequenceId}` : targetCursor || targetActionId || "target";
|
|
3050
|
+
console.log(pc.green(`Recorded ${normalizedActionType} on ${targetLabel}.`));
|
|
3051
|
+
}
|
|
2246
3052
|
return payload;
|
|
2247
3053
|
}
|
|
2248
3054
|
|
|
@@ -2278,7 +3084,7 @@ export function registerSessionCommand(program) {
|
|
|
2278
3084
|
.option("--target-cursor <cursor>", "Target event cursor")
|
|
2279
3085
|
.option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
|
|
2280
3086
|
.option("--note <text>", "Optional action note or reply body")
|
|
2281
|
-
.option("--agent <id>", "Agent id
|
|
3087
|
+
.option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
|
|
2282
3088
|
.option("--idempotency-key <key>", "Explicit idempotency key")
|
|
2283
3089
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
2284
3090
|
.option("--json", "Emit machine-readable output")
|
|
@@ -2292,7 +3098,7 @@ export function registerSessionCommand(program) {
|
|
|
2292
3098
|
.option("--target-sequence <n>", "Target event sequence id")
|
|
2293
3099
|
.option("--target-cursor <cursor>", "Target event cursor")
|
|
2294
3100
|
.option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
|
|
2295
|
-
.option("--agent <id>", "Agent id
|
|
3101
|
+
.option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
|
|
2296
3102
|
.option("--idempotency-key <key>", "Explicit idempotency key")
|
|
2297
3103
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
2298
3104
|
.option("--json", "Emit machine-readable output")
|
|
@@ -2313,7 +3119,7 @@ export function registerSessionCommand(program) {
|
|
|
2313
3119
|
session
|
|
2314
3120
|
.command("reply <sessionId> <targetSequenceId> <message...>")
|
|
2315
3121
|
.description("Reply to a target session event using the message-action channel")
|
|
2316
|
-
.option("--agent <id>", "Agent id
|
|
3122
|
+
.option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
|
|
2317
3123
|
.option("--idempotency-key <key>", "Explicit idempotency key")
|
|
2318
3124
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
2319
3125
|
.option("--json", "Emit machine-readable output")
|
|
@@ -2333,7 +3139,7 @@ export function registerSessionCommand(program) {
|
|
|
2333
3139
|
session
|
|
2334
3140
|
.command("comment <sessionId> <targetSequenceId> <message...>")
|
|
2335
3141
|
.description("Alias for `session reply`; add a threaded comment to a target event")
|
|
2336
|
-
.option("--agent <id>", "Agent id
|
|
3142
|
+
.option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
|
|
2337
3143
|
.option("--idempotency-key <key>", "Explicit idempotency key")
|
|
2338
3144
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
2339
3145
|
.option("--json", "Emit machine-readable output")
|
|
@@ -2352,8 +3158,8 @@ export function registerSessionCommand(program) {
|
|
|
2352
3158
|
|
|
2353
3159
|
session
|
|
2354
3160
|
.command("view <sessionId> <targetSequenceId>")
|
|
2355
|
-
.description("
|
|
2356
|
-
.option("--agent <id>", "Agent id
|
|
3161
|
+
.description("Manually backfill a read receipt for a target session event")
|
|
3162
|
+
.option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
|
|
2357
3163
|
.option("--idempotency-key <key>", "Explicit idempotency key")
|
|
2358
3164
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
2359
3165
|
.option("--json", "Emit machine-readable output")
|
|
@@ -2368,6 +3174,57 @@ export function registerSessionCommand(program) {
|
|
|
2368
3174
|
});
|
|
2369
3175
|
});
|
|
2370
3176
|
|
|
3177
|
+
session
|
|
3178
|
+
.command("pins <sessionId>")
|
|
3179
|
+
.description("List the session's pinned messages with their content so agents can read them")
|
|
3180
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
3181
|
+
.option("--json", "Emit machine-readable output")
|
|
3182
|
+
.action(async (sessionId, options, command) => {
|
|
3183
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
3184
|
+
if (!normalizedSessionId) {
|
|
3185
|
+
throw new Error("session id is required.");
|
|
3186
|
+
}
|
|
3187
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
3188
|
+
await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
|
|
3189
|
+
const result = await fetchSessionPinnedMessages(normalizedSessionId, { targetPath });
|
|
3190
|
+
if (!result.ok) {
|
|
3191
|
+
throw new Error(`Could not load pinned messages (${result.reason || "unknown"}).`);
|
|
3192
|
+
}
|
|
3193
|
+
const pinLimit = result.pinLimit || 10;
|
|
3194
|
+
const payload = {
|
|
3195
|
+
command: "session pins",
|
|
3196
|
+
sessionId: normalizedSessionId,
|
|
3197
|
+
pinLimit,
|
|
3198
|
+
count: result.count,
|
|
3199
|
+
pins: result.pins,
|
|
3200
|
+
};
|
|
3201
|
+
if (shouldEmitJson(options, command)) {
|
|
3202
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3203
|
+
return payload;
|
|
3204
|
+
}
|
|
3205
|
+
if (!result.count) {
|
|
3206
|
+
console.log(pc.gray("No pinned messages in this session."));
|
|
3207
|
+
return payload;
|
|
3208
|
+
}
|
|
3209
|
+
console.log(pc.bold(`📌 Pinned messages (${result.count}/${pinLimit})`));
|
|
3210
|
+
for (const pin of result.pins) {
|
|
3211
|
+
const seqLabel = pin.targetSequenceId ? `#${pin.targetSequenceId}` : "(unknown sequence)";
|
|
3212
|
+
const author = pin.author || "unknown";
|
|
3213
|
+
const pinnedBy = pin.pinnedBy ? ` · pinned by ${pin.pinnedBy}` : "";
|
|
3214
|
+
const when = pin.pinnedAt ? ` · ${pin.pinnedAt}` : "";
|
|
3215
|
+
console.log("");
|
|
3216
|
+
console.log(pc.cyan(`${seqLabel} ${author}${pinnedBy}${when}`));
|
|
3217
|
+
if (pin.content) {
|
|
3218
|
+
for (const line of String(pin.content).split("\n")) {
|
|
3219
|
+
console.log(` ${line}`);
|
|
3220
|
+
}
|
|
3221
|
+
} else {
|
|
3222
|
+
console.log(pc.gray(" (no readable text content for this pinned event)"));
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
return payload;
|
|
3226
|
+
});
|
|
3227
|
+
|
|
2371
3228
|
session
|
|
2372
3229
|
.command("listen")
|
|
2373
3230
|
.description("Background-poll a session for events addressed to this agent or broadcast")
|
|
@@ -2388,12 +3245,33 @@ export function registerSessionCommand(program) {
|
|
|
2388
3245
|
"Seconds after a human message to keep the active interval (default 300)",
|
|
2389
3246
|
"300",
|
|
2390
3247
|
)
|
|
3248
|
+
.option(
|
|
3249
|
+
"--presence-interval <seconds>",
|
|
3250
|
+
"Minimum seconds between remote listener heartbeat events (default 60)",
|
|
3251
|
+
"60",
|
|
3252
|
+
)
|
|
3253
|
+
.option(
|
|
3254
|
+
"--no-presence",
|
|
3255
|
+
"Do not publish durable listener lifecycle/heartbeat events",
|
|
3256
|
+
)
|
|
3257
|
+
.option(
|
|
3258
|
+
"--model <model>",
|
|
3259
|
+
"Model/provider label to publish with listener presence",
|
|
3260
|
+
process.env.SENTINELAYER_AGENT_MODEL || process.env.SENTINELAYER_MODEL || "cli",
|
|
3261
|
+
)
|
|
3262
|
+
.option("--display-name <name>", "Human-readable listener name for presence")
|
|
2391
3263
|
.option("--emit <format>", "Output format: ndjson or text", "ndjson")
|
|
3264
|
+
.option("--transport <mode>", "Listen transport: auto, stream, or poll (default auto)", "auto")
|
|
2392
3265
|
.option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
|
|
2393
3266
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
2394
3267
|
.option("--since <cursor>", "Override the persisted listen cursor")
|
|
3268
|
+
.option("--from-now", "Advance the listen cursor to the latest durable event before polling")
|
|
2395
3269
|
.option("--replay", "Emit matching historical events on the first poll")
|
|
2396
3270
|
.option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
|
|
3271
|
+
.option(
|
|
3272
|
+
"--wake <command>",
|
|
3273
|
+
"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
|
+
)
|
|
2397
3275
|
.action(async (options) => {
|
|
2398
3276
|
const normalizedSessionId = resolveSessionIdOption(options);
|
|
2399
3277
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
@@ -2405,26 +3283,90 @@ export function registerSessionCommand(program) {
|
|
|
2405
3283
|
5,
|
|
2406
3284
|
);
|
|
2407
3285
|
const activeWindowSeconds = parsePositiveInteger(options.activeWindow, "active-window", 300);
|
|
3286
|
+
const presenceIntervalSeconds = parsePositiveInteger(
|
|
3287
|
+
options.presenceInterval,
|
|
3288
|
+
"presence-interval",
|
|
3289
|
+
60,
|
|
3290
|
+
);
|
|
3291
|
+
const agentModel = normalizeString(options.model) || "cli";
|
|
3292
|
+
const displayName = normalizeString(options.displayName) || agentId;
|
|
2408
3293
|
const limit = parsePositiveInteger(options.limit, "limit", 200);
|
|
2409
3294
|
const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
|
|
2410
3295
|
if (!["ndjson", "text"].includes(emitFormat)) {
|
|
2411
3296
|
throw new Error("--emit must be one of: ndjson, text.");
|
|
2412
3297
|
}
|
|
3298
|
+
// Optional wake hook: run a host command on each matched event so the
|
|
3299
|
+
// host can resume/wake its agent (the notify->resume bridge).
|
|
3300
|
+
const emitWakeNotice = (payload = {}) => {
|
|
3301
|
+
if (emitFormat === "ndjson") {
|
|
3302
|
+
console.log(
|
|
3303
|
+
JSON.stringify(
|
|
3304
|
+
createAgentEvent({
|
|
3305
|
+
event: "session_wake_hook",
|
|
3306
|
+
agentId,
|
|
3307
|
+
sessionId: normalizedSessionId,
|
|
3308
|
+
payload,
|
|
3309
|
+
}),
|
|
3310
|
+
),
|
|
3311
|
+
);
|
|
3312
|
+
} else {
|
|
3313
|
+
const status = normalizeString(payload.status) || "fired";
|
|
3314
|
+
const detail = payload.reason ? ` (${payload.reason})` : payload.eventType ? ` ${payload.eventType}` : "";
|
|
3315
|
+
console.log(pc.cyan(`wake hook ${status}${detail}`));
|
|
3316
|
+
}
|
|
3317
|
+
};
|
|
3318
|
+
const wakeRunner = createSessionWakeRunner({
|
|
3319
|
+
command: options.wake,
|
|
3320
|
+
sessionId: normalizedSessionId,
|
|
3321
|
+
agentId,
|
|
3322
|
+
emit: emitWakeNotice,
|
|
3323
|
+
});
|
|
3324
|
+
const requestedTransport = normalizeString(options.transport).toLowerCase() || "auto";
|
|
3325
|
+
if (!["auto", "stream", "poll"].includes(requestedTransport)) {
|
|
3326
|
+
throw new Error("--transport must be one of: auto, stream, poll.");
|
|
3327
|
+
}
|
|
2413
3328
|
const maxPolls =
|
|
2414
3329
|
options.maxPolls === undefined
|
|
2415
3330
|
? null
|
|
2416
3331
|
: parsePositiveInteger(options.maxPolls, "max-polls", 1);
|
|
3332
|
+
const listenTransport = requestedTransport === "auto" && maxPolls !== null
|
|
3333
|
+
? "poll"
|
|
3334
|
+
: requestedTransport;
|
|
2417
3335
|
const since = options.since === undefined ? undefined : String(options.since);
|
|
3336
|
+
if (options.fromNow && options.since !== undefined) {
|
|
3337
|
+
throw new Error("Use either --from-now or --since, not both.");
|
|
3338
|
+
}
|
|
2418
3339
|
const ac = new AbortController();
|
|
2419
3340
|
const onSigint = () => ac.abort();
|
|
2420
3341
|
process.on("SIGINT", onSigint);
|
|
3342
|
+
const listenerId = `listener-${agentId}-${randomUUID()}`;
|
|
3343
|
+
const durablePresenceEnabled = options.presence !== false;
|
|
3344
|
+
const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
|
|
3345
|
+
const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
|
|
3346
|
+
let lastPresenceHeartbeatMs = 0;
|
|
2421
3347
|
|
|
2422
3348
|
if (emitFormat === "text") {
|
|
2423
3349
|
console.log(
|
|
2424
3350
|
pc.gray(
|
|
2425
|
-
`Listening to session ${normalizedSessionId} as ${agentId}; idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
|
|
3351
|
+
`Listening to session ${normalizedSessionId} as ${agentId}; transport=${listenTransport} idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
|
|
2426
3352
|
),
|
|
2427
3353
|
);
|
|
3354
|
+
if (!durablePresenceEnabled) {
|
|
3355
|
+
console.log(
|
|
3356
|
+
pc.gray(
|
|
3357
|
+
"Remote listener presence is disabled; no durable lifecycle events will be written.",
|
|
3358
|
+
),
|
|
3359
|
+
);
|
|
3360
|
+
} else if (!publishPresence) {
|
|
3361
|
+
console.log(
|
|
3362
|
+
pc.gray(
|
|
3363
|
+
"Listener presence is local-only for placeholder, human, and guest agent ids.",
|
|
3364
|
+
),
|
|
3365
|
+
);
|
|
3366
|
+
}
|
|
3367
|
+
if (options.fromNow) {
|
|
3368
|
+
console.log(pc.gray("Priming listener from the latest durable event; old backlog will be skipped."));
|
|
3369
|
+
}
|
|
2428
3370
|
}
|
|
2429
3371
|
|
|
2430
3372
|
try {
|
|
@@ -2438,14 +3380,38 @@ export function registerSessionCommand(program) {
|
|
|
2438
3380
|
limit,
|
|
2439
3381
|
since,
|
|
2440
3382
|
replay: Boolean(options.replay),
|
|
3383
|
+
fromNow: Boolean(options.fromNow),
|
|
3384
|
+
persistStartCursor: Boolean(options.fromNow),
|
|
3385
|
+
transport: listenTransport,
|
|
2441
3386
|
maxPolls,
|
|
2442
3387
|
signal: ac.signal,
|
|
3388
|
+
onCatchup: async (catchup) => {
|
|
3389
|
+
if (emitFormat === "ndjson") {
|
|
3390
|
+
console.log(
|
|
3391
|
+
JSON.stringify(
|
|
3392
|
+
buildListenerCatchupEvent({
|
|
3393
|
+
sessionId: normalizedSessionId,
|
|
3394
|
+
agentId,
|
|
3395
|
+
agentModel,
|
|
3396
|
+
displayName,
|
|
3397
|
+
listenerId,
|
|
3398
|
+
catchup,
|
|
3399
|
+
}),
|
|
3400
|
+
),
|
|
3401
|
+
);
|
|
3402
|
+
} else {
|
|
3403
|
+
console.log(pc.yellow(formatListenerCatchupNotice(catchup)));
|
|
3404
|
+
}
|
|
3405
|
+
},
|
|
2443
3406
|
onEvent: async (event) => {
|
|
2444
3407
|
if (emitFormat === "ndjson") {
|
|
2445
3408
|
console.log(JSON.stringify(event));
|
|
2446
3409
|
} else {
|
|
2447
3410
|
console.log(formatEventLine(event));
|
|
2448
3411
|
}
|
|
3412
|
+
// Fire the wake hook for any matched event (incl. ack/like) so the
|
|
3413
|
+
// host can resume its agent.
|
|
3414
|
+
wakeRunner.trigger(event);
|
|
2449
3415
|
},
|
|
2450
3416
|
onError: async (result) => {
|
|
2451
3417
|
const reason = normalizeString(result?.reason) || "poll_failed";
|
|
@@ -2467,12 +3433,394 @@ export function registerSessionCommand(program) {
|
|
|
2467
3433
|
console.log(pc.yellow(`Listen poll skipped (${reason}).`));
|
|
2468
3434
|
}
|
|
2469
3435
|
},
|
|
3436
|
+
onLifecycle: async (lifecycle) => {
|
|
3437
|
+
if (!publishPresence) return;
|
|
3438
|
+
const lifecycleType = normalizeString(lifecycle?.type);
|
|
3439
|
+
if (lifecycleType === "heartbeat") {
|
|
3440
|
+
const nowMs = Date.now();
|
|
3441
|
+
if (lastPresenceHeartbeatMs > 0 && nowMs - lastPresenceHeartbeatMs < presenceIntervalMs) {
|
|
3442
|
+
return;
|
|
3443
|
+
}
|
|
3444
|
+
lastPresenceHeartbeatMs = nowMs;
|
|
3445
|
+
}
|
|
3446
|
+
await publishListenerPresenceEvent({
|
|
3447
|
+
sessionId: normalizedSessionId,
|
|
3448
|
+
targetPath,
|
|
3449
|
+
agentId,
|
|
3450
|
+
agentModel,
|
|
3451
|
+
displayName,
|
|
3452
|
+
listenerId,
|
|
3453
|
+
lifecycle,
|
|
3454
|
+
});
|
|
3455
|
+
},
|
|
2470
3456
|
});
|
|
2471
3457
|
} finally {
|
|
2472
3458
|
process.removeListener("SIGINT", onSigint);
|
|
2473
3459
|
}
|
|
2474
3460
|
});
|
|
2475
3461
|
|
|
3462
|
+
session
|
|
3463
|
+
.command("daemon [sessionId]")
|
|
3464
|
+
.description("Run the Senti session daemon: hydrate events, emit recaps, and generate checkpoints")
|
|
3465
|
+
.option("--session <id>", "Session id to monitor")
|
|
3466
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
3467
|
+
.option("--tick-interval <seconds>", "Seconds between health ticks (default 30)", "30")
|
|
3468
|
+
.option("--stale-agent-seconds <seconds>", "Seconds before an inactive agent is flagged stale (default 90)", "90")
|
|
3469
|
+
.option("--recap-interval <seconds>", "Seconds between periodic recaps when activity continues (default 300)", "300")
|
|
3470
|
+
.option("--recap-inactivity <seconds>", "Seconds of inactivity before a recap closeout (default 600)", "600")
|
|
3471
|
+
.option("--recap-event-threshold <n>", "Meaningful events required to force a recap before interval (default 5)", "5")
|
|
3472
|
+
.option("--checkpoint-interval <seconds>", "Minimum seconds between checkpoint attempts (default 60)", "60")
|
|
3473
|
+
.option("--checkpoint-min-events <n>", "Minimum events for generated checkpoint windows (default 20)", "20")
|
|
3474
|
+
.option("--checkpoint-max-events <n>", "Maximum events per generated checkpoint window (default 80)", "80")
|
|
3475
|
+
.option("--checkpoint-event-threshold <n>", "Meaningful events required to attempt a checkpoint (default 20)", "20")
|
|
3476
|
+
.option("--checkpoint-idle <seconds>", "Seconds after latest source event before idle checkpoint attempt (default 600)", "600")
|
|
3477
|
+
.option("--no-checkpoints", "Disable durable checkpoint generation")
|
|
3478
|
+
.option("--no-checkpoint-closeout", "Skip the final closeout checkpoint when the daemon stops")
|
|
3479
|
+
.option("--once", "Run one Senti health tick and exit (CI/dogfood smoke)")
|
|
3480
|
+
.option("--json", "Emit machine-readable output")
|
|
3481
|
+
.action(async (sessionId, options, command) => {
|
|
3482
|
+
const emitJson = shouldEmitJson(options, command);
|
|
3483
|
+
const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
|
|
3484
|
+
if (!normalizedSessionId) {
|
|
3485
|
+
throw new Error("session daemon requires a session id (positional or --session).");
|
|
3486
|
+
}
|
|
3487
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
3488
|
+
const tickIntervalMs = parsePositiveMillisecondsFromSeconds(
|
|
3489
|
+
options.tickInterval,
|
|
3490
|
+
"tick-interval",
|
|
3491
|
+
30,
|
|
3492
|
+
);
|
|
3493
|
+
const daemon = await startSenti(normalizedSessionId, {
|
|
3494
|
+
targetPath,
|
|
3495
|
+
autoStart: false,
|
|
3496
|
+
tickIntervalMs,
|
|
3497
|
+
staleAgentSeconds: parsePositiveInteger(options.staleAgentSeconds, "stale-agent-seconds", 90),
|
|
3498
|
+
recapIntervalMs: parsePositiveMillisecondsFromSeconds(options.recapInterval, "recap-interval", 300),
|
|
3499
|
+
recapInactivityMs: parsePositiveMillisecondsFromSeconds(options.recapInactivity, "recap-inactivity", 600),
|
|
3500
|
+
recapActivityThreshold: parsePositiveInteger(options.recapEventThreshold, "recap-event-threshold", 5),
|
|
3501
|
+
checkpointGenerator: options.checkpoints === false ? null : undefined,
|
|
3502
|
+
checkpointIntervalMs: parsePositiveMillisecondsFromSeconds(options.checkpointInterval, "checkpoint-interval", 60),
|
|
3503
|
+
checkpointMinEvents: parsePositiveInteger(options.checkpointMinEvents, "checkpoint-min-events", 20),
|
|
3504
|
+
checkpointMaxEvents: parsePositiveInteger(options.checkpointMaxEvents, "checkpoint-max-events", 80),
|
|
3505
|
+
checkpointEventThreshold: parsePositiveInteger(options.checkpointEventThreshold, "checkpoint-event-threshold", 20),
|
|
3506
|
+
checkpointIdleMs: parsePositiveMillisecondsFromSeconds(options.checkpointIdle, "checkpoint-idle", 600),
|
|
3507
|
+
checkpointCloseoutOnStop: options.once ? false : options.checkpointCloseout !== false,
|
|
3508
|
+
});
|
|
3509
|
+
|
|
3510
|
+
const runTickAndBuildPayload = async () => {
|
|
3511
|
+
const summary = await daemon.runTick(new Date().toISOString());
|
|
3512
|
+
return {
|
|
3513
|
+
command: "session daemon",
|
|
3514
|
+
sessionId: normalizedSessionId,
|
|
3515
|
+
targetPath,
|
|
3516
|
+
once: Boolean(options.once),
|
|
3517
|
+
running: daemon.isRunning(),
|
|
3518
|
+
summary,
|
|
3519
|
+
state: daemon.getState(),
|
|
3520
|
+
};
|
|
3521
|
+
};
|
|
3522
|
+
|
|
3523
|
+
if (options.once) {
|
|
3524
|
+
const payload = await runTickAndBuildPayload();
|
|
3525
|
+
const stopped = await daemon.stop("once_complete");
|
|
3526
|
+
payload.running = false;
|
|
3527
|
+
payload.stopped = {
|
|
3528
|
+
stopped: Boolean(stopped?.stopped),
|
|
3529
|
+
reason: stopped?.reason || "once_complete",
|
|
3530
|
+
checkpointCloseout: stopped?.checkpointCloseout || null,
|
|
3531
|
+
};
|
|
3532
|
+
if (emitJson) {
|
|
3533
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
const checkpoint = payload.summary?.checkpoint || {};
|
|
3537
|
+
const recap = payload.summary?.recap || {};
|
|
3538
|
+
console.log(
|
|
3539
|
+
pc.green(
|
|
3540
|
+
`senti tick: relayed=${Number(payload.summary?.humanMessages?.relayed || 0)} recap=${recap.emitted ? recap.mode || "emitted" : recap.reason || "none"} checkpoint=${checkpoint.created ? checkpoint.checkpointId || "created" : checkpoint.reason || "none"}`,
|
|
3541
|
+
),
|
|
3542
|
+
);
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
const controller = new AbortController();
|
|
3547
|
+
const stop = () => controller.abort();
|
|
3548
|
+
process.once("SIGINT", stop);
|
|
3549
|
+
process.once("SIGTERM", stop);
|
|
3550
|
+
const waitForNextTick = () =>
|
|
3551
|
+
new Promise((resolve) => {
|
|
3552
|
+
let timer = null;
|
|
3553
|
+
const cleanup = () => {
|
|
3554
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
3555
|
+
if (timer) {
|
|
3556
|
+
clearTimeout(timer);
|
|
3557
|
+
timer = null;
|
|
3558
|
+
}
|
|
3559
|
+
};
|
|
3560
|
+
const finish = () => {
|
|
3561
|
+
cleanup();
|
|
3562
|
+
resolve();
|
|
3563
|
+
};
|
|
3564
|
+
const onAbort = () => {
|
|
3565
|
+
finish();
|
|
3566
|
+
};
|
|
3567
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
3568
|
+
timer = setTimeout(finish, tickIntervalMs);
|
|
3569
|
+
});
|
|
3570
|
+
|
|
3571
|
+
try {
|
|
3572
|
+
if (!emitJson) {
|
|
3573
|
+
console.log(
|
|
3574
|
+
pc.green(
|
|
3575
|
+
`senti daemon: monitoring session ${normalizedSessionId}; tick=${Math.round(tickIntervalMs / 1000)}s. Ctrl-C to stop.`,
|
|
3576
|
+
),
|
|
3577
|
+
);
|
|
3578
|
+
}
|
|
3579
|
+
let payload = await runTickAndBuildPayload();
|
|
3580
|
+
if (emitJson) {
|
|
3581
|
+
console.log(JSON.stringify(payload));
|
|
3582
|
+
} else {
|
|
3583
|
+
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"}`));
|
|
3584
|
+
}
|
|
3585
|
+
while (!controller.signal.aborted) {
|
|
3586
|
+
await waitForNextTick();
|
|
3587
|
+
if (controller.signal.aborted) break;
|
|
3588
|
+
payload = await runTickAndBuildPayload();
|
|
3589
|
+
if (emitJson) {
|
|
3590
|
+
console.log(JSON.stringify(payload));
|
|
3591
|
+
} else {
|
|
3592
|
+
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
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
} finally {
|
|
3596
|
+
process.removeListener("SIGINT", stop);
|
|
3597
|
+
process.removeListener("SIGTERM", stop);
|
|
3598
|
+
const stopped = await daemon.stop("signal");
|
|
3599
|
+
if (emitJson) {
|
|
3600
|
+
console.log(
|
|
3601
|
+
JSON.stringify({
|
|
3602
|
+
command: "session daemon",
|
|
3603
|
+
sessionId: normalizedSessionId,
|
|
3604
|
+
targetPath,
|
|
3605
|
+
stopped: {
|
|
3606
|
+
stopped: Boolean(stopped?.stopped),
|
|
3607
|
+
reason: stopped?.reason || "signal",
|
|
3608
|
+
checkpointCloseout: stopped?.checkpointCloseout || null,
|
|
3609
|
+
},
|
|
3610
|
+
}),
|
|
3611
|
+
);
|
|
3612
|
+
} else {
|
|
3613
|
+
console.log(pc.gray(`senti daemon stopped (${stopped?.reason || "signal"}).`));
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
});
|
|
3617
|
+
|
|
3618
|
+
const wake = session
|
|
3619
|
+
.command("wake")
|
|
3620
|
+
.description("Wake or register host CLI sessions for the Senti notification bus");
|
|
3621
|
+
|
|
3622
|
+
wake
|
|
3623
|
+
.command("codex [sessionId]")
|
|
3624
|
+
.description("Resume a Codex CLI session with a Senti wake prompt")
|
|
3625
|
+
.option("--session <id>", "Senti session id")
|
|
3626
|
+
.option("--codex-session <id>", "Codex rollout/session id to resume")
|
|
3627
|
+
.option("--last", "Resume the most recent Codex session instead of a specific id")
|
|
3628
|
+
.option("--message <text>", "Senti message body to inject into Codex")
|
|
3629
|
+
.option("--message-file <path>", "Read Senti message body from a file")
|
|
3630
|
+
.option("--from <id>", "Senti sender id", "senti")
|
|
3631
|
+
.option("--sequence <n>", "Senti source sequence id")
|
|
3632
|
+
.option("--cursor <cursor>", "Senti source cursor")
|
|
3633
|
+
.option("--priority <level>", "Senti source priority")
|
|
3634
|
+
.option("--dashboard-url <url>", "Dashboard URL for the Senti session")
|
|
3635
|
+
.option("--cwd <path>", "Workspace cwd for codex exec", ".")
|
|
3636
|
+
.option("--codex-bin <path>", "Codex executable", "codex")
|
|
3637
|
+
.option("--model <model>", "Optional Codex model override")
|
|
3638
|
+
.option("--codex-json", "Pass --json through to codex exec resume")
|
|
3639
|
+
.option("--skip-git-repo-check", "Pass --skip-git-repo-check through to codex")
|
|
3640
|
+
.option(
|
|
3641
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
3642
|
+
"Pass Codex's dangerous no-approval/no-sandbox flag through to the resumed process",
|
|
3643
|
+
)
|
|
3644
|
+
.option("--timeout-ms <n>", "Wake process timeout in milliseconds", "600000")
|
|
3645
|
+
.option("--dry-run", "Print the resume invocation without spawning Codex")
|
|
3646
|
+
.option("--json", "Emit machine-readable output")
|
|
3647
|
+
.action(async (sessionId, options, command) => {
|
|
3648
|
+
const emitJson = shouldEmitJson(options, command);
|
|
3649
|
+
const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
|
|
3650
|
+
const targetPath = path.resolve(process.cwd(), String(options.cwd || "."));
|
|
3651
|
+
if (options.message && options.messageFile) {
|
|
3652
|
+
throw new Error("Use either --message or --message-file, not both.");
|
|
3653
|
+
}
|
|
3654
|
+
const message = options.messageFile
|
|
3655
|
+
? await fsp.readFile(path.resolve(process.cwd(), String(options.messageFile)), "utf-8")
|
|
3656
|
+
: normalizeString(options.message);
|
|
3657
|
+
const prompt = buildCodexWakePrompt({
|
|
3658
|
+
sentiSessionId: normalizedSessionId,
|
|
3659
|
+
message,
|
|
3660
|
+
from: options.from,
|
|
3661
|
+
sequenceId: parseOptionalPositiveInteger(options.sequence, "sequence"),
|
|
3662
|
+
cursor: options.cursor,
|
|
3663
|
+
priority: options.priority,
|
|
3664
|
+
dashboardUrl: options.dashboardUrl,
|
|
3665
|
+
});
|
|
3666
|
+
const invocation = buildCodexExecResumeInvocation({
|
|
3667
|
+
codexSessionId: options.codexSession,
|
|
3668
|
+
prompt,
|
|
3669
|
+
cwd: targetPath,
|
|
3670
|
+
codexBin: options.codexBin,
|
|
3671
|
+
useLast: Boolean(options.last),
|
|
3672
|
+
json: Boolean(options.codexJson),
|
|
3673
|
+
model: options.model,
|
|
3674
|
+
skipGitRepoCheck: Boolean(options.skipGitRepoCheck),
|
|
3675
|
+
dangerouslyBypassApprovalsAndSandbox: Boolean(options.dangerouslyBypassApprovalsAndSandbox),
|
|
3676
|
+
});
|
|
3677
|
+
const payload = {
|
|
3678
|
+
command: "session wake codex",
|
|
3679
|
+
sessionId: normalizedSessionId,
|
|
3680
|
+
dryRun: Boolean(options.dryRun),
|
|
3681
|
+
invocation,
|
|
3682
|
+
prompt,
|
|
3683
|
+
};
|
|
3684
|
+
if (options.dryRun) {
|
|
3685
|
+
if (emitJson) {
|
|
3686
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3689
|
+
console.log(pc.green("Codex wake dry run"));
|
|
3690
|
+
console.log(`${payload.invocation.command} ${payload.invocation.args.map((arg) => JSON.stringify(arg)).join(" ")}`);
|
|
3691
|
+
return;
|
|
3692
|
+
}
|
|
3693
|
+
const result = await runCodexExecResume({
|
|
3694
|
+
invocation,
|
|
3695
|
+
timeoutMs: parsePositiveInteger(options.timeoutMs, "timeout-ms", 600000),
|
|
3696
|
+
});
|
|
3697
|
+
const output = {
|
|
3698
|
+
...payload,
|
|
3699
|
+
result,
|
|
3700
|
+
};
|
|
3701
|
+
if (emitJson) {
|
|
3702
|
+
console.log(JSON.stringify(output, null, 2));
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3705
|
+
const color = result.exitCode === 0 ? pc.green : pc.yellow;
|
|
3706
|
+
console.log(color(`Codex wake completed with exit code ${result.exitCode}.`));
|
|
3707
|
+
if (normalizeString(result.stderr)) {
|
|
3708
|
+
console.log(pc.gray(result.stderr.trim()));
|
|
3709
|
+
}
|
|
3710
|
+
if (result.exitCode !== 0) {
|
|
3711
|
+
process.exitCode = Number(result.exitCode) || 1;
|
|
3712
|
+
}
|
|
3713
|
+
});
|
|
3714
|
+
|
|
3715
|
+
wake
|
|
3716
|
+
.command("codex-notify [sessionId] [notificationJson]")
|
|
3717
|
+
.description("Record Codex notify payloads so sentid can resume the correct Codex rollout")
|
|
3718
|
+
.option("--session <id>", "Senti session id")
|
|
3719
|
+
.option("--agent <id>", "Senti agent id", process.env.SENTINELAYER_AGENT_ID || "codex")
|
|
3720
|
+
.option("--notification <json>", "Notification JSON override")
|
|
3721
|
+
.option("--path <path>", "Workspace path for the Senti session", ".")
|
|
3722
|
+
.option("--json", "Emit machine-readable output")
|
|
3723
|
+
.action(async (sessionId, notificationJson, options, command) => {
|
|
3724
|
+
const emitJson = shouldEmitJson(options, command);
|
|
3725
|
+
const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
|
|
3726
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
3727
|
+
const payload = normalizeString(options.notification) || normalizeString(notificationJson);
|
|
3728
|
+
const result = await recordCodexWakeRegistration({
|
|
3729
|
+
sessionId: normalizedSessionId,
|
|
3730
|
+
agentId: options.agent,
|
|
3731
|
+
notificationPayload: payload,
|
|
3732
|
+
targetPath,
|
|
3733
|
+
});
|
|
3734
|
+
const output = {
|
|
3735
|
+
command: "session wake codex-notify",
|
|
3736
|
+
sessionId: normalizedSessionId,
|
|
3737
|
+
agentId: normalizeString(options.agent) || "codex",
|
|
3738
|
+
...result,
|
|
3739
|
+
};
|
|
3740
|
+
if (emitJson) {
|
|
3741
|
+
console.log(JSON.stringify(output, null, 2));
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
if (!result.registered) {
|
|
3745
|
+
console.log(pc.gray(`Ignored Codex notification: ${result.reason}.`));
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
console.log(pc.green(`Registered Codex wake target: ${result.registryPath}`));
|
|
3749
|
+
});
|
|
3750
|
+
|
|
3751
|
+
wake
|
|
3752
|
+
.command("daemon [sessionId]")
|
|
3753
|
+
.description("Run the sentid wake daemon: watch the session stream and wake this agent on new messages")
|
|
3754
|
+
.option("--session <id>", "Senti session id")
|
|
3755
|
+
.option("--agent <id>", "Local agent this daemon wakes", process.env.SENTINELAYER_AGENT_ID || "")
|
|
3756
|
+
.option("--host <name>", "Host adapter to wake (claude|codex)", "claude")
|
|
3757
|
+
.option("--resume-session <id>", "Host session id to resume on wake")
|
|
3758
|
+
.option("--cwd <path>", "Workspace cwd", ".")
|
|
3759
|
+
.option("--idle-ms <n>", "Idle poll backoff in milliseconds", "1500")
|
|
3760
|
+
.option("--max-attempts <n>", "Wake retries before dead-letter", "5")
|
|
3761
|
+
.option("--once", "Run a single fetch/dispatch tick and exit (dogfood/CI)")
|
|
3762
|
+
.option("--json", "Emit machine-readable output")
|
|
3763
|
+
.action(async (sessionId, options, command) => {
|
|
3764
|
+
const emitJson = shouldEmitJson(options, command);
|
|
3765
|
+
const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
|
|
3766
|
+
if (!normalizedSessionId) {
|
|
3767
|
+
throw new Error("session wake daemon requires a Senti session id (positional or --session).");
|
|
3768
|
+
}
|
|
3769
|
+
const agentId = normalizeString(options.agent);
|
|
3770
|
+
if (!agentId) {
|
|
3771
|
+
throw new Error("session wake daemon requires --agent (the local agent to wake).");
|
|
3772
|
+
}
|
|
3773
|
+
const host = normalizeString(options.host) || "claude";
|
|
3774
|
+
const resumeSessionId = normalizeString(options.resumeSession);
|
|
3775
|
+
if (!resumeSessionId) {
|
|
3776
|
+
throw new Error("session wake daemon requires --resume-session (the host session id to resume).");
|
|
3777
|
+
}
|
|
3778
|
+
const targetPath = path.resolve(process.cwd(), String(options.cwd || "."));
|
|
3779
|
+
const sentid = createSentid({
|
|
3780
|
+
sessionId: normalizedSessionId,
|
|
3781
|
+
agentId,
|
|
3782
|
+
host,
|
|
3783
|
+
resumeSessionId,
|
|
3784
|
+
targetPath,
|
|
3785
|
+
idleMs: parseOptionalPositiveInteger(options.idleMs, "idle-ms") || 1500,
|
|
3786
|
+
maxAttempts: parseOptionalPositiveInteger(options.maxAttempts, "max-attempts") || 5,
|
|
3787
|
+
logger: emitJson
|
|
3788
|
+
? undefined
|
|
3789
|
+
: (level, msg, meta) => console.error(`[sentid:${level}] ${msg}${meta ? ` ${JSON.stringify(meta)}` : ""}`),
|
|
3790
|
+
});
|
|
3791
|
+
|
|
3792
|
+
if (options.once) {
|
|
3793
|
+
const tick = await sentid.tickOnce({ fetchCursor: null });
|
|
3794
|
+
const out = {
|
|
3795
|
+
command: "session wake daemon",
|
|
3796
|
+
once: true,
|
|
3797
|
+
sessionId: normalizedSessionId,
|
|
3798
|
+
agent: agentId,
|
|
3799
|
+
host,
|
|
3800
|
+
cursor: sentid.getCursor(),
|
|
3801
|
+
dispatched: Array.isArray(tick.results) ? tick.results.length : 0,
|
|
3802
|
+
idle: Boolean(tick.idle),
|
|
3803
|
+
};
|
|
3804
|
+
console.log(
|
|
3805
|
+
emitJson
|
|
3806
|
+
? JSON.stringify(out, null, 2)
|
|
3807
|
+
: pc.green(`sentid tick: cursor=${out.cursor} dispatched=${out.dispatched}${out.idle ? " (idle)" : ""}`),
|
|
3808
|
+
);
|
|
3809
|
+
return;
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
const ac = new AbortController();
|
|
3813
|
+
const stop = () => ac.abort();
|
|
3814
|
+
process.once("SIGINT", stop);
|
|
3815
|
+
process.once("SIGTERM", stop);
|
|
3816
|
+
if (!emitJson) {
|
|
3817
|
+
console.log(
|
|
3818
|
+
pc.green(`sentid daemon: waking ${agentId} (host=${host}) on session ${normalizedSessionId}. Ctrl-C to stop.`),
|
|
3819
|
+
);
|
|
3820
|
+
}
|
|
3821
|
+
await sentid.start({ signal: ac.signal });
|
|
3822
|
+
});
|
|
3823
|
+
|
|
2476
3824
|
const recap = session
|
|
2477
3825
|
.command("recap")
|
|
2478
3826
|
.description("Build deterministic Senti session recaps");
|
|
@@ -2574,6 +3922,9 @@ export function registerSessionCommand(program) {
|
|
|
2574
3922
|
)
|
|
2575
3923
|
.option("--before-sequence <n>", "Remote page ending before this sequence id")
|
|
2576
3924
|
.option("--no-actions", "Do not include remote message actions/replies/reactions")
|
|
3925
|
+
.option("--agent <id>", "Agent id for automatic view receipts", process.env.SENTINELAYER_AGENT_ID || "cli-user")
|
|
3926
|
+
.option("--no-view", "Do not record automatic view receipts for displayed remote messages")
|
|
3927
|
+
.option("--include-control-events", "Include listener lifecycle/control-plane events in transcript output")
|
|
2577
3928
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
2578
3929
|
.option("--json", "Emit machine-readable output")
|
|
2579
3930
|
.action(async (sessionId, options, command) => {
|
|
@@ -2585,6 +3936,11 @@ export function registerSessionCommand(program) {
|
|
|
2585
3936
|
const tail = parsePositiveInteger(options.tail, "tail", 20);
|
|
2586
3937
|
const beforeSequence = parseOptionalPositiveInteger(options.beforeSequence, "before-sequence");
|
|
2587
3938
|
const emitJson = shouldEmitJson(options, command);
|
|
3939
|
+
const includeControlEvents = Boolean(options.includeControlEvents);
|
|
3940
|
+
const remoteTailLimit = includeControlEvents
|
|
3941
|
+
? tail
|
|
3942
|
+
: Math.min(200, Math.max(tail, tail + 20));
|
|
3943
|
+
const readAgentId = await defaultAgentId(options.agent, targetPath);
|
|
2588
3944
|
|
|
2589
3945
|
let hydration = null;
|
|
2590
3946
|
let remoteTail = null;
|
|
@@ -2605,7 +3961,7 @@ export function registerSessionCommand(program) {
|
|
|
2605
3961
|
remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
|
|
2606
3962
|
targetPath,
|
|
2607
3963
|
beforeSequence,
|
|
2608
|
-
limit:
|
|
3964
|
+
limit: remoteTailLimit,
|
|
2609
3965
|
timeoutMs: 15_000,
|
|
2610
3966
|
});
|
|
2611
3967
|
if (options.actions !== false) {
|
|
@@ -2681,7 +4037,23 @@ export function registerSessionCommand(program) {
|
|
|
2681
4037
|
const actionEvents = remoteActions?.ok
|
|
2682
4038
|
? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
|
|
2683
4039
|
: [];
|
|
2684
|
-
const
|
|
4040
|
+
const unacknowledgedHumanMessages = remoteActions?.ok
|
|
4041
|
+
? readProjectionUnacknowledgedHumanMessages(remoteActions)
|
|
4042
|
+
: [];
|
|
4043
|
+
const recentHumanActivity = remoteActions?.ok
|
|
4044
|
+
? readProjectionRecentHumanActivity(remoteActions)
|
|
4045
|
+
: [];
|
|
4046
|
+
const transcriptEvents = includeControlEvents
|
|
4047
|
+
? displayEvents
|
|
4048
|
+
: displayEvents.filter((event) => !isSessionControlEvent(event));
|
|
4049
|
+
const hiddenControlEventCount = displayEvents.length - transcriptEvents.length;
|
|
4050
|
+
const events = mergeSessionActionEvents(transcriptEvents, actionEvents).slice(-tail);
|
|
4051
|
+
const autoView = await recordSessionReadViews(normalizedSessionId, events, {
|
|
4052
|
+
targetPath,
|
|
4053
|
+
agentId: readAgentId,
|
|
4054
|
+
enabled: Boolean(options.remote && options.view !== false),
|
|
4055
|
+
maxTargets: process.env.SENTINELAYER_SESSION_READ_VIEW_MAX_TARGETS || 50,
|
|
4056
|
+
});
|
|
2685
4057
|
const remoteVerified = Boolean(
|
|
2686
4058
|
options.remote &&
|
|
2687
4059
|
((hydration && hydration.ok) || (remoteTail && remoteTail.ok))
|
|
@@ -2694,6 +4066,13 @@ export function registerSessionCommand(program) {
|
|
|
2694
4066
|
beforeSequence,
|
|
2695
4067
|
count: events.length,
|
|
2696
4068
|
events,
|
|
4069
|
+
includeControlEvents,
|
|
4070
|
+
hiddenControlEventCount,
|
|
4071
|
+
unacknowledgedHumanMessageCount: unacknowledgedHumanMessages.length,
|
|
4072
|
+
unacknowledgedHumanMessages,
|
|
4073
|
+
recentHumanActivityCount: recentHumanActivity.length,
|
|
4074
|
+
recentHumanActivity,
|
|
4075
|
+
autoView,
|
|
2697
4076
|
displaySource: !options.remote
|
|
2698
4077
|
? "local"
|
|
2699
4078
|
: remoteTail?.ok
|
|
@@ -2737,6 +4116,18 @@ export function registerSessionCommand(program) {
|
|
|
2737
4116
|
console.log(JSON.stringify(payload, null, 2));
|
|
2738
4117
|
return;
|
|
2739
4118
|
}
|
|
4119
|
+
if (unacknowledgedHumanMessages.length > 0) {
|
|
4120
|
+
console.log(pc.yellow(`Unacknowledged human asks: ${unacknowledgedHumanMessages.length}`));
|
|
4121
|
+
for (const event of unacknowledgedHumanMessages.slice(0, 3)) {
|
|
4122
|
+
console.log(pc.yellow(`- ${formatHumanAskLine(event)}`));
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
if (recentHumanActivity.length > 0) {
|
|
4126
|
+
console.log(pc.yellow(`Recent human activity: ${recentHumanActivity.length}`));
|
|
4127
|
+
for (const action of recentHumanActivity.slice(0, 3)) {
|
|
4128
|
+
console.log(pc.yellow(`- ${formatMessageActionActivityLine(action)}`));
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
2740
4131
|
for (const event of events) {
|
|
2741
4132
|
console.log(formatEventLine(event));
|
|
2742
4133
|
}
|
|
@@ -3048,6 +4439,8 @@ export function registerSessionCommand(program) {
|
|
|
3048
4439
|
.description("Generate a checkpoint from the next uncheckpointed durable event window")
|
|
3049
4440
|
.option("--min-events <n>", "Minimum source events required before creating (default 20)", "20")
|
|
3050
4441
|
.option("--max-events <n>", "Maximum source events to summarize (default 80, max 200)", "80")
|
|
4442
|
+
.option("--catch-up", "Generate multiple consecutive checkpoint windows until caught up or capped")
|
|
4443
|
+
.option("--max-checkpoints <n>", "Maximum checkpoint windows to create with --catch-up (default 5, max 50)", "5")
|
|
3051
4444
|
.option("--operation-id <key>", "Explicit retry key for this generate invocation")
|
|
3052
4445
|
.option("--agent <id>", "Optional agent id recorded as checkpoint creator")
|
|
3053
4446
|
.option("--path <path>", "Workspace path for auth/session context", ".")
|
|
@@ -3061,6 +4454,49 @@ export function registerSessionCommand(program) {
|
|
|
3061
4454
|
const agentId = normalizeString(options.agent)
|
|
3062
4455
|
? await defaultAgentId(options.agent, targetPath)
|
|
3063
4456
|
: "";
|
|
4457
|
+
if (options.catchUp) {
|
|
4458
|
+
const result = await generateSessionCheckpointBatch(normalizedSessionId, {
|
|
4459
|
+
targetPath,
|
|
4460
|
+
minEvents: options.minEvents,
|
|
4461
|
+
maxEvents: options.maxEvents,
|
|
4462
|
+
maxCheckpoints: options.maxCheckpoints,
|
|
4463
|
+
idempotencyKey: options.operationId,
|
|
4464
|
+
createdByAgentId: agentId,
|
|
4465
|
+
});
|
|
4466
|
+
const hydration = result.createdCount > 0
|
|
4467
|
+
? await hydrateAfterCheckpointMutation(normalizedSessionId, { targetPath })
|
|
4468
|
+
: null;
|
|
4469
|
+
const payload = {
|
|
4470
|
+
command: "session checkpoint generate",
|
|
4471
|
+
targetPath,
|
|
4472
|
+
...result,
|
|
4473
|
+
hydration: hydration || undefined,
|
|
4474
|
+
};
|
|
4475
|
+
if (shouldEmitJson(options, command)) {
|
|
4476
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
4477
|
+
return;
|
|
4478
|
+
}
|
|
4479
|
+
if (result.createdCount > 0) {
|
|
4480
|
+
console.log(pc.bold(`checkpoint catch-up generated ${result.createdCount} checkpoint${result.createdCount === 1 ? "" : "s"}`));
|
|
4481
|
+
for (const item of result.results) {
|
|
4482
|
+
if (item.checkpoint) {
|
|
4483
|
+
console.log(formatCheckpointLine(item.checkpoint));
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
console.log(pc.gray(`Stopped: ${result.stoppedReason || "complete"} after ${result.attemptedCount} attempt${result.attemptedCount === 1 ? "" : "s"}.`));
|
|
4487
|
+
if (hydration && !hydration.ok) {
|
|
4488
|
+
console.log(pc.gray(`Local hydrate skipped: ${hydration.reason || "unknown"}`));
|
|
4489
|
+
}
|
|
4490
|
+
return;
|
|
4491
|
+
}
|
|
4492
|
+
const last = result.lastResult || {};
|
|
4493
|
+
console.log(
|
|
4494
|
+
pc.gray(
|
|
4495
|
+
`No checkpoint created: ${normalizeString(last.reason || result.stoppedReason) || "not_needed"} (${Number(last.eventCount || 0)} events, min ${Number(last.minEvents || options.minEvents || 0)}).`,
|
|
4496
|
+
),
|
|
4497
|
+
);
|
|
4498
|
+
return;
|
|
4499
|
+
}
|
|
3064
4500
|
const result = await generateSessionCheckpoint(normalizedSessionId, {
|
|
3065
4501
|
targetPath,
|
|
3066
4502
|
minEvents: options.minEvents,
|
|
@@ -3582,14 +5018,7 @@ export function registerSessionCommand(program) {
|
|
|
3582
5018
|
return;
|
|
3583
5019
|
}
|
|
3584
5020
|
for (const item of trimmed) {
|
|
3585
|
-
|
|
3586
|
-
const created = item.createdAt || "?";
|
|
3587
|
-
const lastActivity = item.lastActivityAt
|
|
3588
|
-
? ` last=${item.lastActivityAt}`
|
|
3589
|
-
: "";
|
|
3590
|
-
console.log(
|
|
3591
|
-
`${item.sessionId} status=${item.status}${archive} created=${created}${lastActivity}`,
|
|
3592
|
-
);
|
|
5021
|
+
console.log(formatSessionListLine(item));
|
|
3593
5022
|
}
|
|
3594
5023
|
if (remote.count > trimmed.length || remote.hasMore) {
|
|
3595
5024
|
console.log(
|
|
@@ -3637,10 +5066,9 @@ export function registerSessionCommand(program) {
|
|
|
3637
5066
|
return;
|
|
3638
5067
|
}
|
|
3639
5068
|
for (const item of trimmed) {
|
|
3640
|
-
const
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
);
|
|
5069
|
+
const line = formatSessionListLine(item);
|
|
5070
|
+
const expires = item.expiresAt ? ` expires=${item.expiresAt}` : "";
|
|
5071
|
+
console.log(`${line}${expires}`);
|
|
3644
5072
|
}
|
|
3645
5073
|
if (sessions.length > trimmed.length) {
|
|
3646
5074
|
console.log(
|