sentinelayer-cli 0.46.4 → 0.48.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.
Files changed (137) hide show
  1. package/README.md +63 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +46 -8
  26. package/src/auth/service.js +34 -15
  27. package/src/auth/session-store.js +105 -47
  28. package/src/board/evidence-resolvers.js +3 -4
  29. package/src/cli.js +23 -3
  30. package/src/commands/ai/identity-lifecycle.js +30 -26
  31. package/src/commands/ai/provision-governance.js +32 -24
  32. package/src/commands/ai/shared.js +6 -4
  33. package/src/commands/audit.js +14 -12
  34. package/src/commands/auth.js +24 -4
  35. package/src/commands/board.js +2 -2
  36. package/src/commands/chat.js +8 -6
  37. package/src/commands/judge.js +125 -0
  38. package/src/commands/legacy-args.js +1 -0
  39. package/src/commands/mcp.js +45 -52
  40. package/src/commands/omargate.js +2 -1
  41. package/src/commands/plugin.js +11 -3
  42. package/src/commands/review.js +9 -4
  43. package/src/commands/scan.js +7 -7
  44. package/src/commands/session.js +607 -57
  45. package/src/commands/skills.js +45 -0
  46. package/src/commands/watch.js +9 -7
  47. package/src/config/output-filesystem.js +156 -0
  48. package/src/config/schema.js +4 -2
  49. package/src/config/service.js +40 -4
  50. package/src/cost/history.js +15 -9
  51. package/src/daemon/artifact-lineage.js +15 -12
  52. package/src/daemon/assignment-ledger.js +31 -25
  53. package/src/daemon/ast-drift.js +7 -3
  54. package/src/daemon/budget-governor.js +26 -20
  55. package/src/daemon/error-worker.js +28 -22
  56. package/src/daemon/fix-cycle.js +5 -4
  57. package/src/daemon/hybrid-mapper.js +19 -16
  58. package/src/daemon/ingest-refresh.js +4 -0
  59. package/src/daemon/jira-lifecycle.js +21 -17
  60. package/src/daemon/operator-control.js +18 -15
  61. package/src/daemon/reliability-lane.js +18 -15
  62. package/src/daemon/scope-engine.js +12 -8
  63. package/src/daemon/watchdog.js +15 -12
  64. package/src/engram/build-state.js +24 -3
  65. package/src/engram/capability-index.js +367 -0
  66. package/src/engram/compile-graph.js +1063 -0
  67. package/src/engram/contracts/action-receipt.js +82 -0
  68. package/src/engram/contracts/capability-index.js +259 -0
  69. package/src/engram/contracts/capability.js +377 -0
  70. package/src/engram/contracts/content-hash.js +8 -0
  71. package/src/engram/contracts/evidence.js +258 -0
  72. package/src/engram/contracts/graph.js +594 -0
  73. package/src/engram/contracts/identity.js +8 -0
  74. package/src/engram/contracts/index.js +149 -0
  75. package/src/engram/contracts/media-asset.js +85 -0
  76. package/src/engram/contracts/money.js +249 -0
  77. package/src/engram/contracts/source-snapshot.js +57 -0
  78. package/src/engram/contracts/task-context-pack.js +114 -0
  79. package/src/engram/contracts/temporal-segment.js +72 -0
  80. package/src/engram/contracts/versions.js +96 -0
  81. package/src/engram/extract-html.js +692 -0
  82. package/src/engram/graph-store.js +877 -0
  83. package/src/engram/index.js +98 -14
  84. package/src/engram/ingest-site.js +132 -0
  85. package/src/engram/live-state.js +1180 -0
  86. package/src/engram/namespace.js +34 -1
  87. package/src/engram/retrieve-site.js +753 -0
  88. package/src/engram/safe-reader.js +597 -0
  89. package/src/engram/source-intake.js +251 -0
  90. package/src/engram/store.js +227 -9
  91. package/src/ingest/engine.js +21 -28
  92. package/src/judge/verdict.js +305 -0
  93. package/src/legacy-cli.js +96 -19
  94. package/src/mcp/bridge-inputs.js +28 -4
  95. package/src/mcp/cli-command-tools.js +70 -0
  96. package/src/mcp/registry.js +17 -13
  97. package/src/mcp/session-stdio-server.js +297 -19
  98. package/src/memory/blackboard.js +5 -4
  99. package/src/net/gh-api-path.js +35 -9
  100. package/src/net/test-egress-guard.js +16 -4
  101. package/src/plugin/manifest.js +12 -8
  102. package/src/review/ai-review.js +110 -10
  103. package/src/review/investor-dd-devtestbot.js +17 -6
  104. package/src/review/investor-dd-orchestrator.js +21 -13
  105. package/src/review/local-review.js +26 -16
  106. package/src/review/omargate-cache.js +5 -5
  107. package/src/review/omargate-orchestrator.js +89 -17
  108. package/src/review/replay.js +9 -4
  109. package/src/review/report.js +22 -13
  110. package/src/review/typesafe-severity.js +201 -0
  111. package/src/session/admission-auth.js +34 -7
  112. package/src/session/admission.js +322 -71
  113. package/src/session/agent-access.js +137 -0
  114. package/src/session/agent-registry.js +2 -1
  115. package/src/session/attachment-summary.js +77 -0
  116. package/src/session/edit-summary.js +19 -0
  117. package/src/session/invitations.js +65 -1
  118. package/src/session/mentions.js +17 -9
  119. package/src/session/message-edits.js +5 -4
  120. package/src/session/preview.js +10 -2
  121. package/src/session/recap.js +3 -2
  122. package/src/session/room-files.js +519 -0
  123. package/src/session/route-classes.js +22 -8
  124. package/src/session/setup-guides.js +4 -0
  125. package/src/session/standup.js +192 -0
  126. package/src/session/sync.js +229 -10
  127. package/src/session/tickets.js +41 -0
  128. package/src/session/transcript.js +2 -1
  129. package/src/session/wake/resolve-target.js +7 -3
  130. package/src/skills/listener-runtime.cjs +92 -0
  131. package/src/skills/service.js +207 -0
  132. package/src/swarm/factory.js +5 -5
  133. package/src/swarm/pentest.js +9 -9
  134. package/src/swarm/report.js +4 -4
  135. package/src/swarm/runtime.js +15 -12
  136. package/src/swarm/scenario-dsl.js +7 -5
  137. package/src/telemetry/ledger.js +16 -7
@@ -0,0 +1,192 @@
1
+ import process from "node:process";
2
+ import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
3
+ import { requestJson } from "../auth/http.js";
4
+ import { resolveActiveAuthSession } from "../auth/service.js";
5
+ import { urlPathSegment } from "../net/url-path.js";
6
+
7
+ const SECTIONS = [
8
+ ["accepted", "Accepted"],
9
+ ["ownerAttested", "Owner-attested (unverified)"],
10
+ ["reportedButUnverified", "Reported but unverified"],
11
+ ["inProgress", "In progress"],
12
+ ["open", "Open"],
13
+ ["blocked", "Blocked"],
14
+ ["inReview", "In review"],
15
+ ["needsOwnerDecision", "Needs an owner decision"],
16
+ ["unknown", "Unknown"],
17
+ ];
18
+ const BOARD_LIMIT = 200;
19
+ const EVIDENCE_KINDS = new Set(["pr-merged", "sha-on-branch", "check-conclusive", "deploy-sha"]);
20
+
21
+ const text = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
22
+ const boolean = (value) => typeof value === "boolean" ? value : null;
23
+ const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
24
+
25
+ function timestamp(value) {
26
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value)) return null;
27
+ const ms = Date.parse(value), day = Date.parse(`${value.slice(0, 10)}T00:00:00Z`);
28
+ if (!Number.isFinite(ms) || !Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== value.slice(0, 10)) return null;
29
+ return new Date(ms).toISOString();
30
+ }
31
+
32
+ function sinceTime(since, now) {
33
+ if (since === undefined) return null;
34
+ if (since === "24h") return new Date(now - 24 * 60 * 60 * 1000).toISOString();
35
+ const parsed = timestamp(since);
36
+ if (!parsed) throw new Error("--since must be 24h or an ISO timestamp with a timezone.");
37
+ return parsed;
38
+ }
39
+
40
+ function evidenceClaim(claim) {
41
+ let reference = null;
42
+ if (claim.kind === "pr-merged") {
43
+ const pr = Number.isSafeInteger(claim.pr) ? String(claim.pr) : text(claim.pr);
44
+ if (pr && /^[1-9]\d*$/.test(pr)) reference = `#${pr}`;
45
+ } else if (claim.kind === "sha-on-branch" || claim.kind === "deploy-sha") {
46
+ reference = text(claim.sha)?.slice(0, 12) || null;
47
+ } else if (claim.kind === "check-conclusive") {
48
+ reference = text(claim.check);
49
+ }
50
+ reference ||= text(claim.url);
51
+ if (reference?.length > 80) reference = `${reference.slice(0, 79)}…`;
52
+ return { kind: claim.kind, reference };
53
+ }
54
+
55
+ function ticketRecord(raw) {
56
+ const t = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
57
+ const evidence = Array.isArray(t.doneVerdict?.evidence)
58
+ ? t.doneVerdict.evidence.filter((claim) => claim && EVIDENCE_KINDS.has(claim.kind)).map(evidenceClaim) : [];
59
+ const ownerAttested = t.doneVerdict?.kind === "owner-attested" || t.completion?.source === "owner_attested";
60
+ return {
61
+ id: text(t.id), title: text(t.title), status: text(t.status),
62
+ assignee: text(t.assignee), acceptedBy: text(t.acceptedBy),
63
+ claimedAt: timestamp(t.claimedAt), createdAt: timestamp(t.createdAt), updatedAt: timestamp(t.updatedAt),
64
+ hasDoneVerdict: boolean(t.hasDoneVerdict), unaccepted: boolean(t.unaccepted), staleClaim: boolean(t.staleClaim),
65
+ lastVerdict: t.lastVerdict && typeof t.lastVerdict === "object"
66
+ ? { verdict: text(t.lastVerdict.verdict), checkedAt: timestamp(t.lastVerdict.checkedAt) } : null,
67
+ completion: t.completion && typeof t.completion === "object"
68
+ ? { source: text(t.completion.source), verified: boolean(t.completion.verified) } : null,
69
+ verdict: { kind: evidence[0]?.kind || null, reference: evidence[0]?.reference || null, evidence },
70
+ ownerAttestation: ownerAttested ? {
71
+ statement: text(t.doneVerdict?.statement), attestedBy: text(t.doneVerdict?.attestedBy),
72
+ attestedAt: timestamp(t.doneVerdict?.attestedAt), verified: false,
73
+ } : null,
74
+ unknown: [],
75
+ };
76
+ }
77
+
78
+ // Select a whole record, never graft a prior verdict onto a newer claim. When
79
+ // timestamps cannot resolve different records, retain only their shared identity.
80
+ function coalesce(items) {
81
+ const groups = new Map();
82
+ for (const raw of items) {
83
+ const item = ticketRecord(raw);
84
+ const key = item.id ? `id:${item.id}` : `record:${JSON.stringify(item)}`;
85
+ if (!groups.has(key)) groups.set(key, new Map());
86
+ groups.get(key).set(JSON.stringify(item), item);
87
+ }
88
+ return [...groups.values()].map((group) => {
89
+ const records = [...group.values()].sort((a, b) => compare(b.updatedAt || "", a.updatedAt || ""));
90
+ if (records.length === 1) return records[0];
91
+ if (records.every((t) => t.updatedAt) && records[0].updatedAt !== records[1].updatedAt) return records[0];
92
+ const conflict = ticketRecord({ id: records[0].id,
93
+ updatedAt: records.every((t) => t.updatedAt) ? records[0].updatedAt : null });
94
+ conflict.unknown.push("conflicting duplicate records");
95
+ return conflict;
96
+ }).sort((a, b) => compare(a.id || "", b.id || "") || compare(JSON.stringify(a), JSON.stringify(b)));
97
+ }
98
+
99
+ export function buildSessionStandup(items, { sessionId, since, now = Date.now(), truncated = false } = {}) {
100
+ const cutoff = sinceTime(since, now);
101
+ const report = { sessionId, since: cutoff, truncated: truncated === true || items.length >= BOARD_LIMIT,
102
+ ...Object.fromEntries(SECTIONS.map(([key]) => [key, []])) };
103
+ for (const item of coalesce(items)) {
104
+ if (cutoff && item.updatedAt && item.updatedAt < cutoff) continue;
105
+ for (const field of ["id", "title", "status", "updatedAt"]) {
106
+ if (item[field] === null) item.unknown.push(field);
107
+ }
108
+ let section = "unknown";
109
+ if (!item.id || item.unknown.includes("conflicting duplicate records") || (cutoff && !item.updatedAt)) {
110
+ // There is not enough evidence to identify or date this work.
111
+ } else if (item.status === "done") {
112
+ if (item.hasDoneVerdict === true && item.ownerAttestation) {
113
+ section = "ownerAttested";
114
+ for (const field of ["statement", "attestedBy", "attestedAt"]) {
115
+ if (item.ownerAttestation[field] === null) item.unknown.push(`ownerAttestation.${field}`);
116
+ }
117
+ } else {
118
+ section = item.hasDoneVerdict === true ? "accepted" : "reportedButUnverified";
119
+ if (item.hasDoneVerdict === null) item.unknown.push("hasDoneVerdict");
120
+ if (!item.verdict.kind) item.unknown.push("verdict.kind");
121
+ if (!item.verdict.evidence.length || item.verdict.evidence.some((claim) => !claim.reference)) item.unknown.push("verdict.reference");
122
+ }
123
+ } else if (item.unaccepted === true) {
124
+ section = "needsOwnerDecision";
125
+ if (!item.createdAt) item.unknown.push("createdAt");
126
+ } else if (["working", "in_progress"].includes(item.status)) {
127
+ section = "inProgress";
128
+ for (const field of ["assignee", "claimedAt", "staleClaim"]) {
129
+ if (item[field] === null) item.unknown.push(field);
130
+ }
131
+ } else if (["open", "assigned", "accepted"].includes(item.status)) {
132
+ section = "open";
133
+ } else if (item.status === "blocked") {
134
+ section = "blocked";
135
+ } else if (item.status === "in_review") {
136
+ section = "inReview";
137
+ } else {
138
+ item.unknown.push("classification");
139
+ if (item.unaccepted === null) item.unknown.push("unaccepted");
140
+ }
141
+ report[section].push(item);
142
+ }
143
+ report.needsOwnerDecision.sort((a, b) => compare(a.createdAt || "~", b.createdAt || "~") || compare(a.id, b.id));
144
+ return report;
145
+ }
146
+
147
+ export async function sessionStandup(sessionId, {
148
+ since, targetPath = process.cwd(), now = Date.now(),
149
+ resolveAuthSession = resolveActiveAuthSession, requestRead = requestJson,
150
+ } = {}) {
151
+ const cutoff = sinceTime(since, now); // Invalid windows refuse before any request.
152
+ const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
153
+ if (!isAuthenticated(auth) || !auth?.apiUrl) throw new Error("Not authenticated for this session.");
154
+ let body;
155
+ try {
156
+ body = await checkedTransport(requestRead)(`${auth.apiUrl.replace(/\/+$/, "")}/api/v1/sessions/${urlPathSegment(sessionId, { label: "sessionId" })}/tickets?limit=${BOARD_LIMIT}`, {
157
+ method: "GET", credential: await credentialFor(auth),
158
+ });
159
+ } catch (error) {
160
+ if (error?.status === 403 || error?.status === 404) {
161
+ throw new Error(`Standup refused (HTTP ${error.status}): this session's tickets are unavailable or you do not have permission to read them.`, { cause: error });
162
+ }
163
+ throw error;
164
+ }
165
+ if (!Array.isArray(body?.items)) throw new Error("The ticket response has no items array; refusing to invent a standup.");
166
+ const truncated = body.hasMore === true || Boolean(text(body.nextBeforeId) || text(body.nextCursor) || text(body.nextAfterId));
167
+ return buildSessionStandup(body.items, { sessionId, ...(cutoff ? { since: cutoff } : {}), now, truncated });
168
+ }
169
+
170
+ export function formatSessionStandup(report) {
171
+ // Keep untrusted server text on one terminal line, without directional overrides.
172
+ const display = (value) => String(value ?? "unknown").replace(/[\u0000-\u001f\u007f-\u009f\u2028-\u202e\u2066-\u2069]/g, " ");
173
+ const lines = [`Standup ${display(report.sessionId)} · since ${report.since || "all returned records"}`];
174
+ if (report.truncated) lines.push("Warning: ticket board is truncated; more tickets may exist beyond this page.");
175
+ for (const [key, heading] of SECTIONS) {
176
+ lines.push("", heading);
177
+ if (!report[key].length) lines.push(" (none)");
178
+ for (const item of report[key]) {
179
+ let detail = "";
180
+ if (key === "accepted") detail = item.verdict.evidence.length
181
+ ? ` · verdict: ${item.verdict.evidence.map((claim) => `${display(claim.kind)} ${display(claim.reference)}`).join("; ")}`
182
+ : ` · re-checkable verdict on record (details not in this read; checked ${display(item.lastVerdict?.checkedAt)})`;
183
+ if (key === "ownerAttested") detail = ` · owner statement by ${display(item.ownerAttestation.attestedBy)} (unverified): ${display(item.ownerAttestation.statement ?? "statement not in this read")}`;
184
+ if (key === "reportedButUnverified") detail = " · no re-checkable done verdict";
185
+ if (key === "inProgress") detail = ` · assignee: ${display(item.assignee)} · since: ${display(item.claimedAt)}${item.staleClaim === true ? " · STALE claim" : ""}`;
186
+ if (key === "needsOwnerDecision") detail = ` · created: ${display(item.createdAt)}`;
187
+ const missing = item.unknown.length ? ` · unknown: ${item.unknown.join(", ")}` : "";
188
+ lines.push(` ${display(item.id)} ${display(item.title)}${detail}${missing}`);
189
+ }
190
+ }
191
+ return lines.join("\n");
192
+ }
@@ -8,8 +8,10 @@ import { credentialedRequest, credentialFor, isAuthenticated } from "../auth/cre
8
8
  import { resolveActiveAuthSession } from "../auth/service.js";
9
9
  import { createAgentEvent } from "../events/schema.js";
10
10
  import { isSessionControlEvent } from "./control-events.js";
11
- import { createSessionMutationHeaders } from "./invitations.js";
11
+ import { createSessionMutationHeaders, throwIfPendingSessionInvitation } from "./invitations.js";
12
12
  import { messageRevision } from "./message-edits.js";
13
+ import { appendAttachmentSummary } from "./attachment-summary.js";
14
+ import { readAdmissionCredentialState } from "./admission.js";
13
15
  import { installTestEgressGuard } from "../net/test-egress-guard.js";
14
16
  import { isUrlPathSegment, urlPathSegment } from "../net/url-path.js";
15
17
 
@@ -660,9 +662,12 @@ async function readResponseJsonWithTimeout(
660
662
  }
661
663
  }
662
664
 
663
- async function readResponseJsonSafely(response, timeoutMs = DEFAULT_SYNC_TIMEOUT_MS) {
665
+ async function readResponseJsonSafely(response, timeoutMs = DEFAULT_SYNC_TIMEOUT_MS, controller = null) {
664
666
  try {
665
- return await readResponseJsonWithTimeout(response, timeoutMs, { fallback: null });
667
+ return await readResponseJsonWithTimeout(response, timeoutMs, {
668
+ fallback: null,
669
+ onTimeout: () => abortRequestController(controller, "response_body_timeout"),
670
+ });
666
671
  } catch {
667
672
  return null;
668
673
  }
@@ -701,6 +706,7 @@ export async function fetchJsonWithFullTimeout(
701
706
  },
702
707
  );
703
708
 
709
+ const sessionRequest = new URL(url).pathname.startsWith("/api/v1/sessions/");
704
710
  let payload = {};
705
711
  if ((response?.ok && readSuccessBody) || readErrorBody || errorBodyStatuses.includes(response?.status)) {
706
712
  const elapsedMs = Math.max(0, Date.now() - startedAtMs);
@@ -709,9 +715,15 @@ export async function fetchJsonWithFullTimeout(
709
715
  fallback: {},
710
716
  onTimeout: () => abortRequestController(controller, "response_body_timeout"),
711
717
  });
718
+ } else if (sessionRequest && (response?.status === 403 || response?.status === 404)) {
719
+ // Older callers discard refusal bodies. Inspect only the invitation signal,
720
+ // within the request deadline; unreadable bodies keep the existing refusal.
721
+ const remainingMs = Math.max(1, resolvedTimeoutMs - (Date.now() - startedAtMs));
722
+ payload = await readResponseJsonSafely(response, remainingMs, controller);
712
723
  } else {
713
724
  cancelResponseBody(response);
714
725
  }
726
+ if (sessionRequest) throwIfPendingSessionInvitation(payload, response?.status);
715
727
  return { response, payload };
716
728
  }
717
729
 
@@ -957,6 +969,8 @@ export async function syncSessionEventToApi(
957
969
  resolveAuthSession = resolveActiveAuthSession,
958
970
  fetchImpl = fetchWithTimeout,
959
971
  nowMs = Date.now,
972
+ noAutoGrant = false,
973
+ readCredential = readAdmissionCredentialState,
960
974
  } = {}
961
975
  ) {
962
976
  const normalizedSessionId = normalizeString(sessionId);
@@ -1069,6 +1083,31 @@ export async function syncSessionEventToApi(
1069
1083
  return { synced: false, reason: "api_403" };
1070
1084
  }
1071
1085
  const agentId = normalizeString(event?.agent?.id);
1086
+ if (noAutoGrant) {
1087
+ return { synced: false, reason: "api_403" };
1088
+ }
1089
+ // Check the stored admission credential: a keeper that joined with
1090
+ // --no-auto-grant persists the preference.
1091
+ if (!noAutoGrant && agentId) {
1092
+ try {
1093
+ const cred = await readCredential(normalizedSessionId, agentId);
1094
+ // A present-but-unreadable or corrupt credential (state "refused")
1095
+ // means the preference cannot be determined; fail closed rather than
1096
+ // silently treating it as "no preference" and auto-granting.
1097
+ if (cred?.state === "refused") {
1098
+ return { synced: false, reason: "api_403" };
1099
+ }
1100
+ if (cred?.credential?.noAutoGrant) {
1101
+ return { synced: false, reason: "api_403" };
1102
+ }
1103
+ } catch (error) {
1104
+ // Only a missing file means no preference. Any other read error
1105
+ // (permissions, corruption, transient I/O) must fail closed: a
1106
+ // 403 must NOT auto-grant because the preference is unreadable.
1107
+ if (!error || error.code !== "ENOENT") throw error;
1108
+ // credential file absent — no preference to apply
1109
+ }
1110
+ }
1072
1111
  if (!agentId || isReservedAgentIdForGrant(agentId)) {
1073
1112
  // Reserved or empty — server enforcement is intentional, no grant
1074
1113
  // attempt is meaningful. Treat as a normal 403 failure.
@@ -1104,6 +1143,7 @@ export async function syncSessionEventToApi(
1104
1143
  remainingMs(), fetchImpl, { readSuccessBody: false },
1105
1144
  ));
1106
1145
  } catch (grantError) {
1146
+ if (grantError?.code === "INVITATION_PENDING") throw grantError;
1107
1147
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
1108
1148
  return {
1109
1149
  synced: false,
@@ -1130,6 +1170,7 @@ export async function syncSessionEventToApi(
1130
1170
  endpoint, requestInit, remainingMs(), fetchImpl, { readSuccessBody: false },
1131
1171
  ));
1132
1172
  } catch (retryError) {
1173
+ if (retryError?.code === "INVITATION_PENDING") throw retryError;
1133
1174
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
1134
1175
  return {
1135
1176
  synced: false,
@@ -1158,6 +1199,7 @@ export async function syncSessionEventToApi(
1158
1199
  reason: `api_${response ? response.status : "no_response"}`,
1159
1200
  };
1160
1201
  } catch (error) {
1202
+ if (error?.code === "INVITATION_PENDING") throw error;
1161
1203
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
1162
1204
  return {
1163
1205
  synced: false,
@@ -1254,6 +1296,7 @@ async function syncSessionAuxPayload(
1254
1296
  status: response.status,
1255
1297
  };
1256
1298
  } catch (error) {
1299
+ if (error?.code === "INVITATION_PENDING") throw error;
1257
1300
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
1258
1301
  return {
1259
1302
  synced: false,
@@ -1284,6 +1327,63 @@ export async function syncSessionErrorToApi(sessionId, error, options = {}) {
1284
1327
  return syncSessionAuxPayload(sessionId, "/errors", { error }, options);
1285
1328
  }
1286
1329
 
1330
+ /**
1331
+ * POST a validated judge-verdict document to the room's judge-verdicts route. The body is the
1332
+ * document only: the server decides who judged from the credential. Never throws; returns
1333
+ * `{ ok, status, code, body, reason }` so the caller can tell "route not here" (404 without a
1334
+ * room or target code) from a refusal it must report.
1335
+ */
1336
+ export async function postJudgeVerdictToApi(
1337
+ sessionId,
1338
+ verdict,
1339
+ {
1340
+ targetPath = process.cwd(),
1341
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1342
+ resolveAuthSession = resolveActiveAuthSession,
1343
+ fetchImpl = fetchWithTimeout,
1344
+ } = {},
1345
+ ) {
1346
+ const normalizedSessionId = normalizeString(sessionId);
1347
+ if (!isUrlPathSegment(normalizedSessionId) || !verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
1348
+ return { ok: false, status: 0, code: "", body: null, reason: "invalid_input" };
1349
+ }
1350
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
1351
+ return { ok: false, status: 0, code: "", body: null, reason: "remote_sync_disabled_env" };
1352
+ }
1353
+ let session = null;
1354
+ try {
1355
+ session = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
1356
+ } catch {
1357
+ return { ok: false, status: 0, code: "", body: null, reason: "no_session" };
1358
+ }
1359
+ if (!isAuthenticated(session)) {
1360
+ return { ok: false, status: 0, code: "", body: null, reason: "not_authenticated" };
1361
+ }
1362
+ const endpoint = `${resolveApiBaseUrl(session)}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/judge-verdicts`;
1363
+ try {
1364
+ const { response, payload } = await fetchJsonWithFullTimeout(
1365
+ endpoint,
1366
+ {
1367
+ method: "POST",
1368
+ headers: { "Content-Type": "application/json" },
1369
+ credential: await credentialFor(session),
1370
+ body: JSON.stringify(verdict),
1371
+ },
1372
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1373
+ fetchImpl,
1374
+ { readErrorBody: true },
1375
+ );
1376
+ const status = response ? response.status : 0;
1377
+ const body = payload && typeof payload === "object" ? payload : null;
1378
+ const code = normalizeString(body?.detail?.error?.code || body?.error?.code || body?.code);
1379
+ if (response?.ok) return { ok: true, status, code: "", body, reason: "" };
1380
+ return { ok: false, status, code, body, reason: `api_${status || "no_response"}` };
1381
+ } catch (error) {
1382
+ if (error?.code === "INVITATION_PENDING") throw error;
1383
+ return { ok: false, status: 0, code: "", body: null, reason: normalizeString(error?.message) || "request_failed" };
1384
+ }
1385
+ }
1386
+
1287
1387
  /**
1288
1388
  * Poll human messages for a session from sentinelayer-api and map them into
1289
1389
  * canonical high-priority session events for local relay.
@@ -1417,6 +1517,7 @@ export async function pollHumanMessages(
1417
1517
  dropped,
1418
1518
  };
1419
1519
  } catch (error) {
1520
+ if (error?.code === "INVITATION_PENDING") throw error;
1420
1521
  recordCircuitFailure(inboundCircuit, normalizedNowMs);
1421
1522
  return {
1422
1523
  ok: false,
@@ -1603,6 +1704,7 @@ export async function pollSessionEvents(
1603
1704
  scanExhausted: typeof rawScanExhausted === "boolean" ? rawScanExhausted : null,
1604
1705
  };
1605
1706
  } catch (error) {
1707
+ if (error?.code === "INVITATION_PENDING") throw error;
1606
1708
  recordCircuitFailure(inboundCircuit, normalizedNowMs);
1607
1709
  return {
1608
1710
  ok: false,
@@ -1745,6 +1847,7 @@ export async function fetchSessionPresence(
1745
1847
  present,
1746
1848
  };
1747
1849
  } catch (error) {
1850
+ if (error?.code === "INVITATION_PENDING") throw error;
1748
1851
  recordCircuitFailure(inboundCircuit, normalizedNowMs);
1749
1852
  return {
1750
1853
  ok: false,
@@ -1771,6 +1874,7 @@ export async function renewSessionPresence(
1771
1874
  displayName = "",
1772
1875
  provider = "",
1773
1876
  clientKind = "cli",
1877
+ minimalPresence = false,
1774
1878
  observedAt = new Date().toISOString(),
1775
1879
  timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1776
1880
  signal = undefined,
@@ -1854,11 +1958,12 @@ export async function renewSessionPresence(
1854
1958
  "Content-Type": "application/json",
1855
1959
  },
1856
1960
  credential: await credentialFor(session),
1857
- body: JSON.stringify(body),
1961
+ body: JSON.stringify(minimalPresence ? { agentId: body.agentId, model: body.model } : body),
1858
1962
  signal,
1859
1963
  },
1860
1964
  normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1861
1965
  fetchImpl,
1966
+ { errorBodyStatuses: [403] },
1862
1967
  );
1863
1968
  if (!response?.ok) {
1864
1969
  if (response?.status === 404) {
@@ -1882,6 +1987,7 @@ export async function renewSessionPresence(
1882
1987
  return {
1883
1988
  ok: false,
1884
1989
  reason: `api_${response ? response.status : "no_response"}`,
1990
+ code: normalizeString(payload?.error?.code || payload?.code || payload?.detail?.code).toUpperCase(),
1885
1991
  status: "degraded",
1886
1992
  recorded: false,
1887
1993
  retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
@@ -1897,6 +2003,7 @@ export async function renewSessionPresence(
1897
2003
  ttlSeconds: normalizePositiveInteger(payload?.ttlSeconds || payload?.ttl_seconds, null),
1898
2004
  };
1899
2005
  } catch (error) {
2006
+ if (error?.code === "INVITATION_PENDING") throw error;
1900
2007
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
1901
2008
  return {
1902
2009
  ok: false,
@@ -2017,6 +2124,7 @@ export async function requestSessionListenerStop(
2017
2124
  control,
2018
2125
  };
2019
2126
  } catch (error) {
2127
+ if (error?.code === "INVITATION_PENDING") throw error;
2020
2128
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
2021
2129
  return {
2022
2130
  ok: false,
@@ -2139,6 +2247,7 @@ export async function updateSessionReadCursor(
2139
2247
  ),
2140
2248
  };
2141
2249
  } catch (error) {
2250
+ if (error?.code === "INVITATION_PENDING") throw error;
2142
2251
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
2143
2252
  return {
2144
2253
  ok: false,
@@ -2230,6 +2339,7 @@ export async function streamSessionEvents(
2230
2339
  const controller = new AbortController();
2231
2340
  const normalizedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
2232
2341
  const normalizedIdleTimeoutMs = normalizePositiveInteger(idleTimeoutMs, 0);
2342
+ const deadlineMs = Date.now() + normalizedTimeoutMs;
2233
2343
  const timeoutHandle = setTimeout(() => controller.abort(), normalizedTimeoutMs);
2234
2344
  if (typeof timeoutHandle.unref === "function") {
2235
2345
  timeoutHandle.unref();
@@ -2321,6 +2431,11 @@ export async function streamSessionEvents(
2321
2431
 
2322
2432
  try {
2323
2433
  if (!response || !response.ok || !response.body) {
2434
+ if (response?.status === 403 || response?.status === 404) {
2435
+ const remainingMs = Math.max(1, deadlineMs - Date.now());
2436
+ const payload = await readResponseJsonSafely(response, remainingMs, controller);
2437
+ throwIfPendingSessionInvitation(payload, response.status);
2438
+ }
2324
2439
  return {
2325
2440
  ok: false,
2326
2441
  reason: `api_${response ? response.status : "no_response"}`,
@@ -2347,6 +2462,7 @@ export async function streamSessionEvents(
2347
2462
  await processSseBlock(buffer, handlers);
2348
2463
  }
2349
2464
  } catch (error) {
2465
+ if (error?.code === "INVITATION_PENDING") throw error;
2350
2466
  return {
2351
2467
  ok: false,
2352
2468
  reason: idleTimedOut
@@ -2495,8 +2611,13 @@ export async function pollSessionEventsBefore(
2495
2611
  events,
2496
2612
  cursor: normalizeString(lastEvent?.cursor) || null,
2497
2613
  beforeSequence: nextBeforeSequenceFromPayload(payload, payload?.events || []),
2614
+ hasMore: typeof payload?.has_more === "boolean" ? payload.has_more : null,
2615
+ partial: typeof payload?.partial === "boolean" ? payload.partial : null,
2616
+ rawEvents: payload?.events,
2617
+ rawNextBeforeSequence: payload?.next_before_sequence ?? null,
2498
2618
  };
2499
2619
  } catch (error) {
2620
+ if (error?.code === "INVITATION_PENDING") throw error;
2500
2621
  recordCircuitFailure(inboundCircuit, normalizedNowMs);
2501
2622
  return {
2502
2623
  ok: false,
@@ -2607,6 +2728,7 @@ export async function listSessionMessageActions(
2607
2728
  : null,
2608
2729
  };
2609
2730
  } catch (error) {
2731
+ if (error?.code === "INVITATION_PENDING") throw error;
2610
2732
  recordCircuitFailure(inboundCircuit, normalizedNowMs);
2611
2733
  return {
2612
2734
  ok: false,
@@ -2698,6 +2820,7 @@ export async function fetchSessionUsageLedger(
2698
2820
  payload: payload && typeof payload === "object" ? payload : {},
2699
2821
  };
2700
2822
  } catch (error) {
2823
+ if (error?.code === "INVITATION_PENDING") throw error;
2701
2824
  recordCircuitFailure(inboundCircuit, normalizedNowMs);
2702
2825
  return {
2703
2826
  ok: false,
@@ -2807,7 +2930,10 @@ export async function fetchSessionPinnedMessages(
2807
2930
  null;
2808
2931
  if (match) {
2809
2932
  base.author = pinnedEventAuthorId(match);
2810
- base.content = pinnedEventContentText(match);
2933
+ base.content = appendAttachmentSummary(pinnedEventContentText(match), { sessionId: normalizedSessionId, ...match });
2934
+ if (Array.isArray(match.payload?.attachments) && match.payload.attachments.length) {
2935
+ base.attachments = match.payload.attachments;
2936
+ }
2811
2937
  base.resolved = true;
2812
2938
  }
2813
2939
  }
@@ -2825,6 +2951,92 @@ export async function fetchSessionPinnedMessages(
2825
2951
  };
2826
2952
  }
2827
2953
 
2954
+ // An attachment id as the API accepts it in a path.
2955
+ const SESSION_ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{8,120}$/;
2956
+
2957
+ /**
2958
+ * Read one room attachment's metadata and a short-lived signed download link:
2959
+ * `GET /api/v1/sessions/{id}/attachments/{attachmentId}?download=true`, which answers
2960
+ * `{ ok, attachment, download: { url, method, expiresIn, expiresAt } }`. The link points at the
2961
+ * file store and carries its own signature; fetch it without API credentials. Someone other
2962
+ * than the uploader gets 404 for a file no sent message has published yet.
2963
+ *
2964
+ * Never throws; a refusal reports the HTTP status and the API error code.
2965
+ */
2966
+ export async function fetchSessionAttachment(
2967
+ sessionId,
2968
+ attachmentId,
2969
+ {
2970
+ targetPath = process.cwd(),
2971
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
2972
+ resolveAuthSession = resolveActiveAuthSession,
2973
+ fetchImpl = fetchWithTimeout,
2974
+ nowMs = Date.now,
2975
+ } = {}
2976
+ ) {
2977
+ const normalizedSessionId = normalizeString(sessionId);
2978
+ const normalizedAttachmentId = normalizeString(attachmentId);
2979
+ if (!isUrlPathSegment(normalizedSessionId)) {
2980
+ return { ok: false, reason: "invalid_session_id", status: null };
2981
+ }
2982
+ if (!SESSION_ATTACHMENT_ID_PATTERN.test(normalizedAttachmentId) || normalizedAttachmentId.includes("..")) {
2983
+ return { ok: false, reason: "invalid_attachment_id", status: null };
2984
+ }
2985
+
2986
+ let session = null;
2987
+ try {
2988
+ session = await resolveAuthSession({
2989
+ cwd: targetPath,
2990
+ env: process.env,
2991
+ autoRotate: false,
2992
+ });
2993
+ } catch {
2994
+ return { ok: false, reason: "no_session", status: null };
2995
+ }
2996
+ if (!isAuthenticated(session)) {
2997
+ return { ok: false, reason: "not_authenticated", status: null };
2998
+ }
2999
+
3000
+ const apiBaseUrl = resolveApiBaseUrl(session);
3001
+ const endpoint =
3002
+ `${apiBaseUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}` +
3003
+ `/attachments/${urlPathSegment(normalizedAttachmentId, { label: "attachmentId" })}?download=true`;
3004
+ try {
3005
+ const { response, payload } = await fetchJsonWithFullTimeout(
3006
+ endpoint,
3007
+ {
3008
+ method: "GET",
3009
+ credential: await credentialFor(session),
3010
+ },
3011
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
3012
+ fetchImpl,
3013
+ { readErrorBody: true },
3014
+ );
3015
+ if (!response || !response.ok) {
3016
+ const status = response ? response.status : null;
3017
+ return {
3018
+ ok: false,
3019
+ reason: `api_${status ?? "no_response"}`,
3020
+ status,
3021
+ errorCode: normalizeString(payload?.error?.code) || null,
3022
+ retryAfterMs: isRateLimitResponse(response, payload)
3023
+ ? retryAfterMsFromResponse(response, payload, Number(nowMs()) || Date.now())
3024
+ : null,
3025
+ };
3026
+ }
3027
+ return {
3028
+ ok: true,
3029
+ reason: "",
3030
+ status: response.status,
3031
+ attachment: payload?.attachment && typeof payload.attachment === "object" ? payload.attachment : null,
3032
+ download: payload?.download && typeof payload.download === "object" ? payload.download : null,
3033
+ };
3034
+ } catch (error) {
3035
+ if (error?.code === "INVITATION_PENDING") throw error;
3036
+ return { ok: false, reason: normalizeString(error?.message) || "attachment_read_failed", status: null };
3037
+ }
3038
+ }
3039
+
2828
3040
  // Native edits never fall back to POST /events or mutate an optimistic local row.
2829
3041
  export async function editSessionMessage(sessionId, {
2830
3042
  targetSequenceId = null,
@@ -2912,6 +3124,7 @@ export async function editSessionMessage(sessionId, {
2912
3124
  recordCircuitSuccess(outboundCircuit);
2913
3125
  return { ok: true, changed: Boolean(payload.changed), replayed: Boolean(payload.replayed), event: payload.event || null, reply: payload.reply || null, notification: payload.notification || null };
2914
3126
  } catch (error) {
3127
+ if (error?.code === "INVITATION_PENDING") throw error;
2915
3128
  recordCircuitFailure(outboundCircuit, timestamp);
2916
3129
  return { ok: false, reason: normalizeString(error?.message) || "message_edit_failed" };
2917
3130
  }
@@ -3063,6 +3276,7 @@ export async function createSessionMessageAction(
3063
3276
  action: payload?.action && typeof payload.action === "object" ? payload.action : null,
3064
3277
  };
3065
3278
  } catch (error) {
3279
+ if (error?.code === "INVITATION_PENDING") throw error;
3066
3280
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
3067
3281
  return {
3068
3282
  ok: false,
@@ -3189,6 +3403,7 @@ export async function searchSessionEvents(
3189
3403
  nextBeforeSequence: Number(payload?.next_before_sequence ?? payload?.nextBeforeSequence) || null,
3190
3404
  };
3191
3405
  } catch (error) {
3406
+ if (error?.code === "INVITATION_PENDING") throw error;
3192
3407
  recordCircuitFailure(inboundCircuit, normalizedNowMs);
3193
3408
  return {
3194
3409
  ok: false,
@@ -3294,6 +3509,7 @@ export async function listSessionsFromApi({
3294
3509
  fetchImpl,
3295
3510
  ));
3296
3511
  } catch (err) {
3512
+ if (err?.code === "INVITATION_PENDING") throw err;
3297
3513
  return {
3298
3514
  ok: false,
3299
3515
  reason: normalizeString(err?.message) || "list_failed",
@@ -3414,6 +3630,7 @@ export async function fetchSessionFromApi(
3414
3630
  fetchImpl,
3415
3631
  ));
3416
3632
  } catch (err) {
3633
+ if (err?.code === "INVITATION_PENDING") throw err;
3417
3634
  return {
3418
3635
  ok: false,
3419
3636
  reason: normalizeString(err?.message) || "fetch_failed",
@@ -3482,13 +3699,15 @@ export async function probeSessionAccess(
3482
3699
 
3483
3700
  let response;
3484
3701
  try {
3485
- response = await credentialedRequest(
3486
- await credentialFor(session),
3702
+ ({ response } = await fetchJsonWithFullTimeout(
3487
3703
  endpoint,
3488
- { method: "GET" },
3489
- { fetchImpl: (target, init) => fetchImpl(target, init, normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)) },
3490
- );
3704
+ { method: "GET", credential: await credentialFor(session) },
3705
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
3706
+ fetchImpl,
3707
+ { readSuccessBody: false },
3708
+ ));
3491
3709
  } catch (err) {
3710
+ if (err?.code === "INVITATION_PENDING") throw err;
3492
3711
  return {
3493
3712
  accessible: false,
3494
3713
  reason: normalizeString(err?.message) || "probe_failed",