sentinelayer-cli 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -163,10 +163,56 @@ sl session join <session-id> --agent <agent-name>
163
163
  sl session action <session-id> working_on --target-sequence <n>
164
164
  ```
165
165
 
166
+ For a room that requires agent admission, the agent declares its goal, requested
167
+ actions, and TTL. The CLI creates an agent-held Ed25519 identity and waits for a
168
+ human decision. When the API reports identity readiness on the approved
169
+ admission, the CLI also waits for the minted AIdenID email and verified signed
170
+ purpose receipt before it claims; when the API does not report it, the approved
171
+ grant is claimed directly. The claim uses proof of possession, then the agent
172
+ joins. The private key and scoped credential are stored locally and are never
173
+ printed:
174
+
175
+ ```bash
176
+ # Agent: request and wait for the owner's decision (maximum grant TTL is 24h).
177
+ sl session join <session-id> --agent review-agent \
178
+ --goal "Review the release and report findings" \
179
+ --scope session.read,session.post --ttl 24h
180
+
181
+ # Or request without waiting, for an asynchronous handoff.
182
+ sl session access request <session-id> --agent review-agent \
183
+ --goal "Review the release and report findings" \
184
+ --scope session.read,session.post --ttl 24h --json
185
+
186
+ # Owner/admin: inspect the untrusted goal claim, then approve, narrow, or deny.
187
+ sl session access mode <session-id> required
188
+ sl session access list <session-id> --status pending
189
+ sl session access approve <session-id> <admission-id> \
190
+ --scope session.read,session.post --ttl 24h
191
+ # sl session access deny <session-id> <admission-id> --note "reason"
192
+ # sl session access revoke <session-id> <admission-id> --reason "work complete"
193
+ ```
194
+
195
+ `session access list --json` reports the `email`, `purpose`, and Jev status the
196
+ API returns; the CLI does not invent successful issuance. On an API that gates
197
+ the claim on identity evidence, approval queues the AIdenID email and
198
+ purpose-receipt work and the claim challenge is withheld until both are ready.
199
+ On an API that does not, approval issues the claim challenge directly.
200
+ The agent must re-run `session join` with the same goal, scope, and TTL after an
201
+ asynchronous request so it can claim and verify its live session-bound receipt.
202
+ A room's admission mode is whatever the API stored for it; `session access list`
203
+ shows it, and an owner changes it with `session access mode <session-id>
204
+ required` (or `legacy`).
205
+
206
+ The owner actions (`approve`, `deny`, `revoke`, `mode`) run on the owner's own
207
+ session. They are unavailable in an agent context, meaning `SENTINELAYER_AGENT_ID`
208
+ is set or agent admission credentials are stored on the machine; owners use the
209
+ web dashboard from such machines. `session access` commands are not exposed
210
+ through the MCP CLI bridge.
211
+
166
212
  Sentinelayer includes a deterministic session coordination surface for multi-agent coding loops:
167
213
 
168
214
  - session event stream and replay (`start`, `join`, `say`, `read`, `status`, `leave`, `list`, `kill`)
169
- - low-noise message actions (`react ack|like|dislike`, `action working_on|disregard`, `reply`/`comment`, `actions`) plus one monotonic per-agent read cursor (`view`)
215
+ - low-noise message actions (`react ack|like|dislike|unlike|undislike`, `action working_on|disregard`, `reply`/`comment`, `actions`) plus one monotonic per-agent read cursor (`view`)
170
216
  - agent lifecycle controls (join/ephemeral TTL presence/leave/kill); listener liveness never enters the durable transcript
171
217
  - recap and context briefing for late-joining agents
172
218
  - analytics + lineage artifacts at session closeout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -98,12 +98,24 @@ import {
98
98
  parseTtlSeconds as parseAdmissionTtlSeconds,
99
99
  runAdmissionJoin,
100
100
  } from "../session/admission.js";
101
+ import {
102
+ decideSessionAdmission,
103
+ getSessionAdmission,
104
+ listSessionAdmissions,
105
+ revokeSessionAdmission,
106
+ setSessionAdmissionMode,
107
+ } from "../session/admission-access.js";
101
108
  import {
102
109
  assertCanonicalAgentId,
103
110
  canonicalAgentId,
104
111
  currentAdmittedAgent,
105
112
  withAgentAdmission,
106
113
  } from "../session/admission-auth.js";
114
+ import {
115
+ SESSION_REACTION_TYPES,
116
+ isSessionReactionType,
117
+ submitSessionReaction,
118
+ } from "../session/reactions.js";
107
119
  import {
108
120
  claimTicket,
109
121
  listTickets,
@@ -557,10 +569,16 @@ const SESSION_MESSAGE_ACTION_TYPES = new Set([
557
569
  "reply",
558
570
  "like",
559
571
  "dislike",
572
+ "unlike",
573
+ "undislike",
560
574
  "disregard",
561
575
  "view",
562
576
  ]);
563
577
 
578
+ // like/dislike/unlike/undislike all go through src/session/reactions.js, the path the
579
+ // local MCP server shares; `ack` keeps the plain message-action path.
580
+ const SESSION_REACT_COMMAND_TYPES = Object.freeze(["ack", ...SESSION_REACTION_TYPES]);
581
+
564
582
  const SESSION_MESSAGE_ACTION_ALIASES = new Map([
565
583
  ["comment", "reply"],
566
584
  ]);
@@ -592,6 +610,16 @@ const SESSION_MESSAGE_ACTION_DESCRIPTIONS = Object.freeze([
592
610
  command: "sl session react <id> dislike --target-sequence <n>",
593
611
  description: "Negative lightweight feedback. Use --target-action-id <uuid> to react to a threaded reply.",
594
612
  },
613
+ {
614
+ type: "unlike",
615
+ command: "sl session react <id> unlike --target-sequence <n>",
616
+ description: "Retract your own active like. Refused when you have none; needs a server with reaction undo.",
617
+ },
618
+ {
619
+ type: "undislike",
620
+ command: "sl session react <id> undislike --target-sequence <n>",
621
+ description: "Retract your own active dislike. Refused when you have none; needs a server with reaction undo.",
622
+ },
595
623
  {
596
624
  type: "disregard",
597
625
  command: "sl session action <id> disregard --target-sequence <n>",
@@ -623,7 +651,7 @@ function shortSha256(value) {
623
651
 
624
652
  function actionEventType(actionType) {
625
653
  if (actionType === "reply") return "session_reply";
626
- if (actionType === "like" || actionType === "dislike") return "session_reaction";
654
+ if (isSessionReactionType(actionType)) return "session_reaction";
627
655
  return "session_action";
628
656
  }
629
657
 
@@ -679,6 +707,7 @@ function buildSessionActionEvent(sessionId, action = {}) {
679
707
  }),
680
708
  );
681
709
  const actorId = actionActorId(action);
710
+ const collapsedIntoActionId = normalizeString(action.collapsedIntoActionId ?? action.collapsed_into_action_id);
682
711
  const event = createAgentEvent({
683
712
  event: actionEventType(actionType),
684
713
  agent: {
@@ -703,6 +732,8 @@ function buildSessionActionEvent(sessionId, action = {}) {
703
732
  metadata: action.metadata && typeof action.metadata === "object" ? action.metadata : undefined,
704
733
  message: actionDisplayMessage(action),
705
734
  source: "session_action",
735
+ // A retained no-op reaction key (sentinelayer-api#914): evidence, not a reaction.
736
+ ...(collapsedIntoActionId ? { collapsedIntoActionId } : {}),
706
737
  },
707
738
  });
708
739
  event.eventId = `session-action-${id}`;
@@ -3488,17 +3519,24 @@ export function registerSessionCommand(program) {
3488
3519
  wait: options.wait !== false,
3489
3520
  waitTimeoutMs: (Number.isFinite(waitSeconds) && waitSeconds >= 0 ? waitSeconds : 900) * 1000,
3490
3521
  targetPath,
3491
- onPending: ({ approveUrl }) => {
3522
+ onPending: ({ approveUrl, phase }) => {
3492
3523
  // stderr, so --json stdout stays a single parseable document.
3493
- const line = `Waiting for a room owner to approve this agent: ${approveUrl}`;
3524
+ const line = phase === "identity"
3525
+ ? "Approval received; waiting for the AIdenID email and signed purpose receipt."
3526
+ : `Waiting for a room owner to approve this agent: ${terminalText(approveUrl, 1024)}`;
3494
3527
  if (emitJson) process.stderr.write(`${line}\n`);
3495
- else console.log(pc.yellow(line));
3528
+ // Keep the line containing a remote URL free of all terminal control
3529
+ // bytes. `terminalText()` strips controls from the value itself; adding
3530
+ // local ANSI colour around it would make that guarantee untestable and
3531
+ // lets OSC-looking input hide inside a coloured terminal span.
3532
+ else if (phase === "identity") console.log(pc.yellow(line));
3533
+ else console.log(line);
3496
3534
  },
3497
3535
  });
3498
3536
  if (admission.status !== "active") {
3499
3537
  // Only an ACTIVE admission joins. Pending, denied, cancelled or expired never
3500
3538
  // registers the agent or materializes the room for it.
3501
- process.exitCode = admission.status === "pending" ? 3 : 4;
3539
+ process.exitCode = ["pending", "approved"].includes(admission.status) ? 3 : 4;
3502
3540
  const payload = {
3503
3541
  command: "session join",
3504
3542
  joined: false,
@@ -3510,9 +3548,12 @@ export function registerSessionCommand(program) {
3510
3548
  if (emitJson) {
3511
3549
  console.log(JSON.stringify(payload, null, 2));
3512
3550
  } else if (admission.status === "pending") {
3513
- console.log(pc.yellow(`Admission pending. A room owner approves it here: ${admission.approveUrl || "(see the session page)"}`));
3551
+ const approval = terminalText(admission.approveUrl, 1024) || "(see the session page)";
3552
+ console.log(`Admission pending. A room owner approves it here: ${approval}`);
3553
+ } else if (admission.status === "approved") {
3554
+ console.log(pc.yellow("Admission approved; AIdenID identity evidence is still provisioning. The agent was not joined."));
3514
3555
  } else {
3515
- console.log(pc.red(`Admission ${admission.status}. The agent was not joined.`));
3556
+ console.log(pc.red(`Admission ${terminalText(admission.status, 16) || "unknown"}. The agent was not joined.`));
3516
3557
  }
3517
3558
  return;
3518
3559
  }
@@ -4265,6 +4306,21 @@ export function registerSessionCommand(program) {
4265
4306
  );
4266
4307
  }
4267
4308
  const agentId = identity.agentId;
4309
+ if (isSessionReactionType(normalizedActionType)) {
4310
+ return runReactionCommand({
4311
+ sessionId: normalizedSessionId,
4312
+ reaction: normalizedActionType,
4313
+ agentId,
4314
+ targetPath,
4315
+ targetSequenceId,
4316
+ targetCursor,
4317
+ targetActionId,
4318
+ note,
4319
+ options,
4320
+ command,
4321
+ commandName,
4322
+ });
4323
+ }
4268
4324
  const idempotencyKey =
4269
4325
  normalizeString(options.idempotencyKey) ||
4270
4326
  defaultActionIdempotencyKey({
@@ -4347,6 +4403,89 @@ export function registerSessionCommand(program) {
4347
4403
  return payload;
4348
4404
  }
4349
4405
 
4406
+ // like/dislike/unlike/undislike: the shared reaction path (src/session/reactions.js)
4407
+ // decides admission, the operation key and the outcome; this only mirrors and prints.
4408
+ // `--idempotency-key` is a RETRY of the intent that minted that key.
4409
+ async function runReactionCommand({
4410
+ sessionId,
4411
+ reaction,
4412
+ agentId,
4413
+ targetPath,
4414
+ targetSequenceId,
4415
+ targetCursor,
4416
+ targetActionId,
4417
+ note,
4418
+ options,
4419
+ command,
4420
+ commandName,
4421
+ }) {
4422
+ const outcome = await submitSessionReaction({
4423
+ surface: "cli",
4424
+ sessionId,
4425
+ agentId,
4426
+ reaction,
4427
+ targetSequenceId,
4428
+ targetCursor,
4429
+ targetActionId,
4430
+ note,
4431
+ idempotencyKey: options.idempotencyKey,
4432
+ targetPath,
4433
+ });
4434
+ let event = null;
4435
+ let localAppend = { appended: false, reason: "not_recorded" };
4436
+ if (outcome.action) {
4437
+ const appended = await appendActionEventIfMissing(
4438
+ sessionId,
4439
+ buildSessionActionEvent(sessionId, outcome.action),
4440
+ { targetPath },
4441
+ );
4442
+ event = appended.event;
4443
+ localAppend = { appended: Boolean(appended.appended), reason: appended.reason || "" };
4444
+ }
4445
+ let evidenceEvent = null;
4446
+ if (outcome.collapsedActionId && outcome.action) {
4447
+ // Mirror the server's retained no-op row: this request's key, collapsed into
4448
+ // the active reaction it repeated.
4449
+ const appended = await appendActionEventIfMissing(
4450
+ sessionId,
4451
+ buildSessionActionEvent(sessionId, {
4452
+ id: outcome.collapsedActionId,
4453
+ actionType: reaction,
4454
+ targetSequenceId: outcome.action.targetSequenceId,
4455
+ targetCursor: outcome.action.targetCursor,
4456
+ targetActionId: outcome.action.targetActionId,
4457
+ actorKind: outcome.action.actorKind,
4458
+ actorId: outcome.action.actorId,
4459
+ actorRole: outcome.action.actorRole,
4460
+ idempotencyKey: outcome.operationKey,
4461
+ collapsedIntoActionId: outcome.action.id,
4462
+ }),
4463
+ { targetPath },
4464
+ );
4465
+ evidenceEvent = appended.event;
4466
+ }
4467
+ const payload = {
4468
+ command: commandName,
4469
+ targetPath,
4470
+ ...outcome,
4471
+ event,
4472
+ evidenceEvent,
4473
+ localAppend,
4474
+ };
4475
+ if (!outcome.ok) process.exitCode = 1;
4476
+ if (shouldEmitJson(options, command)) {
4477
+ console.log(JSON.stringify(payload, null, 2));
4478
+ return payload;
4479
+ }
4480
+ if (!outcome.ok) {
4481
+ console.error(pc.yellow(outcome.message));
4482
+ return payload;
4483
+ }
4484
+ if (event) console.log(formatEventLine(event));
4485
+ if (outcome.outcome !== "applied" || !event) console.log(pc.gray(outcome.message));
4486
+ return payload;
4487
+ }
4488
+
4350
4489
  session
4351
4490
  .command("actions")
4352
4491
  .description("List supported low-noise message actions with examples")
@@ -4373,7 +4512,7 @@ export function registerSessionCommand(program) {
4373
4512
  session
4374
4513
  .command("action <sessionId> <actionType>")
4375
4514
  .description(
4376
- "Create a message action for a target session event (ack, working_on, reply/comment, like, dislike, disregard, view)",
4515
+ "Create a message action for a target session event (ack, working_on, reply/comment, like, dislike, unlike, undislike, disregard, view)",
4377
4516
  )
4378
4517
  .option("--target-sequence <n>", "Target event sequence id")
4379
4518
  .option("--target-cursor <cursor>", "Target event cursor")
@@ -4389,18 +4528,23 @@ export function registerSessionCommand(program) {
4389
4528
 
4390
4529
  session
4391
4530
  .command("react <sessionId> <reaction>")
4392
- .description("React to or acknowledge a target session event with ack, like, or dislike")
4531
+ .description(
4532
+ "React to or acknowledge a target session event with ack, like, or dislike; retract your own like/dislike with unlike/undislike",
4533
+ )
4393
4534
  .option("--target-sequence <n>", "Target event sequence id")
4394
4535
  .option("--target-cursor <cursor>", "Target event cursor")
4395
4536
  .option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
4396
4537
  .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
4397
- .option("--idempotency-key <key>", "Explicit idempotency key")
4538
+ .option(
4539
+ "--idempotency-key <key>",
4540
+ "Retry an earlier reaction as the SAME intent by resending its operationKey (without it, each call is a new intent)",
4541
+ )
4398
4542
  .option("--path <path>", "Workspace path for the session", ".")
4399
4543
  .option("--json", "Emit machine-readable output")
4400
4544
  .action(async (sessionId, reaction, options, command) => {
4401
4545
  const normalizedReaction = normalizeSessionMessageActionType(reaction);
4402
- if (!["ack", "like", "dislike"].includes(normalizedReaction)) {
4403
- throw new Error("reaction must be one of: ack, like, dislike.");
4546
+ if (!SESSION_REACT_COMMAND_TYPES.includes(normalizedReaction)) {
4547
+ throw new Error(`reaction must be one of: ${SESSION_REACT_COMMAND_TYPES.join(", ")}.`);
4404
4548
  }
4405
4549
  await runMessageActionCommand({
4406
4550
  sessionId,
@@ -6787,6 +6931,285 @@ export function registerSessionCommand(program) {
6787
6931
  }
6788
6932
  });
6789
6933
 
6934
+ // ------------------------------------------------------- agent access
6935
+ // Human control plane for the Agent Admission Contract. The request command
6936
+ // delegates to the established agent-side flow; list/decision/revoke use the
6937
+ // matching owner-only REST routes. Goal text is an agent claim and is always
6938
+ // rendered as bounded, untrusted data.
6939
+ const access = session
6940
+ .command("access")
6941
+ .description("Request and govern AIdenID-backed, scoped agent access to a session");
6942
+
6943
+ function accessOutput(options, command, payload, render) {
6944
+ if (shouldEmitJson(options, command)) {
6945
+ console.log(JSON.stringify(payload, null, 2));
6946
+ return;
6947
+ }
6948
+ render();
6949
+ }
6950
+
6951
+ function terminalText(value, maxLength = 240) {
6952
+ let result = "";
6953
+ for (const ch of normalizeString(value).normalize("NFC")) {
6954
+ // Refuse terminal control/invisible formatting from remote values,
6955
+ // including ANSI/OSC initiators and bidi overrides/isolates.
6956
+ result += /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}]/u.test(ch) ? " " : ch;
6957
+ if (result.length >= maxLength) break;
6958
+ }
6959
+ return result.replace(/\s+/gu, " ").trim();
6960
+ }
6961
+
6962
+ function renderAdmissionLine(admission) {
6963
+ const requested = Array.isArray(admission?.requestedScope?.actions)
6964
+ ? terminalText(admission.requestedScope.actions.join(","), 180)
6965
+ : "scope unavailable";
6966
+ const ttl = Number(admission?.requestedScope?.ttlSeconds);
6967
+ const duration = Number.isFinite(ttl) ? ` for ${ttl}s` : "";
6968
+ const identity = terminalText(admission?.displayName, 120) || terminalText(admission?.agentId, 64) || "unknown-agent";
6969
+ console.log(`${terminalText(admission?.admissionId, 64) || "unknown"} ${terminalText(admission?.status, 16) || "unknown"} ${identity}`);
6970
+ console.log(pc.gray(` requested: ${requested}${duration}`));
6971
+ const summary = terminalText(admission?.goal?.summary);
6972
+ if (summary) console.log(pc.gray(` claimed goal (untrusted): ${JSON.stringify(summary)}`));
6973
+ if (admission?.email) {
6974
+ // Human terminal output is routinely copied into logs and support
6975
+ // transcripts. Keep the address in the explicit --json response only.
6976
+ const inboxStatus = terminalText(admission.email.status, 32) || "unknown";
6977
+ console.log(pc.gray(` AIdenID inbox: ${inboxStatus}`));
6978
+ }
6979
+ }
6980
+
6981
+ access
6982
+ .command("mode <sessionId> <mode>")
6983
+ .description("Set whether this room requires admission for agent-attributed access (required or legacy)")
6984
+ .option("--idempotency-key <key>", "Retry the same logical mode change safely")
6985
+ .option("--path <path>", "Workspace path for auth context", ".")
6986
+ .option("--json", "Emit machine-readable output")
6987
+ .action(async (sessionId, mode, options, command) => {
6988
+ const sid = normalizeString(sessionId);
6989
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6990
+ const changed = await setSessionAdmissionMode(sid, mode, {
6991
+ idempotencyKey: options.idempotencyKey,
6992
+ targetPath,
6993
+ });
6994
+ accessOutput(
6995
+ options,
6996
+ command,
6997
+ {
6998
+ command: "session access mode",
6999
+ idempotencyKey: changed.idempotencyKey,
7000
+ ...changed.result,
7001
+ },
7002
+ () => console.log(
7003
+ `Agent admission mode for ${terminalText(sid, 64)}: ${terminalText(changed.result.agentAdmissionMode, 16) || "unknown"}.`,
7004
+ ),
7005
+ );
7006
+ });
7007
+
7008
+ access
7009
+ .command("request <sessionId>")
7010
+ .description("Request agent access; a human must approve before the agent can claim or join")
7011
+ .requiredOption("--agent <id>", "Canonical agent id requesting access")
7012
+ .requiredOption("--goal <text>", "What the agent is coming to do")
7013
+ .option("--display-name <name>", "Human-readable agent name")
7014
+ .option("--deliverable <text>", "An output this agent will produce (repeatable)", collectOption, [])
7015
+ .option("--stop-when <text>", "A condition that ends this agent's work (repeatable)", collectOption, [])
7016
+ .option(
7017
+ "--scope <actions>",
7018
+ "Comma-separated actions: session.read, session.post, session.react, tickets.read, tickets.work",
7019
+ )
7020
+ .option("--ttl <duration>", "Requested grant lifetime, e.g. 90m or 24h (5m..24h)", "2h")
7021
+ .option("--model <model>", "Agent model hint", "cli")
7022
+ .option("--provider <name>", "Model provider hint")
7023
+ .option("--wait", "Wait for approval and claim the grant on this machine")
7024
+ .option("--wait-seconds <n>", "Maximum approval wait when --wait is set", "900")
7025
+ .option("--path <path>", "Workspace path for local admission state", ".")
7026
+ .option("--json", "Emit machine-readable output")
7027
+ .action(async (sessionId, options, command) => {
7028
+ const sid = normalizeString(sessionId);
7029
+ const agentId = canonicalAgentId(options.agent);
7030
+ assertCanonicalAgentId(options.agent, "--agent");
7031
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7032
+ const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
7033
+ if (!Number.isFinite(waitSeconds) || waitSeconds < 0) {
7034
+ throw new Error("--wait-seconds must be a non-negative integer.");
7035
+ }
7036
+ const admission = await runAdmissionJoin(sid, {
7037
+ agentId,
7038
+ displayName: options.displayName,
7039
+ model: options.model,
7040
+ provider: options.provider,
7041
+ goal: options.goal,
7042
+ deliverables: options.deliverable || [],
7043
+ stopConditions: options.stopWhen || [],
7044
+ actions: parseAdmissionScope(options.scope),
7045
+ ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
7046
+ wait: Boolean(options.wait),
7047
+ waitTimeoutMs: waitSeconds * 1000,
7048
+ targetPath,
7049
+ onPending: ({ approveUrl, phase }) => {
7050
+ const line = phase === "identity"
7051
+ ? "Approval received; waiting for the AIdenID email and signed purpose receipt."
7052
+ : `Waiting for a room owner to approve this agent: ${terminalText(approveUrl, 1024)}`;
7053
+ if (shouldEmitJson(options, command)) process.stderr.write(`${line}\n`);
7054
+ // A line carrying a remote URL must remain free of every terminal
7055
+ // control byte, including trusted colour wrappers. This mirrors the
7056
+ // guarded `session join` path and keeps raw support logs auditable.
7057
+ else if (phase === "identity") console.log(pc.yellow(line));
7058
+ else console.log(line);
7059
+ },
7060
+ });
7061
+ const payload = {
7062
+ command: "session access request",
7063
+ sessionId: sid,
7064
+ agentId,
7065
+ admission,
7066
+ readyToJoin: admission.status === "active",
7067
+ };
7068
+ accessOutput(options, command, payload, () => {
7069
+ if (admission.status === "pending") {
7070
+ console.log(`Access requested for ${agentId}; waiting for a room owner.`);
7071
+ if (admission.approveUrl) console.log(`Approval: ${terminalText(admission.approveUrl, 1024)}`);
7072
+ } else if (admission.status === "active") {
7073
+ console.log(`Access active for ${agentId}. The scoped credential is stored locally and was not printed.`);
7074
+ console.log(pc.gray("Run `sl session join` again with the same goal, scope, and TTL to attach this agent."));
7075
+ } else if (admission.status === "approved") {
7076
+ console.log("Access approved; waiting for the AIdenID inbox and signed purpose receipt before claim.");
7077
+ } else {
7078
+ console.log(`Access ${terminalText(admission.status, 16) || "unknown"} for ${agentId}.`);
7079
+ }
7080
+ });
7081
+ });
7082
+
7083
+ access
7084
+ .command("list <sessionId>")
7085
+ .description("List admission requests visible to a room owner or admin")
7086
+ .option("--status <status>", "Filter by admission status")
7087
+ .option("--path <path>", "Workspace path for auth context", ".")
7088
+ .option("--json", "Emit machine-readable output")
7089
+ .action(async (sessionId, options, command) => {
7090
+ const sid = normalizeString(sessionId);
7091
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7092
+ const result = await listSessionAdmissions(sid, {
7093
+ status: options.status,
7094
+ targetPath,
7095
+ });
7096
+ const payload = { command: "session access list", ...result };
7097
+ accessOutput(options, command, payload, () => {
7098
+ console.log(pc.gray(`Admission mode: ${terminalText(result.agentAdmissionMode, 16) || "unknown"}`));
7099
+ if (!Array.isArray(result.admissions) || result.admissions.length === 0) {
7100
+ console.log("No admission requests.");
7101
+ return;
7102
+ }
7103
+ for (const admission of result.admissions) renderAdmissionLine(admission);
7104
+ });
7105
+ });
7106
+
7107
+ access
7108
+ .command("status <sessionId> <admissionId>")
7109
+ .description("Show the requesting delegator's current admission status")
7110
+ .option("--path <path>", "Workspace path for auth context", ".")
7111
+ .option("--json", "Emit machine-readable output")
7112
+ .action(async (sessionId, admissionId, options, command) => {
7113
+ const sid = normalizeString(sessionId);
7114
+ const aid = normalizeString(admissionId);
7115
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7116
+ const result = await getSessionAdmission(sid, aid, { targetPath });
7117
+ accessOutput(
7118
+ options,
7119
+ command,
7120
+ { command: "session access status", sessionId: sid, ...result },
7121
+ () => {
7122
+ console.log(`${terminalText(result.admissionId, 64) || aid} ${terminalText(result.status, 16) || "unknown"} ${terminalText(result.agentId, 64) || "unknown-agent"}`);
7123
+ if (result.approveUrl) console.log(`Approval: ${terminalText(result.approveUrl, 1024)}`);
7124
+ },
7125
+ );
7126
+ });
7127
+
7128
+ access
7129
+ .command("approve <sessionId> <admissionId>")
7130
+ .description("Approve a pending request, optionally narrowing its scope or TTL")
7131
+ .option("--scope <actions>", "Comma-separated granted actions; must be a subset of the request")
7132
+ .option("--ttl <duration>", "Granted lifetime; may only shorten the request (5m..24h)")
7133
+ .option("--note <text>", "Approval note (max 500 characters)")
7134
+ .option("--idempotency-key <key>", "Retry the same logical approval safely")
7135
+ .option("--path <path>", "Workspace path for auth context", ".")
7136
+ .option("--json", "Emit machine-readable output")
7137
+ .action(async (sessionId, admissionId, options, command) => {
7138
+ const sid = normalizeString(sessionId);
7139
+ const aid = normalizeString(admissionId);
7140
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7141
+ const decision = await decideSessionAdmission(sid, aid, {
7142
+ decision: "approve",
7143
+ grantedActions: options.scope === undefined ? undefined : parseAdmissionScope(options.scope),
7144
+ ttlSeconds: options.ttl === undefined ? undefined : parseAdmissionTtlSeconds(options.ttl),
7145
+ note: options.note,
7146
+ idempotencyKey: options.idempotencyKey,
7147
+ targetPath,
7148
+ });
7149
+ const result = decision.result;
7150
+ accessOutput(
7151
+ options,
7152
+ command,
7153
+ { command: "session access approve", sessionId: sid, idempotencyKey: decision.idempotencyKey, admission: result },
7154
+ () => {
7155
+ console.log(`Approved ${terminalText(result.admissionId, 64) || aid} for ${terminalText(result.agentId, 64) || "the agent"}.`);
7156
+ if (result.email) {
7157
+ const inboxStatus = terminalText(result.email.status, 32) || "queued";
7158
+ console.log(pc.gray(`AIdenID inbox: ${inboxStatus}. Use --json to inspect its address.`));
7159
+ }
7160
+ },
7161
+ );
7162
+ });
7163
+
7164
+ access
7165
+ .command("deny <sessionId> <admissionId>")
7166
+ .description("Deny a pending agent admission request")
7167
+ .option("--note <text>", "Denial note (max 500 characters)")
7168
+ .option("--idempotency-key <key>", "Retry the same logical denial safely")
7169
+ .option("--path <path>", "Workspace path for auth context", ".")
7170
+ .option("--json", "Emit machine-readable output")
7171
+ .action(async (sessionId, admissionId, options, command) => {
7172
+ const sid = normalizeString(sessionId);
7173
+ const aid = normalizeString(admissionId);
7174
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7175
+ const decision = await decideSessionAdmission(sid, aid, {
7176
+ decision: "deny",
7177
+ note: options.note,
7178
+ idempotencyKey: options.idempotencyKey,
7179
+ targetPath,
7180
+ });
7181
+ accessOutput(
7182
+ options,
7183
+ command,
7184
+ { command: "session access deny", sessionId: sid, idempotencyKey: decision.idempotencyKey, admission: decision.result },
7185
+ () => console.log(`Denied ${terminalText(decision.result.admissionId, 64) || aid}.`),
7186
+ );
7187
+ });
7188
+
7189
+ access
7190
+ .command("revoke <sessionId> <admissionId>")
7191
+ .description("Revoke an approved or active agent admission")
7192
+ .option("--reason <text>", "Revocation reason (max 200 characters)")
7193
+ .option("--idempotency-key <key>", "Retry the same logical revocation safely")
7194
+ .option("--path <path>", "Workspace path for auth context", ".")
7195
+ .option("--json", "Emit machine-readable output")
7196
+ .action(async (sessionId, admissionId, options, command) => {
7197
+ const sid = normalizeString(sessionId);
7198
+ const aid = normalizeString(admissionId);
7199
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7200
+ const revoked = await revokeSessionAdmission(sid, aid, {
7201
+ reason: options.reason,
7202
+ idempotencyKey: options.idempotencyKey,
7203
+ targetPath,
7204
+ });
7205
+ accessOutput(
7206
+ options,
7207
+ command,
7208
+ { command: "session access revoke", sessionId: sid, idempotencyKey: revoked.idempotencyKey, admission: revoked.result },
7209
+ () => console.log(`Revoked ${terminalText(revoked.result.admissionId, 64) || aid}.`),
7210
+ );
7211
+ });
7212
+
6790
7213
  // ---------------------------------------------------------------- tickets
6791
7214
  // The room's shared backlog (API "Ticket Lease Contract (T2)"). Every subcommand is
6792
7215
  // an ACTOR command: the runCli choke point runs it on the agent's admission.
@@ -64,6 +64,9 @@ const SENSITIVE_COMMAND_PREFIXES = [
64
64
  "ai.identity.target.",
65
65
  "ai.identity.site.",
66
66
  "ai.identity.legal-hold.",
67
+ // Room access administration (request/list/status/approve/deny/revoke/mode) belongs to
68
+ // the room owner's own session; operators use the direct CLI or the web dashboard.
69
+ "session.access.",
67
70
  ];
68
71
  const REDACTION_MARKER = "[REDACTED]";
69
72
 
@@ -655,7 +655,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
655
655
  name: "session_action",
656
656
  title: "Record Senti Session Action",
657
657
  description:
658
- "Record a low-noise message action such as ack, working_on, disregard, like, dislike, or reply; view advances the monotonic read cursor instead of appending an action.",
658
+ "Record a low-noise message action such as ack, working_on, disregard, like, dislike, unlike, undislike, or reply; view advances the monotonic read cursor instead of appending an action.",
659
659
  input_schema: {
660
660
  type: "object",
661
661
  additionalProperties: false,
@@ -665,7 +665,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
665
665
  agentId: { type: "string" },
666
666
  actionType: {
667
667
  type: "string",
668
- enum: ["ack", "working_on", "reply", "like", "dislike", "disregard", "view"],
668
+ enum: ["ack", "working_on", "reply", "like", "dislike", "unlike", "undislike", "disregard", "view"],
669
669
  },
670
670
  targetSequenceId: { type: "integer", minimum: 1 },
671
671
  targetCursor: { type: "string" },
@@ -702,7 +702,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
702
702
  name: "session_react",
703
703
  title: "React To Senti Message",
704
704
  description:
705
- "Acknowledge or react to a target session event with ack, like, or dislike.",
705
+ "Acknowledge or react to a target session event with ack, like, or dislike, or retract your own like/dislike with unlike/undislike. Reactions report an outcome and an operation key; resend that key as idempotencyKey only to retry the same intent.",
706
706
  input_schema: {
707
707
  type: "object",
708
708
  additionalProperties: false,
@@ -710,7 +710,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
710
710
  properties: {
711
711
  sessionId: { type: "string" },
712
712
  agentId: { type: "string" },
713
- reaction: { type: "string", enum: ["ack", "like", "dislike"] },
713
+ reaction: { type: "string", enum: ["ack", "like", "dislike", "unlike", "undislike"] },
714
714
  targetSequenceId: { type: "integer", minimum: 1 },
715
715
  targetCursor: { type: "string" },
716
716
  targetActionId: { type: "string" },