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
@@ -6,6 +6,8 @@ import { createHash, randomUUID } from "node:crypto";
6
6
  import { spawn as defaultSpawn } from "node:child_process";
7
7
 
8
8
  import pc from "picocolors";
9
+ import { appendAttachmentSummary, withAttachmentSummary } from "../session/attachment-summary.js";
10
+ import { formatEditSummary, withEditSummary } from "../session/edit-summary.js";
9
11
 
10
12
  import { credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
11
13
  import { SentinelayerApiError, requestJsonMutation } from "../auth/http.js";
@@ -18,7 +20,7 @@ import {
18
20
  import { recordProvisionedIdentity } from "../ai/identity-store.js";
19
21
  import { fetchAidenIdCredentials } from "../auth/service.js";
20
22
  import { resolveActiveAuthSession } from "../auth/service.js";
21
- import { resolveOutputRoot } from "../config/service.js";
23
+ import { resolveOutputContext } from "../config/service.js";
22
24
  import {
23
25
  listAssignments,
24
26
  releaseLease,
@@ -33,6 +35,7 @@ import {
33
35
  unregisterAgent,
34
36
  } from "../session/agent-registry.js";
35
37
  import { inferSessionAgentIdentity } from "../session/agent-identity.js";
38
+ import { legacyJoinAdmissionRefusal, verifyLegacyAgentJoin } from "../session/agent-access.js";
36
39
  import { startSenti, stopSenti } from "../session/daemon.js";
37
40
  import {
38
41
  getDaemonStatus,
@@ -61,6 +64,7 @@ import {
61
64
  setupSessionGuides,
62
65
  } from "../session/setup-guides.js";
63
66
  import { listSessionTasks } from "../session/tasks.js";
67
+ import { formatSessionStandup, sessionStandup } from "../session/standup.js";
64
68
  import {
65
69
  createSession,
66
70
  DEFAULT_TTL_SECONDS,
@@ -88,6 +92,8 @@ import {
88
92
  } from "../session/control-events.js";
89
93
  import {
90
94
  acceptSessionInvitation,
95
+ acceptPendingSessionInvitation,
96
+ listPendingSessionInvitations,
91
97
  normalizeSessionOnboarding,
92
98
  writeSessionOnboardingBrief,
93
99
  } from "../session/invitations.js";
@@ -118,6 +124,7 @@ import {
118
124
  } from "../session/reactions.js";
119
125
  import {
120
126
  claimTicket,
127
+ createTicket,
121
128
  listTickets,
122
129
  releaseTicketLease,
123
130
  renewTicketLease,
@@ -139,6 +146,7 @@ import {
139
146
  createSessionMessageAction,
140
147
  editSessionMessage,
141
148
  fetchJsonWithFullTimeout,
149
+ fetchSessionPresence,
142
150
  fetchSessionUsageLedger,
143
151
  fetchSessionPinnedMessages,
144
152
  listSessionMessageActions,
@@ -155,7 +163,15 @@ import {
155
163
  updateSessionReadCursor,
156
164
  } from "../session/sync.js";
157
165
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
158
- import { messageRevision, replyRevisionEvent, sessionMessageRoutingEvent } from "../session/message-edits.js";
166
+ import {
167
+ DEFAULT_ROOM_FILES_LIMIT,
168
+ DEFAULT_ROOM_FILES_MAX_PAGES,
169
+ MAX_ROOM_FILES_LIMIT,
170
+ RoomFileError,
171
+ downloadRoomFile,
172
+ listRoomFiles,
173
+ } from "../session/room-files.js";
174
+ import { messageRevision, replyRevisionEvent } from "../session/message-edits.js";
159
175
  import { runSessionRecall } from "../session/recall/index.js";
160
176
  import { mergeLiveSources } from "../session/live-source.js";
161
177
  import { listenSessionEvents } from "../session/listener.js";
@@ -853,7 +869,7 @@ function formatHumanAskLine(event = {}) {
853
869
  const message = humanAskMessage(event);
854
870
  const shortMessage = message.length > 220 ? `${message.slice(0, 217)}...` : message;
855
871
  const suffix = shortMessage ? `: ${shortMessage}` : "";
856
- return `${sequenceLabel} ${humanAskAgentId(event)}${ts ? ` ${ts}` : ""}${suffix}`;
872
+ return appendAttachmentSummary(`${sequenceLabel} ${humanAskAgentId(event)}${ts ? ` ${ts}` : ""}${suffix}`, event);
857
873
  }
858
874
 
859
875
  function formatRemoteHydrateAppendWarning(result = {}) {
@@ -1459,6 +1475,7 @@ async function fetchRemoteSessionDetail(endpoint, credential) {
1459
1475
  endpoint, { method: "GET", credential, redirect: "error" }, 2_000,
1460
1476
  ));
1461
1477
  } catch (err) {
1478
+ if (err?.code === "INVITATION_PENDING") throw err;
1462
1479
  return {
1463
1480
  ok: false,
1464
1481
  response: null,
@@ -1985,6 +2002,7 @@ async function publishListenerPresenceEvent({
1985
2002
  clientKind = "cli",
1986
2003
  listenerId,
1987
2004
  lifecycle = {},
2005
+ minimalPresence = false,
1988
2006
  } = {}) {
1989
2007
  const normalizedType = normalizeString(lifecycle.type) || "heartbeat";
1990
2008
  const identity = inferSessionAgentIdentity({
@@ -1997,6 +2015,7 @@ async function publishListenerPresenceEvent({
1997
2015
  return renewSessionPresence(sessionId, {
1998
2016
  targetPath,
1999
2017
  agentId,
2018
+ minimalPresence,
2000
2019
  state:
2001
2020
  normalizedType === "stopped"
2002
2021
  ? "stopped"
@@ -2439,14 +2458,29 @@ function resolveSessionIdOption(options = {}) {
2439
2458
  return sessionId;
2440
2459
  }
2441
2460
 
2461
+ function formatFileSize(bytes) {
2462
+ const value = Number(bytes) || 0;
2463
+ if (value < 1024) return `${value} B`;
2464
+ const units = ["KB", "MB", "GB"];
2465
+ let size = value / 1024;
2466
+ let unit = 0;
2467
+ while (size >= 1024 && unit < units.length - 1) {
2468
+ size /= 1024;
2469
+ unit += 1;
2470
+ }
2471
+ return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`;
2472
+ }
2473
+
2442
2474
  function formatEventLine(event = {}) {
2475
+ return appendAttachmentSummary(formatEventText(event), event);
2476
+ }
2477
+
2478
+ function formatEventText(event = {}) {
2443
2479
  const ts = normalizeString(event.ts || event.timestamp);
2444
2480
  const type = normalizeString(event.event || event.type) || "event";
2445
2481
  const agentId = normalizeString(event.agent?.id || event.agentId || "unknown");
2446
2482
  if (type === "session_message_edited") {
2447
- const routed = sessionMessageRoutingEvent(event);
2448
- const target = event.payload?.targetActionId ? `reply ${event.payload.targetActionId}` : `#${event.payload?.targetSequenceId || "?"}`;
2449
- return `${ts} ${agentId} ${type} ${target} revision ${event.payload?.messageRevision || "?"}: ${normalizeString(routed.payload?.message || routed.payload?.text || "")}`;
2483
+ return `${ts} ${agentId} ${formatEditSummary(event)}`;
2450
2484
  }
2451
2485
  const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
2452
2486
  if (type === "session_action" || type === "session_reply" || type === "session_reaction") {
@@ -2903,11 +2937,69 @@ async function sessionListenAgent(options) {
2903
2937
  return { sessionId: resolveSessionIdOption(options), agentId: normalizeAgentId(options.agent, "cli-user") };
2904
2938
  }
2905
2939
 
2940
+
2941
+ /**
2942
+ * Build the admission-join options object from parsed Commander options.
2943
+ * Extracted so tests can assert the full derivation from CLI flag to
2944
+ * runAdmissionJoin input without network I/O.
2945
+ */
2946
+ export function buildAdmissionJoinOptions(options, overrides = {}) {
2947
+ const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
2948
+ return {
2949
+ agentId: overrides.agentId ?? "",
2950
+ displayName: overrides.displayName ?? "",
2951
+ model: options.model,
2952
+ provider: options.provider,
2953
+ goal: options.goal,
2954
+ deliverables: options.deliverable || [],
2955
+ stopConditions: options.stopWhen || [],
2956
+ actions: parseAdmissionScope(options.scope),
2957
+ ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
2958
+ wait: options.wait !== false,
2959
+ recoverActive: Boolean(options.recoverAdmission),
2960
+ noAutoGrant: options.autoGrant === false,
2961
+ waitTimeoutMs: (Number.isFinite(waitSeconds) && waitSeconds >= 0 ? waitSeconds : 900) * 1000,
2962
+ targetPath: overrides.targetPath ?? process.cwd(),
2963
+ onPending: overrides.onPending ?? (() => {}),
2964
+ };
2965
+ }
2966
+
2906
2967
  export function registerSessionCommand(program) {
2907
2968
  const session = program
2908
2969
  .command("session")
2909
2970
  .description("Multi-agent ephemeral coordination sessions");
2910
2971
 
2972
+ const invitations = session.command("invitations").description("List and accept pending room invitations");
2973
+ invitations
2974
+ .command("list")
2975
+ .description("List your pending room invitations")
2976
+ .option("--path <path>", "Workspace path for authentication", ".")
2977
+ .option("--json", "Emit the raw API response")
2978
+ .action(async (options, command) => {
2979
+ const result = await listPendingSessionInvitations({ targetPath: path.resolve(options.path) });
2980
+ if (shouldEmitJson(options, command)) {
2981
+ console.log(JSON.stringify(result, null, 2));
2982
+ return;
2983
+ }
2984
+ const entries = Array.isArray(result) ? result : result?.invitations;
2985
+ if (!Array.isArray(entries) || entries.some((entry) => !normalizeString(entry?.invitationId))) {
2986
+ console.log("Pending invitations retrieved. Use `sl session invitations list --json` to inspect the response.");
2987
+ return;
2988
+ }
2989
+ if (entries.length === 0) console.log("No pending invitations.");
2990
+ for (const entry of entries) console.log(`Invitation: ${entry.invitationId}`);
2991
+ });
2992
+ invitations
2993
+ .command("accept <invitationId>")
2994
+ .description("Accept a pending invitation using its invitationId from invitations list")
2995
+ .option("--path <path>", "Workspace path for authentication", ".")
2996
+ .option("--json", "Emit the raw API response")
2997
+ .action(async (invitationId, options, command) => {
2998
+ const result = await acceptPendingSessionInvitation(invitationId, { targetPath: path.resolve(options.path) });
2999
+ if (shouldEmitJson(options, command)) console.log(JSON.stringify(result, null, 2));
3000
+ else console.log("Invitation accepted.");
3001
+ });
3002
+
2911
3003
  session
2912
3004
  .command("start")
2913
3005
  .description(
@@ -3437,6 +3529,8 @@ export function registerSessionCommand(program) {
3437
3529
  .option("--no-wait", "Request admission and return immediately with the approval URL")
3438
3530
  .option("--wait-seconds <n>", "How long to wait for approval before returning pending", "900")
3439
3531
  .option("--cancel-admission", "Cancel this workspace's pending admission request for the session")
3532
+ .option("--recover-admission", "Recover a live admission with the retained agent key; requires the unchanged --goal/scope/ttl")
3533
+ .option("--no-auto-grant", "Skip auto-grant on 403 IDENTITY_FORGERY; fail immediately instead")
3440
3534
  .option("--json", "Emit machine-readable output")
3441
3535
  .action(async (sessionId, options, command) => {
3442
3536
  const normalizedSessionId = normalizeString(sessionId);
@@ -3455,6 +3549,27 @@ export function registerSessionCommand(program) {
3455
3549
  // the local registration and every later implicit command name the same agent.
3456
3550
  if (!explicitAgent) assertCanonicalAgentId(legacyName, "--name");
3457
3551
  const requestedAgent = canonicalAgentId(explicitAgent || legacyName);
3552
+ if (options.recoverAdmission && !normalizeString(options.goal)) {
3553
+ throw new Error("--recover-admission requires --goal and the admission's unchanged scope/ttl.");
3554
+ }
3555
+ const rejectLegacyJoin = (verification, agentId) => {
3556
+ process.exitCode = 4;
3557
+ const payload = {
3558
+ command: "session join", joined: false, sessionId: normalizedSessionId,
3559
+ agentId, remoteAgentVerification: verification,
3560
+ };
3561
+ if (emitJson) console.log(JSON.stringify(payload, null, 2));
3562
+ else {
3563
+ const hint = verification.code.includes("UNAVAILABLE")
3564
+ ? "Retry when the API/presence service is available; no new approval was requested."
3565
+ : verification.code.includes("CONTRACT_MISMATCH")
3566
+ ? "Verify the CLI/server contract; no new authorization was created."
3567
+ : verification.code === "LEGACY_GRANT_ROLE_MISMATCH"
3568
+ ? "Use the already-granted role, or request an explicit owner-approved change."
3569
+ : "Request scoped admission with --goal, or have the owner explicitly authorize its legacy grant.";
3570
+ console.log(pc.red(`Agent was not joined: ${verification.code}. ${hint}`));
3571
+ }
3572
+ };
3458
3573
  if (options.cancelAdmission) {
3459
3574
  const cancelled = await cancelAdmission(normalizedSessionId, {
3460
3575
  agentId: requestedAgent,
@@ -3473,6 +3588,13 @@ export function registerSessionCommand(program) {
3473
3588
  else console.log(`Admission request ${payload.admission.admissionId || ""} cancelled.`);
3474
3589
  return;
3475
3590
  }
3591
+ if (requestedAgent && !normalizeString(options.goal)) {
3592
+ const refusal = await legacyJoinAdmissionRefusal(normalizedSessionId, requestedAgent);
3593
+ if (refusal) {
3594
+ rejectLegacyJoin(refusal, requestedAgent);
3595
+ return;
3596
+ }
3597
+ }
3476
3598
  // Membership first. Only a member may ask for an agent to be admitted, so an
3477
3599
  // invitation is accepted BEFORE the admission request, never after.
3478
3600
  let invitationAccept = null;
@@ -3491,24 +3613,15 @@ export function registerSessionCommand(program) {
3491
3613
  : null;
3492
3614
  }
3493
3615
  let admission = null;
3616
+ let admissionJoinOptions = null;
3494
3617
  if (normalizeString(options.goal)) {
3495
3618
  const admissionAgent = requestedAgent;
3496
3619
  if (!admissionAgent) {
3497
3620
  throw new Error("--goal requires --agent <id>: admission is requested for one named agent.");
3498
3621
  }
3499
- const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
3500
- admission = await runAdmissionJoin(normalizedSessionId, {
3622
+ admissionJoinOptions = buildAdmissionJoinOptions(options, {
3501
3623
  agentId: admissionAgent,
3502
3624
  displayName: legacyName && explicitAgent ? legacyName : "",
3503
- model: options.model,
3504
- provider: options.provider,
3505
- goal: options.goal,
3506
- deliverables: options.deliverable || [],
3507
- stopConditions: options.stopWhen || [],
3508
- actions: parseAdmissionScope(options.scope),
3509
- ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
3510
- wait: options.wait !== false,
3511
- waitTimeoutMs: (Number.isFinite(waitSeconds) && waitSeconds >= 0 ? waitSeconds : 900) * 1000,
3512
3625
  targetPath,
3513
3626
  onPending: ({ approveUrl, phase }) => {
3514
3627
  // stderr, so --json stdout stays a single parseable document.
@@ -3524,6 +3637,7 @@ export function registerSessionCommand(program) {
3524
3637
  else console.log(line);
3525
3638
  },
3526
3639
  });
3640
+ admission = await runAdmissionJoin(normalizedSessionId, admissionJoinOptions);
3527
3641
  if (admission.status !== "active") {
3528
3642
  // Only an ACTIVE admission joins. Pending, denied, cancelled or expired never
3529
3643
  // registers the agent or materializes the room for it.
@@ -3550,6 +3664,34 @@ export function registerSessionCommand(program) {
3550
3664
  }
3551
3665
  }
3552
3666
 
3667
+ const admittedAgentId = admission ? requestedAgent : "";
3668
+ const acceptedOnboarding = normalizeSessionOnboarding(
3669
+ invitationAcceptResult?.onboarding,
3670
+ invitationAcceptResult?.claimedSeat,
3671
+ );
3672
+ const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
3673
+ if (!admission && !explicitAgent && acceptedAgentId) {
3674
+ assertCanonicalAgentId(acceptedAgentId, "the invitation's agent id");
3675
+ }
3676
+ const agentSeed = admission ? admittedAgentId : explicitAgent || acceptedAgentId || legacyName;
3677
+ const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
3678
+ if (admission && resolvedAgentId !== admittedAgentId) {
3679
+ throw new Error(`join would register "${resolvedAgentId}", not the admitted agent "${admittedAgentId}".`);
3680
+ }
3681
+ const roleWasExplicit = optionWasSetByCli(command, "role");
3682
+ const role =
3683
+ normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
3684
+ "coder";
3685
+ const model = normalizeString(options.model) || "cli";
3686
+ const namedLegacyAgent = !admission && Boolean(agentSeed || normalizeString(process.env.SENTINELAYER_AGENT_ID));
3687
+ if (namedLegacyAgent) {
3688
+ const refusal = await legacyJoinAdmissionRefusal(normalizedSessionId, resolvedAgentId);
3689
+ if (refusal) {
3690
+ rejectLegacyJoin(refusal, resolvedAgentId);
3691
+ return;
3692
+ }
3693
+ }
3694
+
3553
3695
  // PR #483 contract: verify the session exists and the caller has access
3554
3696
  // BEFORE materializing local cache state. Without this we'd silently
3555
3697
  // create a phantom local NDJSON for a session that's archived or owned
@@ -3564,21 +3706,36 @@ export function registerSessionCommand(program) {
3564
3706
  // grant's receipt, and never with the human token. A cached "active" is not
3565
3707
  // reported until this succeeds (a revoked grant is not joined), and nothing
3566
3708
  // about the room is fetched on the human's authority.
3567
- const admittedAgentId = admission ? requestedAgent : "";
3568
3709
  let admissionReceipt = null;
3569
3710
  if (admission) {
3570
- try {
3571
- admissionReceipt = await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
3711
+ const verifyAdmissionReceipt = async () => {
3712
+ const receipt = await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
3572
3713
  fetchOwnAdmissionReceipt(normalizedSessionId, { targetPath }),
3573
3714
  );
3574
3715
  // The grant must be for EXACTLY the identity this join registers, byte for
3575
3716
  // byte: a receipt for any other spelling is a different agent's authority.
3576
- const receiptAgent = String(admissionReceipt?.agentId ?? "");
3717
+ const receiptAgent = String(receipt?.agentId ?? "");
3577
3718
  if (receiptAgent !== admittedAgentId) {
3578
3719
  throw new Error(
3579
3720
  `the admission is for agent "${receiptAgent || "(none)"}", not "${admittedAgentId}"`,
3580
3721
  );
3581
3722
  }
3723
+ return receipt;
3724
+ };
3725
+ try {
3726
+ try {
3727
+ admissionReceipt = await verifyAdmissionReceipt();
3728
+ } catch (error) {
3729
+ // One key-possession recovery for a stale bearer, never a different
3730
+ // identity, denied scope, provider/network failure or generic 403.
3731
+ if (options.recoverAdmission || !(error instanceof SentinelayerApiError) ||
3732
+ ![401, 403].includes(error.status) || error.code !== "INVALID_TOKEN") throw error;
3733
+ admission = await runAdmissionJoin(normalizedSessionId, {
3734
+ ...admissionJoinOptions, recoverActive: true,
3735
+ });
3736
+ if (admission.status !== "active") throw new Error("The admission is not active; recovery did not join the agent.");
3737
+ admissionReceipt = await verifyAdmissionReceipt();
3738
+ }
3582
3739
  } catch (error) {
3583
3740
  process.exitCode = 4;
3584
3741
  const payload = {
@@ -3594,7 +3751,7 @@ export function registerSessionCommand(program) {
3594
3751
  },
3595
3752
  };
3596
3753
  if (emitJson) console.log(JSON.stringify(payload, null, 2));
3597
- else console.log(pc.red(`The API no longer accepts this agent's admission; it was not joined.`));
3754
+ else console.log(pc.red(`The API no longer accepts this agent's admission; it was not joined. For a live grant with a retained key, retry the unchanged request with --recover-admission.`));
3598
3755
  return;
3599
3756
  }
3600
3757
  admission = { ...admission, verified: true, receipt: admissionReceipt };
@@ -3622,6 +3779,16 @@ export function registerSessionCommand(program) {
3622
3779
  }
3623
3780
 
3624
3781
  const remoteSession = verification.session || {};
3782
+ let legacyAgentVerification = null;
3783
+ if (namedLegacyAgent) {
3784
+ legacyAgentVerification = await verifyLegacyAgentJoin(normalizedSessionId, {
3785
+ agentId: resolvedAgentId, role, model, targetPath, remoteSession,
3786
+ });
3787
+ if (!legacyAgentVerification.ok) {
3788
+ rejectLegacyJoin(legacyAgentVerification, resolvedAgentId);
3789
+ return;
3790
+ }
3791
+ }
3625
3792
  const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
3626
3793
  targetPath,
3627
3794
  title: normalizeString(remoteSession.title),
@@ -3637,29 +3804,6 @@ export function registerSessionCommand(program) {
3637
3804
  )
3638
3805
  : { skipped: true, reason: "the grant does not include session.read" };
3639
3806
 
3640
- const acceptedOnboarding = normalizeSessionOnboarding(
3641
- invitationAcceptResult?.onboarding,
3642
- invitationAcceptResult?.claimedSeat,
3643
- );
3644
- const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
3645
- // An admitted join registers the admitted identity and nothing else. Otherwise an
3646
- // invitation's onboarding agent id is the identity only when none was given, and
3647
- // it is held to the same canonical rule before anything is registered locally.
3648
- if (!admission && !explicitAgent && acceptedAgentId) {
3649
- assertCanonicalAgentId(acceptedAgentId, "the invitation's agent id");
3650
- }
3651
- const agentSeed = admission ? admittedAgentId : explicitAgent || acceptedAgentId || legacyName;
3652
- const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
3653
- if (admission && resolvedAgentId !== admittedAgentId) {
3654
- throw new Error(`join would register "${resolvedAgentId}", not the admitted agent "${admittedAgentId}".`);
3655
- }
3656
- const roleWasExplicit = optionWasSetByCli(command, "role");
3657
- const role =
3658
- normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
3659
- "coder";
3660
- const model = normalizeString(options.model) || "cli";
3661
- const hasConcreteAgentIdentity = Boolean(explicitAgent || acceptedAgentId);
3662
-
3663
3807
  // Registration updates the local participant projection and returns
3664
3808
  // onboarding directly to this process. It never writes join, identity,
3665
3809
  // or coaching rows into the transcript.
@@ -3671,7 +3815,7 @@ export function registerSessionCommand(program) {
3671
3815
  clientKind: "cli",
3672
3816
  role,
3673
3817
  trackProcessExit: false,
3674
- awaitRemoteSync: hasConcreteAgentIdentity,
3818
+ preserveIdentity: Boolean(admission || namedLegacyAgent),
3675
3819
  });
3676
3820
  const agentJoinRelayed = false;
3677
3821
  const localBriefingPayload =
@@ -3714,6 +3858,7 @@ export function registerSessionCommand(program) {
3714
3858
  materializedLocalSession: localSession.materialized,
3715
3859
  refreshedLocalSession: Boolean(localSession.refreshed),
3716
3860
  verificationSource: verification.source,
3861
+ remoteAgentVerification: legacyAgentVerification,
3717
3862
  joinHydration,
3718
3863
  eventCount: Number.isFinite(eventCount) ? eventCount : 0,
3719
3864
  agentCount: Number.isFinite(agentCount) ? agentCount : 0,
@@ -3820,6 +3965,7 @@ export function registerSessionCommand(program) {
3820
3965
  .option("--stdin", "Read the message body from stdin")
3821
3966
  .option("--force-cli-user", "Allow fallback sends as cli-user when no agent identity can be resolved")
3822
3967
  .option("--local-only", "Append only to the local session cache without remote send confirmation")
3968
+ .option("--no-auto-grant", "Skip auto-grant on 403 IDENTITY_FORGERY; fail immediately instead")
3823
3969
  .option("--path <path>", "Workspace path for the session", ".")
3824
3970
  .option("--json", "Emit machine-readable output")
3825
3971
  .action(asAdmittedAgent(sessionSayAgent, async (sessionId, messageParts, options, command) => {
@@ -3898,6 +4044,7 @@ export function registerSessionCommand(program) {
3898
4044
  for (let attempt = 0; attempt < 2; attempt += 1) {
3899
4045
  remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
3900
4046
  targetPath,
4047
+ noAutoGrant: options.autoGrant === false,
3901
4048
  });
3902
4049
  if (remoteSync?.synced) break;
3903
4050
  }
@@ -3960,6 +4107,7 @@ export function registerSessionCommand(program) {
3960
4107
  .option("--display-name <name>", "Human-readable agent display name")
3961
4108
  .option("--role <role>", "Agent role metadata: coder, reviewer, tester, observer", "coder")
3962
4109
  .option("--to <agent>", "Direct the message to a specific agent id")
4110
+ .option("--no-auto-grant", "Skip auto-grant on 403 IDENTITY_FORGERY; fail immediately instead")
3963
4111
  .option("--path <path>", "Workspace path for the session", ".")
3964
4112
  .option("--json", "Emit machine-readable output")
3965
4113
  .action(async (sessionId, message, options, command) => {
@@ -4029,6 +4177,7 @@ export function registerSessionCommand(program) {
4029
4177
  }
4030
4178
  const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
4031
4179
  targetPath,
4180
+ noAutoGrant: options.autoGrant === false,
4032
4181
  });
4033
4182
  if (!remoteSync?.synced) {
4034
4183
  throw new Error(
@@ -4096,6 +4245,7 @@ export function registerSessionCommand(program) {
4096
4245
  .option("--proposal <text>", "Suggested remediation or next step")
4097
4246
  .option("--message-file <path>", "Read the observation summary from a UTF-8 file")
4098
4247
  .option("--stdin", "Read the observation summary from stdin")
4248
+ .option("--no-auto-grant", "Skip auto-grant on 403 IDENTITY_FORGERY; fail immediately instead")
4099
4249
  .option("--path <path>", "Workspace path for the session", ".")
4100
4250
  .option("--json", "Emit machine-readable output")
4101
4251
  .action(async (sessionId, summaryParts, options, command) => {
@@ -4201,6 +4351,7 @@ export function registerSessionCommand(program) {
4201
4351
  }
4202
4352
  const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
4203
4353
  targetPath,
4354
+ noAutoGrant: options.autoGrant === false,
4204
4355
  });
4205
4356
  if (!remoteSync?.synced) {
4206
4357
  throw new Error(
@@ -4685,6 +4836,132 @@ export function registerSessionCommand(program) {
4685
4836
  return payload;
4686
4837
  });
4687
4838
 
4839
+ const filesCommand = session
4840
+ .command("files <sessionId>")
4841
+ .description(
4842
+ "List the files published in a room: name, size, type, who uploaded it and when, and the attachment id to download it with",
4843
+ )
4844
+ .option("--limit <n>", `Most files to list (default ${DEFAULT_ROOM_FILES_LIMIT}, max ${MAX_ROOM_FILES_LIMIT})`)
4845
+ .option(
4846
+ "--max-pages <n>",
4847
+ `Most pages of room history to read, 200 events each (default ${DEFAULT_ROOM_FILES_MAX_PAGES})`,
4848
+ )
4849
+ .option("--path <path>", "Workspace path for the session", ".")
4850
+ .option("--json", "Emit machine-readable output")
4851
+ .addHelpText(
4852
+ "after",
4853
+ "\nDownload a file with: sl session files get <sessionId> <attachmentId> [--out <path>] [--force]",
4854
+ )
4855
+ .action(async (sessionId, options, command) => {
4856
+ const normalizedSessionId = normalizeString(sessionId);
4857
+ if (!normalizedSessionId) {
4858
+ throw new Error("session id is required.");
4859
+ }
4860
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4861
+ const limit = parsePositiveInteger(options.limit, "limit", DEFAULT_ROOM_FILES_LIMIT);
4862
+ const maxPages = parsePositiveInteger(options.maxPages, "max-pages", DEFAULT_ROOM_FILES_MAX_PAGES);
4863
+ const result = await listRoomFiles(normalizedSessionId, { targetPath, limit, maxPages });
4864
+ if (!result.ok) {
4865
+ throw new Error(`Could not list room files (${result.reason || "unknown"}).`);
4866
+ }
4867
+ const payload = {
4868
+ command: "session files",
4869
+ sessionId: normalizedSessionId,
4870
+ count: result.count,
4871
+ complete: result.complete,
4872
+ pages: result.pages,
4873
+ scannedEvents: result.scannedEvents,
4874
+ files: result.files,
4875
+ };
4876
+ if (shouldEmitJson(options, command)) {
4877
+ console.log(JSON.stringify(payload, null, 2));
4878
+ return payload;
4879
+ }
4880
+ if (!result.count) {
4881
+ console.log(
4882
+ pc.gray(
4883
+ result.complete
4884
+ ? "No files have been published in this room."
4885
+ : `No files in the last ${result.scannedEvents} events; raise --max-pages to read further back.`,
4886
+ ),
4887
+ );
4888
+ return payload;
4889
+ }
4890
+ console.log(pc.bold(`Files published in ${normalizedSessionId} (${result.count}, newest first)`));
4891
+ for (const file of result.files) {
4892
+ const type = file.contentType || file.kind || "unknown type";
4893
+ const who = file.uploadedBy ? ` by ${file.uploadedBy}` : "";
4894
+ const when = file.uploadedAt || file.publishedAt;
4895
+ const message = file.publishedSequenceId ? ` in #${file.publishedSequenceId}` : "";
4896
+ console.log(`${file.filename || "(unnamed)"} ${formatFileSize(file.sizeBytes)} ${type}${who}${when ? ` ${when}` : ""}${message}`);
4897
+ console.log(pc.gray(` attachment id ${file.attachmentId}`));
4898
+ }
4899
+ if (!result.complete) {
4900
+ console.log(pc.gray("More files may exist further back; raise --limit or --max-pages."));
4901
+ }
4902
+ console.log(pc.gray(`Download one with: sl session files get ${normalizedSessionId} <attachmentId> [--out <path>]`));
4903
+ return payload;
4904
+ });
4905
+
4906
+ filesCommand
4907
+ .command("get <sessionId> <attachmentId>")
4908
+ .description(
4909
+ "Download one published room file: streamed to disk, checked against the server's sha256, and written atomically",
4910
+ )
4911
+ .option("--out <path>", "File or directory to write to (default: the server's file name in the workspace)")
4912
+ .option("--force", "Overwrite an existing file")
4913
+ .option("--path <path>", "Workspace path; a relative --out must stay inside it", ".")
4914
+ .option("--json", "Emit machine-readable output")
4915
+ .addHelpText(
4916
+ "after",
4917
+ "\nThe destination folder is checked when the download starts; this does not protect against another local process renaming or relinking folders during the download.",
4918
+ )
4919
+ .action(async (sessionId, attachmentId, options, command) => {
4920
+ const normalizedSessionId = normalizeString(sessionId);
4921
+ const normalizedAttachmentId = normalizeString(attachmentId);
4922
+ if (!normalizedSessionId) {
4923
+ throw new Error("session id is required.");
4924
+ }
4925
+ // `files` also defines --path and --json, so Commander gives them to it: read the merged values.
4926
+ const merged = typeof command?.optsWithGlobals === "function" ? command.optsWithGlobals() : options;
4927
+ const targetPath = path.resolve(process.cwd(), String(merged.path || options.path || "."));
4928
+ let result;
4929
+ try {
4930
+ result = await downloadRoomFile(normalizedSessionId, normalizedAttachmentId, {
4931
+ targetPath,
4932
+ out: options.out,
4933
+ force: Boolean(options.force),
4934
+ });
4935
+ } catch (error) {
4936
+ if (error instanceof RoomFileError) {
4937
+ throw new Error(error.message);
4938
+ }
4939
+ throw error;
4940
+ }
4941
+ const payload = {
4942
+ command: "session files get",
4943
+ sessionId: normalizedSessionId,
4944
+ attachmentId: normalizedAttachmentId,
4945
+ path: result.path,
4946
+ bytes: result.bytes,
4947
+ sha256: result.sha256,
4948
+ verified: result.verified,
4949
+ overwritten: result.overwritten,
4950
+ linkRefetched: result.linkRefetched,
4951
+ attachment: result.attachment,
4952
+ };
4953
+ if (shouldEmitJson(options, command)) {
4954
+ console.log(JSON.stringify(payload, null, 2));
4955
+ return payload;
4956
+ }
4957
+ console.log(
4958
+ pc.green(
4959
+ `Saved ${result.attachment.filename || normalizedAttachmentId} (${formatFileSize(result.bytes)}, sha256 verified) to ${result.path}`,
4960
+ ),
4961
+ );
4962
+ return payload;
4963
+ });
4964
+
4688
4965
  const runFileLockCommand = async (verb, sessionId, files, options, command) => {
4689
4966
  const normalizedSessionId = normalizeString(sessionId);
4690
4967
  if (!normalizedSessionId) {
@@ -5331,6 +5608,7 @@ export function registerSessionCommand(program) {
5331
5608
  "--no-presence",
5332
5609
  "Do not renew ephemeral remote presence",
5333
5610
  )
5611
+ .option("--presence-check", "Check once whether the server lists this agent after the first presence renewal")
5334
5612
  .option(
5335
5613
  "--model <model>",
5336
5614
  "Model/provider label to publish with listener presence",
@@ -5587,6 +5865,29 @@ export function registerSessionCommand(program) {
5587
5865
  let lastPresenceHeartbeatMs = 0;
5588
5866
  let lastPresenceHeartbeatFingerprint = "";
5589
5867
  let nextPresenceAttemptMs = 0;
5868
+ let minimalPresence = false;
5869
+ let presenceCheckPending = Boolean(options.presenceCheck);
5870
+ const checkPresence = async () => {
5871
+ if (!presenceCheckPending) return;
5872
+ presenceCheckPending = false;
5873
+ const roster = await fetchSessionPresence(normalizedSessionId, { targetPath });
5874
+ const present = roster.ok
5875
+ ? roster.present.some((entry) => entry.agentId === agentId)
5876
+ : null;
5877
+ if (emitFormat === "ndjson") {
5878
+ console.log(JSON.stringify(createAgentEvent({
5879
+ event: "session_presence_check",
5880
+ agentId,
5881
+ sessionId: normalizedSessionId,
5882
+ payload: { present, reason: roster.reason || null },
5883
+ })));
5884
+ } else if (present !== null) {
5885
+ console.log(`Presence check: ${agentId} is ${present ? "listed" : "not listed"} in session ${normalizedSessionId}.`);
5886
+ }
5887
+ if (present === null) {
5888
+ console.error(`Presence check: ${agentId} presence is unknown (${roster.reason || "presence_read_failed"}).`);
5889
+ }
5890
+ };
5590
5891
 
5591
5892
  // Periodic in-session success reminders (ack, claim work, reply
5592
5893
  // in-thread). Long-running interactive listeners only — skipped under
@@ -5700,7 +6001,7 @@ export function registerSessionCommand(program) {
5700
6001
  },
5701
6002
  onEvent: async (event) => {
5702
6003
  if (emitFormat === "ndjson") {
5703
- console.log(JSON.stringify(event));
6004
+ console.log(JSON.stringify(withEditSummary(withAttachmentSummary(event))));
5704
6005
  } else {
5705
6006
  console.log(formatEventLine(event));
5706
6007
  }
@@ -5762,7 +6063,10 @@ export function registerSessionCommand(program) {
5762
6063
  );
5763
6064
  }
5764
6065
  }
5765
- if (!publishPresence) return;
6066
+ if (!publishPresence) {
6067
+ await checkPresence();
6068
+ return;
6069
+ }
5766
6070
  const lifecycleType = normalizeString(lifecycle?.type);
5767
6071
  const presenceAttemptMs = Date.now();
5768
6072
  if (lifecycleType !== "stopped" && presenceAttemptMs < nextPresenceAttemptMs) {
@@ -5789,7 +6093,7 @@ export function registerSessionCommand(program) {
5789
6093
  lastPresenceHeartbeatFingerprint = heartbeatDecision.fingerprint;
5790
6094
  lastPresenceHeartbeatMs = nowMs;
5791
6095
  }
5792
- const presenceResult = await publishListenerPresenceEvent({
6096
+ const presenceOptions = {
5793
6097
  sessionId: normalizedSessionId,
5794
6098
  targetPath,
5795
6099
  agentId,
@@ -5799,7 +6103,18 @@ export function registerSessionCommand(program) {
5799
6103
  clientKind,
5800
6104
  listenerId,
5801
6105
  lifecycle: publishLifecycle,
5802
- });
6106
+ minimalPresence,
6107
+ };
6108
+ let presenceResult = await publishListenerPresenceEvent(presenceOptions);
6109
+ if (!minimalPresence && presenceResult?.reason === "api_403" && presenceResult?.code === "AGENT_IDENTITY_REQUIRED") {
6110
+ minimalPresence = true;
6111
+ console.error("Presence renewal refused (403 AGENT_IDENTITY_REQUIRED; runtime/working require scoped agent credentials); retrying with agentId and model only for this listener session.");
6112
+ presenceResult = await publishListenerPresenceEvent({ ...presenceOptions, minimalPresence });
6113
+ }
6114
+ if (!presenceResult?.ok) {
6115
+ console.error(`Presence renewal failed for ${agentId} (${presenceResult?.reason || "not_recorded"}); the server may not list this listener.`);
6116
+ }
6117
+ await checkPresence();
5803
6118
  if (presenceResult?.ok) {
5804
6119
  nextPresenceAttemptMs = 0;
5805
6120
  return;
@@ -6600,11 +6915,11 @@ export function registerSessionCommand(program) {
6600
6915
  tail,
6601
6916
  beforeSequence,
6602
6917
  count: events.length,
6603
- events,
6918
+ events: events.map(withAttachmentSummary).map(withEditSummary),
6604
6919
  includeControlEvents,
6605
6920
  hiddenControlEventCount,
6606
6921
  unacknowledgedHumanMessageCount: unacknowledgedHumanMessages.length,
6607
- unacknowledgedHumanMessages,
6922
+ unacknowledgedHumanMessages: unacknowledgedHumanMessages.map(withAttachmentSummary).map(withEditSummary),
6608
6923
  recentHumanActivityCount: recentHumanActivity.length,
6609
6924
  recentHumanActivity,
6610
6925
  autoView,
@@ -6706,7 +7021,7 @@ export function registerSessionCommand(program) {
6706
7021
  if (item.event) {
6707
7022
  if (!includeControlEvents && isSessionControlEvent(item.event)) continue;
6708
7023
  if (emitJson) {
6709
- console.log(JSON.stringify({ source: item.source, event: item.event }));
7024
+ console.log(JSON.stringify({ source: item.source, event: withEditSummary(withAttachmentSummary(item.event)) }));
6710
7025
  } else {
6711
7026
  const sourceTag = item.source === "sse" ? pc.cyan("[sse]") : pc.gray("[fs] ");
6712
7027
  console.log(`${sourceTag} ${formatEventLine(item.event)}`);
@@ -6737,7 +7052,7 @@ export function registerSessionCommand(program) {
6737
7052
  continue;
6738
7053
  }
6739
7054
  if (emitJson) {
6740
- console.log(JSON.stringify(event));
7055
+ console.log(JSON.stringify(withEditSummary(withAttachmentSummary(event))));
6741
7056
  } else {
6742
7057
  console.log(formatEventLine(event));
6743
7058
  }
@@ -6795,6 +7110,170 @@ export function registerSessionCommand(program) {
6795
7110
  }
6796
7111
  });
6797
7112
 
7113
+ session
7114
+ .command("events <sessionId>")
7115
+ .description("Return one raw, verifiable page of session events from the remote API (ascending by sequence, no local merge). Use --before-sequence to walk backwards.")
7116
+ .option("--remote", "Fetch from the SentinelLayer API (required; this command is remote-only)")
7117
+ .option("--before-sequence <n>", "Return events older than this sequence id (omit for newest)")
7118
+ .option("--limit <n>", "Maximum events (1-200, default 50)", "50")
7119
+ .option("--path <path>", "Workspace path for the session", ".")
7120
+ .option("--no-auto-grant", "Accepted, has no effect (session events is read-only; reads never auto-grant)")
7121
+ .option("--json", "Emit machine-readable output")
7122
+ .action(async (sessionId, options, command) => {
7123
+ const normalizedSessionId = normalizeString(sessionId);
7124
+ if (!normalizedSessionId) {
7125
+ throw new Error("session id is required.");
7126
+ }
7127
+ if (!options.remote) {
7128
+ throw new Error("session events requires --remote (this command is remote-only; use 'session read' for local streams).");
7129
+ }
7130
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7131
+ const beforeSequence = parseOptionalPositiveInteger(options.beforeSequence, "before-sequence");
7132
+ const rawLimit = parsePositiveInteger(options.limit, "limit", 50);
7133
+ if (rawLimit > 200) {
7134
+ throw new Error("limit must be between 1 and 200.");
7135
+ }
7136
+ const limit = Math.max(1, rawLimit);
7137
+ const emitJson = shouldEmitJson(options, command);
7138
+
7139
+ const authSession = await resolveActiveAuthSession({
7140
+ cwd: targetPath,
7141
+ env: process.env,
7142
+ autoRotate: false,
7143
+ });
7144
+ if (!isAuthenticated(authSession)) {
7145
+ throw new Error(`Remote session events requires authentication. Run \`${authLoginHint()}\` first.`);
7146
+ }
7147
+
7148
+ const result = await pollSessionEventsBefore(normalizedSessionId, {
7149
+ targetPath,
7150
+ beforeSequence,
7151
+ limit,
7152
+ timeoutMs: 15_000,
7153
+ });
7154
+
7155
+ const contractViolation = pageContractViolation(normalizedSessionId, beforeSequence, limit, result);
7156
+ const rawNext = result?.rawNextBeforeSequence;
7157
+
7158
+ const payload = {
7159
+ command: "session events",
7160
+ targetPath,
7161
+ sessionId: normalizedSessionId,
7162
+ ok: result.ok && !contractViolation,
7163
+ beforeSequence: beforeSequence || null,
7164
+ count: Array.isArray(result.events) ? result.events.length : 0,
7165
+ nextBeforeSequence: rawNext ?? null,
7166
+ hasMore: Boolean(result.hasMore),
7167
+ partial: Boolean(result.partial),
7168
+ events: Array.isArray(result.events) ? result.events : [],
7169
+ reason: contractViolation ? "page_contract_violation" : (result.reason || ""),
7170
+ contractViolation: contractViolation || undefined,
7171
+ };
7172
+
7173
+ // C3: non-zero exit on failure in --json mode
7174
+ if (emitJson && !payload.ok) {
7175
+ process.exitCode = 1;
7176
+ console.log(JSON.stringify(payload, null, 2));
7177
+ return;
7178
+ }
7179
+
7180
+ if (emitJson) {
7181
+ console.log(JSON.stringify(payload, null, 2));
7182
+ return;
7183
+ }
7184
+ if (!payload.ok) {
7185
+ if (contractViolation) {
7186
+ throw new Error(`Session events page contract violated: ${contractViolation}`);
7187
+ }
7188
+ throw new Error(`Session events fetch failed (${result.reason || "unknown"}).`);
7189
+ }
7190
+ for (const event of result.events || []) {
7191
+ const seq = Number(event.sequenceId || event.sequence_id || 0);
7192
+ const seqLabel = Number.isFinite(seq) && seq > 0 ? `#${Math.floor(seq)}` : "";
7193
+ console.log(`${seqLabel} ${formatEventLine(event)}`);
7194
+ }
7195
+ if (result.beforeSequence && result.events && result.events.length >= limit) {
7196
+ console.log(pc.gray(`More events before sequence ${result.beforeSequence}.`));
7197
+ }
7198
+ });
7199
+
7200
+
7201
+ function pageContractViolation(requestedSessionId, beforeSequence, limit, result) {
7202
+ if (!result || !result.ok) return null;
7203
+
7204
+ const rawEvents = result.rawEvents;
7205
+ const rawNextBeforeSequence = result.rawNextBeforeSequence;
7206
+ const hasMore = result.hasMore;
7207
+ const partial = result.partial;
7208
+
7209
+ // 1. rawEvents is not an Array (missing, or an object)
7210
+ if (!Array.isArray(rawEvents)) {
7211
+ return "page_events_not_array";
7212
+ }
7213
+
7214
+ // C6: coverage fields must be present booleans; absence is fail-closed
7215
+ if (typeof hasMore !== "boolean") {
7216
+ return "page_coverage_fields_missing";
7217
+ }
7218
+ if (typeof partial !== "boolean") {
7219
+ return "page_coverage_fields_missing";
7220
+ }
7221
+
7222
+ // C6: the bridge cannot use a partial page
7223
+ if (partial === true) {
7224
+ return "page_partial";
7225
+ }
7226
+
7227
+ // 5. hasMore === true and rawNextBeforeSequence is null or missing
7228
+ if (hasMore === true && (rawNextBeforeSequence === null || rawNextBeforeSequence === undefined)) {
7229
+ return "page_has_more_without_cursor";
7230
+ }
7231
+
7232
+ // 4. rawNextBeforeSequence is not null and is not a safe positive integer
7233
+ if (rawNextBeforeSequence !== null && rawNextBeforeSequence !== undefined) {
7234
+ if (!Number.isSafeInteger(rawNextBeforeSequence) || rawNextBeforeSequence <= 0) {
7235
+ return "page_next_before_not_safe_positive_integer";
7236
+ }
7237
+ if (Number.isFinite(beforeSequence) && rawNextBeforeSequence >= beforeSequence) {
7238
+ return "next_before_sequence_not_less_than_requested";
7239
+ }
7240
+ }
7241
+
7242
+ // 3. rawEvents.length > the requested limit
7243
+ if (rawEvents.length > limit) {
7244
+ return "page_too_many_events";
7245
+ }
7246
+
7247
+ // 2. Validate each raw event's sequenceId
7248
+ const seen = new Set();
7249
+ for (const event of rawEvents) {
7250
+ const rawSeq = event?.sequenceId;
7251
+ if (typeof rawSeq !== "number" || !Number.isSafeInteger(rawSeq) || rawSeq <= 0) {
7252
+ return "event_sequence_not_safe_positive_integer";
7253
+ }
7254
+
7255
+ // event's sequenceId >= beforeSequence
7256
+ if (Number.isFinite(beforeSequence) && rawSeq >= beforeSequence) {
7257
+ return "event_sequence_not_before_requested";
7258
+ }
7259
+
7260
+ // event carries a sessionId other than the one requested
7261
+ const eventSessionId = normalizeString(event?.sessionId ?? event?.session_id);
7262
+ if (eventSessionId && eventSessionId !== requestedSessionId) {
7263
+ return "event_session_id_mismatch";
7264
+ }
7265
+
7266
+ // duplicate sequence ids
7267
+ if (seen.has(rawSeq)) {
7268
+ return "duplicate_event_sequence";
7269
+ }
7270
+ seen.add(rawSeq);
7271
+ }
7272
+
7273
+ return null;
7274
+ }
7275
+
7276
+
6798
7277
  session
6799
7278
  .command("recall <need>")
6800
7279
  .description(
@@ -7230,12 +7709,38 @@ export function registerSessionCommand(program) {
7230
7709
  );
7231
7710
  });
7232
7711
 
7712
+ session
7713
+ .command("standup <sessionId>")
7714
+ .description("Summarize returned ticket records and their evidence without a model call")
7715
+ .option("--since <24h|ISO>", "Include records updated since this time (inclusive)")
7716
+ .option("--agent <id>", "Agent reading the tickets (defaults to the joined session agent)")
7717
+ .option("--path <path>", "Workspace path for the session", ".")
7718
+ .option("--json", "Emit machine-readable output")
7719
+ .action(async (sessionId, options, command) => {
7720
+ const result = await sessionStandup(normalizeString(sessionId), {
7721
+ since: options.since, targetPath: path.resolve(process.cwd(), String(options.path || ".")),
7722
+ });
7723
+ console.log(shouldEmitJson(options, command) ? JSON.stringify(result, null, 2) : formatSessionStandup(result));
7724
+ });
7725
+
7233
7726
  // ---------------------------------------------------------------- tickets
7234
7727
  // The room's shared backlog (API "Ticket Lease Contract (T2)"). Every subcommand is
7235
7728
  // an ACTOR command: the runCli choke point runs it on the agent's admission.
7236
7729
  const ticket = session
7237
7730
  .command("ticket")
7238
- .description("The room's shared backlog: list, claim, renew, release and submit tickets");
7731
+ .description("The room's shared backlog: list, claim, renew, release and submit tickets")
7732
+ .addHelpText("after", `
7733
+ Workflow:
7734
+ sl session ticket list <sessionId> --available --json
7735
+ sl session ticket claim <sessionId> <ticketId> --expected-version <version>
7736
+ sl session ticket renew <sessionId> <ticketId>
7737
+ sl session ticket submit <sessionId> <ticketId> --sha <commit> --evidence-file evidence.json
7738
+
7739
+ The evidence file is a JSON array of re-checkable claims. Submit means in_review,
7740
+ not done. Use release --reason "handoff" when you cannot continue.
7741
+ Agent work requires that agent's live admission; --agent is not authorization.
7742
+ Guide: https://sentinelayer.com/docs/senti/overview/
7743
+ `);
7239
7744
 
7240
7745
  // Ticket work is attributed by the CREDENTIAL, never by a label: the lease routes take the
7241
7746
  // holder from authentication alone. So an agent identity is allowed only with that agent's
@@ -7264,6 +7769,48 @@ export function registerSessionCommand(program) {
7264
7769
  console.log(line);
7265
7770
  }
7266
7771
 
7772
+ ticket
7773
+ .command("create <sessionId>")
7774
+ .description("Create a ticket in the room. Agent and source attribution are recorded only by servers that support them; the output says whether they were")
7775
+ .requiredOption("--title <text>", "Ticket title")
7776
+ .option("--body <text>", "Ticket description", "")
7777
+ .option("--agent <id>", "Acting agent identity, authorized by the server")
7778
+ .option("--assignee <id>", "Who should own the work")
7779
+ .option("--source-sequence <n>", "Source message in this room")
7780
+ .option("--idempotency-key <key>", "Reuse the key after an uncertain response")
7781
+ .option("--path <path>", "Workspace path for the session", ".")
7782
+ .option("--json", "Emit machine-readable output")
7783
+ .action(async (sessionId, options, command) => {
7784
+ const sid = normalizeString(sessionId), targetPath = path.resolve(process.cwd(), String(options.path || "."));
7785
+ const identity = await resolveSessionSayIdentity({ sessionId: sid, agentId: options.agent, targetPath });
7786
+ const agentId = normalizeString(identity.agentId);
7787
+ const result = await createTicket(sid, { title: options.title, body: options.body, assignee: options.assignee,
7788
+ agentId: agentId === "cli-user" ? "" : agentId, targetPath,
7789
+ sourceSequenceId: options.sourceSequence === undefined ? null : parsePositiveInteger(options.sourceSequence, "source-sequence", 1),
7790
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
7791
+ });
7792
+ const { agentRequested, agentRecorded, sourceRequested, sourceRecorded } = result.provenance;
7793
+ const creator = result.ticket?.creator;
7794
+ let creatorNote = " Creator: not recorded.";
7795
+ if (typeof creator?.id === "string" && creator.id.trim()) {
7796
+ if (creator.kind === "agent") {
7797
+ const assurance = creator.credentialBound === true && ["session_admission", "agent_token"].includes(creator.identitySource)
7798
+ ? "credential-bound"
7799
+ : creator.credentialBound === false && creator.identitySource === "authorized_agent_grant"
7800
+ ? "authorized account grant; not a separate agent credential"
7801
+ : "identity assurance unavailable";
7802
+ creatorNote = ` Creator: agent ${creator.id} (${assurance}).`;
7803
+ } else if (creator.kind === "account") {
7804
+ creatorNote = ` Creator: account ${creator.id} (the credential holder's physical identity is not established).`;
7805
+ }
7806
+ }
7807
+ const notes = [
7808
+ agentRequested && !agentRecorded ? ` The response did not confirm requested agent ${agentRequested}.` : "",
7809
+ sourceRequested !== null && !sourceRecorded ? ` The response did not confirm source #${sourceRequested}.` : "",
7810
+ ].join("");
7811
+ ticketOutput(options, command, { command: "session ticket create", ...result }, `Created ${result.ticket.id}.${creatorNote}${notes}`);
7812
+ });
7813
+
7267
7814
  ticket
7268
7815
  .command("list <sessionId>")
7269
7816
  .description("List the room's tickets (--available: only those claimable now)")
@@ -7627,7 +8174,7 @@ export function registerSessionCommand(program) {
7627
8174
 
7628
8175
  session
7629
8176
  .command("status <sessionId>")
7630
- .description("Show session status, agents, and health")
8177
+ .description("Show workspace session status, agents, and file-lock health")
7631
8178
  .option("--path <path>", "Workspace path for the session", ".")
7632
8179
  .option("--json", "Emit machine-readable output")
7633
8180
  .action(async (sessionId, options, command) => {
@@ -8493,6 +9040,7 @@ export function registerSessionCommand(program) {
8493
9040
  });
8494
9041
  const payload = {
8495
9042
  command: "session setup-guides",
9043
+ skillsInstallCommand: "sl skills install",
8496
9044
  targetPath,
8497
9045
  sessionId: normalizedSessionId,
8498
9046
  sectionHeading: result.sectionHeading,
@@ -8506,6 +9054,7 @@ export function registerSessionCommand(program) {
8506
9054
  }
8507
9055
 
8508
9056
  console.log(pc.bold(`Session guide sync complete for ${normalizedSessionId}`));
9057
+ console.log("Install bundled Senti agent skills: sl skills install");
8509
9058
  console.log(pc.gray(`AGENTS.md: changed=${result.agents.changed} path=${result.agents.path}`));
8510
9059
  console.log(pc.gray(`CLAUDE.md: changed=${result.claude.changed} path=${result.claude.path}`));
8511
9060
  console.log(
@@ -8586,7 +9135,7 @@ export function registerSessionCommand(program) {
8586
9135
  const concurrency = Math.max(1, Math.min(10, requestedConcurrency, count));
8587
9136
  const tags = parseCsvTokens(options.tags, ["session", "swarm"]);
8588
9137
  const apiUrl = normalizeAidenIdApiUrl(options.apiUrl);
8589
- const outputRoot = await resolveOutputRoot({
9138
+ const { outputRoot, outputFs } = await resolveOutputContext({
8590
9139
  cwd: targetPath,
8591
9140
  outputDirOverride: options.outputDir,
8592
9141
  env: process.env,
@@ -8679,6 +9228,7 @@ export function registerSessionCommand(program) {
8679
9228
  for (const identity of provisioned) {
8680
9229
  await recordProvisionedIdentity({
8681
9230
  outputRoot,
9231
+ outputFs,
8682
9232
  response: identity.response || {},
8683
9233
  context: {
8684
9234
  source: "session-provision-emails",